mirror of
				https://gitlab.com/Chill-Projet/chill-bundles.git
				synced 2025-10-31 01:08:26 +00:00 
			
		
		
		
	Compare commits
	
		
			85 Commits
		
	
	
		
			create-adm
			...
			ticket/64-
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 3f6bbbc1b3 | |||
| 31e57d7507 | |||
| 5c098a336d | |||
| e107d20bea | |||
| 491fd81f9b | |||
| 8c2acbd166 | |||
| e291c7abec | |||
| 1e186fab58 | |||
| 83a2c04537 | |||
| e89b33bc1a | |||
| 6b208e9962 | |||
| b0c63fab91 | |||
| 6d4c4d2c74 | |||
| df6087d468 | |||
| ffac143ab9 | |||
| f1bf6023ff | |||
| 71e146e4f0 | |||
| 4234377b7e | |||
| 1be82b3049 | |||
| 808954df42 | |||
| 3f8bb6c5c0 | |||
| 23e1a0d36a | |||
| a594d86346 | |||
| 870907804b | |||
| e9e6c05e3d | |||
| 532f2dd842 | |||
| d14d4d4d8f | |||
| a22cbe0239 | |||
| 98902bdeb8 | |||
| 592a0f3698 | |||
| d469eb19ad | |||
| 4765d4fe28 | |||
|  | 30bcb85549 | ||
| 189a9337b4 | |||
| c030232a73 | |||
| d4f9726f90 | |||
| 8740025dbd | |||
| 6d8ef035ea | |||
| 60eab628ee | |||
| 1fd559b722 | |||
| b526e802d7 | |||
| 60937152c3 | |||
| e566f60a4a | |||
| c06531cddb | |||
| 4a1da25fee | |||
| 02783e5391 | |||
| be3b9f0f56 | |||
| ee006f55d6 | |||
| 13b1c45271 | |||
| ad2b6d63ac | |||
| bfbde078b7 | |||
| d42a1296c4 | |||
| 4b7e3c1601 | |||
| 6ea9af588b | |||
| 0fd76d3fa8 | |||
| 34af53130b | |||
| a1fd395868 | |||
| b8a7cbb321 | |||
| 6124eb9e34 | |||
| a5b06de92a | |||
| 52404956d2 | |||
| 4207efd6bf | |||
| 840fde4ad4 | |||
| 3611ea2518 | |||
| bbd4292cb9 | |||
| 54f8c92240 | |||
| 5330befc8f | |||
| c19206be0c | |||
| 5ff374d2fa | |||
| 4a73aaae94 | |||
| ff2c567d05 | |||
| a734e84f28 | |||
| 4367ed086e | |||
| 3227bfcd3a | |||
| 8d29fb260a | |||
| bda0743c63 | |||
| d9b730627f | |||
| 27548ad654 | |||
| bec7297039 | |||
| 852523e644 | |||
| c05d0aad47 | |||
| 1c0ed9abc8 | |||
| 9aed5cc216 | |||
| e4fe5bff68 | |||
| 4c73c4d9d0 | 
| @@ -1,6 +0,0 @@ | ||||
| kind: Feature | ||||
| body: Admin interface for Motive entity | ||||
| time: 2025-10-07T15:59:45.597029709+02:00 | ||||
| custom: | ||||
|     Issue: "" | ||||
|     SchemaChange: No schema change | ||||
| @@ -1,6 +0,0 @@ | ||||
| kind: Feature | ||||
| body: Add an admin interface for Motive entity | ||||
| time: 2025-10-22T11:15:52.13937955+02:00 | ||||
| custom: | ||||
|     Issue: "" | ||||
|     SchemaChange: Add columns or tables | ||||
| @@ -236,12 +236,14 @@ This must be a decision made by a human, not by an AI. Every AI task must abort | ||||
|  | ||||
| The tests are run from the project's root (not from the bundle's root: so, do not change the directory to any bundle directory before running tests). | ||||
|  | ||||
| Tests must be run using the `symfony` command: | ||||
|  | ||||
| ```bash | ||||
| # Run a specific test file | ||||
| vendor/bin/phpunit path/to/TestFile.php | ||||
| symfony composer exec phpunit -- path/to/TestFile.php | ||||
|  | ||||
| # Run a specific test method | ||||
| vendor/bin/phpunit --filter methodName path/to/TestFile.php | ||||
| symfony composer exec phpunit -- --filter methodName path/to/TestFile.php | ||||
| ``` | ||||
|  | ||||
| #### Test Structure | ||||
|   | ||||
| @@ -66,6 +66,7 @@ framework: | ||||
|             'Chill\MainBundle\Export\Messenger\ExportRequestGenerationMessage': priority | ||||
|             'Chill\MainBundle\Export\Messenger\RemoveExportGenerationMessage': async | ||||
|             'Chill\MainBundle\Notification\Email\NotificationEmailMessages\ScheduleDailyNotificationDigestMessage': async | ||||
|             'Chill\TicketBundle\Messenger\PostTicketUpdateMessage': async | ||||
|             # end of routes added by chill-bundles recipes | ||||
|             # Route your messages to the transports | ||||
|             # 'App\Message\YourMessage': async | ||||
|   | ||||
| @@ -41,6 +41,7 @@ | ||||
|     "typescript": "^5.6.3", | ||||
|     "typescript-eslint": "^8.13.0", | ||||
|     "vue-loader": "^17.0.0", | ||||
|     "vue-tsc": "^3.1.1", | ||||
|     "webpack": "^5.75.0", | ||||
|     "webpack-cli": "^5.0.1" | ||||
|   }, | ||||
|   | ||||
| @@ -1,14 +1,14 @@ | ||||
| <template> | ||||
|     <location /> | ||||
|   <location /> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| import Location from "ChillActivityAssets/vuejs/Activity/components/Location.vue"; | ||||
|  | ||||
| export default { | ||||
|     name: "App", | ||||
|     components: { | ||||
|         Location, | ||||
|     }, | ||||
|   name: "App", | ||||
|   components: { | ||||
|     Location, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
|   | ||||
| @@ -19,7 +19,6 @@ use Chill\MainBundle\Entity\User; | ||||
| use Chill\MainBundle\Entity\Address; | ||||
| use libphonenumber\PhoneNumber; | ||||
| use Symfony\Component\Validator\Constraints as Assert; | ||||
| use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint; | ||||
|  | ||||
| /** | ||||
|  * Immersion. | ||||
| @@ -86,14 +85,14 @@ class Immersion implements \Stringable | ||||
|      * @Assert\NotBlank() | ||||
|      */ | ||||
|     #[ORM\Column(name: 'tuteurPhoneNumber', type: 'phone_number', nullable: true)] | ||||
|     #[PhonenumberConstraint(type: 'any')] | ||||
|     #[\Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber] | ||||
|     private ?PhoneNumber $tuteurPhoneNumber = null; | ||||
|  | ||||
|     #[ORM\Column(name: 'structureAccName', type: \Doctrine\DBAL\Types\Types::TEXT, nullable: true)] | ||||
|     private ?string $structureAccName = null; | ||||
|  | ||||
|     #[ORM\Column(name: 'structureAccPhonenumber', type: 'phone_number', nullable: true)] | ||||
|     #[PhonenumberConstraint(type: 'any')] | ||||
|     #[\Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber] | ||||
|     private ?PhoneNumber $structureAccPhonenumber = null; | ||||
|  | ||||
|     #[ORM\Column(name: 'structureAccEmail', type: \Doctrine\DBAL\Types\Types::TEXT, nullable: true)] | ||||
|   | ||||
| @@ -14,9 +14,9 @@ namespace Chill\MainBundle\Entity; | ||||
| use Chill\MainBundle\Doctrine\Model\TrackCreationInterface; | ||||
| use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface; | ||||
| use Chill\MainBundle\Repository\LocationRepository; | ||||
| use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint; | ||||
| use Doctrine\ORM\Mapping as ORM; | ||||
| use libphonenumber\PhoneNumber; | ||||
| use Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber as MisdPhoneNumberConstraint; | ||||
| use Symfony\Component\Serializer\Annotation as Serializer; | ||||
| use Symfony\Component\Serializer\Annotation\DiscriminatorMap; | ||||
|  | ||||
| @@ -67,12 +67,12 @@ class Location implements TrackCreationInterface, TrackUpdateInterface | ||||
|  | ||||
|     #[Serializer\Groups(['read', 'write', 'docgen:read'])] | ||||
|     #[ORM\Column(type: 'phone_number', nullable: true)] | ||||
|     #[PhonenumberConstraint(type: 'any')] | ||||
|     #[MisdPhoneNumberConstraint(type: [MisdPhoneNumberConstraint::ANY])] | ||||
|     private ?PhoneNumber $phonenumber1 = null; | ||||
|  | ||||
|     #[Serializer\Groups(['read', 'write', 'docgen:read'])] | ||||
|     #[ORM\Column(type: 'phone_number', nullable: true)] | ||||
|     #[PhonenumberConstraint(type: 'any')] | ||||
|     #[MisdPhoneNumberConstraint(type: [MisdPhoneNumberConstraint::ANY])] | ||||
|     private ?PhoneNumber $phonenumber2 = null; | ||||
|  | ||||
|     #[Serializer\Groups(['read'])] | ||||
|   | ||||
| @@ -23,7 +23,6 @@ use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; | ||||
| use Symfony\Component\Security\Core\User\UserInterface; | ||||
| use Symfony\Component\Serializer\Annotation as Serializer; | ||||
| use Symfony\Component\Validator\Context\ExecutionContextInterface; | ||||
| use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint; | ||||
|  | ||||
| /** | ||||
|  * User. | ||||
| @@ -116,7 +115,7 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter | ||||
|      * The user's mobile phone number. | ||||
|      */ | ||||
|     #[ORM\Column(type: 'phone_number', nullable: true)] | ||||
|     #[PhonenumberConstraint] | ||||
|     #[\Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber] | ||||
|     private ?PhoneNumber $phonenumber = null; | ||||
|  | ||||
|     /** | ||||
|   | ||||
| @@ -31,6 +31,8 @@ interface PhoneNumberHelperInterface | ||||
|  | ||||
|     /** | ||||
|      * Return true if the validation is configured and available. | ||||
|      * | ||||
|      * @deprecated this is an internal behaviour of the helper and should not be taken into account outside of the implementation | ||||
|      */ | ||||
|     public function isPhonenumberValidationConfigured(): bool; | ||||
|  | ||||
|   | ||||
| @@ -122,7 +122,7 @@ final class PhonenumberHelper implements PhoneNumberHelperInterface | ||||
|      */ | ||||
|     public function isValidPhonenumberAny($phonenumber): bool | ||||
|     { | ||||
|         if (false === $this->isPhonenumberValidationConfigured()) { | ||||
|         if (false === $this->isConfigured) { | ||||
|             return true; | ||||
|         } | ||||
|         $validation = $this->performTwilioLookup($phonenumber); | ||||
| @@ -142,7 +142,7 @@ final class PhonenumberHelper implements PhoneNumberHelperInterface | ||||
|      */ | ||||
|     public function isValidPhonenumberLandOrVoip($phonenumber): bool | ||||
|     { | ||||
|         if (false === $this->isPhonenumberValidationConfigured()) { | ||||
|         if (false === $this->isConfigured) { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
| @@ -163,7 +163,7 @@ final class PhonenumberHelper implements PhoneNumberHelperInterface | ||||
|      */ | ||||
|     public function isValidPhonenumberMobile($phonenumber): bool | ||||
|     { | ||||
|         if (false === $this->isPhonenumberValidationConfigured()) { | ||||
|         if (false === $this->isConfigured) { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
| @@ -178,7 +178,7 @@ final class PhonenumberHelper implements PhoneNumberHelperInterface | ||||
|  | ||||
|     private function performTwilioLookup($phonenumber) | ||||
|     { | ||||
|         if (false === $this->isPhonenumberValidationConfigured()) { | ||||
|         if (false === $this->isConfigured) { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|   | ||||
| @@ -158,3 +158,18 @@ export const intervalISOToDays = (str: string | null): number | null => { | ||||
|  | ||||
|   return days; | ||||
| }; | ||||
|  | ||||
| export function getTimezoneOffsetString(date: Date, timeZone: string): string { | ||||
|   const utcDate = new Date(date.toLocaleString("en-US", { timeZone: "UTC" })); | ||||
|   const tzDate  = new Date(date.toLocaleString("en-US", { timeZone })); | ||||
|   const offsetMinutes = (utcDate.getTime() - tzDate.getTime()) / (60 * 1000); | ||||
|  | ||||
|   // Inverser le signe pour avoir la convention ±HH:MM | ||||
|   const sign = offsetMinutes <= 0 ? "+" : "-"; | ||||
|   const absMinutes = Math.abs(offsetMinutes); | ||||
|   const hours = String(Math.floor(absMinutes / 60)).padStart(2, "0"); | ||||
|   const minutes = String(absMinutes % 60).padStart(2, "0"); | ||||
|  | ||||
|   return `${sign}${hours}:${minutes}`; | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -54,7 +54,6 @@ $chill-theme-buttons: ( | ||||
|    &.btn-unlink, | ||||
|    &.btn-action, | ||||
|    &.btn-edit, | ||||
|    &.btn-tpchild, | ||||
|    &.btn-wopilink, | ||||
|    &.btn-update { | ||||
|        &, &:hover { | ||||
| @@ -82,7 +81,6 @@ $chill-theme-buttons: ( | ||||
|    &.btn-remove::before, | ||||
|    &.btn-choose::before, | ||||
|    &.btn-notify::before, | ||||
|    &.btn-tpchild::before, | ||||
|    &.btn-download::before, | ||||
|    &.btn-search::before, | ||||
|    &.btn-cancel::before { | ||||
| @@ -112,7 +110,6 @@ $chill-theme-buttons: ( | ||||
|    &.btn-choose::before    { content: "\f00c"; } // fa-check  // f046 fa-check-square-o | ||||
|    &.btn-unlink::before    { content: "\f127"; } // fa-chain-broken | ||||
|    &.btn-notify::before    { content: "\f1d8"; } // fa-paper-plane | ||||
|    &.btn-tpchild::before   { content: "\f007"; } // fa-user | ||||
|    &.btn-download::before  { content: "\f019"; } // fa-download | ||||
|    &.btn-search::before    { content: "\f002"; } // fa-search | ||||
| } | ||||
|   | ||||
| @@ -1,8 +1,14 @@ | ||||
| import { Scope } from "../../types"; | ||||
| import { | ||||
|   DynamicKeys, | ||||
|   Scope, | ||||
|   ValidationExceptionInterface, | ||||
|   ValidationProblemFromMap, | ||||
|   ViolationFromMap | ||||
| } from "../../types"; | ||||
|  | ||||
| export type body = Record<string, boolean | string | number | null>; | ||||
| export type fetchOption = Record<string, boolean | string | number | null>; | ||||
|  | ||||
| export type Primitive = string | number | boolean | null; | ||||
| export type Params = Record<string, number | string>; | ||||
|  | ||||
| export interface Pagination { | ||||
| @@ -25,20 +31,115 @@ export interface TransportExceptionInterface { | ||||
|   name: string; | ||||
| } | ||||
|  | ||||
| export interface ValidationExceptionInterface | ||||
|   extends TransportExceptionInterface { | ||||
|   name: "ValidationException"; | ||||
|   error: object; | ||||
|   violations: string[]; | ||||
|   titles: string[]; | ||||
|   propertyPaths: string[]; | ||||
| export class ValidationException< | ||||
|     M extends Record<string, Record<string, string|number>> = Record< | ||||
|       string, | ||||
|       Record<string, string|number> | ||||
|     >, | ||||
|   > | ||||
|   extends Error | ||||
|   implements ValidationExceptionInterface<M> | ||||
| { | ||||
|   public readonly name = "ValidationException" as const; | ||||
|   public readonly problems: ValidationProblemFromMap<M>; | ||||
|   public readonly violations: string[]; | ||||
|   public readonly violationsList: ViolationFromMap<M>[]; | ||||
|   public readonly titles: string[]; | ||||
|   public readonly propertyPaths: DynamicKeys<M> & string[]; | ||||
|   public readonly byProperty: Record<Extract<keyof M, string>, string[]>; | ||||
|  | ||||
|   constructor(problem: ValidationProblemFromMap<M>) { | ||||
|     const message = [problem.title, problem.detail].filter(Boolean).join(" — "); | ||||
|     super(message); | ||||
|     Object.setPrototypeOf(this, new.target.prototype); | ||||
|  | ||||
|     this.problems = problem; | ||||
|  | ||||
|     this.violationsList = problem.violations; | ||||
|     this.violations = problem.violations.map( | ||||
|       (v) => `${v.title}: ${v.propertyPath}`, | ||||
|     ); | ||||
|  | ||||
|     this.titles = problem.violations.map((v) => v.title); | ||||
|  | ||||
|     this.propertyPaths = problem.violations.map( | ||||
|       (v) => v.propertyPath, | ||||
|     ) as DynamicKeys<M> & string[]; | ||||
|  | ||||
|     this.byProperty = problem.violations.reduce( | ||||
|       (acc, v) => { | ||||
|         const key = v.propertyPath.replace('/\[\d+\]$/', "") as Extract<keyof M, string>; | ||||
|         (acc[key] ||= []).push(v.title); | ||||
|         return acc; | ||||
|       }, | ||||
|       {} as Record<Extract<keyof M, string>, string[]>, | ||||
|     ); | ||||
|  | ||||
|     if (Error.captureStackTrace) { | ||||
|       Error.captureStackTrace(this, ValidationException); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   violationsByNormalizedProperty(property: Extract<keyof M, string>): ViolationFromMap<M>[] { | ||||
|     return this.violationsList.filter((v) => v.propertyPath.replace(/\[\d+\]$/, "") === property); | ||||
|   } | ||||
|  | ||||
|   violationsByNormalizedPropertyAndParams< | ||||
|     P extends Extract<keyof M, string>, | ||||
|     K extends Extract<keyof M[P], string> | ||||
|   >( | ||||
|     property: P, | ||||
|     param: K, | ||||
|     param_value: M[P][K] | ||||
|   ): ViolationFromMap<M>[] | ||||
|   { | ||||
|     const list = this.violationsByNormalizedProperty(property); | ||||
|  | ||||
|     return  list.filter( | ||||
|       (v): boolean => | ||||
|         !!v.parameters && | ||||
|         // `with_parameter in v.parameters` check indexing | ||||
|         param in v.parameters && | ||||
|         // the cast is safe, because we have overloading that bind the types | ||||
|         (v.parameters as M[P])[param] === param_value | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export interface ValidationErrorResponse extends TransportExceptionInterface { | ||||
|   violations: { | ||||
|     title: string; | ||||
|     propertyPath: string; | ||||
|   }[]; | ||||
| /** | ||||
|  * Check that the exception is a ValidationExceptionInterface | ||||
|  * @param x | ||||
|  */ | ||||
| export function isValidationException<M extends Record<string, Record<string, string|number>>>( | ||||
|   x: unknown, | ||||
| ): x is ValidationExceptionInterface<M> { | ||||
|   return ( | ||||
|     x instanceof ValidationException || | ||||
|     (typeof x === "object" && | ||||
|       x !== null && | ||||
|       (x as any).name === "ValidationException") | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export function isValidationProblem(x: unknown): x is { | ||||
|   type: string; | ||||
|   title: string; | ||||
|   violations: { propertyPath: string; title: string }[]; | ||||
| } { | ||||
|   if (!x || typeof x !== "object") return false; | ||||
|   const o = x as any; | ||||
|   return ( | ||||
|     typeof o.type === "string" && | ||||
|     typeof o.title === "string" && | ||||
|     Array.isArray(o.violations) && | ||||
|     o.violations.every( | ||||
|       (v: any) => | ||||
|         v && | ||||
|         typeof v === "object" && | ||||
|         typeof v.propertyPath === "string" && | ||||
|         typeof v.title === "string", | ||||
|     ) | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export interface AccessExceptionInterface extends TransportExceptionInterface { | ||||
| @@ -65,12 +166,151 @@ export interface ConflictHttpExceptionInterface | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Generic api method that can be adapted to any fetch request | ||||
|  * Generic api method that can be adapted to any fetch request. | ||||
|  * | ||||
|  * This method is suitable make a single fetch. When performing a GET to fetch a list of elements, always consider pagination | ||||
|  * and use of the @link{fetchResults} method. | ||||
|  * What this does | ||||
|  * - Performs a single HTTP request using fetch and returns the parsed JSON as Output. | ||||
|  * - Interprets common API errors and throws typed exceptions you can catch in your UI. | ||||
|  * - When the server returns a Symfony validation problem (HTTP 422), the error is | ||||
|  *   rethrown as a typed ValidationException that is aware of your Violation Map (see below). | ||||
|  * | ||||
|  * Important: For GET endpoints that return lists, prefer using fetchResults, which | ||||
|  * handles pagination and aggregation for you. | ||||
|  * | ||||
|  * Violation Map (M): make your 422 errors strongly typed | ||||
|  * ------------------------------------------------------ | ||||
|  * Symfony’s validation problem+json payload looks like this (simplified): | ||||
|  * | ||||
|  *   { | ||||
|  *     "type": "https://symfony.com/errors/validation", | ||||
|  *     "title": "Validation Failed", | ||||
|  *     "violations": [ | ||||
|  *       { | ||||
|  *         "propertyPath": "mobilenumber", | ||||
|  *         "title": "This value is not a valid phone number.", | ||||
|  *         "parameters": { | ||||
|  *           "{{ value }}": "+33 1 02 03 04 05", | ||||
|  *           "{{ types }}": "mobile number" | ||||
|  *         }, | ||||
|  *         "type": "urn:uuid:..." | ||||
|  *       } | ||||
|  *     ] | ||||
|  *   } | ||||
|  * | ||||
|  * The makeFetch generic type parameter M lets you describe, field by field, which | ||||
|  * parameters may appear for each propertyPath. Doing so gives you full type-safety when | ||||
|  * consuming ValidationException in your UI code. | ||||
|  * | ||||
|  * How to build M (Violation Map) | ||||
|  * - M is a map where each key is a server-side propertyPath (string), and the value is a | ||||
|  *   record describing the allowed keys in the parameters object for that property. | ||||
|  * - Keys in parameters are the exact strings you receive from Symfony, including the | ||||
|  *   curly-braced placeholders such as "{{ value }}", "{{ types }}", etc. | ||||
|  * | ||||
|  * Example from Person creation (WritePersonViolationMap) | ||||
|  * ----------------------------------------------------- | ||||
|  * In ChillPersonBundle/Resources/public/vuejs/_api/OnTheFly.ts you’ll find: | ||||
|  * | ||||
|  *   export type WritePersonViolationMap = { | ||||
|  *     gender: { | ||||
|  *       "{{ value }}": string | null; | ||||
|  *     }; | ||||
|  *     mobilenumber: { | ||||
|  *       "{{ types }}": string;          // ex: "mobile number" | ||||
|  *       "{{ value }}": string;          // ex: "+33 1 02 03 04 05" | ||||
|  *     }; | ||||
|  *   }; | ||||
|  * | ||||
|  * This means: | ||||
|  * - If the server reports a violation for propertyPath "gender", the parameters object | ||||
|  *   is expected to contain a key "{{ value }}" with a string or null value. | ||||
|  * - If the server reports a violation for propertyPath "mobilenumber", the parameters | ||||
|  *   may include "{{ value }}" and "{{ types }}" as strings. | ||||
|  * | ||||
|  * How makeFetch uses M | ||||
|  * - When the response has status 422 and the payload matches a Symfony validation | ||||
|  *   problem, makeFetch casts it to ValidationProblemFromMap<M> and throws a | ||||
|  *   ValidationException<M>. | ||||
|  * - The ValidationException exposes helpful, pre-computed fields: | ||||
|  *   - exception.problem: the full typed payload | ||||
|  *   - exception.violations: ["Title: propertyPath", ...] | ||||
|  *   - exception.titles: ["Title 1", "Title 2", ...] | ||||
|  *   - exception.propertyPaths: ["gender", "mobilenumber", ...] (typed from M) | ||||
|  *   - exception.byProperty: { gender: [titles...], mobilenumber: [titles...] } | ||||
|  * | ||||
|  * Typical usage patterns | ||||
|  * ---------------------- | ||||
|  * 1) GET without Validation Map (no 422 expected): | ||||
|  * | ||||
|  *   const centers = await makeFetch<null, { showCenters: boolean; centers: Center[] }>( | ||||
|  *     "GET", | ||||
|  *     "/api/1.0/person/creation/authorized-centers", | ||||
|  *     null | ||||
|  *   ); | ||||
|  * | ||||
|  * 2) POST with body and Violation Map: | ||||
|  * | ||||
|  *   type WritePersonViolationMap = { | ||||
|  *     gender: { "{{ value }}": string | null }; | ||||
|  *     mobilenumber: { "{{ types }}": string; "{{ value }}": string }; | ||||
|  *   }; | ||||
|  * | ||||
|  *   try { | ||||
|  *     const created = await makeFetch<PersonWrite, Person, WritePersonViolationMap>( | ||||
|  *       "POST", | ||||
|  *       "/api/1.0/person/person.json", | ||||
|  *       personPayload | ||||
|  *     ); | ||||
|  *     // Success path | ||||
|  *   } catch (e) { | ||||
|  *     if (isValidationException(e)) { | ||||
|  *       // Fully typed: | ||||
|  *       e.propertyPaths.includes("mobilenumber"); | ||||
|  *       const firstTitleForMobile = e.byProperty.mobilenumber?.[0]; | ||||
|  *       // You can also inspect parameter values: | ||||
|  *       const v = e.problem.violations.find(v => v.propertyPath === "mobilenumber"); | ||||
|  *       const rawValue = v?.parameters?.["{{ value }}"]; // typed as string | ||||
|  *     } else { | ||||
|  *       // Other error handling (AccessException, ConflictHttpException, etc.) | ||||
|  *     } | ||||
|  *   } | ||||
|  * | ||||
|  * Tips to design your Violation Map | ||||
|  * - Use exact propertyPath strings as exposed by the API (they usually match your | ||||
|  *   DTO field names or entity property paths used by the validator). | ||||
|  * - Inside each property, list only the placeholders that you actually read in the UI | ||||
|  *   (you can always add more later). This keeps your types strict but pragmatic. | ||||
|  * - If a field may not include parameters at all, you can set it to an empty object {}. | ||||
|  * - If you don’t care about parameter typing, you can omit M entirely and rely on the | ||||
|  *   default loose typing (Record<string, Primitive>), but you’ll lose safety. | ||||
|  * | ||||
|  * Error taxonomy thrown by makeFetch | ||||
|  * - ValidationException<M> when status = 422 and payload is a validation problem. | ||||
|  * - AccessException when status = 403. | ||||
|  * - ConflictHttpException when status = 409. | ||||
|  * - A generic error object for other non-ok statuses. | ||||
|  * | ||||
|  * @typeParam Input  - Shape of the request body you send (if any) | ||||
|  * @typeParam Output - Shape of the successful JSON response you expect | ||||
|  * @typeParam M      - Violation Map describing the per-field parameters you expect | ||||
|  *                     in Symfony validation violations. See examples above. | ||||
|  * | ||||
|  * @param method  The HTTP method to use (POST, GET, PUT, PATCH, DELETE) | ||||
|  * @param url     The absolute or relative URL to call | ||||
|  * @param body    The request payload. If null/undefined, no body is sent | ||||
|  * @param options Extra fetch options/headers merged into the request | ||||
|  * | ||||
|  * @returns The parsed JSON response typed as Output. For 204 No Content, resolves | ||||
|  *          with undefined (void). | ||||
|  */ | ||||
| export const makeFetch = <Input, Output>( | ||||
| export const makeFetch = async < | ||||
|   Input, | ||||
|   Output, | ||||
|   M extends Record<string, Record<string, string|number>> = Record< | ||||
|     string, | ||||
|     Record<string, string|number> | ||||
|   >, | ||||
| >( | ||||
|   method: "POST" | "GET" | "PUT" | "PATCH" | "DELETE", | ||||
|   url: string, | ||||
|   body?: body | Input | null, | ||||
| @@ -90,7 +330,8 @@ export const makeFetch = <Input, Output>( | ||||
|   if (typeof options !== "undefined") { | ||||
|     opts = Object.assign(opts, options); | ||||
|   } | ||||
|   return fetch(url, opts).then((response) => { | ||||
|  | ||||
|   return fetch(url, opts).then(async (response) => { | ||||
|     if (response.status === 204) { | ||||
|       return Promise.resolve(); | ||||
|     } | ||||
| @@ -100,9 +341,20 @@ export const makeFetch = <Input, Output>( | ||||
|     } | ||||
|  | ||||
|     if (response.status === 422) { | ||||
|       return response.json().then((response) => { | ||||
|         throw ValidationException(response); | ||||
|       }); | ||||
|       // Unprocessable Entity -> payload de validation Symfony | ||||
|       const json = await response.json().catch(() => undefined); | ||||
|  | ||||
|       if (isValidationProblem(json)) { | ||||
|         // On ré-interprète le payload selon M (ParamMap) pour typer les violations | ||||
|         const problem = json as unknown as ValidationProblemFromMap<M>; | ||||
|         throw new ValidationException<M>(problem); | ||||
|       } | ||||
|  | ||||
|       const err = new Error( | ||||
|         "Validation failed but payload is not a ValidationProblem", | ||||
|       ); | ||||
|       (err as any).raw = json; | ||||
|       throw err; | ||||
|     } | ||||
|  | ||||
|     if (response.status === 403) { | ||||
| @@ -167,12 +419,6 @@ function _fetchAction<T>( | ||||
|         throw NotFoundException(response); | ||||
|       } | ||||
|  | ||||
|       if (response.status === 422) { | ||||
|         return response.json().then((response) => { | ||||
|           throw ValidationException(response); | ||||
|         }); | ||||
|       } | ||||
|  | ||||
|       if (response.status === 403) { | ||||
|         throw AccessException(response); | ||||
|       } | ||||
| @@ -231,24 +477,6 @@ export const fetchScopes = (): Promise<Scope[]> => { | ||||
|   return fetchResults("/api/1.0/main/scope.json"); | ||||
| }; | ||||
|  | ||||
| /** | ||||
|  * Error objects to be thrown | ||||
|  */ | ||||
| const ValidationException = ( | ||||
|   response: ValidationErrorResponse, | ||||
| ): ValidationExceptionInterface => { | ||||
|   const error = {} as ValidationExceptionInterface; | ||||
|   error.name = "ValidationException"; | ||||
|   error.violations = response.violations.map( | ||||
|     (violation) => `${violation.title}: ${violation.propertyPath}`, | ||||
|   ); | ||||
|   error.titles = response.violations.map((violation) => violation.title); | ||||
|   error.propertyPaths = response.violations.map( | ||||
|     (violation) => violation.propertyPath, | ||||
|   ); | ||||
|   return error; | ||||
| }; | ||||
|  | ||||
| // eslint-disable-next-line @typescript-eslint/no-unused-vars | ||||
| const AccessException = (response: Response): AccessExceptionInterface => { | ||||
|   const error = {} as AccessExceptionInterface; | ||||
|   | ||||
| @@ -0,0 +1,17 @@ | ||||
| import {Gender, GenderTranslation} from "ChillMainAssets/types"; | ||||
|  | ||||
| /** | ||||
|  * Translates a given gender object into its corresponding gender translation string. | ||||
|  * | ||||
|  * @param {Gender|null} gender - The gender object to be translated, null values are also supported | ||||
|  * @return {GenderTranslation} Returns the gender translation string corresponding to the provided gender, | ||||
|  *                             or "unknown" if the gender is null. | ||||
|  */ | ||||
| export function toGenderTranslation(gender: Gender|null): GenderTranslation | ||||
| { | ||||
|   if (null === gender) { | ||||
|     return "unknown"; | ||||
|   } | ||||
|  | ||||
|   return gender.genderTranslation; | ||||
| } | ||||
| @@ -1,14 +1,65 @@ | ||||
| import { GenericDoc } from "ChillDocStoreAssets/types/generic_doc"; | ||||
| import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types"; | ||||
| import { CreatableEntityType } from "ChillPersonAssets/types"; | ||||
| import {ThirdpartyCompany} from "../../../ChillThirdPartyBundle/Resources/public/types"; | ||||
|  | ||||
| export interface DateTime { | ||||
|   datetime: string; | ||||
|   datetime8601: string; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * A date representation to use when we create or update a date | ||||
|  */ | ||||
| export interface DateTimeWrite { | ||||
|   /** | ||||
|    * Must be a string in format Y-m-d\TH:i:sO | ||||
|    */ | ||||
|   datetime: string; | ||||
| } | ||||
|  | ||||
| export interface Civility { | ||||
|   type: "chill_main_civility"; | ||||
|   id: number; | ||||
|   abbreviation: TranslatableString; | ||||
|   active: boolean; | ||||
|   name: TranslatableString; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Lightweight reference to Civility, to use in POST or PUT requests. | ||||
|  */ | ||||
| export interface SetCivility { | ||||
|   type: "chill_main_civility"; | ||||
|   id: number; | ||||
| } | ||||
|  | ||||
|  | ||||
| /** | ||||
|  * Gender translation. | ||||
|  * | ||||
|  * Match the GenderEnum in PHP code. | ||||
|  */ | ||||
| export type GenderTranslation = "male" | "female" | "neutral" | "unknown"; | ||||
|  | ||||
| /** | ||||
|  * A gender | ||||
|  * | ||||
|  * See also | ||||
|  */ | ||||
| export interface Gender { | ||||
|   type: "chill_main_gender"; | ||||
|   id: number; | ||||
|   label: TranslatableString; | ||||
|   genderTranslation: GenderTranslation; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Lightweight reference to a Gender, used in POST / PUT requests. | ||||
|  */ | ||||
| export interface SetGender { | ||||
|   type: "chill_main_gender"; | ||||
|   id: number; | ||||
|   // TODO | ||||
| } | ||||
|  | ||||
| export interface Household { | ||||
| @@ -28,6 +79,18 @@ export interface Center { | ||||
|   id: number; | ||||
|   type: "center"; | ||||
|   name: string; | ||||
|   isActive: boolean; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * SetCenter is a lightweight reference used in POST/PUT requests to associate an existing center with a resource. | ||||
|  * It links by id only and does not create or modify centers. | ||||
|  * Expected shape: { type: "center", id: number }. | ||||
|  * Requests will fail if the id is invalid, the center doesn't exist, or permissions are insufficient. | ||||
|  */ | ||||
| export interface SetCenter { | ||||
|   id: number; | ||||
|   type: "center"; | ||||
| } | ||||
|  | ||||
| export interface Scope { | ||||
| @@ -119,6 +182,13 @@ export interface Address { | ||||
|   isNoAddress: boolean; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Associate an existing address in write operations. | ||||
|  */ | ||||
| export interface SetAddress { | ||||
|   id: number; | ||||
| } | ||||
|  | ||||
| export interface AddressWithPoint extends Address { | ||||
|   point: Point; | ||||
| } | ||||
| @@ -226,13 +296,63 @@ export interface TransportExceptionInterface { | ||||
|   name: string; | ||||
| } | ||||
|  | ||||
| export interface ValidationExceptionInterface | ||||
|   extends TransportExceptionInterface { | ||||
| type IndexedKey<Base extends string> = `${Base}[${number}]`; | ||||
| type BaseKeys<M> = Extract<keyof M, string>; | ||||
|  | ||||
| export type DynamicKeys<M extends Record<string, Record<string, unknown>>> = | ||||
|   | BaseKeys<M> | ||||
|   | { [K in BaseKeys<M> as IndexedKey<K>]: K }[IndexedKey<BaseKeys<M>>]; | ||||
|  | ||||
| type NormalizeKey<K extends string> = K extends `${infer B}[${number}]` ? B : K; | ||||
|  | ||||
| export type ViolationFromMap<M extends Record<string, Record<string, unknown>>> = { | ||||
|   [K in DynamicKeys<M> & string]: { // <- note le "& string" ici | ||||
|     propertyPath: K; | ||||
|     title: string; | ||||
|     parameters?: M[NormalizeKey<K>]; | ||||
|     type?: string; | ||||
|   } | ||||
| }[DynamicKeys<M> & string]; | ||||
|  | ||||
| export type ValidationProblemFromMap< | ||||
|   M extends Record<string, Record<string, string|number>>, | ||||
| > = { | ||||
|   type: string; | ||||
|   title: string; | ||||
|   detail?: string; | ||||
|   violations: ViolationFromMap<M>[]; | ||||
| } & Record<string, unknown>; | ||||
|  | ||||
| export interface ValidationExceptionInterface< | ||||
|   M extends Record<string, Record<string, string|number>> = Record< | ||||
|     string, | ||||
|     Record<string, string|number> | ||||
|   >, | ||||
| > extends Error { | ||||
|   name: "ValidationException"; | ||||
|   error: object; | ||||
|   /** Full server payload copy  */ | ||||
|   problems: ValidationProblemFromMap<M>; | ||||
|   /** A list of all violations, with property key */ | ||||
|   violationsList: ViolationFromMap<M>[]; | ||||
|   /** Compact list "Title: path" */ | ||||
|   violations: string[]; | ||||
|   /** Only titles */ | ||||
|   titles: string[]; | ||||
|   propertyPaths: string[]; | ||||
|   /** Only property paths */ | ||||
|   propertyPaths: DynamicKeys<M> & string[]; | ||||
|   /** Indexing by property (useful for display by field) */ | ||||
|   byProperty: Record<Extract<keyof M, string>, string[]>; | ||||
|  | ||||
|   violationsByNormalizedProperty(property: Extract<keyof M, string>): ViolationFromMap<M>[]; | ||||
|  | ||||
|   violationsByNormalizedPropertyAndParams< | ||||
|     P extends Extract<keyof M, string>, | ||||
|     K extends Extract<keyof M[P], string> | ||||
|   >( | ||||
|     property: P, | ||||
|     param: K, | ||||
|     param_value: M[P][K] | ||||
|   ): ViolationFromMap<M>[]; | ||||
| } | ||||
|  | ||||
| export interface AccessExceptionInterface extends TransportExceptionInterface { | ||||
| @@ -300,3 +420,23 @@ export interface TabDefinition { | ||||
|   icon: string | null; | ||||
|   counter: () => number; | ||||
| } | ||||
|  | ||||
| export type CreateComponentConfigGeneral = { | ||||
|   action: 'create'; | ||||
|   allowedTypes: CreatableEntityType[]; | ||||
|   query: string; | ||||
|   parent: null; | ||||
| } | ||||
|  | ||||
| export type CreateComponentThirdPartyAddContact = { | ||||
|   action: 'addContact'; | ||||
|   allowedTypes: readonly ['thirdparty']; | ||||
|   query: string; | ||||
|   parent: ThirdpartyCompany; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Configuration for the CreateModal and Create component | ||||
|  */ | ||||
| export type CreateComponentConfig = CreateComponentConfigGeneral | CreateComponentThirdPartyAddContact; | ||||
|  | ||||
|   | ||||
| @@ -12,24 +12,22 @@ | ||||
|     ref="showAddress" | ||||
|   /> | ||||
|  | ||||
|     <!-- step 1 --> | ||||
|     <teleport to="body" v-if="inModal"> | ||||
|         <modal | ||||
|             v-if="flag.suggestPane" | ||||
|             modal-dialog-class="modal-dialog-scrollable modal-xl" | ||||
|             @close="resetPane" | ||||
|         > | ||||
|             <template #header> | ||||
|                 <h2 class="modal-title"> | ||||
|                     {{ trans(getTextTitle) }} | ||||
|                     <span v-if="flag.loading" class="loading"> | ||||
|                         <i class="fa fa-circle-o-notch fa-spin fa-fw" /> | ||||
|                         <span class="sr-only">{{ | ||||
|                             trans(ADDRESS_LOADING) | ||||
|                         }}</span> | ||||
|                     </span> | ||||
|                 </h2> | ||||
|             </template> | ||||
|   <!-- step 1 --> | ||||
|   <teleport to="body" v-if="inModal"> | ||||
|     <modal | ||||
|       v-if="flag.suggestPane" | ||||
|       modal-dialog-class="modal-dialog-scrollable modal-xl" | ||||
|       @close="resetPane" | ||||
|     > | ||||
|       <template #header> | ||||
|         <h2 class="modal-title"> | ||||
|           {{ trans(getTextTitle) }} | ||||
|           <span v-if="flag.loading" class="loading"> | ||||
|             <i class="fa fa-circle-o-notch fa-spin fa-fw" /> | ||||
|             <span class="sr-only">{{ trans(ADDRESS_LOADING) }}</span> | ||||
|           </span> | ||||
|         </h2> | ||||
|       </template> | ||||
|  | ||||
|       <template #body> | ||||
|         <suggest-pane | ||||
| @@ -90,9 +88,7 @@ | ||||
|           {{ trans(getTextTitle) }} | ||||
|           <span v-if="flag.loading" class="loading"> | ||||
|             <i class="fa fa-circle-o-notch fa-spin fa-fw" /> | ||||
|             <span class="sr-only">{{ | ||||
|                             trans(ADDRESS_LOADING) | ||||
|                         }}</span> | ||||
|             <span class="sr-only">{{ trans(ADDRESS_LOADING) }}</span> | ||||
|           </span> | ||||
|         </h2> | ||||
|       </template> | ||||
| @@ -175,9 +171,7 @@ | ||||
|           {{ trans(getTextTitle) }} | ||||
|           <span v-if="flag.loading" class="loading"> | ||||
|             <i class="fa fa-circle-o-notch fa-spin fa-fw" /> | ||||
|             <span class="sr-only">{{ | ||||
|                             trans(ADDRESS_LOADING) | ||||
|                         }}</span> | ||||
|             <span class="sr-only">{{ trans(ADDRESS_LOADING) }}</span> | ||||
|           </span> | ||||
|         </h2> | ||||
|       </template> | ||||
| @@ -248,14 +242,14 @@ import { | ||||
| } from "../api"; | ||||
| import { | ||||
|   CREATE_A_NEW_ADDRESS, | ||||
|     ADDRESS_LOADING, | ||||
|     ACTIVITY_CREATE_ADDRESS, | ||||
|     ACTIVITY_EDIT_ADDRESS, | ||||
|   ADDRESS_LOADING, | ||||
|   ACTIVITY_CREATE_ADDRESS, | ||||
|   ACTIVITY_EDIT_ADDRESS, | ||||
|   CANCEL, | ||||
|     SAVE, | ||||
|     PREVIOUS, | ||||
|     NEXT, | ||||
|     trans, | ||||
|   SAVE, | ||||
|   PREVIOUS, | ||||
|   NEXT, | ||||
|   trans, | ||||
| } from "translator"; | ||||
| import ShowPane from "./ShowPane.vue"; | ||||
| import SuggestPane from "./SuggestPane.vue"; | ||||
| @@ -265,16 +259,17 @@ import DatePane from "./DatePane.vue"; | ||||
| export default { | ||||
|   name: "AddAddress", | ||||
|   setup() { | ||||
|         return { | ||||
|             trans, | ||||
|             CREATE_A_NEW_ADDRESS, | ||||
|             ADDRESS_LOADING, | ||||
|             CANCEL, | ||||
|             SAVE, | ||||
|             PREVIOUS, | ||||
|             NEXT, | ||||
|         }; | ||||
|     },props: ["context", "options", "addressChangedCallback"], | ||||
|     return { | ||||
|       trans, | ||||
|       CREATE_A_NEW_ADDRESS, | ||||
|       ADDRESS_LOADING, | ||||
|       CANCEL, | ||||
|       SAVE, | ||||
|       PREVIOUS, | ||||
|       NEXT, | ||||
|     }; | ||||
|   }, | ||||
|   props: ["context", "options", "addressChangedCallback"], | ||||
|   components: { | ||||
|     Modal, | ||||
|     ShowPane, | ||||
| @@ -394,9 +389,9 @@ export default { | ||||
|       ) { | ||||
|         console.log("this.options.title", this.options.title); | ||||
|  | ||||
|                 return this.context.edit | ||||
|                     ? ACTIVITY_EDIT_ADDRESS | ||||
|                     : ACTIVITY_CREATE_ADDRESS; | ||||
|         return this.context.edit | ||||
|           ? ACTIVITY_EDIT_ADDRESS | ||||
|           : ACTIVITY_CREATE_ADDRESS; | ||||
|       } | ||||
|       return this.context.edit | ||||
|         ? this.defaultz.title.edit | ||||
|   | ||||
| @@ -55,9 +55,7 @@ | ||||
|           :placeholder="trans(ADDRESS_BUILDING_NAME)" | ||||
|           v-model="buildingName" | ||||
|         /> | ||||
|         <label for="buildingName">{{ | ||||
|                     trans(ADDRESS_BUILDING_NAME) | ||||
|                 }}</label> | ||||
|         <label for="buildingName">{{ trans(ADDRESS_BUILDING_NAME) }}</label> | ||||
|       </div> | ||||
|       <div class="form-floating my-1"> | ||||
|         <input | ||||
| @@ -79,9 +77,7 @@ | ||||
|           :placeholder="trans(ADDRESS_DISTRIBUTION)" | ||||
|           v-model="distribution" | ||||
|         /> | ||||
|         <label for="distribution">{{ | ||||
|                     trans(ADDRESS_DISTRIBUTION) | ||||
|                 }}</label> | ||||
|         <label for="distribution">{{ trans(ADDRESS_DISTRIBUTION) }}</label> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| @@ -89,35 +85,36 @@ | ||||
|  | ||||
| <script> | ||||
| import { | ||||
|     ADDRESS_STREET, | ||||
|     ADDRESS_STREET_NUMBER, | ||||
|     ADDRESS_FLOOR, | ||||
|     ADDRESS_CORRIDOR, | ||||
|     ADDRESS_STEPS, | ||||
|     ADDRESS_FLAT, | ||||
|     ADDRESS_BUILDING_NAME, | ||||
|     ADDRESS_DISTRIBUTION, | ||||
|     ADDRESS_EXTRA, | ||||
|     ADDRESS_FILL_AN_ADDRESS, | ||||
|     trans, | ||||
|   ADDRESS_STREET, | ||||
|   ADDRESS_STREET_NUMBER, | ||||
|   ADDRESS_FLOOR, | ||||
|   ADDRESS_CORRIDOR, | ||||
|   ADDRESS_STEPS, | ||||
|   ADDRESS_FLAT, | ||||
|   ADDRESS_BUILDING_NAME, | ||||
|   ADDRESS_DISTRIBUTION, | ||||
|   ADDRESS_EXTRA, | ||||
|   ADDRESS_FILL_AN_ADDRESS, | ||||
|   trans, | ||||
| } from "translator"; | ||||
| export default { | ||||
|   name: "AddressMore", | ||||
|   setup() { | ||||
|         return { | ||||
|             ADDRESS_STREET, | ||||
|             ADDRESS_STREET_NUMBER, | ||||
|             ADDRESS_FLOOR, | ||||
|             ADDRESS_CORRIDOR, | ||||
|             ADDRESS_STEPS, | ||||
|             ADDRESS_FLAT, | ||||
|             ADDRESS_BUILDING_NAME, | ||||
|             ADDRESS_DISTRIBUTION, | ||||
|             ADDRESS_EXTRA, | ||||
|             ADDRESS_FILL_AN_ADDRESS, | ||||
|             trans, | ||||
|         }; | ||||
|     },props: ["entity", "isNoAddress"], | ||||
|     return { | ||||
|       ADDRESS_STREET, | ||||
|       ADDRESS_STREET_NUMBER, | ||||
|       ADDRESS_FLOOR, | ||||
|       ADDRESS_CORRIDOR, | ||||
|       ADDRESS_STEPS, | ||||
|       ADDRESS_FLAT, | ||||
|       ADDRESS_BUILDING_NAME, | ||||
|       ADDRESS_DISTRIBUTION, | ||||
|       ADDRESS_EXTRA, | ||||
|       ADDRESS_FILL_AN_ADDRESS, | ||||
|       trans, | ||||
|     }; | ||||
|   }, | ||||
|   props: ["entity", "isNoAddress"], | ||||
|   computed: { | ||||
|     floor: { | ||||
|       set(value) { | ||||
|   | ||||
| @@ -57,9 +57,7 @@ | ||||
|           :placeholder="trans(ADDRESS_STREET_NUMBER)" | ||||
|           v-model="streetNumber" | ||||
|         /> | ||||
|         <label for="streetNumber">{{ | ||||
|                     trans(ADDRESS_STREET_NUMBER) | ||||
|                 }}</label> | ||||
|         <label for="streetNumber">{{ trans(ADDRESS_STREET_NUMBER) }}</label> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| @@ -72,31 +70,32 @@ import { | ||||
|   fetchReferenceAddresses, | ||||
| } from "../../api.js"; | ||||
| import { | ||||
|     ADDRESS_STREET, | ||||
|     ADDRESS_STREET_NUMBER, | ||||
|     ADDRESS_ADDRESS, | ||||
|     MULTISELECT_SELECTED_LABEL, | ||||
|     MULTISELECT_SELECT_LABEL, | ||||
|     ADDRESS_SELECT_ADDRESS, | ||||
|     ADDRESS_CREATE_ADDRESS, | ||||
|     trans, | ||||
|   ADDRESS_STREET, | ||||
|   ADDRESS_STREET_NUMBER, | ||||
|   ADDRESS_ADDRESS, | ||||
|   MULTISELECT_SELECTED_LABEL, | ||||
|   MULTISELECT_SELECT_LABEL, | ||||
|   ADDRESS_SELECT_ADDRESS, | ||||
|   ADDRESS_CREATE_ADDRESS, | ||||
|   trans, | ||||
| } from "translator"; | ||||
|  | ||||
| export default { | ||||
|   name: "AddressSelection", | ||||
|   components: { VueMultiselect }, | ||||
|   setup() { | ||||
|         return { | ||||
|             ADDRESS_STREET, | ||||
|             ADDRESS_STREET_NUMBER, | ||||
|             ADDRESS_ADDRESS, | ||||
|             MULTISELECT_SELECTED_LABEL, | ||||
|             MULTISELECT_SELECT_LABEL, | ||||
|             ADDRESS_SELECT_ADDRESS, | ||||
|             ADDRESS_CREATE_ADDRESS, | ||||
|             trans, | ||||
|         }; | ||||
|     },props: ["entity", "context", "updateMapCenter", "flag", "checkErrors"], | ||||
|     return { | ||||
|       ADDRESS_STREET, | ||||
|       ADDRESS_STREET_NUMBER, | ||||
|       ADDRESS_ADDRESS, | ||||
|       MULTISELECT_SELECTED_LABEL, | ||||
|       MULTISELECT_SELECT_LABEL, | ||||
|       ADDRESS_SELECT_ADDRESS, | ||||
|       ADDRESS_CREATE_ADDRESS, | ||||
|       trans, | ||||
|     }; | ||||
|   }, | ||||
|   props: ["entity", "context", "updateMapCenter", "flag", "checkErrors"], | ||||
|   data() { | ||||
|     return { | ||||
|       value: this.context.edit ? this.entity.address.addressReference : null, | ||||
|   | ||||
| @@ -61,31 +61,32 @@ | ||||
| import VueMultiselect from "vue-multiselect"; | ||||
| import { searchCities, fetchCities } from "../../api.js"; | ||||
| import { | ||||
|     MULTISELECT_SELECTED_LABEL, | ||||
|     MULTISELECT_SELECT_LABEL, | ||||
|     ADDRESS_POSTAL_CODE_CODE, | ||||
|     ADDRESS_POSTAL_CODE_NAME, | ||||
|     ADDRESS_CREATE_POSTAL_CODE, | ||||
|     ADDRESS_CITY, | ||||
|     ADDRESS_SELECT_CITY, | ||||
|     trans, | ||||
|   MULTISELECT_SELECTED_LABEL, | ||||
|   MULTISELECT_SELECT_LABEL, | ||||
|   ADDRESS_POSTAL_CODE_CODE, | ||||
|   ADDRESS_POSTAL_CODE_NAME, | ||||
|   ADDRESS_CREATE_POSTAL_CODE, | ||||
|   ADDRESS_CITY, | ||||
|   ADDRESS_SELECT_CITY, | ||||
|   trans, | ||||
| } from "translator"; | ||||
|  | ||||
| export default { | ||||
|   name: "CitySelection", | ||||
|   components: { VueMultiselect }, | ||||
|   setup() { | ||||
|         return { | ||||
|             MULTISELECT_SELECTED_LABEL, | ||||
|             MULTISELECT_SELECT_LABEL, | ||||
|             ADDRESS_CITY, | ||||
|             ADDRESS_SELECT_CITY, | ||||
|             ADDRESS_POSTAL_CODE_CODE, | ||||
|             ADDRESS_POSTAL_CODE_NAME, | ||||
|             ADDRESS_CREATE_POSTAL_CODE, | ||||
|             trans, | ||||
|         }; | ||||
|     },props: [ | ||||
|     return { | ||||
|       MULTISELECT_SELECTED_LABEL, | ||||
|       MULTISELECT_SELECT_LABEL, | ||||
|       ADDRESS_CITY, | ||||
|       ADDRESS_SELECT_CITY, | ||||
|       ADDRESS_POSTAL_CODE_CODE, | ||||
|       ADDRESS_POSTAL_CODE_NAME, | ||||
|       ADDRESS_CREATE_POSTAL_CODE, | ||||
|       trans, | ||||
|     }; | ||||
|   }, | ||||
|   props: [ | ||||
|     "entity", | ||||
|     "context", | ||||
|     "focusOnAddress", | ||||
|   | ||||
| @@ -24,27 +24,28 @@ | ||||
| import VueMultiselect from "vue-multiselect"; | ||||
| import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper"; | ||||
| import { | ||||
|     MULTISELECT_SELECTED_LABEL, | ||||
|     MULTISELECT_SELECT_LABEL, | ||||
|     MULTISELECT_DESELECT_LABEL, | ||||
|     ADDRESS_COUNTRY, | ||||
|     ADDRESS_SELECT_COUNTRY, | ||||
|     trans, | ||||
|   MULTISELECT_SELECTED_LABEL, | ||||
|   MULTISELECT_SELECT_LABEL, | ||||
|   MULTISELECT_DESELECT_LABEL, | ||||
|   ADDRESS_COUNTRY, | ||||
|   ADDRESS_SELECT_COUNTRY, | ||||
|   trans, | ||||
| } from "translator"; | ||||
|  | ||||
| export default { | ||||
|   name: "CountrySelection", | ||||
|   components: { VueMultiselect }, | ||||
|   setup() { | ||||
|         return { | ||||
|             MULTISELECT_SELECTED_LABEL, | ||||
|             MULTISELECT_SELECT_LABEL, | ||||
|             MULTISELECT_DESELECT_LABEL, | ||||
|             ADDRESS_COUNTRY, | ||||
|             ADDRESS_SELECT_COUNTRY, | ||||
|             trans, | ||||
|         }; | ||||
|     },props: ["context", "entity", "flag", "checkErrors"], | ||||
|     return { | ||||
|       MULTISELECT_SELECTED_LABEL, | ||||
|       MULTISELECT_SELECT_LABEL, | ||||
|       MULTISELECT_DESELECT_LABEL, | ||||
|       ADDRESS_COUNTRY, | ||||
|       ADDRESS_SELECT_COUNTRY, | ||||
|       trans, | ||||
|     }; | ||||
|   }, | ||||
|   props: ["context", "entity", "flag", "checkErrors"], | ||||
|   emits: ["getCities"], | ||||
|   data() { | ||||
|     return { | ||||
|   | ||||
| @@ -106,10 +106,10 @@ import AddressMap from "./AddAddress/AddressMap"; | ||||
| import AddressMore from "./AddAddress/AddressMore"; | ||||
| import ActionButtons from "./ActionButtons.vue"; | ||||
| import { | ||||
|     ADDRESS_SELECT_AN_ADDRESS_TITLE, | ||||
|     ADDRESS_IS_CONFIDENTIAL, | ||||
|     ADDRESS_IS_NO_ADDRESS, | ||||
|     trans, | ||||
|   ADDRESS_SELECT_AN_ADDRESS_TITLE, | ||||
|   ADDRESS_IS_CONFIDENTIAL, | ||||
|   ADDRESS_IS_NO_ADDRESS, | ||||
|   trans, | ||||
| } from "translator"; | ||||
|  | ||||
| export default { | ||||
| @@ -123,13 +123,14 @@ export default { | ||||
|     ActionButtons, | ||||
|   }, | ||||
|   setup() { | ||||
|         return { | ||||
|             trans, | ||||
|             ADDRESS_SELECT_AN_ADDRESS_TITLE, | ||||
|             ADDRESS_IS_CONFIDENTIAL, | ||||
|             ADDRESS_IS_NO_ADDRESS, | ||||
|         }; | ||||
|     },props: [ | ||||
|     return { | ||||
|       trans, | ||||
|       ADDRESS_SELECT_AN_ADDRESS_TITLE, | ||||
|       ADDRESS_IS_CONFIDENTIAL, | ||||
|       ADDRESS_IS_NO_ADDRESS, | ||||
|     }; | ||||
|   }, | ||||
|   props: [ | ||||
|     "context", | ||||
|     "options", | ||||
|     "defaultz", | ||||
|   | ||||
| @@ -11,9 +11,7 @@ | ||||
|  | ||||
|     <div v-if="flag.success" class="alert alert-success"> | ||||
|       {{ trans(getSuccessText) }} | ||||
|       <span v-if="forceRedirect">{{ | ||||
|                 trans(ADDRESS_WAIT_REDIRECTION) | ||||
|             }}</span> | ||||
|       <span v-if="forceRedirect">{{ trans(ADDRESS_WAIT_REDIRECTION) }}</span> | ||||
|     </div> | ||||
|  | ||||
|     <div | ||||
| @@ -101,34 +99,36 @@ | ||||
| import AddressRenderBox from "ChillMainAssets/vuejs/_components/Entity/AddressRenderBox.vue"; | ||||
| import ActionButtons from "./ActionButtons.vue"; | ||||
| import { | ||||
|     ACTIVITY_CREATE_ADDRESS, | ||||
|     ACTIVITY_EDIT_ADDRESS, | ||||
|     ADDRESS_NOT_YET_ADDRESS, | ||||
|     ADDRESS_WAIT_REDIRECTION, | ||||
|     ADDRESS_LOADING, | ||||
|     ADDRESS_ADDRESS_EDIT_SUCCESS, | ||||
|     ADDRESS_ADDRESS_NEW_SUCCESS, | ||||
|     trans, | ||||
|   ACTIVITY_CREATE_ADDRESS, | ||||
|   ACTIVITY_EDIT_ADDRESS, | ||||
|   ADDRESS_NOT_YET_ADDRESS, | ||||
|   ADDRESS_WAIT_REDIRECTION, | ||||
|   ADDRESS_LOADING, | ||||
|   ADDRESS_ADDRESS_EDIT_SUCCESS, | ||||
|   ADDRESS_ADDRESS_NEW_SUCCESS, | ||||
|   trans, | ||||
| } from "translator"; | ||||
|  | ||||
| export default { | ||||
|   name: "ShowPane", | ||||
|   methods: {},components: { | ||||
|   methods: {}, | ||||
|   components: { | ||||
|     AddressRenderBox, | ||||
|     ActionButtons, | ||||
|   }, | ||||
|   setup() { | ||||
|         return { | ||||
|             trans, | ||||
|             ACTIVITY_CREATE_ADDRESS, | ||||
|             ACTIVITY_EDIT_ADDRESS, | ||||
|             ADDRESS_NOT_YET_ADDRESS, | ||||
|             ADDRESS_WAIT_REDIRECTION, | ||||
|             ADDRESS_LOADING, | ||||
|             ADDRESS_ADDRESS_NEW_SUCCESS, | ||||
|             ADDRESS_ADDRESS_EDIT_SUCCESS, | ||||
|         }; | ||||
|     },props: [ | ||||
|     return { | ||||
|       trans, | ||||
|       ACTIVITY_CREATE_ADDRESS, | ||||
|       ACTIVITY_EDIT_ADDRESS, | ||||
|       ADDRESS_NOT_YET_ADDRESS, | ||||
|       ADDRESS_WAIT_REDIRECTION, | ||||
|       ADDRESS_LOADING, | ||||
|       ADDRESS_ADDRESS_NEW_SUCCESS, | ||||
|       ADDRESS_ADDRESS_EDIT_SUCCESS, | ||||
|     }; | ||||
|   }, | ||||
|   props: [ | ||||
|     "context", | ||||
|     "defaultz", | ||||
|     "options", | ||||
| @@ -169,17 +169,19 @@ export default { | ||||
|           this.options.button.text.create !== null) | ||||
|       ) { | ||||
|         // console.log('this.options.button.text', this.options.button.text) | ||||
|                 return this.context.edit | ||||
|       ? ACTIVITY_CREATE_ADDRESS | ||||
|                     : ACTIVITY_EDIT_ADDRESS; | ||||
|             } | ||||
|             console.log("defaultz", this.defaultz); | ||||
|         return this.context.edit | ||||
|           ? ACTIVITY_CREATE_ADDRESS | ||||
|           : ACTIVITY_EDIT_ADDRESS; | ||||
|       } | ||||
|       console.log("defaultz", this.defaultz); | ||||
|       return this.context.edit | ||||
|         ? this.defaultz.button.text.edit | ||||
|         : this.defaultz.button.text.create; | ||||
|     }, | ||||
|     getSuccessText() { | ||||
|       return this.context.edit ? ADDRESS_ADDRESS_EDIT_SUCCESS : ADDRESS_ADDRESS_NEW_SUCCESS; | ||||
|       return this.context.edit | ||||
|         ? ADDRESS_ADDRESS_EDIT_SUCCESS | ||||
|         : ADDRESS_ADDRESS_NEW_SUCCESS; | ||||
|     }, | ||||
|     onlyButton() { | ||||
|       return typeof this.options.onlyButton !== "undefined" | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| <template> | ||||
|   <ul class="nav nav-tabs"> | ||||
|     <li v-if="allowedTypes.includes('person')" class="nav-item"> | ||||
|     <li v-if="containsPerson" class="nav-item"> | ||||
|       <a class="nav-link" :class="{ active: isActive('person') }"> | ||||
|         <label for="person"> | ||||
|           <input | ||||
| @@ -14,7 +14,7 @@ | ||||
|         </label> | ||||
|       </a> | ||||
|     </li> | ||||
|     <li v-if="allowedTypes.includes('thirdparty')" class="nav-item"> | ||||
|     <li v-if="containsThirdParty" class="nav-item"> | ||||
|       <a class="nav-link" :class="{ active: isActive('thirdparty') }"> | ||||
|         <label for="thirdparty"> | ||||
|           <input | ||||
| @@ -31,49 +31,65 @@ | ||||
|   </ul> | ||||
|  | ||||
|   <div class="my-4"> | ||||
|     <on-the-fly-person | ||||
|     <PersonEdit | ||||
|       v-if="type === 'person'" | ||||
|       :action="action" | ||||
|       action="create" | ||||
|       :query="query" | ||||
|       ref="castPerson" | ||||
|       @onPersonCreated="onPersonCreated" | ||||
|     /> | ||||
|  | ||||
|     <on-the-fly-thirdparty | ||||
|     <ThirdPartyEdit | ||||
|       v-if="type === 'thirdparty'" | ||||
|       :action="action" | ||||
|       :query="query" | ||||
|       :parent="parent" | ||||
|       ref="castThirdparty" | ||||
|       @onThirdPartyCreated="onThirdPartyCreated" | ||||
|       type="" | ||||
|     /> | ||||
|   </div> | ||||
| </template> | ||||
| <script setup> | ||||
| import { ref, computed, onMounted } from "vue"; | ||||
| import OnTheFlyPerson from "ChillPersonAssets/vuejs/_components/OnTheFly/Person.vue"; | ||||
| import OnTheFlyThirdparty from "ChillThirdPartyAssets/vuejs/_components/OnTheFly/ThirdParty.vue"; | ||||
| <script setup lang="ts"> | ||||
| import { computed, onMounted, ref } from "vue"; | ||||
| import { | ||||
|   trans, | ||||
|   ONTHEFLY_CREATE_PERSON, | ||||
|   ONTHEFLY_CREATE_THIRDPARTY, | ||||
|   trans, | ||||
| } from "translator"; | ||||
| import { CreatableEntityType, Person } from "ChillPersonAssets/types"; | ||||
| import { CreateComponentConfig } from "ChillMainAssets/types"; | ||||
| import PersonEdit from "ChillPersonAssets/vuejs/_components/OnTheFly/PersonEdit.vue"; | ||||
| import ThirdPartyEdit from "ChillThirdPartyAssets/vuejs/_components/OnTheFly/ThirdPartyEdit.vue"; | ||||
| import {Thirdparty} from "../../../../../../ChillThirdPartyBundle/Resources/public/types"; | ||||
|  | ||||
| const props = defineProps({ | ||||
|   action: String, | ||||
|   allowedTypes: Array, | ||||
|   query: String, | ||||
| const props = withDefaults(defineProps<CreateComponentConfig>(), { | ||||
|   action: "create", | ||||
|   query: "", | ||||
|   parent: null, | ||||
| }); | ||||
|  | ||||
| const type = ref(null); | ||||
| const emit = | ||||
|   defineEmits<{ | ||||
|     (e: "onPersonCreated", payload: { person: Person }): void, | ||||
|     (e: "onThirdPartyCreated", payload: { thirdParty: Thirdparty }): void, | ||||
|   }>(); | ||||
|  | ||||
| const radioType = computed({ | ||||
| const type = ref<CreatableEntityType | null>(null); | ||||
|  | ||||
| const radioType = computed<CreatableEntityType | null>({ | ||||
|   get: () => type.value, | ||||
|   set: (val) => { | ||||
|   set: (val: CreatableEntityType | null) => { | ||||
|     type.value = val; | ||||
|     console.log("## type:", val, ", action:", props.action); | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const castPerson = ref(null); | ||||
| const castThirdparty = ref(null); | ||||
| type PersonEditComponent = InstanceType<typeof PersonEdit>; | ||||
| type ThirdPartyEditComponent = InstanceType<typeof ThirdPartyEdit>; | ||||
|  | ||||
| const castPerson = ref<PersonEditComponent|null>(null); | ||||
| const castThirdparty = ref<ThirdPartyEditComponent|null>(null); | ||||
|  | ||||
| onMounted(() => { | ||||
|   type.value = | ||||
| @@ -82,30 +98,37 @@ onMounted(() => { | ||||
|       : "person"; | ||||
| }); | ||||
|  | ||||
| function isActive(tab) { | ||||
| function isActive(tab: CreatableEntityType) { | ||||
|   return type.value === tab; | ||||
| } | ||||
|  | ||||
| function castDataByType() { | ||||
|   switch (radioType.value) { | ||||
|     case "person": | ||||
|       return castPerson.value.$data.person; | ||||
|     case "thirdparty": | ||||
|       let data = castThirdparty.value.$data.thirdparty; | ||||
|       if (data.address !== undefined && data.address !== null) { | ||||
|         data.address = { id: data.address.address_id }; | ||||
|       } else { | ||||
|         data.address = null; | ||||
|       } | ||||
|       return data; | ||||
|     default: | ||||
|       throw Error("Invalid type of entity"); | ||||
| const containsThirdParty = computed<boolean>(() => | ||||
|   props.allowedTypes.includes("thirdparty"), | ||||
| ); | ||||
| const containsPerson = computed<boolean>(() => { | ||||
|   if (props.action === 'addContact') { | ||||
|     return false; | ||||
|   } | ||||
|   return props.allowedTypes.includes("person"); | ||||
| }); | ||||
|  | ||||
| function save(): void { | ||||
|   if (radioType.value === "person" && castPerson.value !== null) { | ||||
|     castPerson.value.postPerson(); | ||||
|   } else if (radioType.value === "thirdparty" && castThirdparty.value !== null) { | ||||
|     castThirdparty.value.postThirdParty(); | ||||
|   } | ||||
| } | ||||
|  | ||||
| defineExpose({ | ||||
|   castDataByType, | ||||
| }); | ||||
| function onThirdPartyCreated(payload: { thirdParty: Thirdparty }) { | ||||
|   emit("onThirdPartyCreated", payload); | ||||
| } | ||||
|  | ||||
| function onPersonCreated(payload: { person: Person }) { | ||||
|   emit("onPersonCreated", payload); | ||||
| } | ||||
|  | ||||
| defineExpose({ save }); | ||||
| </script> | ||||
|  | ||||
| <style lang="css" scoped> | ||||
|   | ||||
| @@ -0,0 +1,71 @@ | ||||
| <script setup lang="ts"> | ||||
| import Modal from "ChillMainAssets/vuejs/_components/Modal.vue"; | ||||
| import Create from "ChillMainAssets/vuejs/OnTheFly/components/Create.vue"; | ||||
| import { CreateComponentConfig } from "ChillMainAssets/types"; | ||||
| import { trans, SAVE } from "translator"; | ||||
| import { useTemplateRef } from "vue"; | ||||
| import { Person } from "ChillPersonAssets/types"; | ||||
| import {Thirdparty} from "../../../../../../ChillThirdPartyBundle/Resources/public/types"; | ||||
|  | ||||
| const emit = defineEmits<{ | ||||
|   (e: "onPersonCreated", payload: { person: Person }): void; | ||||
|   (e: "onThirdPartyCreated", payload: { thirdParty: Thirdparty }): void; | ||||
|   (e: "close"): void; | ||||
| }>(); | ||||
|  | ||||
| const props = defineProps<CreateComponentConfig & {modalTitle: string}>(); | ||||
| const modalDialogClass = { "modal-xl": true, "modal-scrollable": true }; | ||||
|  | ||||
| type CreateComponentType = InstanceType<typeof Create>; | ||||
|  | ||||
| const create = useTemplateRef<CreateComponentType>("create"); | ||||
|  | ||||
| const onPersonCreated = ({person}: {person: Person}): void => { | ||||
|   emit("onPersonCreated", {person}); | ||||
| }; | ||||
|  | ||||
| const onThirdPartyCreated = ({thirdParty}: {thirdParty: Thirdparty}): void => { | ||||
|   emit("onThirdPartyCreated", {thirdParty: thirdParty}); | ||||
| } | ||||
|  | ||||
| function save(): void { | ||||
|   console.log("save from CreateModal"); | ||||
|   create.value?.save(); | ||||
| } | ||||
|  | ||||
| defineExpose({ save }); | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|   <teleport to="body"> | ||||
|     <modal | ||||
|       @close="() => emit('close')" | ||||
|       :modal-dialog-class="modalDialogClass" | ||||
|       :hide-footer="false" | ||||
|     > | ||||
|       <template #header> | ||||
|         <h3 class="modal-title">{{ modalTitle }}</h3> | ||||
|       </template> | ||||
|       <template #body-head> | ||||
|         <div class="modal-body"> | ||||
|           <Create | ||||
|             ref="create" | ||||
|             :allowedTypes="props.allowedTypes" | ||||
|             :action="props.action" | ||||
|             :query="props.query" | ||||
|             :parent="props.parent" | ||||
|             @onPersonCreated="onPersonCreated" | ||||
|             @onThirdPartyCreated="onThirdPartyCreated" | ||||
|           ></Create> | ||||
|         </div> | ||||
|       </template> | ||||
|       <template #footer> | ||||
|         <button class="btn btn-save" type="button" @click.prevent="save"> | ||||
|           {{ trans(SAVE) }} | ||||
|         </button> | ||||
|       </template> | ||||
|     </modal> | ||||
|   </teleport> | ||||
| </template> | ||||
|  | ||||
| <style scoped lang="scss"></style> | ||||
| @@ -9,7 +9,7 @@ | ||||
|     class="btn btn-sm" | ||||
|     target="_blank" | ||||
|     :class="classAction" | ||||
|     :title="trans(titleAction)" | ||||
|     :title="titleAction" | ||||
|     @click="openModal" | ||||
|   > | ||||
|     {{ buttonText }}<span v-if="isDead"> (‡)</span> | ||||
| @@ -23,14 +23,14 @@ | ||||
|     > | ||||
|       <template #header> | ||||
|         <h3 v-if="parent" class="modal-title"> | ||||
|           {{ trans(titleModal, { q: parent.text }) }} | ||||
|           {{ titleModal }} | ||||
|         </h3> | ||||
|         <h3 v-else class="modal-title"> | ||||
|           {{ trans(titleModal) }} | ||||
|           {{ titleModal }} | ||||
|         </h3> | ||||
|       </template> | ||||
|  | ||||
|       <template #body v-if="type === 'person'"> | ||||
|       <template #body v-if="type === 'person' && action === 'show'"> | ||||
|         <on-the-fly-person | ||||
|           :id="id" | ||||
|           :type="type" | ||||
| @@ -40,12 +40,21 @@ | ||||
|         <div v-if="hasResourceComment"> | ||||
|           <h3>{{ trans(ONTHEFLY_RESOURCE_COMMENT_TITLE) }}</h3> | ||||
|           <blockquote class="chill-user-quote"> | ||||
|             {{ parent.comment }} | ||||
|             {{ parent?.comment }} | ||||
|           </blockquote> | ||||
|         </div> | ||||
|       </template> | ||||
|  | ||||
|       <template #body v-else-if="type === 'thirdparty'"> | ||||
|       <template #body v-else-if="type === 'person' && action === 'edit'"> | ||||
|         <PersonEdit | ||||
|           :id="id" | ||||
|           :action="'edit'" | ||||
|           :query="''" | ||||
|           ref="castEditPerson" | ||||
|           ></PersonEdit> | ||||
|       </template> | ||||
|  | ||||
|       <template #body v-else-if="type === 'thirdparty' && action === 'show'"> | ||||
|         <on-the-fly-thirdparty | ||||
|           :id="id" | ||||
|           :type="type" | ||||
| @@ -55,11 +64,15 @@ | ||||
|         <div v-if="hasResourceComment"> | ||||
|           <h3>{{ trans(ONTHEFLY_RESOURCE_COMMENT_TITLE) }}</h3> | ||||
|           <blockquote class="chill-user-quote"> | ||||
|             {{ parent.comment }} | ||||
|             {{ parent?.comment }} | ||||
|           </blockquote> | ||||
|         </div> | ||||
|       </template> | ||||
|  | ||||
|       <template #body v-else-if="type === 'thirdparty' && action === 'edit'"> | ||||
|         <ThirdPartyEdit ref="castEditThirdParty" action="edit" :id="id"></ThirdPartyEdit> | ||||
|       </template> | ||||
|  | ||||
|       <template #body v-else-if="parent"> | ||||
|         <on-the-fly-thirdparty | ||||
|           :parent="parent" | ||||
| @@ -73,7 +86,7 @@ | ||||
|         <on-the-fly-create | ||||
|           :action="action" | ||||
|           :allowed-types="allowedTypes" | ||||
|           :query="query" | ||||
|           :query="query || ''" | ||||
|           ref="castNew" | ||||
|         /> | ||||
|       </template> | ||||
| @@ -82,9 +95,9 @@ | ||||
|         <a | ||||
|           v-if="action === 'show'" | ||||
|           :href="buildLocation(id, type)" | ||||
|           :title="trans(titleMessage)" | ||||
|           :title="titleMessage" | ||||
|           class="btn btn-show" | ||||
|           >{{ trans(buttonMessage) }} | ||||
|           >{{ buttonMessage }} | ||||
|         </a> | ||||
|         <a v-else class="btn btn-save" @click="saveAction"> | ||||
|           {{ trans(ACTION_SAVE) }} | ||||
| @@ -93,8 +106,8 @@ | ||||
|     </modal> | ||||
|   </teleport> | ||||
| </template> | ||||
| <script setup> | ||||
| import { ref, computed, defineEmits, defineProps } from "vue"; | ||||
| <script setup lang="ts"> | ||||
| import {ref, computed, defineEmits, defineProps, useTemplateRef} from "vue"; | ||||
| import Modal from "ChillMainAssets/vuejs/_components/Modal.vue"; | ||||
| import OnTheFlyCreate from "./Create.vue"; | ||||
| import OnTheFlyPerson from "ChillPersonAssets/vuejs/_components/OnTheFly/Person.vue"; | ||||
| @@ -104,7 +117,7 @@ import { | ||||
|   ACTION_SHOW, | ||||
|   ACTION_EDIT, | ||||
|   ACTION_CREATE, | ||||
|   ACTION_ADDCONTACT, | ||||
|   ACTION_SAVE, | ||||
|   ONTHEFLY_CREATE_TITLE_DEFAULT, | ||||
|   ONTHEFLY_CREATE_TITLE_PERSON, | ||||
|   ONTHEFLY_CREATE_TITLE_THIRDPARTY, | ||||
| @@ -112,40 +125,66 @@ import { | ||||
|   ONTHEFLY_SHOW_THIRDPARTY, | ||||
|   ONTHEFLY_EDIT_PERSON, | ||||
|   ONTHEFLY_EDIT_THIRDPARTY, | ||||
|   ONTHEFLY_ADDCONTACT_TITLE, | ||||
|   ACTION_REDIRECT_PERSON, | ||||
|   ACTION_REDIRECT_THIRDPARTY, | ||||
|   ONTHEFLY_SHOW_FILE_PERSON, | ||||
|   ONTHEFLY_SHOW_FILE_THIRDPARTY, | ||||
|   ONTHEFLY_SHOW_FILE_DEFAULT, | ||||
|   ONTHEFLY_RESOURCE_COMMENT_TITLE, | ||||
|   ACTION_SAVE, | ||||
|   THIRDPARTY_ADDCONTACT, | ||||
|   THIRDPARTY_ADDCONTACT_TITLE, | ||||
| } from "translator"; | ||||
| import PersonEdit from "ChillPersonAssets/vuejs/_components/OnTheFly/PersonEdit.vue"; | ||||
| import ThirdPartyEdit from "ChillThirdPartyAssets/vuejs/_components/OnTheFly/ThirdPartyEdit.vue"; | ||||
| import ThirdParty from "ChillThirdPartyAssets/vuejs/_components/OnTheFly/ThirdParty.vue"; | ||||
|  | ||||
| const props = defineProps({ | ||||
|   type: String, | ||||
|   id: [String, Number], | ||||
|   action: String, | ||||
|   buttonText: String, | ||||
|   displayBadge: Boolean, | ||||
|   isDead: Boolean, | ||||
|   parent: Object, | ||||
|   allowedTypes: Array, | ||||
|   query: String, | ||||
| // Types | ||||
| type EntityType = "person" | "thirdparty"; | ||||
| type ActionType = "show" | "edit" | "create" | "addContact"; | ||||
|  | ||||
| interface ParentRef { | ||||
|   type: string; | ||||
|   id: string | number; | ||||
|   text?: string; | ||||
|   comment?: string | null; | ||||
| } | ||||
|  | ||||
| interface OnTheFlyComponentProps { | ||||
|   type: EntityType; | ||||
|   id: number; | ||||
|   action: ActionType; | ||||
|   buttonText?: string | null; | ||||
|   displayBadge?: boolean; | ||||
|   isDead?: boolean; | ||||
|   parent?: ParentRef | null; | ||||
|   allowedTypes?: EntityType[]; | ||||
|   query?: string; | ||||
| } | ||||
|  | ||||
| const props = withDefaults(defineProps<OnTheFlyComponentProps>(), { | ||||
|   buttonText: null, | ||||
|   displayBadge: false, | ||||
|   isDead: false, | ||||
|   parent: null, | ||||
|   allowedTypes: () => ["person", "thirdparty"] as EntityType[], | ||||
|   query: "", | ||||
| }); | ||||
|  | ||||
| const emit = defineEmits(["saveFormOnTheFly"]); | ||||
| const emit = defineEmits<{ | ||||
|   (e: "saveFormOnTheFly", payload: { type: string | undefined; data: any }): void; | ||||
| }>(); | ||||
|  | ||||
| const modal = ref({ | ||||
| type castEditPersonType = InstanceType<typeof PersonEdit>; | ||||
| type castEditThirdPartyType = InstanceType<typeof ThirdParty>; | ||||
| const castEditPerson = useTemplateRef<castEditPersonType>('castEditPerson') | ||||
| const castEditThirdParty = useTemplateRef<castEditThirdPartyType>('castEditThirdParty'); | ||||
|  | ||||
| const modal = ref<{ showModal: boolean; modalDialogClass: string }>({ | ||||
|   showModal: false, | ||||
|   modalDialogClass: "modal-dialog-scrollable modal-xl", | ||||
| }); | ||||
|  | ||||
| const castPerson = ref(); | ||||
| const castThirdparty = ref(); | ||||
| const castNew = ref(); | ||||
|  | ||||
| const hasResourceComment = computed(() => { | ||||
| const hasResourceComment = computed<boolean>(() => { | ||||
|   return ( | ||||
|     typeof props.parent !== "undefined" && | ||||
|     props.parent !== null && | ||||
| @@ -156,7 +195,7 @@ const hasResourceComment = computed(() => { | ||||
|   ); | ||||
| }); | ||||
|  | ||||
| const classAction = computed(() => { | ||||
| const classAction = computed<string>(() => { | ||||
|   switch (props.action) { | ||||
|     case "show": | ||||
|       return "btn-show"; | ||||
| @@ -171,174 +210,127 @@ const classAction = computed(() => { | ||||
|   } | ||||
| }); | ||||
|  | ||||
| const titleAction = computed(() => { | ||||
| const titleAction = computed<string>(() => { | ||||
|   switch (props.action) { | ||||
|     case "show": | ||||
|       return ACTION_SHOW; | ||||
|       return ACTION_SHOW as unknown as string; | ||||
|     case "edit": | ||||
|       return ACTION_EDIT; | ||||
|       return ACTION_EDIT as unknown as string; | ||||
|     case "create": | ||||
|       return ACTION_CREATE; | ||||
|       return ACTION_CREATE as unknown as string; | ||||
|     case "addContact": | ||||
|       return ACTION_ADDCONTACT; | ||||
|       return THIRDPARTY_ADDCONTACT as unknown as string; | ||||
|     default: | ||||
|       return ""; | ||||
|   } | ||||
| }); | ||||
|  | ||||
| const titleCreate = computed(() => { | ||||
| const titleCreate = computed<string>(() => { | ||||
|   if (typeof props.allowedTypes === "undefined") { | ||||
|     return ONTHEFLY_CREATE_TITLE_DEFAULT; | ||||
|     return trans(ONTHEFLY_CREATE_TITLE_DEFAULT) | ||||
|   } | ||||
|   return props.allowedTypes.every((t) => t === "person") | ||||
|     ? ONTHEFLY_CREATE_TITLE_PERSON | ||||
|     : props.allowedTypes.every((t) => t === "thirdparty") | ||||
|       ? ONTHEFLY_CREATE_TITLE_THIRDPARTY | ||||
|       : ONTHEFLY_CREATE_TITLE_DEFAULT; | ||||
|   return props.allowedTypes.every((t: EntityType) => t === "person") | ||||
|     ? (trans(ONTHEFLY_CREATE_TITLE_PERSON)) | ||||
|     : props.allowedTypes.every((t: EntityType) => t === "thirdparty") | ||||
|       ? (trans(ONTHEFLY_CREATE_TITLE_THIRDPARTY)) | ||||
|       : (trans(ONTHEFLY_CREATE_TITLE_DEFAULT)); | ||||
| }); | ||||
|  | ||||
| const titleModal = computed(() => { | ||||
| const titleModal = computed<string>(() => { | ||||
|   switch (props.action) { | ||||
|     case "show": | ||||
|       if (props.type == "person") { | ||||
|         return ONTHEFLY_SHOW_PERSON; | ||||
|         return trans(ONTHEFLY_SHOW_PERSON) | ||||
|       } else if (props.type == "thirdparty") { | ||||
|         return ONTHEFLY_SHOW_THIRDPARTY; | ||||
|         return trans(ONTHEFLY_SHOW_THIRDPARTY) | ||||
|       } | ||||
|       break; | ||||
|     case "edit": | ||||
|       if (props.type == "person") { | ||||
|         return ONTHEFLY_EDIT_PERSON; | ||||
|         return trans(ONTHEFLY_EDIT_PERSON) | ||||
|       } else if (props.type == "thirdparty") { | ||||
|         return ONTHEFLY_EDIT_THIRDPARTY; | ||||
|         return trans(ONTHEFLY_EDIT_THIRDPARTY) | ||||
|       } | ||||
|       break; | ||||
|     case "create": | ||||
|       return titleCreate.value; | ||||
|     case "addContact": | ||||
|       return ONTHEFLY_ADDCONTACT_TITLE; | ||||
|       return trans(THIRDPARTY_ADDCONTACT_TITLE) | ||||
|     default: | ||||
|       break; | ||||
|   } | ||||
|   return ""; | ||||
| }); | ||||
|  | ||||
| const titleMessage = computed(() => { | ||||
| const titleMessage = computed<string>(() => { | ||||
|   switch (props.type) { | ||||
|     case "person": | ||||
|       return ACTION_REDIRECT_PERSON; | ||||
|       return trans(ACTION_REDIRECT_PERSON); | ||||
|     case "thirdparty": | ||||
|       return ACTION_REDIRECT_THIRDPARTY; | ||||
|       return trans(ACTION_REDIRECT_THIRDPARTY); | ||||
|     default: | ||||
|       return ""; | ||||
|   } | ||||
| }); | ||||
|  | ||||
| const buttonMessage = computed(() => { | ||||
| const buttonMessage = computed<string>(() => { | ||||
|   switch (props.type) { | ||||
|     case "person": | ||||
|       return ONTHEFLY_SHOW_FILE_PERSON; | ||||
|       return trans(ONTHEFLY_SHOW_FILE_PERSON); | ||||
|     case "thirdparty": | ||||
|       return ONTHEFLY_SHOW_FILE_THIRDPARTY; | ||||
|       return trans(ONTHEFLY_SHOW_FILE_THIRDPARTY); | ||||
|     default: | ||||
|       return ONTHEFLY_SHOW_FILE_DEFAULT; | ||||
|       return trans(ONTHEFLY_SHOW_FILE_DEFAULT); | ||||
|   } | ||||
| }); | ||||
|  | ||||
| const isDisplayBadge = computed(() => { | ||||
|   return props.displayBadge === true && props.buttonText !== null; | ||||
| const isDisplayBadge = computed<boolean>(() => { | ||||
|   return props.displayBadge && props.buttonText !== null; | ||||
| }); | ||||
|  | ||||
| const badgeType = computed(() => { | ||||
|   return "entity-" + props.type + " badge-" + props.type; | ||||
| const badgeType = computed<string>(() => { | ||||
|   return "entity-" + (props.type ?? "") + " badge-" + (props.type ?? ""); | ||||
| }); | ||||
|  | ||||
| const getReturnPath = computed(() => { | ||||
| const getReturnPath = computed<string>(() => { | ||||
|   return `?returnPath=${window.location.pathname}${window.location.search}${window.location.hash}`; | ||||
| }); | ||||
|  | ||||
| function closeModal() { | ||||
| function closeModal(): void { | ||||
|   modal.value.showModal = false; | ||||
| } | ||||
|  | ||||
| function openModal() { | ||||
| function openModal(): void { | ||||
|   modal.value.showModal = true; | ||||
| } | ||||
|  | ||||
| function changeActionTo(action) { | ||||
|   console.log(action); | ||||
|   // Not reactive in setup, but you can emit or use a ref if needed | ||||
| } | ||||
|  | ||||
| function saveAction() { | ||||
|   let type = props.type, | ||||
|     data = {}; | ||||
|   switch (type) { | ||||
|     case "person": | ||||
|       data = castPerson.value?.$data.person; | ||||
|       break; | ||||
|     case "thirdparty": | ||||
|       data = castThirdparty.value?.$data.thirdparty; | ||||
|       break; | ||||
|     default: | ||||
|       if (typeof props.type === "undefined") { | ||||
|         if (props.action === "addContact") { | ||||
|           type = "thirdparty"; | ||||
|           data = castThirdparty.value?.$data.thirdparty; | ||||
|           data.parent = { | ||||
|             type: "thirdparty", | ||||
|             id: props.parent.id, | ||||
|           }; | ||||
|           data.civility = | ||||
|             data.civility !== null | ||||
|               ? { | ||||
|                   type: "chill_main_civility", | ||||
|                   id: data.civility.id, | ||||
|                 } | ||||
|               : null; | ||||
|           data.profession = data.profession !== "" ? data.profession : ""; | ||||
|         } else { | ||||
|           type = castNew.value.radioType; | ||||
|           data = castNew.value.castDataByType(); | ||||
|           if (typeof data.civility !== "undefined" && null !== data.civility) { | ||||
|             data.civility = | ||||
|               data.civility !== null | ||||
|                 ? { | ||||
|                     type: "chill_main_civility", | ||||
|                     id: data.civility.id, | ||||
|                   } | ||||
|                 : null; | ||||
|           } | ||||
|           if ( | ||||
|             typeof data.profession !== "undefined" && | ||||
|             "" !== data.profession | ||||
|           ) { | ||||
|             data.profession = data.profession !== "" ? data.profession : ""; | ||||
|           } | ||||
|         } | ||||
|       } else { | ||||
|         throw "error with object type"; | ||||
|       } | ||||
|   } | ||||
|   emit("saveFormOnTheFly", { type: type, data: data }); | ||||
| } | ||||
|  | ||||
| function buildLocation(id, type) { | ||||
| function buildLocation(id: string | number | undefined, type: EntityType | undefined): string | undefined { | ||||
|   if (type === "person") { | ||||
|     return encodeURI(`/fr/person/${id}/general${getReturnPath.value}`); | ||||
|   } else if (type === "thirdparty") { | ||||
|     return encodeURI(`/fr/3party/3party/${id}/view${getReturnPath.value}`); | ||||
|   } | ||||
|   return undefined; | ||||
| } | ||||
|  | ||||
| async function saveAction() { | ||||
|   if (props.type === "person") { | ||||
|     const person = await castEditPerson.value?.postPerson(); | ||||
|     if (null !== person) { | ||||
|       emit("saveFormOnTheFly", {type: props.type, data: person}) | ||||
|     } | ||||
|   } else if (props.type === 'thirdparty') { | ||||
|     const thirdParty = await castEditThirdParty.value?.postThirdParty(); | ||||
|     if (null !== thirdParty) { | ||||
|       emit("saveFormOnTheFly", {type: props.type, data: thirdParty }) | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
|  | ||||
| defineExpose({ | ||||
|   openModal, | ||||
|   closeModal, | ||||
|   changeActionTo, | ||||
|   saveAction, | ||||
|   castPerson, | ||||
|   castThirdparty, | ||||
|   castNew, | ||||
|   hasResourceComment, | ||||
|   modal, | ||||
|   isDisplayBadge, | ||||
|   | ||||
| @@ -12,13 +12,20 @@ | ||||
|           >{{ trans(USER_CURRENT_USER) }}</span | ||||
|         > | ||||
|         <span | ||||
|           v-else | ||||
|           v-else-if="!isEntityHousehold(p)" | ||||
|           :class="getBadgeClass(p)" | ||||
|           class="chill_denomination" | ||||
|           :style="getBadgeStyle(p)" | ||||
|         > | ||||
|           {{ p.text }} | ||||
|         </span> | ||||
|         <span v-else | ||||
|               :class="getBadgeClass(p)" | ||||
|               class="chill_denomination" | ||||
|               :style="getBadgeStyle(p)" | ||||
|         > | ||||
|           Ménage n°{{ p.id }} | ||||
|         </span> | ||||
|       </li> | ||||
|     </ul> | ||||
|     <ul class="record_actions mb-0"> | ||||
| @@ -40,6 +47,7 @@ | ||||
|           :key="uniqid" | ||||
|           :buttonTitle="translatedListOfTypes" | ||||
|           :modalTitle="translatedListOfTypes" | ||||
|           :allowCreate="true" | ||||
|           @addNewPersons="addNewEntity" | ||||
|         > | ||||
|         </add-persons> | ||||
| @@ -53,9 +61,12 @@ | ||||
|         @click="addNewSuggested(s)" | ||||
|         style="margin: 0" | ||||
|       > | ||||
|         <span :class="getBadgeClass(s)" :style="getBadgeStyle(s)"> | ||||
|         <span v-if="!isEntityHousehold(s)" :class="getBadgeClass(s)" :style="getBadgeStyle(s)"> | ||||
|           {{ s.text }} | ||||
|         </span> | ||||
|         <span v-else> | ||||
|           Ménage n°{{ s.id }} | ||||
|         </span> | ||||
|       </li> | ||||
|     </ul> | ||||
|   </div> | ||||
| @@ -74,8 +85,9 @@ import AddPersons from "ChillPersonAssets/vuejs/_components/AddPersons.vue"; | ||||
| import { | ||||
|   Entities, | ||||
|   EntitiesOrMe, | ||||
|   EntityType, | ||||
|   EntityType, isEntityHousehold, | ||||
|   SearchOptions, | ||||
|   Suggestion, | ||||
| } from "ChillPersonAssets/types"; | ||||
| import { | ||||
|   PICK_ENTITY_MODAL_TITLE, | ||||
| @@ -182,7 +194,7 @@ function addNewSuggested(entity: EntitiesOrMe) { | ||||
|   emits("addNewEntity", { entity }); | ||||
| } | ||||
|  | ||||
| function addNewEntity({ selected }: addNewEntities) { | ||||
| function addNewEntity({ selected }: { selected: Suggestion[] }) { | ||||
|   Object.values(selected).forEach((item) => { | ||||
|     emits("addNewEntity", { entity: item.result }); | ||||
|   }); | ||||
|   | ||||
| @@ -1,28 +1,28 @@ | ||||
| <template> | ||||
|   <i :class="['fa', genderClass, 'px-1']" /> | ||||
|   <i :class="['bi', genderClass]"></i> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| <script setup lang="ts"> | ||||
| import { computed } from "vue"; | ||||
| const props = defineProps({ | ||||
|   gender: { | ||||
|     type: Object, | ||||
|     required: true, | ||||
|   }, | ||||
| }); | ||||
| import type { Gender } from "ChillMainAssets/types"; | ||||
| import {toGenderTranslation} from "ChillMainAssets/lib/api/genderHelper"; | ||||
|  | ||||
| const genderClass = computed(() => { | ||||
|   switch (props.gender.genderTranslation) { | ||||
|     case "woman": | ||||
|       return "fa-venus"; | ||||
|     case "man": | ||||
|       return "fa-mars"; | ||||
|     case "both": | ||||
|       return "fa-neuter"; | ||||
| interface GenderIconRenderBoxProps { | ||||
|   gender: Gender; | ||||
| } | ||||
|  | ||||
| const props = defineProps<GenderIconRenderBoxProps>(); | ||||
|  | ||||
| const genderClass = computed<string>(() => { | ||||
|   switch (toGenderTranslation(props.gender)) { | ||||
|     case "female": | ||||
|       return "bi-gender-female"; | ||||
|     case "male": | ||||
|       return "bi-gender-male"; | ||||
|     case "neutral": | ||||
|     case "unknown": | ||||
|       return "fa-genderless"; | ||||
|     default: | ||||
|       return "fa-genderless"; | ||||
|       return "bi-gender-neuter"; | ||||
|   } | ||||
| }); | ||||
| </script> | ||||
|   | ||||
| @@ -52,23 +52,15 @@ import { trans, MODAL_ACTION_CLOSE } from "translator"; | ||||
| import { defineProps } from "vue"; | ||||
|  | ||||
| export interface ModalProps { | ||||
|   modalDialogClass: string; | ||||
|   hideFooter: boolean; | ||||
|   modalDialogClass?: string | Record<string, boolean>; | ||||
|   hideFooter?: boolean; | ||||
|   show?: boolean; | ||||
| } | ||||
|  | ||||
| defineProps({ | ||||
|   modalDialogClass: { | ||||
|     type: String, | ||||
|     default: "", | ||||
|   }, | ||||
|   hideFooter: { | ||||
|     type: Boolean, | ||||
|     default: false, | ||||
|   }, | ||||
|   show: { | ||||
|     type: Boolean, | ||||
|     default: true, | ||||
|   }, | ||||
| const props = withDefaults(defineProps<ModalProps>(), { | ||||
|   modalDialogClass: "", | ||||
|   hideFooter: false, | ||||
|   show: true, | ||||
| }); | ||||
|  | ||||
| const emits = defineEmits<{ | ||||
|   | ||||
| @@ -0,0 +1,57 @@ | ||||
| import {ref} from "vue"; | ||||
| import {ValidationExceptionInterface} from "ChillMainAssets/types"; | ||||
|  | ||||
| export function useViolationList<T extends Record<string, Record<string, string>>>() { | ||||
|   type ViolationKey = Extract<keyof T, string>; | ||||
|   const violationsList = ref<ValidationExceptionInterface<T>|null>(null); | ||||
|  | ||||
|   function violationTitles<P extends ViolationKey>(property: P): string[] { | ||||
|     if (null === violationsList.value) { | ||||
|       return []; | ||||
|     } | ||||
|     const r = violationsList.value.violationsByNormalizedProperty(property).map((v) => v.title); | ||||
|  | ||||
|  | ||||
|     return r; | ||||
|  | ||||
|   } | ||||
|   function violationTitlesWithParameter< | ||||
|     P extends ViolationKey, | ||||
|     Param extends Extract<keyof T[P], string> | ||||
|   >( | ||||
|     property: P, | ||||
|     with_parameter: Param, | ||||
|     with_parameter_value: T[P][Param], | ||||
|   ): string[] { | ||||
|     if (violationsList.value === null) { | ||||
|       return []; | ||||
|     } | ||||
|     return violationsList.value.violationsByNormalizedPropertyAndParams(property, with_parameter, with_parameter_value) | ||||
|       .map((v) => v.title); | ||||
|   } | ||||
|  | ||||
|  | ||||
|   function hasViolation<P extends ViolationKey>(property: P): boolean { | ||||
|     return violationTitles(property).length > 0; | ||||
|   } | ||||
|   function hasViolationWithParameter< | ||||
|     P extends ViolationKey, | ||||
|     Param extends Extract<keyof T[P], string> | ||||
|   >( | ||||
|     property: P, | ||||
|     with_parameter: Param, | ||||
|     with_parameter_value: T[P][Param], | ||||
|   ): boolean { | ||||
|     return violationTitlesWithParameter(property, with_parameter, with_parameter_value).length > 0; | ||||
|   } | ||||
|  | ||||
|   function setValidationException<V extends ValidationExceptionInterface<T>>(validationException: V): void { | ||||
|     violationsList.value = validationException; | ||||
|   } | ||||
|  | ||||
|   function cleanException(): void { | ||||
|     violationsList.value = null; | ||||
|   } | ||||
|  | ||||
|   return {violationTitles, violationTitlesWithParameter, setValidationException, cleanException, hasViolationWithParameter, hasViolation}; | ||||
| } | ||||
| @@ -80,9 +80,7 @@ class ExtractPhonenumberFromPattern | ||||
|             } | ||||
|  | ||||
|             if (5 < $length) { | ||||
|                 $filtered = \trim(\strtr($subject, [$matches[0] => ''])); | ||||
|  | ||||
|                 return new SearchExtractionResult($filtered, [\implode('', $phonenumber)]); | ||||
|                 return new SearchExtractionResult($subject, [\implode('', $phonenumber)]); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|   | ||||
| @@ -22,7 +22,7 @@ class DateNormalizer implements ContextAwareNormalizerInterface, DenormalizerInt | ||||
| { | ||||
|     public function __construct(private readonly RequestStack $requestStack, private readonly ParameterBagInterface $parameterBag) {} | ||||
|  | ||||
|     public function denormalize($data, $type, $format = null, array $context = []) | ||||
|     public function denormalize($data, $type, $format = null, array $context = []): ?\DateTimeInterface | ||||
|     { | ||||
|         if (null === $data) { | ||||
|             return null; | ||||
| @@ -51,7 +51,7 @@ class DateNormalizer implements ContextAwareNormalizerInterface, DenormalizerInt | ||||
|         return $result; | ||||
|     } | ||||
|  | ||||
|     public function normalize($date, $format = null, array $context = []) | ||||
|     public function normalize($date, $format = null, array $context = []): array | ||||
|     { | ||||
|         /* @var DateTimeInterface $date */ | ||||
|         switch ($format) { | ||||
|   | ||||
| @@ -45,8 +45,11 @@ class PhonenumberNormalizer implements ContextAwareNormalizerInterface, Denormal | ||||
|  | ||||
|         try { | ||||
|             return $this->phoneNumberUtil->parse($data, $this->defaultCarrierCode); | ||||
|         } catch (NumberParseException $e) { | ||||
|             throw new UnexpectedValueException($e->getMessage(), $e->getCode(), $e); | ||||
|         } catch (NumberParseException) { | ||||
|             $phonenumber = new PhoneNumber(); | ||||
|             $phonenumber->setRawInput($data); | ||||
|  | ||||
|             return $phonenumber; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -43,20 +43,20 @@ final class ExtractPhonenumberFromPatternTest extends KernelTestCase | ||||
|  | ||||
|         yield ['BE', 'Diallo 15/06/2021', 0, [], 'Diallo 15/06/2021', 'no phonenumber and a date']; | ||||
|  | ||||
|         yield ['BE', 'Diallo 0486 123 456', 1, ['+32486123456'], 'Diallo', 'a phonenumber and a name']; | ||||
|         yield ['BE', 'Diallo 0486 123 456', 1, ['+32486123456'], 'Diallo 0486 123 456', 'a phonenumber and a name']; | ||||
|  | ||||
|         yield ['BE', 'Diallo 123 456', 1, ['123456'], 'Diallo', 'a number and a name, without leadiing 0']; | ||||
|         yield ['BE', 'Diallo 123 456', 1, ['123456'], 'Diallo 123 456', 'a number and a name, without leadiing 0']; | ||||
|  | ||||
|         yield ['BE', '123 456', 1, ['123456'], '', 'only phonenumber']; | ||||
|         yield ['BE', '123 456', 1, ['123456'], '123 456', 'only phonenumber']; | ||||
|  | ||||
|         yield ['BE', '0123 456', 1, ['+32123456'], '', 'only phonenumber with a leading 0']; | ||||
|         yield ['BE', '0123 456', 1, ['+32123456'], '0123 456', 'only phonenumber with a leading 0']; | ||||
|  | ||||
|         yield ['FR', '123 456', 1, ['123456'], '', 'only phonenumber']; | ||||
|         yield ['FR', '123 456', 1, ['123456'], '123 456', 'only phonenumber']; | ||||
|  | ||||
|         yield ['FR', '0123 456', 1, ['+33123456'], '', 'only phonenumber with a leading 0']; | ||||
|         yield ['FR', '0123 456', 1, ['+33123456'], '0123 456', 'only phonenumber with a leading 0']; | ||||
|  | ||||
|         yield ['FR', 'Diallo 0486 123 456', 1, ['+33486123456'], 'Diallo', 'a phonenumber and a name']; | ||||
|         yield ['FR', 'Diallo 0486 123 456', 1, ['+33486123456'], 'Diallo 0486 123 456', 'a phonenumber and a name']; | ||||
|  | ||||
|         yield ['FR', 'Diallo +32486 123 456', 1, ['+32486123456'], 'Diallo', 'a phonenumber and a name']; | ||||
|         yield ['FR', 'Diallo +32486 123 456', 1, ['+32486123456'], 'Diallo +32486 123 456', 'a phonenumber and a name']; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -13,6 +13,9 @@ namespace Chill\MainBundle\Validation\Constraint; | ||||
|  | ||||
| use Symfony\Component\Validator\Constraint; | ||||
|  | ||||
| /** | ||||
|  * @deprecated use odolbeau/phonenumber validator instead | ||||
|  */ | ||||
| #[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD)] | ||||
| class PhonenumberConstraint extends Constraint | ||||
| { | ||||
|   | ||||
| @@ -16,6 +16,9 @@ use Psr\Log\LoggerInterface; | ||||
| use Symfony\Component\Validator\Constraint; | ||||
| use Symfony\Component\Validator\ConstraintValidator; | ||||
|  | ||||
| /** | ||||
|  * @deprecated use https://github.com/odolbeau/phone-number-bundle/blob/master/src/Validator/Constraints/PhoneNumberValidator.php instead | ||||
|  */ | ||||
| final class ValidPhonenumber extends ConstraintValidator | ||||
| { | ||||
|     public function __construct(private readonly LoggerInterface $logger, private readonly PhoneNumberHelperInterface $phonenumberHelper) {} | ||||
|   | ||||
| @@ -136,34 +136,6 @@ filter_order: | ||||
|     Search: Chercher dans la liste | ||||
|     By date: Filtrer par date | ||||
|     search_box: Filtrer par contenu | ||||
| renderbox: | ||||
|     person: "Usager" | ||||
|     birthday: | ||||
|         man: "Né le" | ||||
|         woman: "Née le" | ||||
|         neutral: "Né·e le" | ||||
|         unknown: "Né·e le" | ||||
|     deathdate: "Date de décès" | ||||
|     household_without_address: "Le ménage de l'usager est sans adresse" | ||||
|     no_data: "Aucune information renseignée" | ||||
|     type: | ||||
|         thirdparty: "Tiers" | ||||
|         person: "Usager" | ||||
|     holder: "Titulaire" | ||||
|     years_old: >- | ||||
|         {n, plural, | ||||
|             =0 {0 an} | ||||
|             one {1 an} | ||||
|             other {# ans} | ||||
|         } | ||||
|     residential_address: "Adresse de résidence" | ||||
|     located_at: "réside chez" | ||||
|     household_number: "Ménage n°{number}" | ||||
|     current_members: "Membres actuels" | ||||
|     no_current_address: "Sans adresse actuellement" | ||||
|     new_household: "Nouveau ménage" | ||||
|     no_members_yet: "Aucun membre actuellement" | ||||
|  | ||||
| pick_entity: | ||||
|     add: "Ajouter" | ||||
|     modal_title: >- | ||||
|   | ||||
| @@ -935,11 +935,12 @@ onthefly: | ||||
|         thirdparty: Détails du tiers | ||||
|         file_person: Ouvrir la fiche de l'usager | ||||
|         file_thirdparty: Voir le Tiers | ||||
|         file_default: Voir | ||||
|     edit: | ||||
|         person: Modifier un usager | ||||
|         thirdparty: Modifier un tiers | ||||
|     create: | ||||
|         button: Créer {q} | ||||
|         button: Créer "q" | ||||
|         title: | ||||
|             default: Création d'un nouvel usager ou d'un tiers professionnel | ||||
|             person: Création d'un nouvel usager | ||||
|   | ||||
| @@ -0,0 +1,70 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\PersonBundle\Actions\PersonCreate; | ||||
|  | ||||
| use Chill\MainBundle\Entity\Address; | ||||
| use Chill\MainBundle\Entity\Center; | ||||
| use Chill\MainBundle\Entity\Civility; | ||||
| use Chill\MainBundle\Entity\Gender; | ||||
| use Chill\PersonBundle\Entity\Identifier\PersonIdentifier; | ||||
| use Chill\PersonBundle\Entity\PersonAltName; | ||||
| use libphonenumber\PhoneNumber; | ||||
| use Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber as MisdPhoneNumberConstraint; | ||||
| use Symfony\Component\Validator\Constraints as Assert; | ||||
| use Chill\PersonBundle\Validator\Constraints\Person\Birthdate; | ||||
|  | ||||
| class PersonCreateDTO | ||||
| { | ||||
|     #[Assert\NotBlank(message: 'The firstname cannot be empty')] | ||||
|     #[Assert\Length(max: 255)] | ||||
|     public string $firstName; | ||||
|  | ||||
|     #[Assert\NotBlank(message: 'The lastname cannot be empty')] | ||||
|     #[Assert\Length(max: 255)] | ||||
|     public string $lastName; | ||||
|  | ||||
|     #[Birthdate] | ||||
|     public ?\DateTime $birthdate = null; | ||||
|  | ||||
|     #[Assert\NotNull(message: 'The gender must be set')] | ||||
|     public ?Gender $gender = null; | ||||
|  | ||||
|     public ?Civility $civility = null; | ||||
|  | ||||
|     #[MisdPhoneNumberConstraint(type: [MisdPhoneNumberConstraint::FIXED_LINE, MisdPhoneNumberConstraint::VOIP, MisdPhoneNumberConstraint::PERSONAL_NUMBER])] | ||||
|     public ?PhoneNumber $phonenumber = null; | ||||
|  | ||||
|     #[MisdPhoneNumberConstraint(type: [MisdPhoneNumberConstraint::MOBILE])] | ||||
|     public ?PhoneNumber $mobilenumber = null; | ||||
|  | ||||
|     #[Assert\Email] | ||||
|     public ?string $email = ''; | ||||
|  | ||||
|     // Checkbox that indicates whether the address form was checked in creation form | ||||
|     public bool $addressForm = false; | ||||
|  | ||||
|     // Selected address value (unmapped in Person entity during creation) | ||||
|     public ?Address $address = null; | ||||
|  | ||||
|     public ?Center $center = null; | ||||
|  | ||||
|     /** | ||||
|      * @var array<string, PersonAltName> where the key is the altname's key | ||||
|      */ | ||||
|     public array $altNames = []; | ||||
|  | ||||
|     /** | ||||
|      * @var array<string, PersonIdentifier> | ||||
|      */ | ||||
|     #[Assert\Valid(traverse: true)] | ||||
|     public array $identifiers = []; | ||||
| } | ||||
| @@ -0,0 +1,96 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\PersonBundle\Actions\PersonCreate\Service; | ||||
|  | ||||
| use Chill\PersonBundle\Actions\PersonCreate\PersonCreateDTO; | ||||
| use Chill\PersonBundle\Config\ConfigPersonAltNamesHelper; | ||||
| use Chill\PersonBundle\Entity\Identifier\PersonIdentifier; | ||||
| use Chill\PersonBundle\Entity\Person; | ||||
| use Chill\PersonBundle\Entity\PersonAltName; | ||||
| use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface; | ||||
|  | ||||
| class PersonCreateDTOFactory | ||||
| { | ||||
|     public function __construct( | ||||
|         private readonly ConfigPersonAltNamesHelper $configPersonAltNamesHelper, | ||||
|         private readonly PersonIdentifierManagerInterface $personIdentifierManager, | ||||
|     ) {} | ||||
|  | ||||
|     public function createPersonCreateDTO(Person $person): PersonCreateDTO | ||||
|     { | ||||
|         $dto = new PersonCreateDTO(); | ||||
|         $dto->firstName = $person->getFirstName(); | ||||
|         $dto->lastName = $person->getLastName(); | ||||
|         $dto->birthdate = $person->getBirthdate(); | ||||
|         $dto->gender = $person->getGender(); | ||||
|         $dto->civility = $person->getCivility(); | ||||
|         $dto->phonenumber = $person->getPhonenumber(); | ||||
|         $dto->mobilenumber = $person->getMobilenumber(); | ||||
|         $dto->email = $person->getEmail(); | ||||
|         $dto->center = $person->getCenter(); | ||||
|         // address/addressForm are not mapped on Person entity; left to defaults | ||||
|  | ||||
|         foreach ($this->configPersonAltNamesHelper->getChoices() as $key => $labels) { | ||||
|             $altName = $person->getAltNames()->findFirst(fn (int $k, PersonAltName $altName) => $altName->getKey() === $key); | ||||
|             if (null === $altName) { | ||||
|                 $altName = new PersonAltName(); | ||||
|                 $altName->setKey($key); | ||||
|             } | ||||
|             $dto->altNames[$key] = $altName; | ||||
|         } | ||||
|  | ||||
|         foreach ($this->personIdentifierManager->getWorkers() as $worker) { | ||||
|             $identifier = $person | ||||
|                 ->getIdentifiers() | ||||
|                 ->findFirst(fn (int $key, PersonIdentifier $identifier) => $worker->getDefinition() === $identifier->getDefinition()); | ||||
|             if (null === $identifier) { | ||||
|                 $identifier = new PersonIdentifier($worker->getDefinition()); | ||||
|                 $person->addIdentifier($identifier); | ||||
|             } | ||||
|             $dto->identifiers['identifier_'.$worker->getDefinition()->getId()] = $identifier; | ||||
|         } | ||||
|  | ||||
|         return $dto; | ||||
|     } | ||||
|  | ||||
|     public function mapPersonCreateDTOtoPerson(PersonCreateDTO $personCreateDTO, Person $person): void | ||||
|     { | ||||
|         $person | ||||
|             ->setFirstName($personCreateDTO->firstName) | ||||
|             ->setLastName($personCreateDTO->lastName) | ||||
|             ->setBirthdate($personCreateDTO->birthdate) | ||||
|             ->setGender($personCreateDTO->gender) | ||||
|             ->setCivility($personCreateDTO->civility) | ||||
|             ->setPhonenumber($personCreateDTO->phonenumber) | ||||
|             ->setMobilenumber($personCreateDTO->mobilenumber) | ||||
|             ->setEmail($personCreateDTO->email) | ||||
|             ->setCenter($personCreateDTO->center); | ||||
|  | ||||
|         foreach ($personCreateDTO->altNames as $altName) { | ||||
|             if ('' === $altName->getLabel()) { | ||||
|                 $person->removeAltName($altName); | ||||
|             } else { | ||||
|                 $person->addAltName($altName); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         foreach ($personCreateDTO->identifiers as $identifier) { | ||||
|             $worker = $this->personIdentifierManager->buildWorkerByPersonIdentifierDefinition($identifier->getDefinition()); | ||||
|             if ($worker->isEmpty($identifier)) { | ||||
|                 $person->removeIdentifier($identifier); | ||||
|             } else { | ||||
|                 $person->addIdentifier($identifier); | ||||
|             } | ||||
|         } | ||||
|         // Note: address and addressForm are handled by controller/form during creation, not mapped here | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,108 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\PersonBundle\Actions\PersonEdit; | ||||
|  | ||||
| use Chill\MainBundle\Entity\Civility; | ||||
| use Chill\MainBundle\Entity\Country; | ||||
| use Chill\MainBundle\Entity\Embeddable\CommentEmbeddable; | ||||
| use Chill\MainBundle\Entity\Gender; | ||||
| use Chill\MainBundle\Entity\Language; | ||||
| use Chill\PersonBundle\Entity\AdministrativeStatus; | ||||
| use Chill\PersonBundle\Entity\EmploymentStatus; | ||||
| use Chill\PersonBundle\Entity\Identifier\PersonIdentifier; | ||||
| use Chill\PersonBundle\Entity\MaritalStatus; | ||||
| use Chill\PersonBundle\Entity\PersonAltName; | ||||
| use Chill\PersonBundle\Validator\Constraints\Person\Birthdate; | ||||
| use Doctrine\Common\Collections\Collection; | ||||
| use libphonenumber\PhoneNumber; | ||||
| use Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber as MisdPhoneNumberConstraint; | ||||
| use Symfony\Component\Validator\Constraints as Assert; | ||||
|  | ||||
| class PersonEditDTO | ||||
| { | ||||
|     #[Assert\NotBlank(message: 'The firstname cannot be empty')] | ||||
|     #[Assert\Length(max: 255)] | ||||
|     public string $firstName; | ||||
|  | ||||
|     #[Assert\NotBlank(message: 'The lastname cannot be empty')] | ||||
|     #[Assert\Length(max: 255)] | ||||
|     public string $lastName; | ||||
|  | ||||
|     #[Birthdate] | ||||
|     public ?\DateTime $birthdate = null; | ||||
|  | ||||
|     #[Assert\GreaterThanOrEqual(propertyPath: 'birthdate')] | ||||
|     #[Assert\LessThanOrEqual('today')] | ||||
|     public ?\DateTimeImmutable $deathdate = null; | ||||
|  | ||||
|     #[Assert\NotNull(message: 'The gender must be set')] | ||||
|     public ?Gender $gender = null; | ||||
|  | ||||
|     #[Assert\Valid] | ||||
|     public CommentEmbeddable $genderComment; | ||||
|  | ||||
|     public ?int $numberOfChildren = null; | ||||
|  | ||||
|     /** | ||||
|      * @var array<string, PersonAltName> where the key is the altname's key | ||||
|      */ | ||||
|     public array $altNames = []; | ||||
|  | ||||
|     public string $memo = ''; | ||||
|  | ||||
|     public ?EmploymentStatus $employmentStatus = null; | ||||
|  | ||||
|     public ?AdministrativeStatus $administrativeStatus = null; | ||||
|  | ||||
|     public string $placeOfBirth = ''; | ||||
|  | ||||
|     public ?string $contactInfo = ''; | ||||
|  | ||||
|     #[MisdPhoneNumberConstraint(type: [MisdPhoneNumberConstraint::FIXED_LINE, MisdPhoneNumberConstraint::VOIP, MisdPhoneNumberConstraint::PERSONAL_NUMBER])] | ||||
|     public ?PhoneNumber $phonenumber = null; | ||||
|  | ||||
|     #[MisdPhoneNumberConstraint(type: [MisdPhoneNumberConstraint::MOBILE])] | ||||
|     public ?PhoneNumber $mobilenumber = null; | ||||
|  | ||||
|     public ?bool $acceptSms = false; | ||||
|  | ||||
|     #[Assert\Valid(traverse: true)] | ||||
|     public Collection $otherPhonenumbers; // Collection<int, \Chill\PersonBundle\Entity\PersonPhone> | ||||
|  | ||||
|     #[Assert\Email] | ||||
|     public ?string $email = ''; | ||||
|  | ||||
|     public ?bool $acceptEmail = false; | ||||
|  | ||||
|     public ?Country $countryOfBirth = null; | ||||
|  | ||||
|     public ?Country $nationality = null; | ||||
|  | ||||
|     public Collection $spokenLanguages; // Collection<int, Language> | ||||
|  | ||||
|     public ?Civility $civility = null; | ||||
|  | ||||
|     public ?MaritalStatus $maritalStatus = null; | ||||
|  | ||||
|     public ?\DateTimeInterface $maritalStatusDate = null; | ||||
|  | ||||
|     #[Assert\Valid] | ||||
|     public CommentEmbeddable $maritalStatusComment; | ||||
|  | ||||
|     public ?array $cFData = null; | ||||
|  | ||||
|     /** | ||||
|      * @var array<string, PersonIdentifier> | ||||
|      */ | ||||
|     #[Assert\Valid(traverse: true)] | ||||
|     public array $identifiers = []; | ||||
| } | ||||
| @@ -0,0 +1,130 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\PersonBundle\Actions\PersonEdit\Service; | ||||
|  | ||||
| use Chill\PersonBundle\Actions\PersonEdit\PersonEditDTO; | ||||
| use Chill\PersonBundle\Config\ConfigPersonAltNamesHelper; | ||||
| use Chill\PersonBundle\Entity\Identifier\PersonIdentifier; | ||||
| use Chill\PersonBundle\Entity\Person; | ||||
| use Chill\PersonBundle\Entity\PersonAltName; | ||||
| use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface; | ||||
|  | ||||
| class PersonEditDTOFactory | ||||
| { | ||||
|     public function __construct( | ||||
|         private readonly ConfigPersonAltNamesHelper $configPersonAltNamesHelper, | ||||
|         private readonly PersonIdentifierManagerInterface $personIdentifierManager, | ||||
|     ) {} | ||||
|  | ||||
|     public function createPersonEditDTO(Person $person): PersonEditDTO | ||||
|     { | ||||
|         $dto = new PersonEditDTO(); | ||||
|         $dto->firstName = $person->getFirstName(); | ||||
|         $dto->lastName = $person->getLastName(); | ||||
|         $dto->birthdate = $person->getBirthdate(); | ||||
|         $dto->deathdate = (null !== $deathDate = $person->getDeathdate()) ? \DateTimeImmutable::createFromInterface($deathDate) : null; | ||||
|         $dto->gender = $person->getGender(); | ||||
|         $dto->genderComment = $person->getGenderComment(); | ||||
|         $dto->numberOfChildren = $person->getNumberOfChildren(); | ||||
|         $dto->memo = $person->getMemo() ?? ''; | ||||
|         $dto->employmentStatus = $person->getEmploymentStatus(); | ||||
|         $dto->administrativeStatus = $person->getAdministrativeStatus(); | ||||
|         $dto->placeOfBirth = $person->getPlaceOfBirth() ?? ''; | ||||
|         $dto->contactInfo = $person->getcontactInfo(); | ||||
|         $dto->phonenumber = $person->getPhonenumber(); | ||||
|         $dto->mobilenumber = $person->getMobilenumber(); | ||||
|         $dto->acceptSms = $person->getAcceptSMS(); | ||||
|         $dto->otherPhonenumbers = $person->getOtherPhoneNumbers(); | ||||
|         $dto->email = $person->getEmail(); | ||||
|         $dto->acceptEmail = $person->getAcceptEmail(); | ||||
|         $dto->countryOfBirth = $person->getCountryOfBirth(); | ||||
|         $dto->nationality = $person->getNationality(); | ||||
|         $dto->spokenLanguages = $person->getSpokenLanguages(); | ||||
|         $dto->civility = $person->getCivility(); | ||||
|         $dto->maritalStatus = $person->getMaritalStatus(); | ||||
|         $dto->maritalStatusDate = $person->getMaritalStatusDate(); | ||||
|         $dto->maritalStatusComment = $person->getMaritalStatusComment(); | ||||
|         $dto->cFData = $person->getCFData(); | ||||
|  | ||||
|  | ||||
|         foreach ($this->configPersonAltNamesHelper->getChoices() as $key => $labels) { | ||||
|             $altName = $person->getAltNames()->findFirst(fn (int $k, PersonAltName $altName) => $altName->getKey() === $key); | ||||
|             if (null === $altName) { | ||||
|                 $altName = new PersonAltName(); | ||||
|                 $altName->setKey($key); | ||||
|             } | ||||
|             $dto->altNames[$key] = $altName; | ||||
|         } | ||||
|  | ||||
|         foreach ($this->personIdentifierManager->getWorkers() as $worker) { | ||||
|             $identifier = $person | ||||
|                 ->getIdentifiers() | ||||
|                 ->findFirst(fn (int $key, PersonIdentifier $identifier) => $worker->getDefinition() === $identifier->getDefinition()); | ||||
|             if (null === $identifier) { | ||||
|                 $identifier = new PersonIdentifier($worker->getDefinition()); | ||||
|                 $person->addIdentifier($identifier); | ||||
|             } | ||||
|             $dto->identifiers['identifier_'.$worker->getDefinition()->getId()] = $identifier; | ||||
|         } | ||||
|  | ||||
|         return $dto; | ||||
|     } | ||||
|  | ||||
|     public function mapPersonEditDTOtoPerson(PersonEditDTO $personEditDTO, Person $person): void | ||||
|     { | ||||
|         // Copy all editable fields from the DTO back to the Person entity | ||||
|         $person | ||||
|             ->setFirstName($personEditDTO->firstName) | ||||
|             ->setLastName($personEditDTO->lastName) | ||||
|             ->setBirthdate($personEditDTO->birthdate) | ||||
|             ->setDeathdate($personEditDTO->deathdate) | ||||
|             ->setGender($personEditDTO->gender) | ||||
|             ->setGenderComment($personEditDTO->genderComment) | ||||
|             ->setNumberOfChildren($personEditDTO->numberOfChildren) | ||||
|             ->setMemo($personEditDTO->memo) | ||||
|             ->setEmploymentStatus($personEditDTO->employmentStatus) | ||||
|             ->setAdministrativeStatus($personEditDTO->administrativeStatus) | ||||
|             ->setPlaceOfBirth($personEditDTO->placeOfBirth) | ||||
|             ->setcontactInfo($personEditDTO->contactInfo) | ||||
|             ->setPhonenumber($personEditDTO->phonenumber) | ||||
|             ->setMobilenumber($personEditDTO->mobilenumber) | ||||
|             ->setAcceptSMS($personEditDTO->acceptSms ?? false) | ||||
|             ->setOtherPhoneNumbers($personEditDTO->otherPhonenumbers) | ||||
|             ->setEmail($personEditDTO->email) | ||||
|             ->setAcceptEmail($personEditDTO->acceptEmail ?? false) | ||||
|             ->setCountryOfBirth($personEditDTO->countryOfBirth) | ||||
|             ->setNationality($personEditDTO->nationality) | ||||
|             ->setSpokenLanguages($personEditDTO->spokenLanguages) | ||||
|             ->setCivility($personEditDTO->civility) | ||||
|             ->setMaritalStatus($personEditDTO->maritalStatus) | ||||
|             ->setMaritalStatusDate($personEditDTO->maritalStatusDate) | ||||
|             ->setMaritalStatusComment($personEditDTO->maritalStatusComment) | ||||
|             ->setCFData($personEditDTO->cFData); | ||||
|  | ||||
|         foreach ($personEditDTO->altNames as $altName) { | ||||
|             if ('' === $altName->getLabel()) { | ||||
|                 $person->removeAltName($altName); | ||||
|             } else { | ||||
|                 $person->addAltName($altName); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         foreach ($personEditDTO->identifiers as $identifier) { | ||||
|             $worker = $this->personIdentifierManager->buildWorkerByPersonIdentifierDefinition($identifier->getDefinition()); | ||||
|             if ($worker->isEmpty($identifier)) { | ||||
|                 $person->removeIdentifier($identifier); | ||||
|             } else { | ||||
|                 $person->addIdentifier($identifier); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -28,6 +28,8 @@ class ConfigPersonAltNamesHelper | ||||
|  | ||||
|     /** | ||||
|      * get the choices as key => values. | ||||
|      * | ||||
|      * @return array<string, array<string, string>> where the key is the altName's key, and the value is an array of TranslatableString | ||||
|      */ | ||||
|     public function getChoices(): array | ||||
|     { | ||||
|   | ||||
| @@ -11,36 +11,22 @@ declare(strict_types=1); | ||||
|  | ||||
| namespace Chill\PersonBundle\Controller; | ||||
|  | ||||
| use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface; | ||||
| use Chill\PersonBundle\Config\ConfigPersonAltNamesHelper; | ||||
| use Chill\PersonBundle\Entity\Household\Household; | ||||
| use Chill\PersonBundle\Entity\Household\HouseholdMember; | ||||
| use Chill\PersonBundle\Entity\Person; | ||||
| use Chill\PersonBundle\Form\CreationPersonType; | ||||
| use Chill\PersonBundle\Privacy\PrivacyEvent; | ||||
| use Chill\PersonBundle\Repository\PersonRepository; | ||||
| use Chill\PersonBundle\Search\SimilarPersonMatcher; | ||||
| use Chill\PersonBundle\Security\Authorization\PersonVoter; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter; | ||||
| use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; | ||||
| use Symfony\Component\EventDispatcher\EventDispatcherInterface; | ||||
| use Symfony\Component\Form\Extension\Core\Type\SubmitType; | ||||
| use Symfony\Component\Form\Form; | ||||
| use Symfony\Component\HttpFoundation\Request; | ||||
| use Symfony\Component\HttpFoundation\Response; | ||||
| use Symfony\Component\HttpFoundation\Session\SessionInterface; | ||||
| use Symfony\Component\Routing\Annotation\Route; | ||||
| use Symfony\Component\Validator\Validator\ValidatorInterface; | ||||
| use Symfony\Contracts\Translation\TranslatorInterface; | ||||
| use function hash; | ||||
|  | ||||
| final class PersonController extends AbstractController | ||||
| { | ||||
|     public function __construct( | ||||
|         private readonly AuthorizationHelperInterface $authorizationHelper, | ||||
|         private readonly SimilarPersonMatcher $similarPersonMatcher, | ||||
|         private readonly TranslatorInterface $translator, | ||||
|         private readonly EventDispatcherInterface $eventDispatcher, | ||||
|         private readonly PersonRepository $personRepository, | ||||
|         private readonly ConfigPersonAltNamesHelper $configPersonAltNameHelper, | ||||
| @@ -85,110 +71,6 @@ final class PersonController extends AbstractController | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Method for creating a new person. | ||||
|      * | ||||
|      * The controller register data from a previous post on the form, and | ||||
|      * register it in the session. | ||||
|      * | ||||
|      * The next post compare the data with previous one and, if yes, show a | ||||
|      * review page if there are "alternate persons". | ||||
|      */ | ||||
|     #[Route(path: '/{_locale}/person/new', name: 'chill_person_new')] | ||||
|     public function newAction(Request $request): Response | ||||
|     { | ||||
|         $person = new Person(); | ||||
|  | ||||
|         $authorizedCenters = $this->authorizationHelper->getReachableCenters($this->getUser(), PersonVoter::CREATE); | ||||
|  | ||||
|         if (1 === \count($authorizedCenters)) { | ||||
|             $person->setCenter($authorizedCenters[0]); | ||||
|         } | ||||
|  | ||||
|         $form = $this->createForm(CreationPersonType::class, $person) | ||||
|             ->add('editPerson', SubmitType::class, [ | ||||
|                 'label' => 'Add the person', | ||||
|             ])->add('createPeriod', SubmitType::class, [ | ||||
|                 'label' => 'Add the person and create an accompanying period', | ||||
|             ])->add('createHousehold', SubmitType::class, [ | ||||
|                 'label' => 'Add the person and create a household', | ||||
|             ]); | ||||
|  | ||||
|         $form->handleRequest($request); | ||||
|  | ||||
|         if (Request::METHOD_GET === $request->getMethod()) { | ||||
|             $this->lastPostDataReset(); | ||||
|         } elseif ( | ||||
|             Request::METHOD_POST === $request->getMethod() | ||||
|             && $form->isValid() | ||||
|         ) { | ||||
|             $alternatePersons = $this->similarPersonMatcher | ||||
|                 ->matchPerson($person); | ||||
|  | ||||
|             if ( | ||||
|                 false === $this->isLastPostDataChanges($form, $request, true) | ||||
|                 || 0 === \count($alternatePersons) | ||||
|             ) { | ||||
|                 $this->em->persist($person); | ||||
|  | ||||
|                 $this->em->flush(); | ||||
|                 $this->lastPostDataReset(); | ||||
|  | ||||
|                 $address = $form->get('address')->getData(); | ||||
|                 $addressForm = (bool) $form->get('addressForm')->getData(); | ||||
|  | ||||
|                 if (null !== $address && $addressForm) { | ||||
|                     $household = new Household(); | ||||
|  | ||||
|                     $member = new HouseholdMember(); | ||||
|                     $member->setPerson($person); | ||||
|                     $member->setStartDate(new \DateTimeImmutable()); | ||||
|  | ||||
|                     $household->addMember($member); | ||||
|                     $household->setForceAddress($address); | ||||
|  | ||||
|                     $this->em->persist($member); | ||||
|                     $this->em->persist($household); | ||||
|                     $this->em->flush(); | ||||
|  | ||||
|                     if ($form->get('createHousehold')->isClicked()) { | ||||
|                         return $this->redirectToRoute('chill_person_household_members_editor', [ | ||||
|                             'persons' => [$person->getId()], | ||||
|                             'household' => $household->getId(), | ||||
|                         ]); | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 if ($form->get('createPeriod')->isClicked()) { | ||||
|                     return $this->redirectToRoute('chill_person_accompanying_course_new', [ | ||||
|                         'person_id' => [$person->getId()], | ||||
|                     ]); | ||||
|                 } | ||||
|  | ||||
|                 if ($form->get('createHousehold')->isClicked()) { | ||||
|                     return $this->redirectToRoute('chill_person_household_members_editor', [ | ||||
|                         'persons' => [$person->getId()], | ||||
|                     ]); | ||||
|                 } | ||||
|  | ||||
|                 return $this->redirectToRoute( | ||||
|                     'chill_person_general_edit', | ||||
|                     ['person_id' => $person->getId()] | ||||
|                 ); | ||||
|             } | ||||
|         } elseif (Request::METHOD_POST === $request->getMethod() && !$form->isValid()) { | ||||
|             $this->addFlash('error', $this->translator->trans('This form contains errors')); | ||||
|         } | ||||
|  | ||||
|         return $this->render( | ||||
|             '@ChillPerson/Person/create.html.twig', | ||||
|             [ | ||||
|                 'form' => $form->createView(), | ||||
|                 'alternatePersons' => $alternatePersons ?? [], | ||||
|             ] | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     #[Route(path: '/{_locale}/person/{person_id}/general', name: 'chill_person_view')] | ||||
|     public function viewAction(int $person_id) | ||||
|     { | ||||
| @@ -250,51 +132,4 @@ final class PersonController extends AbstractController | ||||
|  | ||||
|         return $errors; | ||||
|     } | ||||
|  | ||||
|     private function isLastPostDataChanges(Form $form, Request $request, bool $replace = false): bool | ||||
|     { | ||||
|         /** @var SessionInterface $session */ | ||||
|         $session = $this->get('session'); | ||||
|  | ||||
|         if (!$session->has('last_person_data')) { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         $newPost = $this->lastPostDataBuildHash($form, $request); | ||||
|  | ||||
|         $isChanged = $session->get('last_person_data') !== $newPost; | ||||
|  | ||||
|         if ($replace) { | ||||
|             $session->set('last_person_data', $newPost); | ||||
|         } | ||||
|  | ||||
|         return $isChanged; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * build the hash for posted data. | ||||
|      * | ||||
|      * For privacy reasons, the data are hashed using sha512 | ||||
|      */ | ||||
|     private function lastPostDataBuildHash(Form $form, Request $request): string | ||||
|     { | ||||
|         $fields = []; | ||||
|         $ignoredFields = ['form_status', '_token']; | ||||
|  | ||||
|         foreach ($request->request->all()[$form->getName()] as $field => $value) { | ||||
|             if (\in_array($field, $ignoredFields, true)) { | ||||
|                 continue; | ||||
|             } | ||||
|             $fields[$field] = \is_array($value) ? | ||||
|                 \implode(',', $value) : $value; | ||||
|         } | ||||
|         ksort($fields); | ||||
|  | ||||
|         return \hash('sha512', \implode('&', $fields)); | ||||
|     } | ||||
|  | ||||
|     private function lastPostDataReset(): void | ||||
|     { | ||||
|         $this->get('session')->set('last_person_data', ''); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,205 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\PersonBundle\Controller; | ||||
|  | ||||
| use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface; | ||||
| use Chill\PersonBundle\Actions\PersonCreate\Service\PersonCreateDTOFactory; | ||||
| use Chill\PersonBundle\Entity\Household\Household; | ||||
| use Chill\PersonBundle\Entity\Household\HouseholdMember; | ||||
| use Chill\PersonBundle\Entity\Person; | ||||
| use Chill\PersonBundle\Form\CreationPersonType; | ||||
| use Chill\PersonBundle\Search\SimilarPersonMatcher; | ||||
| use Chill\PersonBundle\Security\Authorization\PersonVoter; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; | ||||
| use Symfony\Component\Form\ClickableInterface; | ||||
| use Symfony\Component\Form\Extension\Core\Type\SubmitType; | ||||
| use Symfony\Component\Form\Form; | ||||
| use Symfony\Component\Form\FormInterface; | ||||
| use Symfony\Component\HttpFoundation\Request; | ||||
| use Symfony\Component\HttpFoundation\Response; | ||||
| use Symfony\Component\HttpFoundation\Session\SessionInterface; | ||||
| use Symfony\Component\Routing\Annotation\Route; | ||||
| use Symfony\Contracts\Translation\TranslatorInterface; | ||||
|  | ||||
| final class PersonCreateController extends AbstractController | ||||
| { | ||||
|     public function __construct( | ||||
|         private readonly AuthorizationHelperInterface $authorizationHelper, | ||||
|         private readonly SimilarPersonMatcher $similarPersonMatcher, | ||||
|         private readonly TranslatorInterface $translator, | ||||
|         private readonly EntityManagerInterface $em, | ||||
|         private readonly PersonCreateDTOFactory $personCreateDTOFactory, | ||||
|     ) {} | ||||
|  | ||||
|     /** | ||||
|      * Method for creating a new person. | ||||
|      * | ||||
|      * The controller registers data from a previous post on the form and | ||||
|      * registers it in the session. | ||||
|      * | ||||
|      * The next post compares the data with the previous one and, if yes, shows a | ||||
|      * review page if there are "alternate persons". | ||||
|      */ | ||||
|     #[Route(path: '/{_locale}/person/new', name: 'chill_person_new')] | ||||
|     public function newAction(Request $request, SessionInterface $session): Response | ||||
|     { | ||||
|         $person = new Person(); | ||||
|  | ||||
|         $authorizedCenters = $this->authorizationHelper->getReachableCenters($this->getUser(), PersonVoter::CREATE); | ||||
|  | ||||
|         $dto = $this->personCreateDTOFactory->createPersonCreateDTO($person); | ||||
|  | ||||
|         if (1 === \count($authorizedCenters)) { | ||||
|             $dto->center = $authorizedCenters[0]; | ||||
|         } | ||||
|  | ||||
|         $form = $this->createForm(CreationPersonType::class, $dto) | ||||
|             ->add('editPerson', SubmitType::class, [ | ||||
|                 'label' => 'Add the person', | ||||
|             ])->add('createPeriod', SubmitType::class, [ | ||||
|                 'label' => 'Add the person and create an accompanying period', | ||||
|             ])->add('createHousehold', SubmitType::class, [ | ||||
|                 'label' => 'Add the person and create a household', | ||||
|             ]); | ||||
|  | ||||
|         $form->handleRequest($request); | ||||
|  | ||||
|         if (Request::METHOD_GET === $request->getMethod()) { | ||||
|             $this->lastPostDataReset($session); | ||||
|         } elseif ( | ||||
|             Request::METHOD_POST === $request->getMethod() | ||||
|             && $form->isValid() | ||||
|         ) { | ||||
|             $alternatePersons = $this->similarPersonMatcher | ||||
|                 ->matchPerson($person); | ||||
|  | ||||
|             $createHouseholdButton = $form->get('createHousehold'); | ||||
|             $createPeriodButton = $form->get('createPeriod'); | ||||
|             $editPersonButton = $form->get('editPerson'); | ||||
|  | ||||
|             if (!$createHouseholdButton instanceof ClickableInterface) { | ||||
|                 throw new \UnexpectedValueException(); | ||||
|             } | ||||
|             if (!$createPeriodButton instanceof ClickableInterface) { | ||||
|                 throw new \UnexpectedValueException(); | ||||
|             } | ||||
|             if (!$editPersonButton instanceof ClickableInterface) { | ||||
|                 throw new \UnexpectedValueException(); | ||||
|             } | ||||
|  | ||||
|             if ( | ||||
|                 false === $this->isLastPostDataChanges($form, $request, $session) | ||||
|                 || 0 === \count($alternatePersons) | ||||
|             ) { | ||||
|                 $this->personCreateDTOFactory->mapPersonCreateDTOtoPerson($dto, $person); | ||||
|                 $this->em->persist($person); | ||||
|  | ||||
|                 $this->em->flush(); | ||||
|                 $this->lastPostDataReset($session); | ||||
|  | ||||
|                 $address = $dto->address; | ||||
|                 $addressForm = $dto->addressForm; | ||||
|  | ||||
|                 if (null !== $address && $addressForm) { | ||||
|                     $household = new Household(); | ||||
|  | ||||
|                     $member = new HouseholdMember(); | ||||
|                     $member->setPerson($person); | ||||
|                     $member->setStartDate(new \DateTimeImmutable()); | ||||
|  | ||||
|                     $household->addMember($member); | ||||
|                     $household->setForceAddress($address); | ||||
|  | ||||
|                     $this->em->persist($member); | ||||
|                     $this->em->persist($household); | ||||
|                     $this->em->flush(); | ||||
|  | ||||
|                     if ($createHouseholdButton->isClicked()) { | ||||
|                         return $this->redirectToRoute('chill_person_household_members_editor', [ | ||||
|                             'persons' => [$person->getId()], | ||||
|                             'household' => $household->getId(), | ||||
|                         ]); | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 if ($createPeriodButton->isClicked()) { | ||||
|                     return $this->redirectToRoute('chill_person_accompanying_course_new', [ | ||||
|                         'person_id' => [$person->getId()], | ||||
|                     ]); | ||||
|                 } | ||||
|  | ||||
|                 if ($createHouseholdButton->isClicked()) { | ||||
|                     return $this->redirectToRoute('chill_person_household_members_editor', [ | ||||
|                         'persons' => [$person->getId()], | ||||
|                     ]); | ||||
|                 } | ||||
|  | ||||
|                 return $this->redirectToRoute( | ||||
|                     'chill_person_general_edit', | ||||
|                     ['person_id' => $person->getId()] | ||||
|                 ); | ||||
|             } | ||||
|         } elseif (Request::METHOD_POST === $request->getMethod() && !$form->isValid()) { | ||||
|             $this->addFlash('error', $this->translator->trans('This form contains errors')); | ||||
|         } | ||||
|  | ||||
|         return $this->render( | ||||
|             '@ChillPerson/Person/create.html.twig', | ||||
|             [ | ||||
|                 'form' => $form->createView(), | ||||
|                 'alternatePersons' => $alternatePersons ?? [], | ||||
|             ] | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     private function isLastPostDataChanges(FormInterface $form, Request $request, SessionInterface $session): bool | ||||
|     { | ||||
|         if (!$session->has('last_person_data')) { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         $newPost = $this->lastPostDataBuildHash($form, $request); | ||||
|  | ||||
|         $isChanged = $session->get('last_person_data') !== $newPost; | ||||
|         $session->set('last_person_data', $newPost); | ||||
|  | ||||
|         return $isChanged; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * build the hash for posted data. | ||||
|      * | ||||
|      * For privacy reasons, the data are hashed using sha512 | ||||
|      */ | ||||
|     private function lastPostDataBuildHash(FormInterface $form, Request $request): string | ||||
|     { | ||||
|         $fields = []; | ||||
|         $ignoredFields = ['form_status', '_token', 'identifiers']; | ||||
|  | ||||
|         foreach ($request->request->all()[$form->getName()] as $field => $value) { | ||||
|             if (\in_array($field, $ignoredFields, true)) { | ||||
|                 continue; | ||||
|             } | ||||
|             $fields[$field] = \is_array($value) ? | ||||
|                 \implode(',', $value) : $value; | ||||
|         } | ||||
|         ksort($fields); | ||||
|  | ||||
|         return \hash('sha512', \implode('&', $fields)); | ||||
|     } | ||||
|  | ||||
|     private function lastPostDataReset(SessionInterface $session): void | ||||
|     { | ||||
|         $session->set('last_person_data', ''); | ||||
|     } | ||||
| } | ||||
| @@ -12,6 +12,7 @@ declare(strict_types=1); | ||||
| namespace Chill\PersonBundle\Controller; | ||||
|  | ||||
| use Chill\CustomFieldsBundle\EntityRepository\CustomFieldsDefaultGroupRepository; | ||||
| use Chill\PersonBundle\Actions\PersonEdit\Service\PersonEditDTOFactory; | ||||
| use Chill\PersonBundle\Entity\Person; | ||||
| use Chill\PersonBundle\Form\PersonType; | ||||
| use Chill\PersonBundle\Security\Authorization\PersonVoter; | ||||
| @@ -38,6 +39,7 @@ final readonly class PersonEditController | ||||
|         private EntityManagerInterface $entityManager, | ||||
|         private UrlGeneratorInterface $urlGenerator, | ||||
|         private Environment $twig, | ||||
|         private PersonEditDTOFactory $personEditDTOFactory, | ||||
|     ) {} | ||||
|  | ||||
|     /** | ||||
| @@ -50,9 +52,11 @@ final readonly class PersonEditController | ||||
|             throw new AccessDeniedHttpException('You are not allowed to edit this person.'); | ||||
|         } | ||||
|  | ||||
|         $dto = $this->personEditDTOFactory->createPersonEditDTO($person); | ||||
|  | ||||
|         $form = $this->formFactory->create( | ||||
|             PersonType::class, | ||||
|             $person, | ||||
|             $dto, | ||||
|             ['cFGroup' => $this->customFieldsDefaultGroupRepository->findOneByEntity(Person::class)?->getCustomFieldsGroup()] | ||||
|         ); | ||||
|  | ||||
| @@ -62,6 +66,7 @@ final readonly class PersonEditController | ||||
|             $session | ||||
|                 ->getFlashBag()->add('error', new TranslatableMessage('This form contains errors')); | ||||
|         } elseif ($form->isSubmitted() && $form->isValid()) { | ||||
|             $this->personEditDTOFactory->mapPersonEditDTOtoPerson($dto, $person); | ||||
|             $this->entityManager->flush(); | ||||
|  | ||||
|             $session->getFlashBag()->add('success', new TranslatableMessage('The person data has been updated')); | ||||
|   | ||||
| @@ -0,0 +1,47 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\PersonBundle\Controller; | ||||
|  | ||||
| use Chill\MainBundle\Pagination\PaginatorFactoryInterface; | ||||
| use Chill\MainBundle\Serializer\Model\Collection; | ||||
| use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface; | ||||
| use Symfony\Component\HttpFoundation\JsonResponse; | ||||
| use Symfony\Component\HttpFoundation\Response; | ||||
| use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; | ||||
| use Symfony\Component\Routing\Annotation\Route; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
| use Symfony\Component\Serializer\SerializerInterface; | ||||
|  | ||||
| final readonly class PersonIdentifierListApiController | ||||
| { | ||||
|     public function __construct( | ||||
|         private Security $security, | ||||
|         private SerializerInterface $serializer, | ||||
|         private PersonIdentifierManagerInterface $personIdentifierManager, | ||||
|         private PaginatorFactoryInterface $paginatorFactory, | ||||
|     ) {} | ||||
|  | ||||
|     #[Route('/api/1.0/person/identifiers/workers', name: 'person_person_identifiers_worker_list')] | ||||
|     public function list(): Response | ||||
|     { | ||||
|         if (!$this->security->isGranted('ROLE_USER')) { | ||||
|             throw new AccessDeniedHttpException(); | ||||
|         } | ||||
|  | ||||
|         $workers = $this->personIdentifierManager->getWorkers(); | ||||
|         $paginator = $this->paginatorFactory->create(count($workers)); | ||||
|         $paginator->setItemsPerPage(count($workers)); | ||||
|         $collection = new Collection($workers, $paginator); | ||||
|  | ||||
|         return new JsonResponse($this->serializer->serialize($collection, 'json'), json: true); | ||||
|     } | ||||
| } | ||||
| @@ -96,7 +96,6 @@ class ChillPersonExtension extends Extension implements PrependExtensionInterfac | ||||
|         // We can get rid of this file when the service 'chill.person.repository.person' is no more used. | ||||
|         // We should use the PersonRepository service instead of a custom service name. | ||||
|         $loader->load('services/repository.yaml'); | ||||
|         $loader->load('services/serializer.yaml'); | ||||
|         $loader->load('services/security.yaml'); | ||||
|         $loader->load('services/doctrineEventListener.yaml'); | ||||
|         $loader->load('services/accompanyingPeriodConsistency.yaml'); | ||||
|   | ||||
| @@ -0,0 +1,42 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\PersonBundle\Entity\Identifier; | ||||
|  | ||||
| enum IdentifierPresenceEnum: string | ||||
| { | ||||
|     /** | ||||
|      * The person identifier is not editable by any user. | ||||
|      * | ||||
|      * The identifier is intended to be added by an import script, for instance. | ||||
|      */ | ||||
|     case NOT_EDITABLE = 'NOT_EDITABLE'; | ||||
|  | ||||
|     /** | ||||
|      * The person identifier is present on the edit form only. | ||||
|      */ | ||||
|     case ON_EDIT = 'ON_EDIT'; | ||||
|  | ||||
|     /** | ||||
|      * The person identifier is present on both person's creation form, and edit form. | ||||
|      */ | ||||
|     case ON_CREATION = 'ON_CREATION'; | ||||
|  | ||||
|     /** | ||||
|      * The person identifier is required to create the person. It should not be empty. | ||||
|      */ | ||||
|     case REQUIRED = 'REQUIRED'; | ||||
|  | ||||
|     public function isEditableByUser(): bool | ||||
|     { | ||||
|         return IdentifierPresenceEnum::NOT_EDITABLE !== $this; | ||||
|     } | ||||
| } | ||||
| @@ -12,15 +12,24 @@ declare(strict_types=1); | ||||
| namespace Chill\PersonBundle\Entity\Identifier; | ||||
|  | ||||
| use Chill\PersonBundle\Entity\Person; | ||||
| use Chill\PersonBundle\PersonIdentifier\Validator\UniqueIdentifierConstraint; | ||||
| use Chill\PersonBundle\PersonIdentifier\Validator\ValidIdentifierConstraint; | ||||
| use Doctrine\ORM\Mapping as ORM; | ||||
| use Symfony\Component\Serializer\Annotation as Serializer; | ||||
|  | ||||
| #[ORM\Entity] | ||||
| #[ORM\Table(name: 'chill_person_identifier')] | ||||
| #[ORM\UniqueConstraint(name: 'chill_person_identifier_unique', columns: ['definition_id', 'canonical'])] | ||||
| #[ORM\UniqueConstraint(name: 'chill_person_identifier_unique_person_definition', columns: ['definition_id', 'person_id'])] | ||||
| #[UniqueIdentifierConstraint] | ||||
| #[ValidIdentifierConstraint] | ||||
| #[Serializer\DiscriminatorMap('type', ['person_identifier' => PersonIdentifier::class])] | ||||
| class PersonIdentifier | ||||
| { | ||||
|     #[ORM\Id] | ||||
|     #[ORM\Column(name: 'id', type: \Doctrine\DBAL\Types\Types::INTEGER)] | ||||
|     #[ORM\GeneratedValue] | ||||
|     #[Serializer\Groups(['read'])] | ||||
|     private ?int $id = null; | ||||
|  | ||||
|     #[ORM\ManyToOne(targetEntity: Person::class)] | ||||
| @@ -28,14 +37,16 @@ class PersonIdentifier | ||||
|     private ?Person $person = null; | ||||
|  | ||||
|     #[ORM\Column(name: 'value', type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]', 'jsonb' => true])] | ||||
|     #[Serializer\Groups(['read'])] | ||||
|     private array $value = []; | ||||
|  | ||||
|     #[ORM\Column(name: 'canonical', type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => '[]'])] | ||||
|     #[ORM\Column(name: 'canonical', type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])] | ||||
|     private string $canonical = ''; | ||||
|  | ||||
|     public function __construct( | ||||
|         #[ORM\ManyToOne(targetEntity: PersonIdentifierDefinition::class)] | ||||
|         #[ORM\JoinColumn(name: 'definition_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] | ||||
|         #[ORM\JoinColumn(name: 'definition_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')] | ||||
|         #[Serializer\Groups(['read'])] | ||||
|         private PersonIdentifierDefinition $definition, | ||||
|     ) {} | ||||
|  | ||||
|   | ||||
| @@ -11,30 +11,35 @@ declare(strict_types=1); | ||||
|  | ||||
| namespace Chill\PersonBundle\Entity\Identifier; | ||||
|  | ||||
| use Doctrine\DBAL\Types\Types; | ||||
| use Doctrine\ORM\Mapping as ORM; | ||||
| use Symfony\Component\Serializer\Annotation as Serializer; | ||||
|  | ||||
| #[ORM\Entity] | ||||
| #[ORM\Table(name: 'chill_person_identifier_definition')] | ||||
| #[Serializer\DiscriminatorMap('type', ['person_identifier_definition' => PersonIdentifierDefinition::class])] | ||||
| class PersonIdentifierDefinition | ||||
| { | ||||
|     #[ORM\Id] | ||||
|     #[ORM\Column(name: 'id', type: \Doctrine\DBAL\Types\Types::INTEGER)] | ||||
|     #[ORM\Column(name: 'id', type: Types::INTEGER)] | ||||
|     #[ORM\GeneratedValue] | ||||
|     #[Serializer\Groups(['read'])] | ||||
|     private ?int $id = null; | ||||
|  | ||||
|     #[ORM\Column(name: 'active', type: \Doctrine\DBAL\Types\Types::BOOLEAN, nullable: false, options: ['default' => true])] | ||||
|     #[ORM\Column(name: 'active', type: Types::BOOLEAN, nullable: false, options: ['default' => true])] | ||||
|     private bool $active = true; | ||||
|  | ||||
|     public function __construct( | ||||
|         #[ORM\Column(name: 'label', type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]'])] | ||||
|         #[ORM\Column(name: 'label', type: Types::JSON, nullable: false, options: ['default' => '[]'])] | ||||
|         private array $label, | ||||
|         #[ORM\Column(name: 'engine', type: \Doctrine\DBAL\Types\Types::STRING, length: 100)] | ||||
|         #[ORM\Column(name: 'engine', type: Types::STRING, length: 100)] | ||||
|         #[Serializer\Groups(['read'])] | ||||
|         private string $engine, | ||||
|         #[ORM\Column(name: 'is_searchable', type: \Doctrine\DBAL\Types\Types::BOOLEAN, options: ['default' => false])] | ||||
|         #[ORM\Column(name: 'is_searchable', type: Types::BOOLEAN, options: ['default' => false])] | ||||
|         private bool $isSearchable = false, | ||||
|         #[ORM\Column(name: 'is_editable_by_users', type: \Doctrine\DBAL\Types\Types::BOOLEAN, nullable: false, options: ['default' => false])] | ||||
|         private bool $isEditableByUsers = false, | ||||
|         #[ORM\Column(name: 'data', type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]', 'jsonb' => true])] | ||||
|         #[ORM\Column(name: 'presence', type: Types::STRING, nullable: false, enumType: IdentifierPresenceEnum::class, options: ['default' => IdentifierPresenceEnum::ON_EDIT])] | ||||
|         private IdentifierPresenceEnum $presence = IdentifierPresenceEnum::ON_EDIT, | ||||
|         #[ORM\Column(name: 'data', type: Types::JSON, nullable: false, options: ['default' => '[]', 'jsonb' => true])] | ||||
|         private array $data = [], | ||||
|     ) {} | ||||
|  | ||||
| @@ -58,11 +63,6 @@ class PersonIdentifierDefinition | ||||
|         return $this->engine; | ||||
|     } | ||||
|  | ||||
|     public function setEngine(string $engine): void | ||||
|     { | ||||
|         $this->engine = $engine; | ||||
|     } | ||||
|  | ||||
|     public function isSearchable(): bool | ||||
|     { | ||||
|         return $this->isSearchable; | ||||
| @@ -75,12 +75,7 @@ class PersonIdentifierDefinition | ||||
|  | ||||
|     public function isEditableByUsers(): bool | ||||
|     { | ||||
|         return $this->isEditableByUsers; | ||||
|     } | ||||
|  | ||||
|     public function setIsEditableByUsers(bool $isEditableByUsers): void | ||||
|     { | ||||
|         $this->isEditableByUsers = $isEditableByUsers; | ||||
|         return $this->presence->isEditableByUser(); | ||||
|     } | ||||
|  | ||||
|     public function isActive(): bool | ||||
| @@ -104,4 +99,16 @@ class PersonIdentifierDefinition | ||||
|     { | ||||
|         $this->data = $data; | ||||
|     } | ||||
|  | ||||
|     public function getPresence(): IdentifierPresenceEnum | ||||
|     { | ||||
|         return $this->presence; | ||||
|     } | ||||
|  | ||||
|     public function setPresence(IdentifierPresenceEnum $presence): self | ||||
|     { | ||||
|         $this->presence = $presence; | ||||
|  | ||||
|         return $this; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -27,7 +27,6 @@ use Chill\MainBundle\Entity\Language; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature; | ||||
| use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint; | ||||
| use Chill\PersonBundle\Entity\Household\Household; | ||||
| use Chill\PersonBundle\Entity\Household\HouseholdMember; | ||||
| use Chill\PersonBundle\Entity\Household\PersonHouseholdAddress; | ||||
| @@ -36,6 +35,7 @@ use Chill\PersonBundle\Entity\Person\PersonCenterCurrent; | ||||
| use Chill\PersonBundle\Entity\Person\PersonCenterHistory; | ||||
| use Chill\PersonBundle\Entity\Person\PersonCurrentAddress; | ||||
| use Chill\PersonBundle\Entity\Person\PersonResource; | ||||
| use Chill\PersonBundle\PersonIdentifier\Validator\RequiredIdentifierConstraint; | ||||
| use Chill\PersonBundle\Validator\Constraints\Household\HouseholdMembershipSequential; | ||||
| use Chill\PersonBundle\Validator\Constraints\Person\Birthdate; | ||||
| use Chill\PersonBundle\Validator\Constraints\Person\PersonHasCenter; | ||||
| @@ -47,6 +47,7 @@ use Doctrine\Common\Collections\ReadableCollection; | ||||
| use Doctrine\Common\Collections\Selectable; | ||||
| use Doctrine\ORM\Mapping as ORM; | ||||
| use libphonenumber\PhoneNumber; | ||||
| use Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber as MisdPhoneNumberConstraint; | ||||
| use Symfony\Component\Serializer\Annotation\DiscriminatorMap; | ||||
| use Symfony\Component\Validator\Constraints as Assert; | ||||
| use Symfony\Component\Validator\Context\ExecutionContextInterface; | ||||
| @@ -273,6 +274,8 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI | ||||
|     private ?int $id = null; | ||||
|  | ||||
|     #[ORM\OneToMany(mappedBy: 'person', targetEntity: PersonIdentifier::class, cascade: ['persist', 'remove'], orphanRemoval: true)] | ||||
|     #[RequiredIdentifierConstraint] | ||||
|     #[Assert\Valid] | ||||
|     private Collection $identifiers; | ||||
|  | ||||
|     /** | ||||
| @@ -319,7 +322,7 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI | ||||
|      * The person's mobile phone number. | ||||
|      */ | ||||
|     #[ORM\Column(type: 'phone_number', nullable: true)] | ||||
|     #[PhonenumberConstraint(type: 'mobile')] | ||||
|     #[MisdPhoneNumberConstraint(type: [MisdPhoneNumberConstraint::MOBILE])] | ||||
|     private ?PhoneNumber $mobilenumber = null; | ||||
|  | ||||
|     /** | ||||
| @@ -359,7 +362,7 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI | ||||
|      * The person's phonenumber. | ||||
|      */ | ||||
|     #[ORM\Column(type: 'phone_number', nullable: true)] | ||||
|     #[PhonenumberConstraint(type: 'landline')] | ||||
|     #[MisdPhoneNumberConstraint(type: [MisdPhoneNumberConstraint::FIXED_LINE, MisdPhoneNumberConstraint::VOIP, MisdPhoneNumberConstraint::PERSONAL_NUMBER])] | ||||
|     private ?PhoneNumber $phonenumber = null; | ||||
|  | ||||
|     /** | ||||
|   | ||||
| @@ -11,15 +11,14 @@ declare(strict_types=1); | ||||
|  | ||||
| namespace Chill\PersonBundle\Form; | ||||
|  | ||||
| use Chill\MainBundle\Entity\Address; | ||||
| use Chill\MainBundle\Form\Event\CustomizeFormEvent; | ||||
| use Chill\MainBundle\Form\Type\ChillDateType; | ||||
| use Chill\MainBundle\Form\Type\ChillPhoneNumberType; | ||||
| use Chill\MainBundle\Form\Type\PickAddressType; | ||||
| use Chill\MainBundle\Form\Type\PickCenterType; | ||||
| use Chill\MainBundle\Form\Type\PickCivilityType; | ||||
| use Chill\PersonBundle\Actions\PersonCreate\PersonCreateDTO; | ||||
| use Chill\PersonBundle\Config\ConfigPersonAltNamesHelper; | ||||
| use Chill\PersonBundle\Entity\Person; | ||||
| use Chill\PersonBundle\Form\Type\PersonAltNameType; | ||||
| use Chill\PersonBundle\Form\Type\PickGenderType; | ||||
| use Chill\PersonBundle\Security\Authorization\PersonVoter; | ||||
| @@ -33,6 +32,7 @@ use Symfony\Component\Form\FormBuilderInterface; | ||||
| use Symfony\Component\OptionsResolver\OptionsResolver; | ||||
| use Symfony\Component\Validator\Constraints\Callback; | ||||
| use Symfony\Component\Validator\Context\ExecutionContextInterface; | ||||
| use Symfony\Component\Validator\Exception\UnexpectedTypeException; | ||||
|  | ||||
| final class CreationPersonType extends AbstractType | ||||
| { | ||||
| @@ -80,15 +80,18 @@ final class CreationPersonType extends AbstractType | ||||
|             ->add('addressForm', CheckboxType::class, [ | ||||
|                 'label' => 'Create a household and add an address', | ||||
|                 'required' => false, | ||||
|                 'mapped' => false, | ||||
|                 'help' => 'A new household will be created. The person will be member of this household.', | ||||
|             ]) | ||||
|             ->add('address', PickAddressType::class, [ | ||||
|                 'required' => false, | ||||
|                 'mapped' => false, | ||||
|                 'label' => false, | ||||
|             ]); | ||||
|  | ||||
|         $builder->add('identifiers', PersonIdentifiersType::class, [ | ||||
|             'by_reference' => false, | ||||
|             'step' => 'on_create', | ||||
|         ]); | ||||
|  | ||||
|         if ($this->askCenters) { | ||||
|             $builder | ||||
|                 ->add('center', PickCenterType::class, [ | ||||
| @@ -112,7 +115,7 @@ final class CreationPersonType extends AbstractType | ||||
|     public function configureOptions(OptionsResolver $resolver) | ||||
|     { | ||||
|         $resolver->setDefaults([ | ||||
|             'data_class' => Person::class, | ||||
|             'data_class' => PersonCreateDTO::class, | ||||
|             'constraints' => [ | ||||
|                 new Callback($this->validateCheckedAddress(...)), | ||||
|             ], | ||||
| @@ -129,10 +132,12 @@ final class CreationPersonType extends AbstractType | ||||
|  | ||||
|     public function validateCheckedAddress($data, ExecutionContextInterface $context, $payload): void | ||||
|     { | ||||
|         /** @var bool $addressFrom */ | ||||
|         $addressFrom = $context->getObject()->get('addressForm')->getData(); | ||||
|         /** @var ?Address $address */ | ||||
|         $address = $context->getObject()->get('address')->getData(); | ||||
|         if (!$data instanceof PersonCreateDTO) { | ||||
|             throw new UnexpectedTypeException($data, PersonCreateDTO::class); | ||||
|         } | ||||
|  | ||||
|         $addressFrom = $data->addressForm; | ||||
|         $address = $data->address; | ||||
|  | ||||
|         if ($addressFrom && null === $address) { | ||||
|             $context->buildViolation('person_creation.If you want to create an household, an address is required') | ||||
|   | ||||
| @@ -12,10 +12,7 @@ declare(strict_types=1); | ||||
| namespace Chill\PersonBundle\Form\DataMapper; | ||||
|  | ||||
| use Chill\PersonBundle\Entity\PersonAltName; | ||||
| use Doctrine\Common\Collections\ArrayCollection; | ||||
| use Doctrine\Common\Collections\Collection; | ||||
| use Symfony\Component\Form\DataMapperInterface; | ||||
| use Symfony\Component\Form\Exception\UnexpectedTypeException; | ||||
|  | ||||
| class PersonAltNameDataMapper implements DataMapperInterface | ||||
| { | ||||
| @@ -25,62 +22,24 @@ class PersonAltNameDataMapper implements DataMapperInterface | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         if (!$viewData instanceof Collection) { | ||||
|             throw new UnexpectedTypeException($viewData, Collection::class); | ||||
|         } | ||||
|  | ||||
|         $mapIndexToKey = []; | ||||
|  | ||||
|         foreach ($viewData->getIterator() as $key => $altName) { | ||||
|             /* @var PersonAltName $altName */ | ||||
|             $mapIndexToKey[$altName->getKey()] = $key; | ||||
|         if (!is_array($viewData)) { | ||||
|             throw new \InvalidArgumentException('View data must be an array'); | ||||
|         } | ||||
|  | ||||
|         foreach ($forms as $key => $form) { | ||||
|             if (\array_key_exists($key, $mapIndexToKey)) { | ||||
|                 $form->setData($viewData->get($mapIndexToKey[$key])->getLabel()); | ||||
|             $personAltName = $viewData[$key]; | ||||
|             if (!$personAltName instanceof PersonAltName) { | ||||
|                 throw new \InvalidArgumentException('PersonAltName must be an instance of PersonAltName'); | ||||
|             } | ||||
|             $form->setData($personAltName->getLabel()); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public function mapFormsToData(\Traversable $forms, &$viewData): void | ||||
|     { | ||||
|         $mapIndexToKey = []; | ||||
|  | ||||
|         if (\is_array($viewData)) { | ||||
|             $dataIterator = $viewData; | ||||
|         } else { | ||||
|             $dataIterator = $viewData instanceof ArrayCollection ? | ||||
|                  $viewData->toArray() : $viewData->getIterator(); | ||||
|         } | ||||
|  | ||||
|         foreach ($dataIterator as $key => $altName) { | ||||
|             /* @var PersonAltName $altName */ | ||||
|             $mapIndexToKey[$altName->getKey()] = $key; | ||||
|         } | ||||
|  | ||||
|         foreach ($forms as $key => $form) { | ||||
|             $isEmpty = empty($form->getData()); | ||||
|  | ||||
|             if (\array_key_exists($key, $mapIndexToKey)) { | ||||
|                 if ($isEmpty) { | ||||
|                     $viewData->remove($mapIndexToKey[$key]); | ||||
|                 } else { | ||||
|                     $viewData->get($mapIndexToKey[$key])->setLabel($form->getData()); | ||||
|                 } | ||||
|             } else { | ||||
|                 if (!$isEmpty) { | ||||
|                     $altName = (new PersonAltName()) | ||||
|                         ->setKey($key) | ||||
|                         ->setLabel($form->getData()); | ||||
|  | ||||
|                     if (\is_array($viewData)) { | ||||
|                         $viewData[] = $altName; | ||||
|                     } else { | ||||
|                         $viewData->add($altName); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|             $personAltName = array_find($viewData, fn (PersonAltName $altName) => $altName->getKey() === $key); | ||||
|             $personAltName->setLabel($form->getData()); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -14,60 +14,51 @@ namespace Chill\PersonBundle\Form\DataMapper; | ||||
| use Chill\PersonBundle\Entity\Identifier\PersonIdentifier; | ||||
| use Chill\PersonBundle\PersonIdentifier\Exception\UnexpectedTypeException; | ||||
| use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface; | ||||
| use Chill\PersonBundle\Repository\Identifier\PersonIdentifierDefinitionRepository; | ||||
| use Doctrine\Common\Collections\Collection; | ||||
| use Symfony\Component\Form\DataMapperInterface; | ||||
| use Symfony\Component\Form\FormInterface; | ||||
|  | ||||
| final readonly class PersonIdentifiersDataMapper implements DataMapperInterface | ||||
| { | ||||
|     public function __construct( | ||||
|         private PersonIdentifierManagerInterface $identifierManager, | ||||
|         private PersonIdentifierDefinitionRepository $identifierDefinitionRepository, | ||||
|     ) {} | ||||
|  | ||||
|     /** | ||||
|      * @pure | ||||
|      */ | ||||
|     public function mapDataToForms($viewData, \Traversable $forms): void | ||||
|     { | ||||
|         if (!$viewData instanceof Collection) { | ||||
|             throw new UnexpectedTypeException($viewData, Collection::class); | ||||
|         if (!$viewData instanceof PersonIdentifier) { | ||||
|             throw new UnexpectedTypeException($viewData, PersonIdentifier::class); | ||||
|         } | ||||
|         /** @var array<string, FormInterface> $formsByKey */ | ||||
|         $formsByKey = iterator_to_array($forms); | ||||
|  | ||||
|         foreach ($this->identifierManager->getWorkers() as $worker) { | ||||
|             if (!$worker->getDefinition()->isEditableByUsers()) { | ||||
|                 continue; | ||||
|             } | ||||
|             $form = $formsByKey['identifier_'.$worker->getDefinition()->getId()]; | ||||
|             $identifier = $viewData->findFirst(fn (int $key, PersonIdentifier $identifier) => $worker->getDefinition()->getId() === $identifier->getId()); | ||||
|             if (null === $identifier) { | ||||
|                 $identifier = new PersonIdentifier($worker->getDefinition()); | ||||
|             } | ||||
|             $form->setData($identifier->getValue()); | ||||
|         $worker = $this->identifierManager->buildWorkerByPersonIdentifierDefinition($viewData->getDefinition()); | ||||
|         if (!$worker->getDefinition()->isEditableByUsers()) { | ||||
|             return; | ||||
|         } | ||||
|         foreach ($forms as $key => $form) { | ||||
|             $form->setData($viewData->getValue()[$key] ?? $worker->getDefaultValue()[$key] ?? ''); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @pure | ||||
|      */ | ||||
|     public function mapFormsToData(\Traversable $forms, &$viewData): void | ||||
|     { | ||||
|         if (!$viewData instanceof Collection) { | ||||
|             throw new UnexpectedTypeException($viewData, Collection::class); | ||||
|         if (!$viewData instanceof PersonIdentifier) { | ||||
|             throw new UnexpectedTypeException($viewData, PersonIdentifier::class); | ||||
|         } | ||||
|         $worker = $this->identifierManager->buildWorkerByPersonIdentifierDefinition($viewData->getDefinition()); | ||||
|         if (!$worker->getDefinition()->isEditableByUsers()) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         $values = []; | ||||
|         foreach ($forms as $name => $form) { | ||||
|             $identifierId = (int) substr((string) $name, 11); | ||||
|             $identifier = $viewData->findFirst(fn (int $key, PersonIdentifier $identifier) => $identifier->getId() === $identifierId); | ||||
|             $definition = $this->identifierDefinitionRepository->find($identifierId); | ||||
|             if (null === $identifier) { | ||||
|                 $identifier = new PersonIdentifier($definition); | ||||
|                 $viewData->add($identifier); | ||||
|             } | ||||
|             if (!$identifier->getDefinition()->isEditableByUsers()) { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             $worker = $this->identifierManager->buildWorkerByPersonIdentifierDefinition($definition); | ||||
|             $identifier->setValue($form->getData()); | ||||
|             $identifier->setCanonical($worker->canonicalizeValue($identifier->getValue())); | ||||
|             $values[$name] = $form->getData(); | ||||
|         } | ||||
|  | ||||
|         $viewData->setValue($values); | ||||
|         $viewData->setCanonical($worker->canonicalizeValue($viewData->getValue())); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -12,10 +12,12 @@ declare(strict_types=1); | ||||
| namespace Chill\PersonBundle\Form; | ||||
|  | ||||
| use Chill\MainBundle\Templating\TranslatableStringHelperInterface; | ||||
| use Chill\PersonBundle\Entity\Identifier\IdentifierPresenceEnum; | ||||
| use Chill\PersonBundle\Form\DataMapper\PersonIdentifiersDataMapper; | ||||
| use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface; | ||||
| use Symfony\Component\Form\AbstractType; | ||||
| use Symfony\Component\Form\FormBuilderInterface; | ||||
| use Symfony\Component\OptionsResolver\OptionsResolver; | ||||
|  | ||||
| final class PersonIdentifiersType extends AbstractType | ||||
| { | ||||
| @@ -27,22 +29,34 @@ final class PersonIdentifiersType extends AbstractType | ||||
|  | ||||
|     public function buildForm(FormBuilderInterface $builder, array $options): void | ||||
|     { | ||||
|         foreach ($this->identifierManager->getWorkers() as $worker) { | ||||
|         foreach ($this->identifierManager->getWorkers() as $k => $worker) { | ||||
|             if (!$worker->getDefinition()->isEditableByUsers()) { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             // skip some on creation | ||||
|             if ('on_create' === $options['step'] | ||||
|                 && IdentifierPresenceEnum::ON_EDIT === $worker->getDefinition()->getPresence()) { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             $subBuilder = $builder->create( | ||||
|                 'identifier_'.$worker->getDefinition()->getId(), | ||||
|                 options: [ | ||||
|                     'compound' => true, | ||||
|                     'label' => $this->translatableStringHelper->localize($worker->getDefinition()->getLabel()), | ||||
|                     'error_bubbling' => false, | ||||
|                 ] | ||||
|             ); | ||||
|             $subBuilder->setDataMapper($this->identifiersDataMapper); | ||||
|             $worker->buildForm($subBuilder); | ||||
|             $builder->add($subBuilder); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|         $builder->setDataMapper($this->identifiersDataMapper); | ||||
|     public function configureOptions(OptionsResolver $resolver): void | ||||
|     { | ||||
|         $resolver->setDefault('step', 'on_edit') | ||||
|             ->setAllowedValues('step', ['on_edit', 'on_create']); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -21,8 +21,8 @@ use Chill\MainBundle\Form\Type\PickCivilityType; | ||||
| use Chill\MainBundle\Form\Type\Select2CountryType; | ||||
| use Chill\MainBundle\Form\Type\Select2LanguageType; | ||||
| use Chill\MainBundle\Templating\TranslatableStringHelperInterface; | ||||
| use Chill\PersonBundle\Actions\PersonEdit\PersonEditDTO; | ||||
| use Chill\PersonBundle\Config\ConfigPersonAltNamesHelper; | ||||
| use Chill\PersonBundle\Entity\Person; | ||||
| use Chill\PersonBundle\Entity\PersonPhone; | ||||
| use Chill\PersonBundle\Form\Type\PersonAltNameType; | ||||
| use Chill\PersonBundle\Form\Type\PersonPhoneType; | ||||
| @@ -242,7 +242,7 @@ class PersonType extends AbstractType | ||||
|     public function configureOptions(OptionsResolver $resolver): void | ||||
|     { | ||||
|         $resolver->setDefaults([ | ||||
|             'data_class' => Person::class, | ||||
|             'data_class' => PersonEditDTO::class, | ||||
|         ]); | ||||
|  | ||||
|         $resolver->setRequired([ | ||||
|   | ||||
| @@ -32,6 +32,7 @@ class PersonAltNameType extends AbstractType | ||||
|                 [ | ||||
|                     'label' => $label, | ||||
|                     'required' => false, | ||||
|                     'empty_data' => '', | ||||
|                 ] | ||||
|             ); | ||||
|         } | ||||
|   | ||||
| @@ -13,20 +13,26 @@ namespace Chill\PersonBundle\PersonIdentifier\Identifier; | ||||
|  | ||||
| use Chill\PersonBundle\Entity\Identifier\PersonIdentifier; | ||||
| use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition; | ||||
| use Chill\PersonBundle\PersonIdentifier\IdentifierViolationDTO; | ||||
| use Chill\PersonBundle\PersonIdentifier\PersonIdentifierEngineInterface; | ||||
| use Symfony\Component\Form\Extension\Core\Type\TextType; | ||||
| use Symfony\Component\Form\FormBuilderInterface; | ||||
|  | ||||
| final readonly class StringIdentifier implements PersonIdentifierEngineInterface | ||||
| { | ||||
|     public const NAME = 'chill-person-bundle.string-identifier'; | ||||
|  | ||||
|     private const ONLY_NUMBERS = 'only_numbers'; | ||||
|     private const FIXED_LENGTH = 'fixed_length'; | ||||
|  | ||||
|     public static function getName(): string | ||||
|     { | ||||
|         return 'chill-person-bundle.string-identifier'; | ||||
|         return self::NAME; | ||||
|     } | ||||
|  | ||||
|     public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string | ||||
|     { | ||||
|         return $value['content'] ?? ''; | ||||
|         return trim($value['content'] ?? ''); | ||||
|     } | ||||
|  | ||||
|     public function buildForm(FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void | ||||
| @@ -36,6 +42,37 @@ final readonly class StringIdentifier implements PersonIdentifierEngineInterface | ||||
|  | ||||
|     public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string | ||||
|     { | ||||
|         return $identifier?->getValue()['content'] ?? ''; | ||||
|         return trim($identifier?->getValue()['content'] ?? ''); | ||||
|     } | ||||
|  | ||||
|     public function isEmpty(PersonIdentifier $identifier): bool | ||||
|     { | ||||
|         return '' === trim($identifier->getValue()['content'] ?? ''); | ||||
|     } | ||||
|  | ||||
|     public function validate(PersonIdentifier $identifier, PersonIdentifierDefinition $definition): array | ||||
|     { | ||||
|         $config = $definition->getData(); | ||||
|         $content = (string) ($identifier->getValue()['content'] ?? ''); | ||||
|         $violations = []; | ||||
|  | ||||
|         if (($config[self::ONLY_NUMBERS] ?? false) && !preg_match('/^[0-9]+$/', $content)) { | ||||
|             $violations[] = new IdentifierViolationDTO('person_identifier.only_number', '2a3352c0-a2b9-11f0-a767-b7a3f80e52f1'); | ||||
|         } | ||||
|  | ||||
|         if (null !== ($config[self::FIXED_LENGTH] ?? null) && strlen($content) !== $config[self::FIXED_LENGTH]) { | ||||
|             $violations[] = new IdentifierViolationDTO( | ||||
|                 'person_identifier.fixed_length', | ||||
|                 '2b02a8fe-a2b9-11f0-bfe5-033300972783', | ||||
|                 ['limit' => (string) $config[self::FIXED_LENGTH]] | ||||
|             ); | ||||
|         } | ||||
|  | ||||
|         return $violations; | ||||
|     } | ||||
|  | ||||
|     public function getDefaultValue(PersonIdentifierDefinition $definition): array | ||||
|     { | ||||
|         return ['content' => '']; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,31 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\PersonBundle\PersonIdentifier; | ||||
|  | ||||
| /** | ||||
|  * Data Transfer Object to create a ConstraintViolationListInterface. | ||||
|  */ | ||||
| class IdentifierViolationDTO | ||||
| { | ||||
|     public function __construct( | ||||
|         public string $message, | ||||
|         /** | ||||
|          * @var string an UUID | ||||
|          */ | ||||
|         public string $code, | ||||
|         /** | ||||
|          * @var array<string, string> | ||||
|          */ | ||||
|         public array $parameters = [], | ||||
|         public string $messageDomain = 'validators', | ||||
|     ) {} | ||||
| } | ||||
| @@ -0,0 +1,39 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\PersonBundle\PersonIdentifier\Normalizer; | ||||
|  | ||||
| use Chill\PersonBundle\PersonIdentifier\PersonIdentifierWorker; | ||||
| use Symfony\Component\Serializer\Normalizer\NormalizerInterface; | ||||
|  | ||||
| final readonly class PersonIdentifierWorkerNormalizer implements NormalizerInterface | ||||
| { | ||||
|     public function normalize($object, ?string $format = null, array $context = []): array | ||||
|     { | ||||
|         if (!$object instanceof PersonIdentifierWorker) { | ||||
|             throw new \Symfony\Component\Serializer\Exception\UnexpectedValueException(); | ||||
|         } | ||||
|  | ||||
|         return [ | ||||
|             'type' => 'person_identifier_worker', | ||||
|             'definition_id' => $object->getDefinition()->getId(), | ||||
|             'engine' => $object->getDefinition()->getEngine(), | ||||
|             'label' => $object->getDefinition()->getLabel(), | ||||
|             'isActive' => $object->getDefinition()->isActive(), | ||||
|             'presence' => $object->getDefinition()->getPresence()->value, | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|     public function supportsNormalization($data, ?string $format = null): bool | ||||
|     { | ||||
|         return $data instanceof PersonIdentifierWorker; | ||||
|     } | ||||
| } | ||||
| @@ -19,9 +19,29 @@ interface PersonIdentifierEngineInterface | ||||
| { | ||||
|     public static function getName(): string; | ||||
|  | ||||
|     /** | ||||
|      * @phpstan-pure | ||||
|      */ | ||||
|     public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string; | ||||
|  | ||||
|     public function buildForm(FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void; | ||||
|  | ||||
|     public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string; | ||||
|  | ||||
|     /** | ||||
|      * Return true if the identifier must be considered as empty. | ||||
|      * | ||||
|      * This is in use when the identifier is validated and must be required. If the identifier is empty and is required | ||||
|      * by the definition, the validation will fails. | ||||
|      */ | ||||
|     public function isEmpty(PersonIdentifier $identifier): bool; | ||||
|  | ||||
|     /** | ||||
|      * Return a list of @see{IdentifierViolationDTO} to generatie violation errors. | ||||
|      * | ||||
|      * @return list<IdentifierViolationDTO> | ||||
|      */ | ||||
|     public function validate(PersonIdentifier $identifier, PersonIdentifierDefinition $definition): array; | ||||
|  | ||||
|     public function getDefaultValue(PersonIdentifierDefinition $definition): array; | ||||
| } | ||||
|   | ||||
| @@ -13,6 +13,7 @@ namespace Chill\PersonBundle\PersonIdentifier; | ||||
|  | ||||
| use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition; | ||||
| use Chill\PersonBundle\PersonIdentifier\Exception\EngineNotFoundException; | ||||
| use Chill\PersonBundle\PersonIdentifier\Exception\PersonIdentifierDefinitionNotFoundException; | ||||
| use Chill\PersonBundle\Repository\Identifier\PersonIdentifierDefinitionRepository; | ||||
|  | ||||
| final readonly class PersonIdentifierManager implements PersonIdentifierManagerInterface | ||||
| @@ -44,8 +45,16 @@ final readonly class PersonIdentifierManager implements PersonIdentifierManagerI | ||||
|         return $workers; | ||||
|     } | ||||
|  | ||||
|     public function buildWorkerByPersonIdentifierDefinition(PersonIdentifierDefinition $personIdentifierDefinition): PersonIdentifierWorker | ||||
|     public function buildWorkerByPersonIdentifierDefinition(int|PersonIdentifierDefinition $personIdentifierDefinition): PersonIdentifierWorker | ||||
|     { | ||||
|         if (is_int($personIdentifierDefinition)) { | ||||
|             $id = $personIdentifierDefinition; | ||||
|             $personIdentifierDefinition = $this->personIdentifierDefinitionRepository->find($id); | ||||
|             if (null === $personIdentifierDefinition) { | ||||
|                 throw new PersonIdentifierDefinitionNotFoundException($id); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return new PersonIdentifierWorker($this->getEngine($personIdentifierDefinition->getEngine()), $personIdentifierDefinition); | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -18,9 +18,16 @@ interface PersonIdentifierManagerInterface | ||||
|     /** | ||||
|      * Build PersonIdentifierWorker's for all active definition. | ||||
|      * | ||||
|      * Only active definition are returned. | ||||
|      * | ||||
|      * @return list<PersonIdentifierWorker> | ||||
|      */ | ||||
|     public function getWorkers(): array; | ||||
|  | ||||
|     public function buildWorkerByPersonIdentifierDefinition(PersonIdentifierDefinition $personIdentifierDefinition): PersonIdentifierWorker; | ||||
|     /** | ||||
|      * @param int|PersonIdentifierDefinition $personIdentifierDefinition an instance of PersonIdentifierDefinition, or his id | ||||
|      * | ||||
|      * @throw PersonIdentifierNotFoundException | ||||
|      */ | ||||
|     public function buildWorkerByPersonIdentifierDefinition(int|PersonIdentifierDefinition $personIdentifierDefinition): PersonIdentifierWorker; | ||||
| } | ||||
|   | ||||
| @@ -15,7 +15,7 @@ use Chill\PersonBundle\Entity\Identifier\PersonIdentifier; | ||||
| use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition; | ||||
| use Symfony\Component\Form\FormBuilderInterface; | ||||
|  | ||||
| final readonly class PersonIdentifierWorker | ||||
| readonly class PersonIdentifierWorker | ||||
| { | ||||
|     public function __construct( | ||||
|         private PersonIdentifierEngineInterface $identifierEngine, | ||||
| @@ -46,4 +46,25 @@ final readonly class PersonIdentifierWorker | ||||
|     { | ||||
|         return $this->identifierEngine->renderAsString($identifier, $this->definition); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Return true if the identifier must be considered as empty. | ||||
|      */ | ||||
|     public function isEmpty(PersonIdentifier $identifier): bool | ||||
|     { | ||||
|         return $this->identifierEngine->isEmpty($identifier); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @return list<IdentifierViolationDTO> | ||||
|      */ | ||||
|     public function validate(PersonIdentifier $identifier, PersonIdentifierDefinition $definition): array | ||||
|     { | ||||
|         return $this->identifierEngine->validate($identifier, $definition); | ||||
|     } | ||||
|  | ||||
|     public function getDefaultValue(): array | ||||
|     { | ||||
|         return $this->identifierEngine->getDefaultValue($this->definition); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,28 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\PersonBundle\PersonIdentifier\Validator; | ||||
|  | ||||
| use Symfony\Component\Validator\Constraint; | ||||
|  | ||||
| /** | ||||
|  * Test that the required constraints are present. | ||||
|  */ | ||||
| #[\Attribute] | ||||
| class RequiredIdentifierConstraint extends Constraint | ||||
| { | ||||
|     public string $message = 'person_identifier.This identifier must be set'; | ||||
|  | ||||
|     public function getTargets(): string | ||||
|     { | ||||
|         return self::PROPERTY_CONSTRAINT; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,53 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\PersonBundle\PersonIdentifier\Validator; | ||||
|  | ||||
| use Chill\PersonBundle\Entity\Identifier\IdentifierPresenceEnum; | ||||
| use Chill\PersonBundle\Entity\Identifier\PersonIdentifier; | ||||
| use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface; | ||||
| use Doctrine\Common\Collections\Collection; | ||||
| use Symfony\Component\Validator\Constraint; | ||||
| use Symfony\Component\Validator\ConstraintValidator; | ||||
| use Symfony\Component\Validator\Exception\UnexpectedTypeException; | ||||
| use Symfony\Component\Validator\Exception\UnexpectedValueException; | ||||
|  | ||||
| final class RequiredIdentifierConstraintValidator extends ConstraintValidator | ||||
| { | ||||
|     public function __construct(private readonly PersonIdentifierManagerInterface $identifierManager) {} | ||||
|  | ||||
|     public function validate($value, Constraint $constraint) | ||||
|     { | ||||
|         if (!$constraint instanceof RequiredIdentifierConstraint) { | ||||
|             throw new UnexpectedTypeException($constraint, RequiredIdentifierConstraint::class); | ||||
|         } | ||||
|  | ||||
|         if (!$value instanceof Collection) { | ||||
|             throw new UnexpectedValueException($value, Collection::class); | ||||
|         } | ||||
|  | ||||
|         foreach ($this->identifierManager->getWorkers() as $worker) { | ||||
|             if (IdentifierPresenceEnum::REQUIRED !== $worker->getDefinition()->getPresence()) { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             $identifier = $value->findFirst(fn (int $key, PersonIdentifier $identifier) => $identifier->getDefinition() === $worker->getDefinition()); | ||||
|  | ||||
|             if (null === $identifier || $worker->isEmpty($identifier)) { | ||||
|                 $this->context->buildViolation($constraint->message) | ||||
|                     ->setParameter('{{ value }}', $worker->renderAsString($identifier)) | ||||
|                     ->setParameter('definition_id', (string) $worker->getDefinition()->getId()) | ||||
|                     ->setCode('c08b7b32-947f-11f0-8608-9b8560e9bf05') | ||||
|                     ->addViolation(); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,25 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\PersonBundle\PersonIdentifier\Validator; | ||||
|  | ||||
| use Symfony\Component\Validator\Constraint; | ||||
|  | ||||
| #[\Attribute] | ||||
| class UniqueIdentifierConstraint extends Constraint | ||||
| { | ||||
|     public string $message = 'person_identifier.Identifier must be unique. The same identifier already exists for {{ persons }}'; | ||||
|  | ||||
|     public function getTargets(): string | ||||
|     { | ||||
|         return self::CLASS_CONSTRAINT; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,53 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\PersonBundle\PersonIdentifier\Validator; | ||||
|  | ||||
| use Chill\PersonBundle\Entity\Identifier\PersonIdentifier; | ||||
| use Chill\PersonBundle\Repository\Identifier\PersonIdentifierRepository; | ||||
| use Chill\PersonBundle\Templating\Entity\PersonRenderInterface; | ||||
| use Symfony\Component\Validator\Constraint; | ||||
| use Symfony\Component\Validator\ConstraintValidator; | ||||
| use Symfony\Component\Validator\Exception\UnexpectedTypeException; | ||||
| use Symfony\Component\Validator\Exception\UnexpectedValueException; | ||||
|  | ||||
| class UniqueIdentifierConstraintValidator extends ConstraintValidator | ||||
| { | ||||
|     public function __construct( | ||||
|         private readonly PersonIdentifierRepository $personIdentifierRepository, | ||||
|         private readonly PersonRenderInterface $personRender, | ||||
|     ) {} | ||||
|  | ||||
|     public function validate($value, Constraint $constraint): void | ||||
|     { | ||||
|         if (!$constraint instanceof UniqueIdentifierConstraint) { | ||||
|             throw new UnexpectedTypeException($constraint, UniqueIdentifierConstraint::class); | ||||
|         } | ||||
|  | ||||
|         if (!$value instanceof PersonIdentifier) { | ||||
|             throw new UnexpectedValueException($value, PersonIdentifier::class); | ||||
|         } | ||||
|  | ||||
|         $identifiers = $this->personIdentifierRepository->findByDefinitionAndCanonical($value->getDefinition(), $value->getValue()); | ||||
|  | ||||
|         if (count($identifiers) > 0) { | ||||
|             if (count($identifiers) > 1 || $identifiers[0]->getPerson() !== $value->getPerson()) { | ||||
|                 $persons = array_map(fn (PersonIdentifier $idf): string => $this->personRender->renderString($idf->getPerson(), []), $identifiers); | ||||
|  | ||||
|                 $this->context->buildViolation($constraint->message) | ||||
|                     ->setParameter('{{ persons }}', implode(', ', $persons)) | ||||
|                     ->setParameter('definition_id', (string) $value->getDefinition()->getId()) | ||||
|                     ->addViolation(); | ||||
|  | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,23 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\PersonBundle\PersonIdentifier\Validator; | ||||
|  | ||||
| use Symfony\Component\Validator\Constraint; | ||||
|  | ||||
| #[\Attribute] | ||||
| class ValidIdentifierConstraint extends Constraint | ||||
| { | ||||
|     public function getTargets(): string | ||||
|     { | ||||
|         return self::CLASS_CONSTRAINT; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,47 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\PersonBundle\PersonIdentifier\Validator; | ||||
|  | ||||
| use Chill\PersonBundle\Entity\Identifier\PersonIdentifier; | ||||
| use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface; | ||||
| use Symfony\Component\Validator\Constraint; | ||||
| use Symfony\Component\Validator\ConstraintValidator; | ||||
| use Symfony\Component\Validator\Exception\UnexpectedTypeException; | ||||
| use Symfony\Component\Validator\Exception\UnexpectedValueException; | ||||
|  | ||||
| final class ValidIdentifierConstraintValidator extends ConstraintValidator | ||||
| { | ||||
|     public function __construct(private readonly PersonIdentifierManagerInterface $personIdentifierManager) {} | ||||
|  | ||||
|     public function validate($value, Constraint $constraint): void | ||||
|     { | ||||
|         if (!$constraint instanceof ValidIdentifierConstraint) { | ||||
|             throw new UnexpectedTypeException($constraint, ValidIdentifierConstraint::class); | ||||
|         } | ||||
|  | ||||
|         if (!$value instanceof PersonIdentifier) { | ||||
|             throw new UnexpectedValueException($value, PersonIdentifier::class); | ||||
|         } | ||||
|  | ||||
|         $worker = $this->personIdentifierManager->buildWorkerByPersonIdentifierDefinition($value->getDefinition()); | ||||
|  | ||||
|         $violations = $worker->validate($value, $value->getDefinition()); | ||||
|  | ||||
|         foreach ($violations as $violation) { | ||||
|             $this->context->buildViolation($violation->message) | ||||
|                 ->setParameters($violation->parameters) | ||||
|                 ->setParameter('{{ code }}', $violation->code) | ||||
|                 ->setParameter('definition_id', (string) $value->getDefinition()->getId()) | ||||
|                 ->addViolation(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,42 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\PersonBundle\Repository\Identifier; | ||||
|  | ||||
| use Chill\PersonBundle\Entity\Identifier\PersonIdentifier; | ||||
| use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition; | ||||
| use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface; | ||||
| use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; | ||||
| use Doctrine\Persistence\ManagerRegistry; | ||||
|  | ||||
| class PersonIdentifierRepository extends ServiceEntityRepository | ||||
| { | ||||
|     public function __construct(ManagerRegistry $registry, private readonly PersonIdentifierManagerInterface $personIdentifierManager) | ||||
|     { | ||||
|         parent::__construct($registry, PersonIdentifier::class); | ||||
|     } | ||||
|  | ||||
|     public function findByDefinitionAndCanonical(PersonIdentifierDefinition $definition, array|string $valueOrCanonical): array | ||||
|     { | ||||
|         return $this->createQueryBuilder('p') | ||||
|             ->where('p.definition = :definition') | ||||
|             ->andWhere('p.canonical = :canonical') | ||||
|             ->setParameter('definition', $definition) | ||||
|             ->setParameter( | ||||
|                 'canonical', | ||||
|                 is_string($valueOrCanonical) ? | ||||
|                     $valueOrCanonical : | ||||
|                     $this->personIdentifierManager->buildWorkerByPersonIdentifierDefinition($definition)->canonicalizeValue($valueOrCanonical), | ||||
|             ) | ||||
|             ->getQuery() | ||||
|             ->getResult(); | ||||
|     } | ||||
| } | ||||
| @@ -17,6 +17,8 @@ use Chill\MainBundle\Search\ParsingException; | ||||
| use Chill\MainBundle\Search\SearchApiQuery; | ||||
| use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface; | ||||
| use Chill\PersonBundle\Entity\Person; | ||||
| use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface; | ||||
| use Chill\PersonBundle\PersonIdentifier\PersonIdentifierWorker; | ||||
| use Chill\PersonBundle\Security\Authorization\PersonVoter; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Doctrine\ORM\NonUniqueResultException; | ||||
| @@ -27,7 +29,13 @@ use Symfony\Component\Security\Core\Security; | ||||
|  | ||||
| final readonly class PersonACLAwareRepository implements PersonACLAwareRepositoryInterface | ||||
| { | ||||
|     public function __construct(private Security $security, private EntityManagerInterface $em, private CountryRepository $countryRepository, private AuthorizationHelperInterface $authorizationHelper) {} | ||||
|     public function __construct( | ||||
|         private Security $security, | ||||
|         private EntityManagerInterface $em, | ||||
|         private CountryRepository $countryRepository, | ||||
|         private AuthorizationHelperInterface $authorizationHelper, | ||||
|         private PersonIdentifierManagerInterface $personIdentifierManager, | ||||
|     ) {} | ||||
|  | ||||
|     public function buildAuthorizedQuery( | ||||
|         ?string $default = null, | ||||
| @@ -107,6 +115,15 @@ final readonly class PersonACLAwareRepository implements PersonACLAwareRepositor | ||||
|         $query | ||||
|             ->setFromClause('chill_person_person AS person'); | ||||
|  | ||||
|         $idDefinitionWorkers = array_map( | ||||
|             fn (PersonIdentifierWorker $worker) => $worker->getDefinition()->getId(), | ||||
|             array_filter( | ||||
|                 $this->personIdentifierManager->getWorkers(), | ||||
|                 fn (PersonIdentifierWorker $worker) => $worker->getDefinition()->isSearchable() | ||||
|             ) | ||||
|         ); | ||||
|         $idDefinitionWorkerQuestionMarks = implode(', ', array_fill(0, count($idDefinitionWorkers), '?')); | ||||
|  | ||||
|         $pertinence = []; | ||||
|         $pertinenceArgs = []; | ||||
|         $andWhereSearchClause = []; | ||||
| @@ -124,20 +141,53 @@ final readonly class PersonACLAwareRepository implements PersonACLAwareRepositor | ||||
|                     '(starts_with(LOWER(UNACCENT(lastname)), UNACCENT(LOWER(?))))::int'; | ||||
|                 \array_push($pertinenceArgs, $str, $str, $str, $str); | ||||
|  | ||||
|                 $andWhereSearchClause[] = | ||||
|                     '(LOWER(UNACCENT(?)) <<% person.fullnamecanonical OR '. | ||||
|                     "person.fullnamecanonical LIKE '%' || LOWER(UNACCENT(?)) || '%' )"; | ||||
|                 \array_push($andWhereSearchClauseArgs, $str, $str); | ||||
|                 $q = [ | ||||
|                     'LOWER(UNACCENT(?)) <<% person.fullnamecanonical', | ||||
|                     "person.fullnamecanonical LIKE '%' || LOWER(UNACCENT(?)) || '%' ", | ||||
|                 ]; | ||||
|                 $qArguments = [$str, $str]; | ||||
|  | ||||
|                 if (count($idDefinitionWorkers) > 0) { | ||||
|                     $q[] = $mq = "EXISTS ( | ||||
|                         SELECT 1 FROM chill_person_identifier AS identifier | ||||
|                         WHERE identifier.canonical LIKE LOWER(UNACCENT(?)) || '%' AND identifier.definition_id IN ({$idDefinitionWorkerQuestionMarks}) | ||||
|                         AND person.id = identifier.person_id | ||||
|                     )"; | ||||
|                     $pertinence[] = "({$mq})::int * 1000000"; | ||||
|                     $qArguments = [...$qArguments, $str, ...$idDefinitionWorkers]; | ||||
|                     $pertinenceArgs = [...$pertinenceArgs, $str, ...$idDefinitionWorkers]; | ||||
|                 } | ||||
|  | ||||
|                 $andWhereSearchClause[] = '('.implode(' OR ', $q).')'; | ||||
|                 $andWhereSearchClauseArgs = [...$andWhereSearchClauseArgs, ...$qArguments]; | ||||
|             } | ||||
|  | ||||
|             $query->andWhereClause( | ||||
|                 \implode(' AND ', $andWhereSearchClause), | ||||
|                 $andWhereSearchClauseArgs | ||||
|             ); | ||||
|         } else { | ||||
|             $pertinence = ['1']; | ||||
|             $pertinenceArgs = []; | ||||
|         } | ||||
|  | ||||
|         if (null !== $phonenumber) { | ||||
|             $personPhoneClause = "person.phonenumber LIKE '%' || ? || '%' OR person.mobilenumber LIKE '%' || ? || '%' OR EXISTS (SELECT 1 FROM chill_person_phone where person_id = person.id AND phonenumber LIKE '%' || ? || '%')"; | ||||
|             if (count($andWhereSearchClauseArgs) > 0) { | ||||
|                 $initialSearchClause = '(('.\implode(' AND ', $andWhereSearchClause).') OR '.$personPhoneClause.')'; | ||||
|             } | ||||
|             $andWhereSearchClauseArgs = [...$andWhereSearchClauseArgs, $phonenumber, $phonenumber, $phonenumber]; | ||||
|  | ||||
|             // drastically increase pertinence | ||||
|             $pertinence[] = "(person.phonenumber LIKE '%' || ? || '%' OR person.mobilenumber LIKE '%' || ? || '%' OR EXISTS (SELECT 1 FROM chill_person_phone where person_id = person.id AND phonenumber LIKE '%' || ? || '%'))::int * 1000000"; | ||||
|             $pertinenceArgs = [...$pertinenceArgs, $phonenumber, $phonenumber, $phonenumber]; | ||||
|         } else { | ||||
|             $initialSearchClause = \implode(' AND ', $andWhereSearchClause); | ||||
|         } | ||||
|  | ||||
|         if (isset($initialSearchClause)) { | ||||
|             $query->andWhereClause( | ||||
|                 $initialSearchClause, | ||||
|                 $andWhereSearchClauseArgs | ||||
|             ); | ||||
|         } | ||||
|  | ||||
|         $query | ||||
|             ->setSelectPertinence(\implode(' + ', $pertinence), $pertinenceArgs); | ||||
|  | ||||
| @@ -176,14 +226,6 @@ final readonly class PersonACLAwareRepository implements PersonACLAwareRepositor | ||||
|             ); | ||||
|         } | ||||
|  | ||||
|         if (null !== $phonenumber) { | ||||
|             $query->andWhereClause( | ||||
|                 "person.phonenumber LIKE '%' || ? || '%' OR person.mobilenumber LIKE '%' || ? || '%' OR pp.phonenumber LIKE '%' || ? || '%'", | ||||
|                 [$phonenumber, $phonenumber, $phonenumber] | ||||
|             ); | ||||
|             $query->setFromClause($query->getFromClause().' LEFT JOIN chill_person_phone pp ON pp.person_id = person.id'); | ||||
|         } | ||||
|  | ||||
|         if (null !== $city) { | ||||
|             $query->setFromClause($query->getFromClause().' '. | ||||
|                 'JOIN view_chill_person_current_address vcpca ON vcpca.person_id = person.id '. | ||||
|   | ||||
| @@ -10,16 +10,40 @@ import { | ||||
|   Scope, | ||||
|   Job, | ||||
|   PrivateCommentEmbeddable, | ||||
|   TranslatableString, | ||||
|   DateTimeWrite, | ||||
|   SetGender, | ||||
|   SetCenter, | ||||
|   SetCivility, Gender, | ||||
| } from "ChillMainAssets/types"; | ||||
| import { StoredObject } from "ChillDocStoreAssets/types"; | ||||
| import { Thirdparty } from "../../../ChillThirdPartyBundle/Resources/public/types"; | ||||
| import { Calendar } from "../../../ChillCalendarBundle/Resources/public/types"; | ||||
| import Person from "./vuejs/_components/OnTheFly/Person.vue"; | ||||
|  | ||||
| /** | ||||
|  * An alternative name, as configured locally | ||||
|  */ | ||||
| export interface AltName { | ||||
|   label: string; | ||||
|   labels: TranslatableString; | ||||
|   key: string; | ||||
| } | ||||
|  | ||||
| export interface PersonAltNameWrite { | ||||
|   key: string; | ||||
|   value: string; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * An altname for a person | ||||
|  */ | ||||
| export interface PersonAltName { | ||||
|   label: string; | ||||
|   /** | ||||
|    * will match a key in @link{AltName} | ||||
|    */ | ||||
|   key: string; | ||||
| } | ||||
|  | ||||
| export interface Person { | ||||
|   id: number; | ||||
|   type: "person"; | ||||
| @@ -27,7 +51,7 @@ export interface Person { | ||||
|   textAge: string; | ||||
|   firstName: string; | ||||
|   lastName: string; | ||||
|   altNames: AltName[]; | ||||
|   altNames: PersonAltName[]; | ||||
|   suffixText: string; | ||||
|   current_household_address: Address | null; | ||||
|   birthdate: DateTime | null; | ||||
| @@ -36,11 +60,63 @@ export interface Person { | ||||
|   phonenumber: string; | ||||
|   mobilenumber: string; | ||||
|   email: string; | ||||
|   gender: "woman" | "man" | "other"; | ||||
|   gender: Gender; | ||||
|   centers: Center[]; | ||||
|   civility: Civility | null; | ||||
|   current_household_id: number; | ||||
|   current_residential_addresses: Address[]; | ||||
|   current_residential_addresses: ResidentialAddress[]; | ||||
|   /** | ||||
|    * The person id as configured by the user | ||||
|    */ | ||||
|   personId: string; | ||||
|   identifiers: PersonIdentifier[]; | ||||
| } | ||||
|  | ||||
| export interface PersonIdentifier { | ||||
|   id: number; | ||||
|   type: "person_identifier"; | ||||
|   value: object; | ||||
|   definition: PersonIdentifierDefinition; | ||||
| } | ||||
|  | ||||
| export interface PersonIdentifierDefinition { | ||||
|   id: number; | ||||
|   type: "person_identifier_definition"; | ||||
|   engine: string; | ||||
| } | ||||
|  | ||||
| export interface ResidentialAddress { | ||||
|   address: Address | null; | ||||
|   endDate: DateTime | null; | ||||
|   hostPerson: Person | null; | ||||
|   hostThirdParty: Thirdparty | null; | ||||
|   startDate: DateTime | null; | ||||
| } | ||||
|  | ||||
| export interface PersonIdentifierWrite { | ||||
|   type: "person_identifier"; | ||||
|   definition_id: number; | ||||
|   value: object; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Person representation to create or update a Person | ||||
|  */ | ||||
| export interface PersonWrite { | ||||
|   type: "person"; | ||||
|   firstName: string; | ||||
|   lastName: string; | ||||
|   altNames: PersonAltNameWrite[]; | ||||
|   addressId: number | null; | ||||
|   birthdate: DateTimeWrite | null; | ||||
|   deathdate: DateTimeWrite | null; | ||||
|   phonenumber: string; | ||||
|   mobilenumber: string; | ||||
|   email: string; | ||||
|   gender: SetGender | null; | ||||
|   center: SetCenter | null; | ||||
|   civility: SetCivility | null; | ||||
|   identifiers: PersonIdentifierWrite[]; | ||||
| } | ||||
|  | ||||
| export interface AccompanyingPeriod { | ||||
| @@ -329,22 +405,50 @@ export interface AccompanyingPeriodWorkEvaluationDocument { | ||||
|   workflows: object[]; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Entity types that a user can create through AddPersons component | ||||
|  */ | ||||
| export type CreatableEntityType = "person" | "thirdparty"; | ||||
|  | ||||
| /** | ||||
|  * Entities that can be search and selected by a user | ||||
|  */ | ||||
| export type EntityType = | ||||
|   | CreatableEntityType | ||||
|   | "user_group" | ||||
|   | "user" | ||||
|   | "person" | ||||
|   | "thirdparty" | ||||
|   | "household"; | ||||
|  | ||||
| export type Entities = (UserGroup | User | Person | Thirdparty | Household) & { | ||||
|   address?: Address | null; | ||||
|   kind?: string; | ||||
|   text?: string; | ||||
|   profession?: string; | ||||
| }; | ||||
| export type Entities = (UserGroup | User | Person | Thirdparty | Household); | ||||
|  | ||||
| export function isEntityHousehold(e: Entities): e is Household { | ||||
|   return e.type === "household"; | ||||
| } | ||||
|  | ||||
| export type EntitiesOrMe = "me" | Entities; | ||||
|  | ||||
|  | ||||
| // Type guards to discriminate Suggestions by their result kind | ||||
| export function isSuggestionForUserGroup(s: Suggestion): s is Suggestion & { result: UserGroup } { | ||||
|   return (s as any)?.result?.type === "user_group"; | ||||
| } | ||||
|  | ||||
| export function isSuggestionForUser(s: Suggestion): s is Suggestion & { result: User } { | ||||
|   return (s as any)?.result?.type === "user"; | ||||
| } | ||||
|  | ||||
| export function isSuggestionForPerson(s: Suggestion): s is Suggestion & { result: Person } { | ||||
|   return (s as any)?.result?.type === "person"; | ||||
| } | ||||
|  | ||||
| export function isSuggestionForThirdParty(s: Suggestion): s is Suggestion & { result: Thirdparty } { | ||||
|   return (s as any)?.result?.type === "thirdparty"; | ||||
| } | ||||
|  | ||||
| export function isSuggestionForHousehold(s: Suggestion): s is Suggestion & { result: Household } { | ||||
|   return (s as any)?.result?.type === "household"; | ||||
| } | ||||
|  | ||||
| export type AddPersonResult = Entities & { | ||||
|   parent?: Entities | null; | ||||
| }; | ||||
| @@ -352,9 +456,8 @@ export type AddPersonResult = Entities & { | ||||
| export interface Suggestion { | ||||
|   key: string; | ||||
|   relevance: number; | ||||
|   result: AddPersonResult; | ||||
|   result: Entities; | ||||
| } | ||||
|  | ||||
| export interface SearchPagination { | ||||
|   first: number; | ||||
|   items_per_page: number; | ||||
| @@ -366,12 +469,13 @@ export interface SearchPagination { | ||||
| export interface Search { | ||||
|   count: number; | ||||
|   pagination: SearchPagination; | ||||
|   results: Suggestion[]; | ||||
|   results: {relevance: number, result: Entities}[]; | ||||
| } | ||||
|  | ||||
| export interface SearchOptions { | ||||
|   uniq: boolean; | ||||
|   type: string[]; | ||||
|   /** @deprecated */ | ||||
|   type: EntityType[]; | ||||
|   priority: number | null; | ||||
|   button: { | ||||
|     size: string; | ||||
| @@ -381,6 +485,17 @@ export interface SearchOptions { | ||||
|   }; | ||||
| } | ||||
|  | ||||
| type PersonIdentifierPresence = 'NOT_EDITABLE' | 'ON_EDIT' | 'ON_CREATION' | 'REQUIRED'; | ||||
|  | ||||
| export interface PersonIdentifierWorker { | ||||
|   type: "person_identifier_worker"; | ||||
|   definition_id: number; | ||||
|   engine: string; | ||||
|   label: TranslatableString; | ||||
|   isActive: boolean; | ||||
|   presence: PersonIdentifierPresence; | ||||
| } | ||||
|  | ||||
| export class MakeFetchException extends Error { | ||||
|   sta: number; | ||||
|   txt: string; | ||||
|   | ||||
| @@ -322,7 +322,7 @@ export default { | ||||
|         } | ||||
|       }); | ||||
|     }, | ||||
|     addNewPersons({ selected, modal }) { | ||||
|     addNewPersons({ selected }) { | ||||
|       //console.log('@@@ CLICK button addNewPersons', selected); | ||||
|       this.$store | ||||
|         .dispatch("addRequestor", selected.shift()) | ||||
| @@ -337,7 +337,6 @@ export default { | ||||
|         }); | ||||
|  | ||||
|       this.$refs.addPersons.resetSearch(); // to cast child method | ||||
|       modal.showModal = false; | ||||
|     }, | ||||
|     saveFormOnTheFly(payload) { | ||||
|       console.log( | ||||
|   | ||||
| @@ -1,88 +0,0 @@ | ||||
| import { makeFetch } from "ChillMainAssets/lib/api/apiMethods"; | ||||
|  | ||||
| /* | ||||
|  * GET a person by id | ||||
|  */ | ||||
| const getPerson = (id) => { | ||||
|   const url = `/api/1.0/person/person/${id}.json`; | ||||
|   return fetch(url).then((response) => { | ||||
|     if (response.ok) { | ||||
|       return response.json(); | ||||
|     } | ||||
|     throw Error("Error with request resource response"); | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| const getPersonAltNames = () => | ||||
|   fetch("/api/1.0/person/config/alt_names.json").then((response) => { | ||||
|     if (response.ok) { | ||||
|       return response.json(); | ||||
|     } | ||||
|     throw Error("Error with request resource response"); | ||||
|   }); | ||||
|  | ||||
| const getCivilities = () => | ||||
|   fetch("/api/1.0/main/civility.json").then((response) => { | ||||
|     if (response.ok) { | ||||
|       return response.json(); | ||||
|     } | ||||
|     throw Error("Error with request resource response"); | ||||
|   }); | ||||
|  | ||||
| const getGenders = () => makeFetch("GET", "/api/1.0/main/gender.json"); | ||||
| // .then(response => { | ||||
| //     console.log(response) | ||||
| //     if (response.ok) { return response.json(); } | ||||
| //     throw Error('Error with request resource response'); | ||||
| // }); | ||||
|  | ||||
| const getCentersForPersonCreation = () => | ||||
|   makeFetch("GET", "/api/1.0/person/creation/authorized-centers", null); | ||||
|  | ||||
| /* | ||||
|  * POST a new person | ||||
|  */ | ||||
| const postPerson = (body) => { | ||||
|   const url = `/api/1.0/person/person.json`; | ||||
|   return fetch(url, { | ||||
|     method: "POST", | ||||
|     headers: { | ||||
|       "Content-Type": "application/json;charset=utf-8", | ||||
|     }, | ||||
|     body: JSON.stringify(body), | ||||
|   }).then((response) => { | ||||
|     if (response.ok) { | ||||
|       return response.json(); | ||||
|     } | ||||
|     throw Error("Error with request resource response"); | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| /* | ||||
|  * PATCH an existing person | ||||
|  */ | ||||
| const patchPerson = (id, body) => { | ||||
|   const url = `/api/1.0/person/person/${id}.json`; | ||||
|   return fetch(url, { | ||||
|     method: "PATCH", | ||||
|     headers: { | ||||
|       "Content-Type": "application/json;charset=utf-8", | ||||
|     }, | ||||
|     body: JSON.stringify(body), | ||||
|   }).then((response) => { | ||||
|     if (response.ok) { | ||||
|       return response.json(); | ||||
|     } | ||||
|     throw Error("Error with request resource response"); | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| export { | ||||
|   getCentersForPersonCreation, | ||||
|   getPerson, | ||||
|   getPersonAltNames, | ||||
|   getCivilities, | ||||
|   getGenders, | ||||
|   postPerson, | ||||
|   patchPerson, | ||||
| }; | ||||
| @@ -0,0 +1,116 @@ | ||||
| import { fetchResults, makeFetch } from "ChillMainAssets/lib/api/apiMethods"; | ||||
| import {Center, Civility, Gender, SetCenter} from "ChillMainAssets/types"; | ||||
| import { | ||||
|   AltName, | ||||
|   Person, PersonIdentifier, | ||||
|   PersonIdentifierWorker, | ||||
|   PersonWrite, | ||||
| } from "ChillPersonAssets/types"; | ||||
| import person from "ChillPersonAssets/vuejs/_components/OnTheFly/Person.vue"; | ||||
|  | ||||
| /* | ||||
|  * GET a person by id | ||||
|  */ | ||||
| export const getPerson = async (id: number): Promise<Person> => { | ||||
|   const url = `/api/1.0/person/person/${id}.json`; | ||||
|   return fetch(url).then((response) => { | ||||
|     if (response.ok) { | ||||
|       return response.json(); | ||||
|     } | ||||
|     throw Error("Error with request resource response"); | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| export const personToWritePerson = (person: Person): PersonWrite => { | ||||
|   return { | ||||
|     type: "person", | ||||
|     firstName: person.firstName, | ||||
|     lastName: person.lastName, | ||||
|     altNames: person.altNames.map((altName) => ({key: altName.key, value: altName.label})), | ||||
|     addressId: null, | ||||
|     birthdate: null === person.birthdate ? null : {datetime: person.birthdate.datetime8601}, | ||||
|     deathdate: null === person.deathdate ? null : {datetime: person.deathdate.datetime8601}, | ||||
|     phonenumber: person.phonenumber, | ||||
|     mobilenumber: person.mobilenumber, | ||||
|     center: null === person.centers ? null : person.centers | ||||
|       .map((center): SetCenter => ({id: center.id, type: "center"})) | ||||
|       .find(() => true) || null, | ||||
|     email: person.email, | ||||
|     civility: null === person.civility ? null : {id: person.civility.id, type: "chill_main_civility"}, | ||||
|     gender: null === person.gender ? null : {id: person.gender.id, type: "chill_main_gender"}, | ||||
|     identifiers: person.identifiers.map((identifier: PersonIdentifier) => ({type: "person_identifier", definition_id: identifier.definition.id, value: identifier.value})), | ||||
|   } | ||||
| } | ||||
|  | ||||
| export const getPersonAltNames = async (): Promise<AltName[]> => | ||||
|   fetch("/api/1.0/person/config/alt_names.json").then((response) => { | ||||
|     if (response.ok) { | ||||
|       return response.json(); | ||||
|     } | ||||
|     throw Error("Error with request resource response"); | ||||
|   }); | ||||
|  | ||||
| export const getCivilities = async (): Promise<Civility[]> => | ||||
|   fetchResults("/api/1.0/main/civility.json"); | ||||
|  | ||||
| export const getGenders = async (): Promise<Gender[]> => | ||||
|   fetchResults("/api/1.0/main/gender.json"); | ||||
|  | ||||
| export const getCentersForPersonCreation = async (): Promise<{ | ||||
|   showCenters: boolean; | ||||
|   centers: Center[]; | ||||
| }> => makeFetch("GET", "/api/1.0/person/creation/authorized-centers", null); | ||||
|  | ||||
| export const getPersonIdentifiers = async (): Promise< | ||||
|   PersonIdentifierWorker[] | ||||
| > => fetchResults("/api/1.0/person/identifiers/workers"); | ||||
|  | ||||
| export interface WritePersonViolationMap | ||||
|   extends Record<string, Record<string, string>> { | ||||
|   firstName: { | ||||
|     "{{ value }}": string | ||||
|   }; | ||||
|   lastName: { | ||||
|     "{{ value }}": string; | ||||
|   }; | ||||
|   gender: { | ||||
|     "{{ value }}": string; | ||||
|   }; | ||||
|   mobilenumber: { | ||||
|     "{{ types }}": string; // ex: "mobile number" | ||||
|     "{{ value }}": string; // ex: "+33 1 02 03 04 05" | ||||
|   }; | ||||
|   phonenumber: { | ||||
|     "{{ types }}": string; // ex: "mobile number" | ||||
|     "{{ value }}": string; // ex: "+33 1 02 03 04 05" | ||||
|   }; | ||||
|   email: { | ||||
|     "{{ value }}": string; | ||||
|   }; | ||||
|   center: { | ||||
|     "{{ value }}": string; | ||||
|   }; | ||||
|   civility: { | ||||
|     "{{ value }}": string; | ||||
|   }; | ||||
|   birthdate: {}; | ||||
|   identifiers: { | ||||
|     "{{ value }}": string; | ||||
|     "definition_id": string; | ||||
|   }; | ||||
| } | ||||
| export const createPerson = async (person: PersonWrite): Promise<Person> => { | ||||
|   return makeFetch<PersonWrite, Person, WritePersonViolationMap>( | ||||
|     "POST", | ||||
|     "/api/1.0/person/person.json", | ||||
|     person, | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export const editPerson = async (person: PersonWrite, personId: number): Promise<Person> => { | ||||
|   return makeFetch<PersonWrite, Person, WritePersonViolationMap>( | ||||
|     "PATCH", | ||||
|     `/api/1.0/person/person/${personId}.json`, | ||||
|     person, | ||||
|   ); | ||||
| } | ||||
| @@ -3,487 +3,242 @@ | ||||
|     class="btn" | ||||
|     :class="getClassButton" | ||||
|     :title="buttonTitle" | ||||
|     @click="openModal" | ||||
|     @click="openModalChoose" | ||||
|   > | ||||
|     <span v-if="displayTextButton">{{ buttonTitle }}</span> | ||||
|   </a> | ||||
|  | ||||
|   <teleport to="body"> | ||||
|     <modal | ||||
|       v-if="showModal" | ||||
|       @close="closeModal" | ||||
|       :modal-dialog-class="modalDialogClass" | ||||
|       :show="showModal" | ||||
|       :hide-footer="false" | ||||
|     > | ||||
|       <template #header> | ||||
|         <h3 class="modal-title"> | ||||
|           {{ modalTitle }} | ||||
|         </h3> | ||||
|       </template> | ||||
|   <person-choose-modal | ||||
|     v-if="showModalChoose" | ||||
|     ref="personChooseModal" | ||||
|     :modal-title="modalTitle" | ||||
|     :options="options" | ||||
|     :selected="selected" | ||||
|     :modal-dialog-class="'modal-dialog-scrollable modal-xl'" | ||||
|     :allow-create="props.allowCreate" | ||||
|     @close="closeModalChoose" | ||||
|     @onPickEntities="onPickEntities" | ||||
|     @onAskForCreate="onAskForCreate" | ||||
|     @triggerAddContact="triggerAddContact" | ||||
|     @updateSelected="updateSelected" | ||||
|     @cleanSelected="emptySelected" | ||||
|   /> | ||||
|  | ||||
|       <template #body-head> | ||||
|         <div class="modal-body"> | ||||
|           <div class="search"> | ||||
|             <label class="col-form-label" style="float: right"> | ||||
|               {{ | ||||
|                 trans(ADD_PERSONS_SUGGESTED_COUNTER, { | ||||
|                   count: suggestedCounter, | ||||
|                 }) | ||||
|               }} | ||||
|             </label> | ||||
|   <CreateModal | ||||
|     v-if="creatableEntityTypes.length > 0 && showModalCreate && null == thirdPartyParentAddContact" | ||||
|     action="create" | ||||
|     :allowed-types="creatableEntityTypes" | ||||
|     :query="query" | ||||
|     :parent="null" | ||||
|     modalTitle="test" | ||||
|     @close="closeModalCreate" | ||||
|     @onPersonCreated="onPersonCreated" | ||||
|     @onThirdPartyCreated="onThirdPartyCreated" | ||||
|   ></CreateModal> | ||||
|  | ||||
|             <input | ||||
|               id="search-persons" | ||||
|               name="query" | ||||
|               v-model="query" | ||||
|               :placeholder="trans(ADD_PERSONS_SEARCH_SOME_PERSONS)" | ||||
|               ref="searchRef" | ||||
|             /> | ||||
|             <i class="fa fa-search fa-lg" /> | ||||
|             <i | ||||
|               class="fa fa-times" | ||||
|               v-if="queryLength >= 3" | ||||
|               @click="resetSuggestion" | ||||
|             /> | ||||
|           </div> | ||||
|         </div> | ||||
|         <div class="modal-body" v-if="checkUniq === 'checkbox'"> | ||||
|           <div class="count"> | ||||
|             <span> | ||||
|               <a v-if="suggestedCounter > 2" @click="selectAll"> | ||||
|                 {{ trans(ACTION_CHECK_ALL) }} | ||||
|               </a> | ||||
|               <a v-if="selectedCounter > 0" @click="resetSelection"> | ||||
|                 <i v-if="suggestedCounter > 2"> • </i> | ||||
|                 {{ trans(ACTION_RESET) }} | ||||
|               </a> | ||||
|             </span> | ||||
|             <span v-if="selectedCounter > 0"> | ||||
|               {{ | ||||
|                 trans(ADD_PERSONS_SELECTED_COUNTER, { | ||||
|                   count: selectedCounter, | ||||
|                 }) | ||||
|               }} | ||||
|             </span> | ||||
|           </div> | ||||
|         </div> | ||||
|       </template> | ||||
|  | ||||
|       <template #body> | ||||
|         <div class="results"> | ||||
|           <person-suggestion | ||||
|             v-for="item in selectedAndSuggested.slice().reverse()" | ||||
|             :key="itemKey(item)" | ||||
|             :item="item" | ||||
|             :search="search" | ||||
|             :type="checkUniq" | ||||
|             @save-form-on-the-fly="saveFormOnTheFly" | ||||
|             @new-prior-suggestion="newPriorSuggestion" | ||||
|             @update-selected="updateSelected" | ||||
|           /> | ||||
|  | ||||
|           <div class="create-button"> | ||||
|             <on-the-fly | ||||
|               v-if=" | ||||
|                 queryLength >= 3 && | ||||
|                 (options.type.includes('person') || | ||||
|                   options.type.includes('thirdparty')) | ||||
|               " | ||||
|               :button-text="trans(ONTHEFLY_CREATE_BUTTON, { q: query })" | ||||
|               :allowed-types="options.type" | ||||
|               :query="query" | ||||
|               action="create" | ||||
|               @save-form-on-the-fly="saveFormOnTheFly" | ||||
|               ref="onTheFly" | ||||
|             /> | ||||
|           </div> | ||||
|         </div> | ||||
|       </template> | ||||
|  | ||||
|       <template #footer> | ||||
|         <button | ||||
|           class="btn btn-create" | ||||
|           @click.prevent=" | ||||
|             () => { | ||||
|               $emit('addNewPersons', { | ||||
|                 selected: selectedComputed, | ||||
|               }); | ||||
|               query = ''; | ||||
|               closeModal(); | ||||
|             } | ||||
|           " | ||||
|         > | ||||
|           {{ trans(ACTION_ADD) }} | ||||
|         </button> | ||||
|       </template> | ||||
|     </modal> | ||||
|   </teleport> | ||||
|   <CreateModal | ||||
|     v-if="showModalCreate && thirdPartyParentAddContact !== null" | ||||
|     :allowed-types="['thirdparty']" | ||||
|     action="addContact" | ||||
|     modalTitle="test" | ||||
|     :parent="thirdPartyParentAddContact" | ||||
|     :query="''" | ||||
|     @close="closeModalCreate" | ||||
|     @onPersonCreated="onPersonCreated" | ||||
|     @onThirdPartyCreated="onThirdPartyCreated" | ||||
|   ></CreateModal> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| // eslint-disable-next-line @typescript-eslint/no-unused-vars | ||||
| import Modal from "ChillMainAssets/vuejs/_components/Modal.vue"; | ||||
| import { ref, reactive, computed, nextTick, watch, shallowRef } from "vue"; | ||||
| import PersonSuggestion from "./AddPersons/PersonSuggestion.vue"; | ||||
| import { searchEntities } from "ChillPersonAssets/vuejs/_api/AddPersons"; | ||||
| import OnTheFly from "ChillMainAssets/vuejs/OnTheFly/components/OnTheFly.vue"; | ||||
|  | ||||
| import { makeFetch } from "ChillMainAssets/lib/api/apiMethods"; | ||||
|  | ||||
| import { | ||||
|   trans, | ||||
|   ADD_PERSONS_SUGGESTED_COUNTER, | ||||
|   ADD_PERSONS_SEARCH_SOME_PERSONS, | ||||
|   ADD_PERSONS_SELECTED_COUNTER, | ||||
|   ONTHEFLY_CREATE_BUTTON, | ||||
|   ACTION_CHECK_ALL, | ||||
|   ACTION_RESET, | ||||
|   ACTION_ADD, | ||||
| } from "translator"; | ||||
| import { | ||||
| import {ref, computed, nextTick, useTemplateRef} from "vue"; | ||||
| import PersonChooseModal from "./AddPersons/PersonChooseModal.vue"; | ||||
| import type { | ||||
|   Suggestion, | ||||
|   Search, | ||||
|   AddPersonResult as OriginalResult, | ||||
|   SearchOptions, | ||||
|   CreatableEntityType, | ||||
|   EntityType, | ||||
|   Person, | ||||
| } from "ChillPersonAssets/types"; | ||||
| import { marked } from "marked"; | ||||
| import options = marked.options; | ||||
| import CreateModal from "ChillMainAssets/vuejs/OnTheFly/components/CreateModal.vue"; | ||||
| import {Thirdparty, ThirdpartyCompany} from "../../../../../ChillThirdPartyBundle/Resources/public/types"; | ||||
|  | ||||
| // Extend Result type to include optional addressId | ||||
| type Result = OriginalResult & { addressId?: number }; | ||||
| interface AddPersonsConfig { | ||||
|   suggested?: Suggestion[]; | ||||
|   buttonTitle: string; | ||||
|   modalTitle: string; | ||||
|   options: SearchOptions; | ||||
|   allowCreate?: boolean; | ||||
|   types?: EntityType[] | undefined; | ||||
| } | ||||
|  | ||||
| const props = defineProps({ | ||||
|   suggested: { type: Array as () => Suggestion[], default: () => [] }, | ||||
|   selected: { type: Array as () => Suggestion[], default: () => [] }, | ||||
|   buttonTitle: { type: String, required: true }, | ||||
|   modalTitle: { type: String, required: true }, | ||||
|   options: { type: Object as () => SearchOptions, required: true }, | ||||
| const props = withDefaults(defineProps<AddPersonsConfig>(), { | ||||
|   suggested: () => [], | ||||
|   allowCreate: () => true, | ||||
|   types: () => ["person"], | ||||
| }); | ||||
|  | ||||
| defineEmits(["addNewPersons"]); | ||||
| const emit = | ||||
|   defineEmits<{ | ||||
|     (e: "addNewPersons", payload: { selected: Suggestion[] }): void; | ||||
|   } | ||||
|   >(); | ||||
|  | ||||
| const showModal = ref(false); | ||||
| const modalDialogClass = ref("modal-dialog-scrollable modal-xl"); | ||||
| type PersonChooseModalType = InstanceType<typeof PersonChooseModal>; | ||||
| const personChooseModal = useTemplateRef<PersonChooseModalType>('personChooseModal'); | ||||
|  | ||||
| const modal = shallowRef({ | ||||
|   showModal: false, | ||||
|   modalDialogClass: "modal-dialog-scrollable modal-xl", | ||||
| }); | ||||
| /** | ||||
|  * Flag to show/hide the modal "choose". | ||||
|  */ | ||||
| const showModalChoose = ref(false); | ||||
|  | ||||
| const search = reactive({ | ||||
|   query: "" as string, | ||||
|   previousQuery: "" as string, | ||||
|   currentSearchQueryController: null as AbortController | null, | ||||
|   suggested: props.suggested as Suggestion[], | ||||
|   selected: props.selected as Suggestion[], | ||||
|   priorSuggestion: {} as Partial<Suggestion>, | ||||
| }); | ||||
| /** | ||||
|  * Flag to show/hide the modal "create". | ||||
|  */ | ||||
| const showModalCreate = ref(false); | ||||
|  | ||||
| const searchRef = ref<HTMLInputElement | null>(null); | ||||
| const onTheFly = ref<InstanceType<typeof OnTheFly> | null>(null); | ||||
| /** | ||||
|  * Store the previous search query, stored while going from "search" state to "create" | ||||
|  */ | ||||
| const query = ref(""); | ||||
|  | ||||
| const query = computed({ | ||||
|   get: () => search.query, | ||||
|   set: (val) => setQuery(val), | ||||
| }); | ||||
| const queryLength = computed(() => search.query.length); | ||||
| const suggestedCounter = computed(() => search.suggested.length); | ||||
| const selectedComputed = computed(() => search.selected); | ||||
| const selectedCounter = computed(() => search.selected.length); | ||||
| /** | ||||
|  * Temporarily store the thirdparty company when calling "addContact" | ||||
|  */ | ||||
| const thirdPartyParentAddContact = ref<ThirdpartyCompany|null>(null); | ||||
|  | ||||
| /** | ||||
|  * Contains the selected elements. | ||||
|  * | ||||
|  * If the property option.uniq is true, this will contains only one element. | ||||
|  * | ||||
|  * Suggestion must be added/removed using the @link{addSuggestionToSelected} and @link{removeSuggestionFromSelected} | ||||
|  * methods. | ||||
|  */ | ||||
| const selected = ref<Map<string, Suggestion>>(new Map()); | ||||
|  | ||||
| const getClassButton = computed(() => { | ||||
|   let size = props.options?.button?.size ?? ""; | ||||
|   let type = props.options?.button?.type ?? "btn-create"; | ||||
|   return size ? size + " " + type : type; | ||||
|   const size = props.options?.button?.size ?? ""; | ||||
|   const type = props.options?.button?.type ?? "btn-create"; | ||||
|   return size ? `${size} ${type}` : type; | ||||
| }); | ||||
|  | ||||
| const displayTextButton = computed(() => | ||||
|   props.options?.button?.display !== undefined | ||||
|     ? props.options.button.display | ||||
|     : true, | ||||
| ); | ||||
|  | ||||
| const checkUniq = computed(() => | ||||
|   props.options.uniq === true ? "radio" : "checkbox", | ||||
| ); | ||||
|  | ||||
| const priorSuggestion = computed(() => search.priorSuggestion); | ||||
| const hasPriorSuggestion = computed(() => !!search.priorSuggestion.key); | ||||
|  | ||||
| const itemKey = (item: Suggestion) => item.result.type + item.result.id; | ||||
|  | ||||
| function addPriorSuggestion() { | ||||
|   if (hasPriorSuggestion.value) { | ||||
|     // Type assertion is safe here due to the checks above | ||||
|     search.suggested.unshift(priorSuggestion.value as Suggestion); | ||||
|     search.selected.unshift(priorSuggestion.value as Suggestion); | ||||
|     newPriorSuggestion(null); | ||||
| const creatableEntityTypes = computed<CreatableEntityType[]>(() => { | ||||
|   if (typeof props.options.type !== "undefined") { | ||||
|     return props.options.type.filter( | ||||
|       (e: EntityType) => e === "thirdparty" || e === "person", | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| const selectedAndSuggested = computed(() => { | ||||
|   addPriorSuggestion(); | ||||
|   const uniqBy = (a: Suggestion[], key: (item: Suggestion) => string) => [ | ||||
|     ...new Map(a.map((x) => [key(x), x])).values(), | ||||
|   ]; | ||||
|   let union = [ | ||||
|     ...new Set([ | ||||
|       ...search.suggested.slice().reverse(), | ||||
|       ...search.selected.slice().reverse(), | ||||
|     ]), | ||||
|   ]; | ||||
|   return uniqBy(union, (k: Suggestion) => k.key); | ||||
|   return props.types.filter( | ||||
|     (e: EntityType) => e === "thirdparty" || e === "person", | ||||
|   ); | ||||
| }); | ||||
|  | ||||
| function openModal() { | ||||
|   showModal.value = true; | ||||
|   nextTick(() => { | ||||
|     if (searchRef.value) searchRef.value.focus(); | ||||
|   }); | ||||
| } | ||||
| function closeModal() { | ||||
|   showModal.value = false; | ||||
| function onAskForCreate(payload: { query: string }): void { | ||||
|   query.value = payload.query; | ||||
|   closeModalChoose(); | ||||
|   showModalCreate.value = true; | ||||
| } | ||||
|  | ||||
| function setQuery(q: string) { | ||||
|   search.query = q; | ||||
| function openModalChoose(): void { | ||||
|   showModalChoose.value = true; | ||||
| } | ||||
|  | ||||
|   // Clear previous search if any | ||||
|   if (search.currentSearchQueryController) { | ||||
|     search.currentSearchQueryController.abort(); | ||||
|     search.currentSearchQueryController = null; | ||||
| function closeModalChoose(): void { | ||||
|   showModalChoose.value = false; | ||||
| } | ||||
|  | ||||
| function closeModalCreate(): void { | ||||
|   if (null !== thirdPartyParentAddContact) { | ||||
|     thirdPartyParentAddContact.value = null; | ||||
|   } | ||||
|  | ||||
|   if (q === "") { | ||||
|     loadSuggestions([]); | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   // Debounce delay based on query length | ||||
|   const delay = q.length > 3 ? 300 : 700; | ||||
|  | ||||
|   setTimeout(() => { | ||||
|     // Only search if query hasn't changed in the meantime | ||||
|     if (q !== search.query) return; | ||||
|  | ||||
|     search.currentSearchQueryController = new AbortController(); | ||||
|  | ||||
|     searchEntities( | ||||
|       { query: q, options: props.options }, | ||||
|       search.currentSearchQueryController.signal, | ||||
|     ) | ||||
|       .then((suggested: Search) => { | ||||
|         loadSuggestions(suggested.results); | ||||
|       }) | ||||
|       .catch((error: DOMException) => { | ||||
|         if (error instanceof DOMException && error.name === "AbortError") { | ||||
|           // Request was aborted, ignore | ||||
|           return; | ||||
|         } | ||||
|         throw error; | ||||
|       }); | ||||
|   }, delay); | ||||
|   showModalCreate.value = false; | ||||
| } | ||||
|  | ||||
| function loadSuggestions(suggestedArr: Suggestion[]) { | ||||
|   search.suggested = suggestedArr; | ||||
|   search.suggested.forEach((item) => { | ||||
|     item.key = itemKey(item); | ||||
|   }); | ||||
| } | ||||
|  | ||||
| function updateSelected(value: Suggestion[]) { | ||||
|   search.selected = value; | ||||
| } | ||||
|  | ||||
| function resetSuggestion() { | ||||
|   search.query = ""; | ||||
|   search.suggested = []; | ||||
| } | ||||
|  | ||||
| function resetSelection() { | ||||
|   search.selected = []; | ||||
| } | ||||
|  | ||||
| function resetSearch() { | ||||
|   resetSelection(); | ||||
|   resetSuggestion(); | ||||
| } | ||||
|  | ||||
| function selectAll() { | ||||
|   search.suggested.forEach((item) => { | ||||
|     search.selected.push(item); | ||||
|   }); | ||||
| } | ||||
|  | ||||
| function newPriorSuggestion(entity: Result | null) { | ||||
|   if (entity !== null) { | ||||
|     let suggestion = { | ||||
|       key: entity.type + entity.id, | ||||
|       relevance: 0.5, | ||||
|       result: entity, | ||||
|     }; | ||||
|     search.priorSuggestion = suggestion; | ||||
| /** | ||||
|  * Called by PersonSuggestion's updateSelection event, when an element is checked/unchecked | ||||
|  */ | ||||
| function updateSelected(payload: {suggestion: Suggestion, isSelected: boolean}): void { | ||||
|   if (payload.isSelected) { | ||||
|     addSuggestionToSelected(payload.suggestion); | ||||
|   } else { | ||||
|     search.priorSuggestion = {}; | ||||
|     removeSuggestionFromSelected(payload.suggestion); | ||||
|   } | ||||
| } | ||||
|  | ||||
| async function saveFormOnTheFly({ | ||||
|   type, | ||||
|   data, | ||||
| }: { | ||||
|   type: string; | ||||
|   data: Result; | ||||
| }) { | ||||
|   try { | ||||
|     if (type === "person") { | ||||
|       const responsePerson: Result = await makeFetch( | ||||
|         "POST", | ||||
|         "/api/1.0/person/person.json", | ||||
|         data, | ||||
|       ); | ||||
|       newPriorSuggestion(responsePerson); | ||||
|       if (onTheFly.value) onTheFly.value.closeModal(); | ||||
| function addSuggestionToSelected(suggestion: Suggestion): void { | ||||
|   if (props.options.uniq) { | ||||
|     selected.value.clear(); | ||||
|   } | ||||
|   selected.value.set(suggestion.key, suggestion); | ||||
| } | ||||
|  | ||||
|       if (data.addressId != null) { | ||||
|         const household = { type: "household" }; | ||||
|         const address = { id: data.addressId }; | ||||
|         try { | ||||
|           const responseHousehold: Result = await makeFetch( | ||||
|             "POST", | ||||
|             "/api/1.0/person/household.json", | ||||
|             household, | ||||
|           ); | ||||
|           const member = { | ||||
|             concerned: [ | ||||
|               { | ||||
|                 person: { | ||||
|                   type: "person", | ||||
|                   id: responsePerson.id, | ||||
|                 }, | ||||
|                 start_date: { | ||||
|                   datetime: `${new Date().toISOString().split("T")[0]}T00:00:00+02:00`, | ||||
|                 }, | ||||
|                 holder: false, | ||||
|                 comment: null, | ||||
|               }, | ||||
|             ], | ||||
|             destination: { | ||||
|               type: "household", | ||||
|               id: responseHousehold.id, | ||||
|             }, | ||||
|             composition: null, | ||||
|           }; | ||||
|           await makeFetch( | ||||
|             "POST", | ||||
|             "/api/1.0/person/household/members/move.json", | ||||
|             member, | ||||
|           ); | ||||
|           try { | ||||
|             const _response = await makeFetch( | ||||
|               "POST", | ||||
|               `/api/1.0/person/household/${responseHousehold.id}/address.json`, | ||||
|               address, | ||||
|             ); | ||||
|             console.log(_response); | ||||
|           } catch (error) { | ||||
|             console.error(error); | ||||
|           } | ||||
|         } catch (error) { | ||||
|           console.error(error); | ||||
|         } | ||||
|       } | ||||
|     } else if (type === "thirdparty") { | ||||
|       const response: Result = await makeFetch( | ||||
|         "POST", | ||||
|         "/api/1.0/thirdparty/thirdparty.json", | ||||
|         data, | ||||
|       ); | ||||
|       newPriorSuggestion(response); | ||||
|       if (onTheFly.value) onTheFly.value.closeModal(); | ||||
|     } | ||||
|   } catch (error) { | ||||
|     console.error(error); | ||||
| function removeSuggestionFromSelected(suggestion: Suggestion): void { | ||||
|   selected.value.delete(suggestion.key); | ||||
| } | ||||
|  | ||||
| function emptySelected(): void { | ||||
|   selected.value = new Map(); | ||||
| } | ||||
|  | ||||
| function onPickEntities(): void { | ||||
|   const alls = Array.from(selected.value.values()); | ||||
|   emit("addNewPersons", { selected: alls }); | ||||
|   closeModalChoose(); | ||||
| } | ||||
|  | ||||
| function triggerAddContact({parent}: {parent: ThirdpartyCompany}): void { | ||||
|   closeModalChoose(); | ||||
|   openModalChoose(); | ||||
|   thirdPartyParentAddContact.value = parent; | ||||
|   showModalCreate.value = true; | ||||
| } | ||||
|  | ||||
| function onPersonCreated(payload: { person: Person }): void { | ||||
|   showModalCreate.value = false; | ||||
|   const suggestion = { | ||||
|     result: payload.person, | ||||
|     relevance: 999999, | ||||
|     key: "person", | ||||
|   }; | ||||
|   addSuggestionToSelected(suggestion); | ||||
|   if (props.options.uniq) { | ||||
|     emit("addNewPersons", { selected: [suggestion] }); | ||||
|   } else { | ||||
|     openModalChoose(); | ||||
|   } | ||||
| } | ||||
|  | ||||
| watch( | ||||
|   () => props.selected, | ||||
|   (newSelected) => { | ||||
|     search.selected = newSelected; | ||||
|   }, | ||||
|   { deep: true }, | ||||
| ); | ||||
| function onThirdPartyCreated(payload: {thirdParty: Thirdparty}): void { | ||||
|   showModalCreate.value = false; | ||||
|   const suggestion = { | ||||
|     result: payload.thirdParty, | ||||
|     relevance: 999999, | ||||
|     key: "thirdparty", | ||||
|   }; | ||||
|   addSuggestionToSelected(suggestion); | ||||
|   if (props.options.uniq) { | ||||
|     emit("addNewPersons", { selected: [suggestion] }); | ||||
|   } else { | ||||
|     openModalChoose(); | ||||
|   } | ||||
| } | ||||
|  | ||||
| watch( | ||||
|   () => props.suggested, | ||||
|   (newSuggested) => { | ||||
|     search.suggested = newSuggested; | ||||
|   }, | ||||
|   { deep: true }, | ||||
| ); | ||||
| function resetSearch(): void { | ||||
|   selected.value = new Map(); | ||||
|   personChooseModal.value?.resetSearch(); | ||||
| } | ||||
|  | ||||
| watch( | ||||
|   () => modal, | ||||
|   (val) => { | ||||
|     showModal.value = val.value.showModal; | ||||
|     modalDialogClass.value = val.value.modalDialogClass; | ||||
|   }, | ||||
|   { deep: true }, | ||||
| ); | ||||
|  | ||||
| defineExpose({ | ||||
|   resetSearch, | ||||
|   showModal, | ||||
| }); | ||||
| defineExpose({resetSearch}) | ||||
| </script> | ||||
|  | ||||
| <style lang="scss"> | ||||
| li.add-persons { | ||||
|   a { | ||||
|     cursor: pointer; | ||||
|   } | ||||
| } | ||||
| div.body-head { | ||||
|   overflow-y: unset; | ||||
|   div.modal-body:first-child { | ||||
|     margin: auto 4em; | ||||
|     div.search { | ||||
|       position: relative; | ||||
|       input { | ||||
|         width: 100%; | ||||
|         padding: 1.2em 1.5em 1.2em 2.5em; | ||||
|         //margin: 1em 0; | ||||
|       } | ||||
|       i { | ||||
|         position: absolute; | ||||
|         opacity: 0.5; | ||||
|         padding: 0.65em 0; | ||||
|         top: 50%; | ||||
|       } | ||||
|       i.fa-search { | ||||
|         left: 0.5em; | ||||
|       } | ||||
|       i.fa-times { | ||||
|         right: 1em; | ||||
|         padding: 0.75em 0; | ||||
|         cursor: pointer; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|   div.modal-body:last-child { | ||||
|     padding-bottom: 0; | ||||
|   } | ||||
|   div.count { | ||||
|     margin: -0.5em 0 0.7em; | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     a { | ||||
|       cursor: pointer; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| .create-button > a { | ||||
|   margin-top: 0.5em; | ||||
|   margin-left: 2.6em; | ||||
| } | ||||
| <style lang="scss" scoped> | ||||
| /* Button styles can remain here if needed */ | ||||
| </style> | ||||
|   | ||||
| @@ -0,0 +1,345 @@ | ||||
| <template> | ||||
|   <teleport to="body"> | ||||
|     <modal | ||||
|       @close="() => emit('close')" | ||||
|       :modal-dialog-class="modalDialogClass" | ||||
|       :hide-footer="false" | ||||
|     > | ||||
|       <template #header> | ||||
|         <h3 class="modal-title">{{ modalTitle }}</h3> | ||||
|       </template> | ||||
|  | ||||
|       <template #body-head> | ||||
|         <div class="modal-body"> | ||||
|           <div class="search"> | ||||
|             <label class="col-form-label" style="float: right"> | ||||
|               {{ | ||||
|                 trans(ADD_PERSONS_SUGGESTED_COUNTER, { | ||||
|                   count: suggestedCounter, | ||||
|                 }) | ||||
|               }} | ||||
|             </label> | ||||
|  | ||||
|             <input | ||||
|               id="search-persons" | ||||
|               name="query" | ||||
|               v-model="query" | ||||
|               :placeholder="trans(ADD_PERSONS_SEARCH_SOME_PERSONS)" | ||||
|               ref="searchRef" | ||||
|             /> | ||||
|             <i class="fa fa-search fa-lg" /> | ||||
|             <i | ||||
|               class="fa fa-times" | ||||
|               v-if="queryLength >= 3" | ||||
|               @click="resetSuggestion" | ||||
|             /> | ||||
|           </div> | ||||
|         </div> | ||||
|  | ||||
|         <div class="modal-body" v-if="checkUniq === 'checkbox'"> | ||||
|           <div class="count"> | ||||
|             <span> | ||||
|               <a v-if="suggestedCounter > 2" @click="selectAll"> | ||||
|                 {{ trans(ACTION_CHECK_ALL) }} | ||||
|               </a> | ||||
|               <a v-if="selectedCounter > 0" @click="resetSelection"> | ||||
|                 <i v-if="suggestedCounter > 2"> • </i> | ||||
|                 {{ trans(ACTION_RESET) }} | ||||
|               </a> | ||||
|             </span> | ||||
|             <span v-if="selectedCounter > 0"> | ||||
|               {{ | ||||
|                 trans(ADD_PERSONS_SELECTED_COUNTER, { count: selectedCounter }) | ||||
|               }} | ||||
|             </span> | ||||
|           </div> | ||||
|         </div> | ||||
|       </template> | ||||
|  | ||||
|       <template #body> | ||||
|         <div class="results"> | ||||
|           <person-suggestion | ||||
|             v-for="item in selectedAndSuggested" | ||||
|             :key="item.key" | ||||
|             :item="item" | ||||
|             :isSelected="item.isSelected" | ||||
|             :type="checkUniq" | ||||
|             @update-selected="(payload) => emit('updateSelected', payload)" | ||||
|             @trigger-add-contact="triggerAddContact" | ||||
|           /> | ||||
|  | ||||
|           <div v-if="hasNoResult"> | ||||
|             <div class="noResult chill-no-data-statement"> | ||||
|               {{ | ||||
|                 trans(ADD_PERSONS_SUGGESTED_COUNTER, { | ||||
|                   count: suggestedCounter, | ||||
|                 }) | ||||
|               }} | ||||
|             </div> | ||||
|           </div> | ||||
|  | ||||
|           <div | ||||
|             v-if="props.allowCreate && query.length > 0" | ||||
|             class="create-button" | ||||
|           > | ||||
|             <button type="button" class="btn btn-submit" @click="emit('onAskForCreate', { query })"> | ||||
|               {{ trans(ONTHEFLY_CREATE_BUTTON, { q: query }) }} | ||||
|             </button> | ||||
|           </div> | ||||
|         </div> | ||||
|       </template> | ||||
|  | ||||
|       <template #footer> | ||||
|         <button | ||||
|           type="button" | ||||
|           class="btn btn-create" | ||||
|           @click.prevent="pickEntities" | ||||
|         > | ||||
|           {{ trans(ACTION_ADD) }} | ||||
|         </button> | ||||
|       </template> | ||||
|     </modal> | ||||
|   </teleport> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { ref, reactive, computed, nextTick, watch, onMounted } from "vue"; | ||||
| import Modal from "ChillMainAssets/vuejs/_components/Modal.vue"; | ||||
| import PersonSuggestion from "./PersonSuggestion.vue"; | ||||
| import { searchEntities } from "ChillPersonAssets/vuejs/_api/AddPersons"; | ||||
|  | ||||
| import { | ||||
|   trans, | ||||
|   ADD_PERSONS_SUGGESTED_COUNTER, | ||||
|   ADD_PERSONS_SEARCH_SOME_PERSONS, | ||||
|   ADD_PERSONS_SELECTED_COUNTER, | ||||
|   ONTHEFLY_CREATE_BUTTON, | ||||
|   ACTION_CHECK_ALL, | ||||
|   ACTION_RESET, | ||||
|   ACTION_ADD, | ||||
| } from "translator"; | ||||
|  | ||||
| import type { | ||||
|   Suggestion, | ||||
|   Search, | ||||
|   SearchOptions, | ||||
|   Entities, | ||||
| } from "ChillPersonAssets/types"; | ||||
| import {ThirdpartyCompany} from "../../../../../../ChillThirdPartyBundle/Resources/public/types"; | ||||
|  | ||||
| interface Props { | ||||
|   modalTitle: string; | ||||
|   options: SearchOptions; | ||||
|   suggested?: Suggestion[]; | ||||
|   selected: Map<string, Suggestion>; | ||||
|   modalDialogClass?: string; | ||||
|   allowCreate?: boolean; | ||||
| } | ||||
|  | ||||
| const props = withDefaults(defineProps<Props>(), { | ||||
|   suggested: () => [], | ||||
|   modalDialogClass: "modal-dialog-scrollable modal-xl", | ||||
|   allowCreate: () => true, | ||||
| }); | ||||
|  | ||||
| const emit = defineEmits<{ | ||||
|   (e: "close"): void; | ||||
|   (e: "onPickEntities"): void; | ||||
|   (e: "onAskForCreate", payload: { query: string }): void; | ||||
|   (e: "triggerAddContact", payload: { parent: ThirdpartyCompany }): void; | ||||
|   (e: "updateSelected", payload: {suggestion: Suggestion, isSelected: boolean}): void; | ||||
|   (e: "cleanSelected"): void; | ||||
| }>(); | ||||
|  | ||||
| const searchRef = ref<HTMLInputElement | null>(null); | ||||
|  | ||||
| onMounted(() => { | ||||
|   // give the focus on the search bar | ||||
|   searchRef.value?.focus(); | ||||
| }); | ||||
|  | ||||
| const search = reactive({ | ||||
|   query: "" as string, | ||||
|   previousQuery: "" as string, | ||||
|   currentSearchQueryController: null as AbortController | null, | ||||
|   priorSuggestion: {} as Partial<Suggestion>, | ||||
|   hasPreviousQuery: false, | ||||
| }); | ||||
|  | ||||
| /** | ||||
|  * Contains the suggested entities from the search results. | ||||
|  * | ||||
|  * In other words, those entities are displayed and selectable by the user | ||||
|  */ | ||||
| const suggested = ref<Suggestion[]>([]); | ||||
|  | ||||
| const query = computed({ | ||||
|   get: () => search.query, | ||||
|   set: (val: string) => setQuery(val), | ||||
| }); | ||||
| const queryLength = computed(() => search.query.length); | ||||
| const suggestedCounter = computed(() => suggested.value.length); | ||||
| const selectedCounter = computed(() => props.selected.size); | ||||
|  | ||||
| const checkUniq = computed(() => | ||||
|   props.options.uniq ? "radio" : "checkbox", | ||||
| ); | ||||
|  | ||||
| const selectedAndSuggested = computed<(Suggestion & {isSelected: boolean})[]>(() => { | ||||
|   const selectedAndSuggested = []; | ||||
|  | ||||
|   // add selected that are not in the search results | ||||
|   for (const selected of props.selected.values()) { | ||||
|     if (!suggested.value.some((s: Suggestion) => s.key === selected.key)) { | ||||
|       selectedAndSuggested.push({...selected, isSelected: false}); | ||||
|     } | ||||
|   } | ||||
|   for (const suggestion of suggested.value) { | ||||
|     selectedAndSuggested.push({...suggestion, isSelected: props.selected.has(suggestion.key)}) | ||||
|   } | ||||
|  | ||||
|   return selectedAndSuggested; | ||||
| }); | ||||
|  | ||||
| const hasNoResult = computed(() => search.hasPreviousQuery && suggested.value.length === 0); | ||||
|  | ||||
| function setQuery(q: string) { | ||||
|   search.query = q; | ||||
|  | ||||
|   if (search.currentSearchQueryController) { | ||||
|     search.currentSearchQueryController.abort(); | ||||
|     search.currentSearchQueryController = null; | ||||
|   } | ||||
|  | ||||
|   if (q === "") { | ||||
|     loadSuggestions([]); | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   const delay = q.length > 3 ? 300 : 700; | ||||
|  | ||||
|   setTimeout(() => { | ||||
|     if (q !== search.query) return; | ||||
|  | ||||
|     search.currentSearchQueryController = new AbortController(); | ||||
|  | ||||
|     searchEntities( | ||||
|       { query: q, options: props.options }, | ||||
|       search.currentSearchQueryController.signal, | ||||
|     ) | ||||
|       .then((suggested: Search) => { | ||||
|         loadSuggestions(suggested.results); | ||||
|         search.hasPreviousQuery = true; | ||||
|       }) | ||||
|       .catch((error: DOMException) => { | ||||
|         if (error instanceof DOMException && error.name === "AbortError") { | ||||
|           return; | ||||
|         } | ||||
|         throw error; | ||||
|       }); | ||||
|   }, delay); | ||||
| } | ||||
|  | ||||
|  | ||||
| function loadSuggestions(suggestedArr: {relevance: number, result: Entities}[]): void { | ||||
|   suggested.value = suggestedArr.map((item) => { | ||||
|     return { | ||||
|       key: item.result.type + item.result.id, | ||||
|       relevance: item.relevance, | ||||
|       result: item.result | ||||
|     } | ||||
|   }); | ||||
| } | ||||
|  | ||||
| function resetSuggestion() { | ||||
|   search.query = ""; | ||||
|   suggested.value = []; | ||||
| } | ||||
|  | ||||
| function resetSelection() { | ||||
|   emit("cleanSelected"); | ||||
| } | ||||
|  | ||||
| function resetSearch() { | ||||
|   resetSelection(); | ||||
|   resetSuggestion(); | ||||
| } | ||||
|  | ||||
| function selectAll() { | ||||
|   suggested.value.forEach((suggestion: Suggestion) => { | ||||
|     emit("updateSelected", {suggestion, isSelected: true}) | ||||
|   }); | ||||
| } | ||||
|  | ||||
| function triggerAddContact(payload: {parent: ThirdpartyCompany}) { | ||||
|   emit("triggerAddContact", payload); | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Triggered when the user clicks on the "add" button. | ||||
|  */ | ||||
| function pickEntities(): void { | ||||
|   emit("onPickEntities", ); | ||||
|   search.query = ""; | ||||
|   emit("close"); | ||||
| } | ||||
|  | ||||
| defineExpose({ resetSearch }); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| li.add-persons { | ||||
|   a { | ||||
|     cursor: pointer; | ||||
|   } | ||||
| } | ||||
|  | ||||
| div.body-head { | ||||
|   overflow-y: unset; | ||||
|   div.modal-body:first-child { | ||||
|     margin: auto 4em; | ||||
|     div.search { | ||||
|       position: relative; | ||||
|       input { | ||||
|         width: 100%; | ||||
|         padding: 1.2em 1.5em 1.2em 2.5em; | ||||
|       } | ||||
|       i { | ||||
|         position: absolute; | ||||
|         opacity: 0.5; | ||||
|         padding: 0.65em 0; | ||||
|         top: 50%; | ||||
|       } | ||||
|       i.fa-search { | ||||
|         left: 0.5em; | ||||
|       } | ||||
|       i.fa-times { | ||||
|         right: 1em; | ||||
|         padding: 0.75em 0; | ||||
|         cursor: pointer; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|   div.modal-body:last-child { | ||||
|     padding-bottom: 0; | ||||
|   } | ||||
|   div.count { | ||||
|     margin: -0.5em 0 0.7em; | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     a { | ||||
|       cursor: pointer; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| .create-button > button { | ||||
|   margin-top: 0.5em; | ||||
|   margin-left: 0.6em; | ||||
| } | ||||
| .noResult { | ||||
|   text-align: center; | ||||
|   margin: 2em; | ||||
|   font-size: large; | ||||
| } | ||||
| </style> | ||||
| @@ -1,39 +1,38 @@ | ||||
| <template> | ||||
|   <div class="list-item" :class="{ checked: isChecked }"> | ||||
|   <div class="list-item" :class="{ checked: props.isSelected }"> | ||||
|     <label> | ||||
|       <div> | ||||
|         <input | ||||
|           :type="type" | ||||
|           v-model="selected" | ||||
|           :value="props.item.key" | ||||
|           name="item" | ||||
|           :id="item.key" | ||||
|           :value="setValueByType(item, type)" | ||||
|           :id="props.item.key" | ||||
|           :checked="props.isSelected" | ||||
|           @click="onUpdateValue" | ||||
|         /> | ||||
|       </div> | ||||
|  | ||||
|       <suggestion-person | ||||
|         v-if="item.result.type === 'person'" | ||||
|         v-if="isSuggestionForPerson(item)" | ||||
|         :item="item" | ||||
|       ></suggestion-person> | ||||
|  | ||||
|       <suggestion-third-party | ||||
|         v-if="item.result.type === 'thirdparty'" | ||||
|         @newPriorSuggestion="newPriorSuggestion" | ||||
|         v-if="isSuggestionForThirdParty(item)" | ||||
|         @trigger-add-contact="triggerAddContact" | ||||
|         :item="item" | ||||
|       ></suggestion-third-party> | ||||
|  | ||||
|       <suggestion-user | ||||
|         v-if="item.result.type === 'user'" | ||||
|         v-if="isSuggestionForUser(item)" | ||||
|         :item="item" | ||||
|       ></suggestion-user> | ||||
|  | ||||
|       <suggestion-user-group | ||||
|         v-if="item.result.type === 'user_group'" | ||||
|         v-if="isSuggestionForUserGroup(item)" | ||||
|         :item="item" | ||||
|       ></suggestion-user-group> | ||||
|  | ||||
|       <suggestion-household | ||||
|         v-if="item.result.type === 'household'" | ||||
|         v-if="isSuggestionForHousehold(item)" | ||||
|         :item="item" | ||||
|       ></suggestion-household> | ||||
|     </label> | ||||
| @@ -51,31 +50,38 @@ import SuggestionHousehold from "./TypeHousehold.vue"; | ||||
| import SuggestionUserGroup from "./TypeUserGroup.vue"; | ||||
|  | ||||
| // Types | ||||
| import { Result, Suggestion } from "ChillPersonAssets/types"; | ||||
| import { | ||||
|   isSuggestionForHousehold, | ||||
|   isSuggestionForPerson, | ||||
|   isSuggestionForThirdParty, isSuggestionForUser, | ||||
|   isSuggestionForUserGroup, | ||||
|   Suggestion | ||||
| } from "ChillPersonAssets/types"; | ||||
| import {ThirdpartyCompany} from "../../../../../../ChillThirdPartyBundle/Resources/public/types"; | ||||
|  | ||||
| const props = defineProps<{ | ||||
|   item: Suggestion; | ||||
|   search: { selected: Suggestion[] }; | ||||
|   type: string; | ||||
|   isSelected: boolean; | ||||
|   type: "radio"|"checkbox"; | ||||
| }>(); | ||||
| const emit = defineEmits<{ | ||||
|   (e: "updateSelected", payload: {suggestion: Suggestion, isSelected: boolean}): void; | ||||
|   (e: "triggerAddContact", payload: { parent: ThirdpartyCompany }): void; | ||||
| }>(); | ||||
| const emit = defineEmits(["updateSelected", "newPriorSuggestion"]); | ||||
|  | ||||
| // v-model for selected | ||||
| const selected = computed({ | ||||
|   get: () => props.search.selected, | ||||
|   set: (value) => emit("updateSelected", value), | ||||
| }); | ||||
| const isChecked = computed<boolean>(() => props.isSelected) | ||||
|  | ||||
| const isChecked = computed( | ||||
|   () => props.search.selected.indexOf(props.item) !== -1, | ||||
| ); | ||||
|  | ||||
| function setValueByType(value: Suggestion, type: string) { | ||||
|   return type === "radio" ? [value] : value; | ||||
| const onUpdateValue = (event: Event) => { | ||||
|   const target = event?.target; | ||||
|   if (!(target instanceof HTMLInputElement)) { | ||||
|     console.error("the value of checked is not an HTMLInputElement"); | ||||
|     return; | ||||
|   } | ||||
|   emit("updateSelected", {suggestion: props.item, isSelected: props.type === "radio" ? true : target.checked}); | ||||
| } | ||||
|  | ||||
| function newPriorSuggestion(response: Result) { | ||||
|   emit("newPriorSuggestion", response); | ||||
| function triggerAddContact(payload: {parent: ThirdpartyCompany}) { | ||||
|   emit("triggerAddContact", payload); | ||||
| } | ||||
| </script> | ||||
|  | ||||
|   | ||||
| @@ -16,9 +16,10 @@ import { defineProps } from "vue"; | ||||
| import BadgeEntity from "ChillMainAssets/vuejs/_components/BadgeEntity.vue"; | ||||
| import HouseholdRenderBox from "ChillPersonAssets/vuejs/_components/Entity/HouseholdRenderBox.vue"; | ||||
| import { Suggestion } from "ChillPersonAssets/types"; | ||||
| import {Household} from "ChillMainAssets/types"; | ||||
|  | ||||
| interface TypeHouseholdProps { | ||||
|   item: Suggestion; | ||||
|   item: Suggestion & {result: Household}; | ||||
| } | ||||
|  | ||||
| defineProps<TypeHouseholdProps>(); | ||||
|   | ||||
| @@ -23,7 +23,7 @@ import { computed, defineProps } from "vue"; | ||||
| import OnTheFly from "ChillMainAssets/vuejs/OnTheFly/components/OnTheFly.vue"; | ||||
| import BadgeEntity from "ChillMainAssets/vuejs/_components/BadgeEntity.vue"; | ||||
| import PersonText from "ChillPersonAssets/vuejs/_components/Entity/PersonText.vue"; | ||||
| import { Person } from "ChillPersonAssets/types"; | ||||
| import {Person, Suggestion} from "ChillPersonAssets/types"; | ||||
|  | ||||
| function formatDate(dateString: string | undefined, format: string) { | ||||
|   if (!dateString) return ""; | ||||
| @@ -36,9 +36,7 @@ function formatDate(dateString: string | undefined, format: string) { | ||||
| } | ||||
|  | ||||
| const props = defineProps<{ | ||||
|   item: { | ||||
|     result: Person; // add other fields as needed | ||||
|   }; | ||||
|   item: Suggestion & { result: Person }, | ||||
| }>(); | ||||
|  | ||||
| const hasBirthdate = computed(() => props.item.result.birthdate !== null); | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| <template> | ||||
|   <div class="container tpartycontainer"> | ||||
|     <div class="tparty-identification"> | ||||
|       <span v-if="item.result.profession" class="profession">{{ | ||||
|       <span v-if="(isThirdpartyChild(item.result) || isThirdpartyContact(item.result)) && item.result.profession" class="profession">{{ | ||||
|         item.result.profession | ||||
|       }}</span> | ||||
|       <span class="name"> {{ item.result.text }}  </span> | ||||
| @@ -12,20 +12,18 @@ | ||||
|         </template> | ||||
|       </span> | ||||
|     </div> | ||||
|     <div class="tpartyparent" v-if="hasParent"> | ||||
|       <span class="name"> > {{ item.result.parent?.text }} </span> | ||||
|     <div class="tpartyparent" v-if="isThirdpartyChild(item.result) && null !== item.result.parent"> | ||||
|       <span class="name"> > {{ item.result.parent.text }} </span> | ||||
|     </div> | ||||
|   </div> | ||||
|  | ||||
|   <div class="right_actions"> | ||||
|     <badge-entity :entity="item.result" :options="{ displayLong: true }" /> | ||||
|     <on-the-fly | ||||
|       v-if="item.result.kind === 'company'" | ||||
|       :parent="item.result" | ||||
|       @save-form-on-the-fly="saveFormOnTheFly" | ||||
|       action="addContact" | ||||
|       ref="onTheFly" | ||||
|     /> | ||||
|     <a | ||||
|       v-if="item.result.type === 'thirdparty' && item.result.kind === 'company'" | ||||
|       class="btn btn-tpchild" | ||||
|       @click="emit('triggerAddContact', {parent: item.result})" | ||||
|     ><i class="bi bi-person-fill-add"></i></a> | ||||
|     <on-the-fly type="thirdparty" :id="item.result.id" action="show" /> | ||||
|   </div> | ||||
| </template> | ||||
| @@ -37,15 +35,19 @@ import BadgeEntity from "ChillMainAssets/vuejs/_components/BadgeEntity.vue"; | ||||
| import { makeFetch } from "ChillMainAssets/lib/api/apiMethods"; | ||||
| import { useToast } from "vue-toast-notification"; | ||||
| import { Result, Suggestion } from "ChillPersonAssets/types"; | ||||
| import { Thirdparty } from "src/Bundle/ChillThirdPartyBundle/Resources/public/types"; | ||||
| import { | ||||
|   isThirdpartyChild, | ||||
|   isThirdpartyContact, | ||||
|   Thirdparty, ThirdpartyCompany | ||||
| } from "./../../../../../../ChillThirdPartyBundle/Resources/public/types"; | ||||
|  | ||||
| interface TypeThirdPartyProps { | ||||
|   item: Suggestion; | ||||
|   item: Suggestion & {result: Thirdparty}; | ||||
| } | ||||
|  | ||||
| const props = defineProps<TypeThirdPartyProps>(); | ||||
|  | ||||
| const emit = defineEmits<(e: "newPriorSuggestion", payload: unknown) => void>(); | ||||
| const emit = defineEmits<(e: "triggerAddContact", payload: {parent: ThirdpartyCompany}) => void>(); | ||||
|  | ||||
| const onTheFly = ref<InstanceType<typeof OnTheFly> | null>(null); | ||||
| const toast = useToast(); | ||||
| @@ -54,47 +56,23 @@ const hasAddress = computed(() => { | ||||
|   if (props.item.result.address !== null) { | ||||
|     return true; | ||||
|   } | ||||
|   if (props.item.result.parent !== null) { | ||||
|     if (props.item.result.parent) { | ||||
|       return props.item.result.parent.address !== null; | ||||
|     } | ||||
|   if (isThirdpartyChild(props.item.result) && props.item.result.parent !== null) { | ||||
|     return props.item.result.parent.address !== null; | ||||
|   } | ||||
|   return false; | ||||
| }); | ||||
|  | ||||
| const hasParent = computed(() => { | ||||
|   return props.item.result.parent !== null; | ||||
|   return false; | ||||
| }); | ||||
|  | ||||
| const getAddress = computed(() => { | ||||
|   if (props.item.result.address !== null) { | ||||
|     return props.item.result.address; | ||||
|   } | ||||
|   if (props.item.result.parent && props.item.result.parent.address !== null) { | ||||
|   if (isThirdpartyChild(props.item.result) && props.item.result.parent !== null && props.item.result.parent.address !== null) { | ||||
|     return props.item.result.parent.address; | ||||
|   } | ||||
|   return null; | ||||
| }); | ||||
|  | ||||
| function saveFormOnTheFly({ data }: { data: Thirdparty }) { | ||||
|   makeFetch("POST", "/api/1.0/thirdparty/thirdparty.json", data) | ||||
|     .then((response: unknown) => { | ||||
|       const result = response as Result; | ||||
|       emit("newPriorSuggestion", result); | ||||
|       if (onTheFly.value) onTheFly.value.closeModal(); | ||||
|     }) | ||||
|     .catch((error: unknown) => { | ||||
|       const errorResponse = error as { name: string; violations: string[] }; | ||||
|       if (errorResponse.name === "ValidationException") { | ||||
|         for (let v of errorResponse.violations) { | ||||
|           if (toast) toast.open({ message: v }); | ||||
|         } | ||||
|       } else { | ||||
|         if (toast) toast.open({ message: "An error occurred" }); | ||||
|       } | ||||
|     }); | ||||
| } | ||||
|  | ||||
| // i18n config (if needed elsewhere) | ||||
| const i18n = { | ||||
|   messages: { | ||||
|   | ||||
| @@ -1,11 +1,11 @@ | ||||
| <template> | ||||
|   <div class="container usercontainer"> | ||||
|     <div class="user-identification"> | ||||
|       <UserRenderBoxBadge :user="item.result" /> | ||||
|       <UserRenderBoxBadge :user="props.item.result" /> | ||||
|     </div> | ||||
|   </div> | ||||
|   <div class="right_actions"> | ||||
|     <BadgeEntity :entity="item.result" :options="{ displayLong: true }" /> | ||||
|     <BadgeEntity :entity="props.item.result" :options="{ displayLong: true }" /> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| @@ -14,18 +14,14 @@ import { computed, defineProps } from "vue"; | ||||
| import BadgeEntity from "ChillMainAssets/vuejs/_components/BadgeEntity.vue"; | ||||
| import UserRenderBoxBadge from "ChillMainAssets/vuejs/_components/Entity/UserRenderBoxBadge.vue"; | ||||
| import { Suggestion } from "ChillPersonAssets/types"; | ||||
| import {User} from "ChillMainAssets/types"; | ||||
|  | ||||
| interface TypeUserProps { | ||||
|   item: Suggestion; | ||||
|   item: Suggestion & {result: User}; | ||||
| } | ||||
|  | ||||
| const props = defineProps<TypeUserProps>(); | ||||
|  | ||||
| const hasParent = computed(() => props.item.result.parent !== null); | ||||
|  | ||||
| defineExpose({ | ||||
|   hasParent, | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   | ||||
| @@ -15,7 +15,7 @@ import UserGroupRenderBox from "ChillMainAssets/vuejs/_components/Entity/UserGro | ||||
| import { Suggestion } from "ChillPersonAssets/types"; | ||||
|  | ||||
| interface TypeUserGroupProps { | ||||
|   item: Suggestion; | ||||
|   item: Suggestion & {result: UserGroup}; | ||||
| } | ||||
|  | ||||
| const props = defineProps<TypeUserGroupProps>(); | ||||
|   | ||||
| @@ -5,46 +5,16 @@ | ||||
|         <div class="item-col"> | ||||
|           <div class="entity-label"> | ||||
|             <div :class="'denomination h' + options.hLevel"> | ||||
|               <a v-if="options.addLink === true" :href="getUrl"> | ||||
|                 <!-- use person-text here to avoid code duplication ? TODO --> | ||||
|                 <span class="firstname">{{ person.firstName }}</span> | ||||
|                 <span class="lastname">{{ person.lastName }}</span> | ||||
|                 <span v-if="person.suffixText" class="suffixtext" | ||||
|                   > {{ person.suffixText }}</span | ||||
|                 > | ||||
|                 <span | ||||
|                   v-if="person.altNames && options.addAltNames == true" | ||||
|                   class="altnames" | ||||
|                 > | ||||
|                   <span :class="'altname altname-' + altNameKey">{{ | ||||
|                     altNameLabel | ||||
|                   }}</span> | ||||
|                 </span> | ||||
|               </a> | ||||
|  | ||||
|               <!-- use person-text here to avoid code duplication ? TODO --> | ||||
|               <span class="firstname">{{ person.firstName + " " }}</span> | ||||
|               <span class="lastname">{{ person.lastName }}</span> | ||||
|               <span v-if="person.suffixText" class="suffixtext" | ||||
|                 > {{ person.suffixText }}</span | ||||
|               > | ||||
|               <span v-if="person.deathdate" class="deathdate"> (‡)</span> | ||||
|               <span | ||||
|                 v-if="person.altNames && options.addAltNames == true" | ||||
|                 class="altnames" | ||||
|               > | ||||
|                 <span :class="'altname altname-' + altNameKey">{{ | ||||
|                   altNameLabel | ||||
|                 }}</span> | ||||
|               </span> | ||||
|  | ||||
|               <span | ||||
|                 v-if="options.addId == true" | ||||
|                 class="id-number" | ||||
|                 :title="'n° ' + person.id" | ||||
|                 >{{ person.id }}</span | ||||
|               > | ||||
|  | ||||
|               <template v-if="options.addLink === true"> | ||||
|                 <a v-if="options.addLink === true" :href="getUrl"> | ||||
|                   <span>{{ person.text }}</span> | ||||
|                   <span v-if="person.deathdate" class="deathdate"> (‡)</span> | ||||
|                 </a> | ||||
|               </template> | ||||
|               <template v-else> | ||||
|                 <span>{{ person.text }}</span> | ||||
|                 <span v-if="person.deathdate" class="deathdate"> (‡)</span> | ||||
|               </template> | ||||
|               <badge-entity | ||||
|                 v-if="options.addEntity === true" | ||||
|                 :entity="person" | ||||
| @@ -52,61 +22,36 @@ | ||||
|               /> | ||||
|             </div> | ||||
|  | ||||
|             <p> | ||||
|                 <span | ||||
|                   v-if="options.addId == true" | ||||
|                   :title="person.personId" | ||||
|                 ><i class="bi bi-info-circle"></i> {{ person.personId }}</span | ||||
|                 > | ||||
|             </p> | ||||
|  | ||||
|             <p v-if="options.addInfo === true" class="moreinfo"> | ||||
|               <gender-icon-render-box | ||||
|                 v-if="person.gender" | ||||
|                 :gender="person.gender" | ||||
|               /> | ||||
|               <time | ||||
|                 v-if="person.birthdate && !person.deathdate" | ||||
|                 :datetime="person.birthdate" | ||||
|                 :title="birthdate" | ||||
|               /> <span | ||||
|                 v-if="person.birthdate" | ||||
|               > | ||||
|                 {{ | ||||
|                   trans(birthdateTranslation) + | ||||
|                   " " + | ||||
|                   new Intl.DateTimeFormat("fr-FR", { | ||||
|                     dateStyle: "long", | ||||
|                   }).format(birthdate) | ||||
|                 }} | ||||
|               </time> | ||||
|  | ||||
|               <time | ||||
|                 v-else-if="person.birthdate && person.deathdate" | ||||
|                 :datetime="person.deathdate" | ||||
|                 :title="person.deathdate" | ||||
|               > | ||||
|                 {{ | ||||
|                   new Intl.DateTimeFormat("fr-FR", { | ||||
|                     dateStyle: "long", | ||||
|                   }).format(birthdate) | ||||
|                 }} | ||||
|                 - | ||||
|                 {{ | ||||
|                   new Intl.DateTimeFormat("fr-FR", { | ||||
|                     dateStyle: "long", | ||||
|                   }).format(deathdate) | ||||
|                 }} | ||||
|               </time> | ||||
|  | ||||
|               <time | ||||
|                 v-else-if="person.deathdate" | ||||
|                 :datetime="person.deathdate" | ||||
|                 :title="person.deathdate" | ||||
|               > | ||||
|                 {{ | ||||
|                   trans(RENDERBOX_DEATHDATE) + | ||||
|                   " " + | ||||
|                   new Intl.DateTimeFormat("fr-FR", { | ||||
|                     dateStyle: "long", | ||||
|                   }).format(deathdate) | ||||
|                 }} | ||||
|               </time> | ||||
|  | ||||
|                 {{ trans(RENDERBOX_BIRTHDAY_STATEMENT, {gender: toGenderTranslation(person.gender), birthdate: ISOToDate(person.birthdate?.datetime)}) }} | ||||
|               </span> | ||||
|               <span v-if="options.addAge && person.birthdate" class="age"> | ||||
|                 ({{ trans(RENDERBOX_YEARS_OLD, { n: person.age }) }}) | ||||
|                 ({{ trans(RENDERBOX_YEARS_OLD, {n: person.age}) }}) | ||||
|               </span> | ||||
|             </p> | ||||
|  | ||||
|             <p> | ||||
|               <span | ||||
|                 v-if="person.deathdate" | ||||
|               > | ||||
|                 {{ trans(RENDERBOX_DEATHDATE_STATEMENT, {gender: toGenderTranslation(person.gender), deathdate: ISOToDate(person.deathdate?.datetime)}) }} | ||||
|               </span> | ||||
|             </p> | ||||
|  | ||||
|           </div> | ||||
|         </div> | ||||
|  | ||||
| @@ -114,11 +59,11 @@ | ||||
|           <div class="float-button bottom"> | ||||
|             <div class="box"> | ||||
|               <div class="action"> | ||||
|                 <slot name="record-actions" /> | ||||
|                 <slot name="record-actions"/> | ||||
|               </div> | ||||
|               <ul class="list-content fa-ul"> | ||||
|                 <li v-if="person.current_household_id"> | ||||
|                   <i class="fa fa-li fa-map-marker" /> | ||||
|                   <i class="fa fa-li fa-map-marker"/> | ||||
|                   <address-render-box | ||||
|                     v-if="person.current_household_address" | ||||
|                     :address="person.current_household_address" | ||||
| @@ -130,11 +75,6 @@ | ||||
|                   <a | ||||
|                     v-if="options.addHouseholdLink === true" | ||||
|                     :href="getCurrentHouseholdUrl" | ||||
|                     :title=" | ||||
|                       trans(PERSONS_ASSOCIATED_SHOW_HOUSEHOLD_NUMBER, { | ||||
|                         id: person.current_household_id, | ||||
|                       }) | ||||
|                     " | ||||
|                   > | ||||
|                     <span class="badge rounded-pill bg-chill-beige"> | ||||
|                       <i | ||||
| @@ -144,7 +84,7 @@ | ||||
|                   </a> | ||||
|                 </li> | ||||
|                 <li v-else-if="options.addNoData"> | ||||
|                   <i class="fa fa-li fa-map-marker" /> | ||||
|                   <i class="fa fa-li fa-map-marker"/> | ||||
|                   <p class="chill-no-data-statement"> | ||||
|                     {{ trans(RENDERBOX_NO_DATA) }} | ||||
|                   </p> | ||||
| @@ -160,7 +100,7 @@ | ||||
|                     v-for="(addr, i) in person.current_residential_addresses" | ||||
|                     :key="i" | ||||
|                   > | ||||
|                     <i class="fa fa-li fa-map-marker" /> | ||||
|                     <i class="fa fa-li fa-map-marker"/> | ||||
|                     <div v-if="addr.address"> | ||||
|                       <span class="item-key"> | ||||
|                         {{ trans(RENDERBOX_RESIDENTIAL_ADDRESS) }}: | ||||
| @@ -180,9 +120,10 @@ | ||||
|                           :person="addr.hostPerson" | ||||
|                         /> | ||||
|                       </span> | ||||
|  | ||||
|                       <address-render-box | ||||
|                         v-if="addr.hostPerson.address" | ||||
|                         :address="addr.hostPerson.address" | ||||
|                         v-if="addr.hostPerson?.current_household_address" | ||||
|                         :address="addr.hostPerson.current_household_address" | ||||
|                         :is-multiline="isMultiline" | ||||
|                       /> | ||||
|                     </div> | ||||
| @@ -204,36 +145,36 @@ | ||||
|                 </template> | ||||
|  | ||||
|                 <li v-if="person.email"> | ||||
|                   <i class="fa fa-li fa-envelope-o" /> | ||||
|                   <i class="fa fa-li fa-envelope-o"/> | ||||
|                   <a :href="'mailto: ' + person.email">{{ person.email }}</a> | ||||
|                 </li> | ||||
|                 <li v-else-if="options.addNoData"> | ||||
|                   <i class="fa fa-li fa-envelope-o" /> | ||||
|                   <i class="fa fa-li fa-envelope-o"/> | ||||
|                   <p class="chill-no-data-statement"> | ||||
|                     {{ trans(RENDERBOX_NO_DATA) }} | ||||
|                   </p> | ||||
|                 </li> | ||||
|  | ||||
|                 <li v-if="person.mobilenumber"> | ||||
|                   <i class="fa fa-li fa-mobile" /> | ||||
|                   <i class="fa fa-li fa-mobile"/> | ||||
|                   <a :href="'tel: ' + person.mobilenumber"> | ||||
|                     {{ person.mobilenumber }} | ||||
|                   </a> | ||||
|                 </li> | ||||
|                 <li v-else-if="options.addNoData"> | ||||
|                   <i class="fa fa-li fa-mobile" /> | ||||
|                   <i class="fa fa-li fa-mobile"/> | ||||
|                   <p class="chill-no-data-statement"> | ||||
|                     {{ trans(RENDERBOX_NO_DATA) }} | ||||
|                   </p> | ||||
|                 </li> | ||||
|                 <li v-if="person.phonenumber"> | ||||
|                   <i class="fa fa-li fa-phone" /> | ||||
|                   <i class="fa fa-li fa-phone"/> | ||||
|                   <a :href="'tel: ' + person.phonenumber"> | ||||
|                     {{ person.phonenumber }} | ||||
|                   </a> | ||||
|                 </li> | ||||
|                 <li v-else-if="options.addNoData"> | ||||
|                   <i class="fa fa-li fa-phone" /> | ||||
|                   <i class="fa fa-li fa-phone"/> | ||||
|                   <p class="chill-no-data-statement"> | ||||
|                     {{ trans(RENDERBOX_NO_DATA) }} | ||||
|                   </p> | ||||
| @@ -246,25 +187,25 @@ | ||||
|                     options.addCenter | ||||
|                   " | ||||
|                 > | ||||
|                   <i class="fa fa-li fa-long-arrow-right" /> | ||||
|                   <i class="fa fa-li fa-long-arrow-right"/> | ||||
|                   <template v-for="c in person.centers"> | ||||
|                     {{ c.name }} | ||||
|                   </template> | ||||
|                 </li> | ||||
|                 <li v-else-if="options.addNoData"> | ||||
|                   <i class="fa fa-li fa-long-arrow-right" /> | ||||
|                   <i class="fa fa-li fa-long-arrow-right"/> | ||||
|                   <p class="chill-no-data-statement"> | ||||
|                     {{ trans(RENDERBOX_NO_DATA) }} | ||||
|                   </p> | ||||
|                 </li> | ||||
|                 <slot name="custom-zone" /> | ||||
|                 <slot name="custom-zone"/> | ||||
|               </ul> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       <slot name="end-bloc" /> | ||||
|       <slot name="end-bloc"/> | ||||
|     </section> | ||||
|   </div> | ||||
|  | ||||
| @@ -278,11 +219,11 @@ | ||||
|         class="fa-stack fa-holder" | ||||
|         :title="trans(RENDERBOX_HOLDER)" | ||||
|       > | ||||
|         <i class="fa fa-circle fa-stack-1x text-success" /> | ||||
|         <i class="fa fa-circle fa-stack-1x text-success"/> | ||||
|         <i class="fa fa-stack-1x">T</i> | ||||
|       </span> | ||||
|  | ||||
|       <person-text :person="person" /> | ||||
|       <person-text :person="person"/> | ||||
|     </a> | ||||
|     <span v-else> | ||||
|       <span | ||||
| @@ -290,18 +231,18 @@ | ||||
|         class="fa-stack fa-holder" | ||||
|         :title="trans(RENDERBOX_HOLDER)" | ||||
|       > | ||||
|         <i class="fa fa-circle fa-stack-1x text-success" /> | ||||
|         <i class="fa fa-circle fa-stack-1x text-success"/> | ||||
|         <i class="fa fa-stack-1x">T</i> | ||||
|       </span> | ||||
|       <person-text :person="person" /> | ||||
|       <person-text :person="person"/> | ||||
|     </span> | ||||
|     <slot name="post-badge" /> | ||||
|     <slot name="post-badge"/> | ||||
|   </span> | ||||
| </template> | ||||
|  | ||||
| <script setup> | ||||
| import { computed } from "vue"; | ||||
| import { ISOToDate } from "ChillMainAssets/chill/js/date"; | ||||
| <script setup lang="ts"> | ||||
| import {computed} from "vue"; | ||||
| import {ISOToDate} from "ChillMainAssets/chill/js/date"; | ||||
| import AddressRenderBox from "ChillMainAssets/vuejs/_components/Entity/AddressRenderBox.vue"; | ||||
| import GenderIconRenderBox from "ChillMainAssets/vuejs/_components/Entity/GenderIconRenderBox.vue"; | ||||
| import BadgeEntity from "ChillMainAssets/vuejs/_components/BadgeEntity.vue"; | ||||
| @@ -311,108 +252,70 @@ import { | ||||
|   trans, | ||||
|   RENDERBOX_HOLDER, | ||||
|   RENDERBOX_NO_DATA, | ||||
|   RENDERBOX_DEATHDATE, | ||||
|   RENDERBOX_DEATHDATE_STATEMENT, | ||||
|   RENDERBOX_HOUSEHOLD_WITHOUT_ADDRESS, | ||||
|   RENDERBOX_RESIDENTIAL_ADDRESS, | ||||
|   RENDERBOX_LOCATED_AT, | ||||
|   RENDERBOX_BIRTHDAY_MAN, | ||||
|   RENDERBOX_BIRTHDAY_WOMAN, | ||||
|   RENDERBOX_BIRTHDAY_UNKNOWN, | ||||
|   RENDERBOX_BIRTHDAY_NEUTRAL, | ||||
|   PERSONS_ASSOCIATED_SHOW_HOUSEHOLD_NUMBER, | ||||
|   RENDERBOX_BIRTHDAY_STATEMENT, | ||||
|   // PERSONS_ASSOCIATED_SHOW_HOUSEHOLD_NUMBER, | ||||
|   RENDERBOX_YEARS_OLD, | ||||
| } from "translator"; | ||||
| import {Person} from "ChillPersonAssets/types"; | ||||
| import {toGenderTranslation} from "ChillMainAssets/lib/api/genderHelper"; | ||||
|  | ||||
| const props = defineProps({ | ||||
|   person: { | ||||
|     required: true, | ||||
|   }, | ||||
|   options: { | ||||
|     type: Object, | ||||
|     required: false, | ||||
|   }, | ||||
|   render: { | ||||
|     type: String, | ||||
|   }, | ||||
|   returnPath: { | ||||
|     type: String, | ||||
|   }, | ||||
|   showResidentialAddresses: { | ||||
|     type: Boolean, | ||||
|     default: false, | ||||
|   }, | ||||
| interface RenderOptions { | ||||
|   addInfo?: boolean; | ||||
|   addEntity?: boolean; | ||||
|   addAltNames?: boolean; | ||||
|   addAge?: boolean; | ||||
|   addId?: boolean; | ||||
|   addLink?: boolean; | ||||
|   hLevel?: number; | ||||
|   entityDisplayLong?: boolean; | ||||
|   addCenter?: boolean; | ||||
|   addNoData?: boolean; | ||||
|   isMultiline?: boolean; | ||||
|   isHolder?: boolean; | ||||
|   addHouseholdLink?: boolean; | ||||
| } | ||||
|  | ||||
| interface Props { | ||||
|   person: Person; | ||||
|   options?: RenderOptions; | ||||
|   render?: "bloc" | "badge"; | ||||
|   returnPath?: string; | ||||
|   showResidentialAddresses?: boolean; | ||||
| } | ||||
|  | ||||
| const props = withDefaults(defineProps<Props>(), { | ||||
|   render: "bloc", | ||||
|   options: () => ({ | ||||
|     addInfo: true, | ||||
|     addEntity: false, | ||||
|     addAltNames: true, | ||||
|     addAge: true, | ||||
|     addId: true, | ||||
|     addLink: false, | ||||
|     hLevel: 3, | ||||
|     entityDisplayLong: true, | ||||
|     addCenter: true, | ||||
|     addNoData: true, | ||||
|     isMultiline: true, | ||||
|     isHolder: false, | ||||
|     addHouseholdLink: true | ||||
|   }), | ||||
| }); | ||||
|  | ||||
| const birthdateTranslation = computed(() => { | ||||
|   if (props.person.gender) { | ||||
|     const { genderTranslation } = props.person.gender; | ||||
|     switch (genderTranslation) { | ||||
|       case "man": | ||||
|         return RENDERBOX_BIRTHDAY_MAN; | ||||
|       case "woman": | ||||
|         return RENDERBOX_BIRTHDAY_WOMAN; | ||||
|       case "neutral": | ||||
|         return RENDERBOX_BIRTHDAY_NEUTRAL; | ||||
|       case "unknown": | ||||
|         return RENDERBOX_BIRTHDAY_UNKNOWN; | ||||
|       default: | ||||
|         return RENDERBOX_BIRTHDAY_UNKNOWN; | ||||
|     } | ||||
|   } else { | ||||
|     return RENDERBOX_BIRTHDAY_UNKNOWN; | ||||
|   } | ||||
| }); | ||||
|  | ||||
| const isMultiline = computed(() => { | ||||
| const isMultiline = computed<boolean>(() => { | ||||
|   return props.options?.isMultiline || false; | ||||
| }); | ||||
|  | ||||
| const birthdate = computed(() => { | ||||
|   if ( | ||||
|     props.person.birthdate !== null && | ||||
|     props.person.birthdate !== undefined && | ||||
|     props.person.birthdate.datetime | ||||
|   ) { | ||||
|     return ISOToDate(props.person.birthdate.datetime); | ||||
|   } else { | ||||
|     return ""; | ||||
|   } | ||||
| }); | ||||
|  | ||||
| const deathdate = computed(() => { | ||||
|   if ( | ||||
|     props.person.deathdate !== null && | ||||
|     props.person.deathdate !== undefined && | ||||
|     props.person.deathdate.datetime | ||||
|   ) { | ||||
|     return new Date(props.person.deathdate.datetime); | ||||
|   } else { | ||||
|     return ""; | ||||
|   } | ||||
| }); | ||||
|  | ||||
| const altNameLabel = computed(() => { | ||||
|   let altNameLabel = ""; | ||||
|   (props.person.altNames || []).forEach( | ||||
|     (altName) => (altNameLabel += altName.label), | ||||
|   ); | ||||
|   return altNameLabel; | ||||
| }); | ||||
|  | ||||
| const altNameKey = computed(() => { | ||||
|   let altNameKey = ""; | ||||
|   (props.person.altNames || []).forEach( | ||||
|     (altName) => (altNameKey += altName.key), | ||||
|   ); | ||||
|   return altNameKey; | ||||
| }); | ||||
|  | ||||
| const getUrl = computed(() => { | ||||
| const getUrl = computed<string>(() => { | ||||
|   return `/fr/person/${props.person.id}/general`; | ||||
| }); | ||||
|  | ||||
| const getCurrentHouseholdUrl = computed(() => { | ||||
|   let returnPath = props.returnPath ? `?returnPath=${props.returnPath}` : ``; | ||||
| const getCurrentHouseholdUrl = computed<string>(() => { | ||||
|   const returnPath = props.returnPath ? `?returnPath=${props.returnPath}` : ``; | ||||
|   return `/fr/person/household/${props.person.current_household_id}/summary${returnPath}`; | ||||
| }); | ||||
| </script> | ||||
|   | ||||
| @@ -1,13 +1,7 @@ | ||||
| <template> | ||||
|   <span v-if="isCut">{{ cutText }}</span> | ||||
|   <span v-else class="person-text"> | ||||
|     <span class="firstname">{{ person.firstName }}</span> | ||||
|     <span class="lastname"> {{ person.lastName }}</span> | ||||
|     <span v-if="person.altNames && person.altNames.length > 0" class="altnames"> | ||||
|       <span :class="'altname altname-' + altNameKey" | ||||
|         > ({{ altNameLabel }})</span | ||||
|       > | ||||
|     </span> | ||||
|     <span>{{ person.text }}</span> | ||||
|     <span v-if="person.suffixText" class="suffixtext" | ||||
|       > {{ person.suffixText }}</span | ||||
|     > | ||||
| @@ -33,16 +27,6 @@ const props = defineProps<{ | ||||
|  | ||||
| const { person, isCut = false, addAge = true } = toRefs(props); | ||||
|  | ||||
| const altNameLabel = computed(() => { | ||||
|   if (!person.value.altNames) return ""; | ||||
|   return person.value.altNames.map((a: AltName) => a.label).join(""); | ||||
| }); | ||||
|  | ||||
| const altNameKey = computed(() => { | ||||
|   if (!person.value.altNames) return ""; | ||||
|   return person.value.altNames.map((a: AltName) => a.key).join(""); | ||||
| }); | ||||
|  | ||||
| const cutText = computed(() => { | ||||
|   if (!person.value.text) return ""; | ||||
|   const more = person.value.text.length > 15 ? "…" : ""; | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| <template> | ||||
|   <div v-if="action === 'show'"> | ||||
|   <div v-if="action === 'show' && person !== null"> | ||||
|     <div class="flex-table"> | ||||
|       <person-render-box | ||||
|         render="bloc" | ||||
| @@ -21,446 +21,31 @@ | ||||
|     </div> | ||||
|   </div> | ||||
|  | ||||
|   <div v-else-if="action === 'edit' || action === 'create'"> | ||||
|     <div class="form-floating mb-3"> | ||||
|       <input | ||||
|         class="form-control form-control-lg" | ||||
|         id="lastname" | ||||
|         v-model="lastName" | ||||
|         :placeholder="trans(PERSON_MESSAGES_PERSON_LASTNAME)" | ||||
|         @change="checkErrors" | ||||
|       /> | ||||
|       <label for="lastname">{{ trans(PERSON_MESSAGES_PERSON_LASTNAME) }}</label> | ||||
|     </div> | ||||
|  | ||||
|     <div v-if="queryItems"> | ||||
|       <ul class="list-suggest add-items inline"> | ||||
|         <li | ||||
|           v-for="(qi, i) in queryItems" | ||||
|           :key="i" | ||||
|           @click="addQueryItem('lastName', qi)" | ||||
|         > | ||||
|           <span class="person-text">{{ qi }}</span> | ||||
|         </li> | ||||
|       </ul> | ||||
|     </div> | ||||
|  | ||||
|     <div class="form-floating mb-3"> | ||||
|       <input | ||||
|         class="form-control form-control-lg" | ||||
|         id="firstname" | ||||
|         v-model="firstName" | ||||
|         :placeholder="trans(PERSON_MESSAGES_PERSON_FIRSTNAME)" | ||||
|         @change="checkErrors" | ||||
|       /> | ||||
|       <label for="firstname">{{ | ||||
|         trans(PERSON_MESSAGES_PERSON_FIRSTNAME) | ||||
|       }}</label> | ||||
|     </div> | ||||
|  | ||||
|     <div v-if="queryItems"> | ||||
|       <ul class="list-suggest add-items inline"> | ||||
|         <li | ||||
|           v-for="(qi, i) in queryItems" | ||||
|           :key="i" | ||||
|           @click="addQueryItem('firstName', qi)" | ||||
|         > | ||||
|           <span class="person-text">{{ qi }}</span> | ||||
|         </li> | ||||
|       </ul> | ||||
|     </div> | ||||
|  | ||||
|     <div | ||||
|       v-for="(a, i) in config.altNames" | ||||
|       :key="a.key" | ||||
|       class="form-floating mb-3" | ||||
|     > | ||||
|       <input | ||||
|         class="form-control form-control-lg" | ||||
|         :id="a.key" | ||||
|         :value="personAltNamesLabels[i]" | ||||
|         @input="onAltNameInput" | ||||
|       /> | ||||
|       <label :for="a.key">{{ localizeString(a.labels) }}</label> | ||||
|     </div> | ||||
|  | ||||
|     <!--  TODO fix placeholder if undefined | ||||
|    --> | ||||
|     <div class="form-floating mb-3"> | ||||
|       <select class="form-select form-select-lg" id="gender" v-model="gender"> | ||||
|         <option selected disabled> | ||||
|           {{ trans(PERSON_MESSAGES_PERSON_GENDER_PLACEHOLDER) }} | ||||
|         </option> | ||||
|         <option v-for="g in config.genders" :value="g.id" :key="g.id"> | ||||
|           {{ g.label }} | ||||
|         </option> | ||||
|       </select> | ||||
|       <label>{{ trans(PERSON_MESSAGES_PERSON_GENDER_TITLE) }}</label> | ||||
|     </div> | ||||
|  | ||||
|     <div | ||||
|       class="form-floating mb-3" | ||||
|       v-if="showCenters && config.centers.length > 1" | ||||
|     > | ||||
|       <select class="form-select form-select-lg" id="center" v-model="center"> | ||||
|         <option selected disabled> | ||||
|           {{ trans(PERSON_MESSAGES_PERSON_CENTER_PLACEHOLDER) }} | ||||
|         </option> | ||||
|         <option v-for="c in config.centers" :value="c" :key="c.id"> | ||||
|           {{ c.name }} | ||||
|         </option> | ||||
|       </select> | ||||
|       <label>{{ trans(PERSON_MESSAGES_PERSON_CENTER_TITLE) }}</label> | ||||
|     </div> | ||||
|  | ||||
|     <div class="form-floating mb-3"> | ||||
|       <select | ||||
|         class="form-select form-select-lg" | ||||
|         id="civility" | ||||
|         v-model="civility" | ||||
|       > | ||||
|         <option selected disabled> | ||||
|           {{ trans(PERSON_MESSAGES_PERSON_CIVILITY_PLACEHOLDER) }} | ||||
|         </option> | ||||
|         <option v-for="c in config.civilities" :value="c.id" :key="c.id"> | ||||
|           {{ localizeString(c.name) }} | ||||
|         </option> | ||||
|       </select> | ||||
|       <label>{{ trans(PERSON_MESSAGES_PERSON_CIVILITY_TITLE) }}</label> | ||||
|     </div> | ||||
|  | ||||
|     <div class="input-group mb-3"> | ||||
|       <span class="input-group-text" id="phonenumber"> | ||||
|         <i class="fa fa-fw fa-phone"></i> | ||||
|       </span> | ||||
|       <input | ||||
|         class="form-control form-control-lg" | ||||
|         v-model="phonenumber" | ||||
|         :placeholder="trans(PERSON_MESSAGES_PERSON_PHONENUMBER)" | ||||
|         :aria-label="trans(PERSON_MESSAGES_PERSON_PHONENUMBER)" | ||||
|         aria-describedby="phonenumber" | ||||
|       /> | ||||
|     </div> | ||||
|  | ||||
|     <div class="input-group mb-3"> | ||||
|       <span class="input-group-text" id="mobilenumber"> | ||||
|         <i class="fa fa-fw fa-mobile"></i> | ||||
|       </span> | ||||
|       <input | ||||
|         class="form-control form-control-lg" | ||||
|         v-model="mobilenumber" | ||||
|         :placeholder="trans(PERSON_MESSAGES_PERSON_MOBILENUMBER)" | ||||
|         :aria-label="trans(PERSON_MESSAGES_PERSON_MOBILENUMBER)" | ||||
|         aria-describedby="mobilenumber" | ||||
|       /> | ||||
|     </div> | ||||
|  | ||||
|     <div class="input-group mb-3"> | ||||
|       <span class="input-group-text" id="email"> | ||||
|         <i class="fa fa-fw fa-at"></i> | ||||
|       </span> | ||||
|       <input | ||||
|         class="form-control form-control-lg" | ||||
|         v-model="email" | ||||
|         :placeholder="trans(PERSON_MESSAGES_PERSON_EMAIL)" | ||||
|         :aria-label="trans(PERSON_MESSAGES_PERSON_EMAIL)" | ||||
|         aria-describedby="email" | ||||
|       /> | ||||
|     </div> | ||||
|  | ||||
|     <div v-if="action === 'create'" class="input-group mb-3 form-check"> | ||||
|       <input | ||||
|         class="form-check-input" | ||||
|         type="checkbox" | ||||
|         v-model="showAddressForm" | ||||
|         name="showAddressForm" | ||||
|       /> | ||||
|       <label class="form-check-label"> | ||||
|         {{ trans(PERSON_MESSAGES_PERSON_ADDRESS_SHOW_ADDRESS_FORM) }} | ||||
|       </label> | ||||
|     </div> | ||||
|     <div | ||||
|       v-if="action === 'create' && showAddressFormValue" | ||||
|       class="form-floating mb-3" | ||||
|     > | ||||
|       <p>{{ trans(PERSON_MESSAGES_PERSON_ADDRESS_WARNING) }}</p> | ||||
|       <AddAddress | ||||
|         :context="addAddress.context" | ||||
|         :options="addAddress.options" | ||||
|         :addressChangedCallback="submitNewAddress" | ||||
|         ref="addAddress" | ||||
|       /> | ||||
|     </div> | ||||
|  | ||||
|     <div class="alert alert-warning" v-if="errors.length"> | ||||
|       <ul> | ||||
|         <li v-for="(e, i) in errors" :key="i">{{ e }}</li> | ||||
|       </ul> | ||||
|     </div> | ||||
|   <div v-else-if="props.action === 'edit' || props.action === 'create'"> | ||||
|     <PersonEdit | ||||
|       :id="props.id" | ||||
|       :type="props.type" | ||||
|       :action="props.action" | ||||
|       :query="props.query" | ||||
|     /> | ||||
|   </div> | ||||
| </template> | ||||
| <script setup> | ||||
| import { ref, reactive, computed, onMounted } from "vue"; | ||||
| import { | ||||
|   getCentersForPersonCreation, | ||||
|   getCivilities, | ||||
|   getGenders, | ||||
|   getPerson, | ||||
|   getPersonAltNames, | ||||
| } from "../../_api/OnTheFly"; | ||||
| <script setup lang="ts"> | ||||
| import { ref, onMounted } from "vue"; | ||||
| import { getPerson } from "../../_api/OnTheFly"; | ||||
| import PersonRenderBox from "../Entity/PersonRenderBox.vue"; | ||||
| import AddAddress from "ChillMainAssets/vuejs/Address/components/AddAddress.vue"; | ||||
| import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper"; | ||||
| import { | ||||
|   trans, | ||||
|   PERSON_MESSAGES_PERSON_LASTNAME, | ||||
|   PERSON_MESSAGES_PERSON_FIRSTNAME, | ||||
|   PERSON_MESSAGES_PERSON_GENDER_PLACEHOLDER, | ||||
|   PERSON_MESSAGES_PERSON_GENDER_TITLE, | ||||
|   PERSON_MESSAGES_PERSON_CENTER_PLACEHOLDER, | ||||
|   PERSON_MESSAGES_PERSON_CENTER_TITLE, | ||||
|   PERSON_MESSAGES_PERSON_CIVILITY_PLACEHOLDER, | ||||
|   PERSON_MESSAGES_PERSON_CIVILITY_TITLE, | ||||
|   PERSON_MESSAGES_PERSON_PHONENUMBER, | ||||
|   PERSON_MESSAGES_PERSON_MOBILENUMBER, | ||||
|   PERSON_MESSAGES_PERSON_EMAIL, | ||||
|   PERSON_MESSAGES_PERSON_ADDRESS_SHOW_ADDRESS_FORM, | ||||
|   PERSON_MESSAGES_PERSON_ADDRESS_WARNING, | ||||
| } from "translator"; | ||||
| import PersonEdit from "./PersonEdit.vue"; | ||||
| import type { Person } from "ChillPersonAssets/types"; | ||||
|  | ||||
| const props = defineProps({ | ||||
|   id: [String, Number], | ||||
|   type: String, | ||||
|   action: String, | ||||
|   query: String, | ||||
| }); | ||||
|  | ||||
| const person = reactive({ | ||||
|   type: "person", | ||||
|   lastName: "", | ||||
|   firstName: "", | ||||
|   altNames: [], | ||||
|   addressId: null, | ||||
|   center: null, | ||||
|   gender: null, | ||||
|   civility: null, | ||||
|   birthdate: null, | ||||
|   phonenumber: "", | ||||
|   mobilenumber: "", | ||||
|   email: "", | ||||
| }); | ||||
|  | ||||
| const config = reactive({ | ||||
|   altNames: [], | ||||
|   civilities: [], | ||||
|   centers: [], | ||||
|   genders: [], | ||||
| }); | ||||
|  | ||||
| const showCenters = ref(false); | ||||
| const showAddressFormValue = ref(false); | ||||
| const errors = ref([]); | ||||
|  | ||||
| const addAddress = reactive({ | ||||
|   options: { | ||||
|     button: { | ||||
|       text: { create: "person.address.create_address" }, | ||||
|       size: "btn-sm", | ||||
|     }, | ||||
|     title: { create: "person.address.create_address" }, | ||||
|   }, | ||||
|   context: { | ||||
|     target: {}, | ||||
|     edit: false, | ||||
|     addressId: null, | ||||
|     defaults: window.addaddress, | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const firstName = computed({ | ||||
|   get: () => person.firstName, | ||||
|   set: (value) => { | ||||
|     person.firstName = value; | ||||
|   }, | ||||
| }); | ||||
| const lastName = computed({ | ||||
|   get: () => person.lastName, | ||||
|   set: (value) => { | ||||
|     person.lastName = value; | ||||
|   }, | ||||
| }); | ||||
| const gender = computed({ | ||||
|   get: () => (person.gender ? person.gender.id : null), | ||||
|   set: (value) => { | ||||
|     person.gender = { id: value, type: "chill_main_gender" }; | ||||
|   }, | ||||
| }); | ||||
| const civility = computed({ | ||||
|   get: () => (person.civility ? person.civility.id : null), | ||||
|   set: (value) => { | ||||
|     person.civility = { id: value, type: "chill_main_civility" }; | ||||
|   }, | ||||
| }); | ||||
| const birthDate = computed({ | ||||
|   get: () => (person.birthdate ? person.birthdate.datetime.split("T")[0] : ""), | ||||
|   set: (value) => { | ||||
|     if (person.birthdate) { | ||||
|       person.birthdate.datetime = value + "T00:00:00+0100"; | ||||
|     } else { | ||||
|       person.birthdate = { datetime: value + "T00:00:00+0100" }; | ||||
|     } | ||||
|   }, | ||||
| }); | ||||
| const phonenumber = computed({ | ||||
|   get: () => person.phonenumber, | ||||
|   set: (value) => { | ||||
|     person.phonenumber = value; | ||||
|   }, | ||||
| }); | ||||
| const mobilenumber = computed({ | ||||
|   get: () => person.mobilenumber, | ||||
|   set: (value) => { | ||||
|     person.mobilenumber = value; | ||||
|   }, | ||||
| }); | ||||
| const email = computed({ | ||||
|   get: () => person.email, | ||||
|   set: (value) => { | ||||
|     person.email = value; | ||||
|   }, | ||||
| }); | ||||
| const showAddressForm = computed({ | ||||
|   get: () => showAddressFormValue.value, | ||||
|   set: (value) => { | ||||
|     showAddressFormValue.value = value; | ||||
|   }, | ||||
| }); | ||||
| const center = computed({ | ||||
|   get: () => { | ||||
|     const c = config.centers.find( | ||||
|       (c) => person.center !== null && person.center.id === c.id, | ||||
|     ); | ||||
|     return typeof c === "undefined" ? null : c; | ||||
|   }, | ||||
|   set: (value) => { | ||||
|     person.center = { id: value.id, type: value.type }; | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const genderClass = computed(() => { | ||||
|   switch (person.gender && person.gender.id) { | ||||
|     case "woman": | ||||
|       return "fa-venus"; | ||||
|     case "man": | ||||
|       return "fa-mars"; | ||||
|     case "both": | ||||
|       return "fa-neuter"; | ||||
|     case "unknown": | ||||
|       return "fa-genderless"; | ||||
|     default: | ||||
|       return "fa-genderless"; | ||||
|   } | ||||
| }); | ||||
|  | ||||
| const genderTranslation = computed(() => { | ||||
|   switch (person.gender && person.gender.genderTranslation) { | ||||
|     case "woman": | ||||
|       return PERSON_MESSAGES_PERSON_GENDER_WOMAN; | ||||
|     case "man": | ||||
|       return PERSON_MESSAGES_PERSON_GENDER_MAN; | ||||
|     case "neutral": | ||||
|       return PERSON_MESSAGES_PERSON_GENDER_NEUTRAL; | ||||
|     case "unknown": | ||||
|       return PERSON_MESSAGES_PERSON_GENDER_UNKNOWN; | ||||
|     default: | ||||
|       return PERSON_MESSAGES_PERSON_GENDER_UNKNOWN; | ||||
|   } | ||||
| }); | ||||
| const feminized = computed(() => | ||||
|   person.gender && person.gender.id === "woman" ? "e" : "", | ||||
| ); | ||||
| const personAltNamesLabels = computed(() => | ||||
|   person.altNames.map((a) => (a ? a.label : "")), | ||||
| ); | ||||
| const queryItems = computed(() => | ||||
|   props.query ? props.query.split(" ") : null, | ||||
| ); | ||||
|  | ||||
| function checkErrors() { | ||||
|   errors.value = []; | ||||
|   if (person.lastName === "") { | ||||
|     errors.value.push("Le nom ne doit pas être vide."); | ||||
|   } | ||||
|   if (person.firstName === "") { | ||||
|     errors.value.push("Le prénom ne doit pas être vide."); | ||||
|   } | ||||
|   if (!person.gender) { | ||||
|     errors.value.push("Le genre doit être renseigné"); | ||||
|   } | ||||
|   if (showCenters.value && person.center === null) { | ||||
|     errors.value.push("Le centre doit être renseigné"); | ||||
|   } | ||||
| interface Props { | ||||
|   id: number; | ||||
|   type?: string; | ||||
|   action: "show" | "edit" | "create"; | ||||
|   query?: string; | ||||
| } | ||||
|  | ||||
| function loadData() { | ||||
|   getPerson(props.id).then((p) => { | ||||
|     Object.assign(person, p); | ||||
|   }); | ||||
| } | ||||
| const props = withDefaults(defineProps<Props>(), {query: ""}); | ||||
|  | ||||
| function onAltNameInput(event) { | ||||
|   const key = event.target.id; | ||||
|   const label = event.target.value; | ||||
|   let updateAltNames = person.altNames.filter((a) => a.key !== key); | ||||
|   updateAltNames.push({ key: key, label: label }); | ||||
|   person.altNames = updateAltNames; | ||||
| } | ||||
| const person = ref<Person | null>(null); | ||||
|  | ||||
| function addQueryItem(field, queryItem) { | ||||
|   switch (field) { | ||||
|     case "lastName": | ||||
|       person.lastName = person.lastName | ||||
|         ? (person.lastName += ` ${queryItem}`) | ||||
|         : queryItem; | ||||
|       break; | ||||
|     case "firstName": | ||||
|       person.firstName = person.firstName | ||||
|         ? (person.firstName += ` ${queryItem}`) | ||||
|         : queryItem; | ||||
|       break; | ||||
|   } | ||||
| } | ||||
|  | ||||
| function submitNewAddress(payload) { | ||||
|   person.addressId = payload.addressId; | ||||
| } | ||||
|  | ||||
| onMounted(() => { | ||||
|   getPersonAltNames().then((altNames) => { | ||||
|     config.altNames = altNames; | ||||
|   }); | ||||
|   getCivilities().then((civilities) => { | ||||
|     if ("results" in civilities) { | ||||
|       config.civilities = civilities.results; | ||||
|     } | ||||
|   }); | ||||
|   getGenders().then((genders) => { | ||||
|     if ("results" in genders) { | ||||
|       config.genders = genders.results; | ||||
|     } | ||||
|   }); | ||||
|   if (props.action !== "create") { | ||||
|     loadData(); | ||||
|   } else { | ||||
|     getCentersForPersonCreation().then((params) => { | ||||
|       config.centers = params.centers.filter((c) => c.isActive); | ||||
|       showCenters.value = params.showCenters; | ||||
|       if (showCenters.value && config.centers.length === 1) { | ||||
|         person.center = config.centers[0]; | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
| }); | ||||
|  | ||||
| defineExpose(genderClass, genderTranslation, feminized, birthDate); | ||||
| </script> | ||||
|   | ||||
| @@ -0,0 +1,695 @@ | ||||
| <template> | ||||
|   <div v-if="action === 'create' || (action === 'edit' && dataLoaded)"> | ||||
|     <div class="mb-3"> | ||||
|       <div class="input-group has-validation"> | ||||
|         <div class="form-floating"> | ||||
|           <input | ||||
|             class="form-control form-control-lg" | ||||
|             :class="{ 'is-invalid': violations.hasViolation('lastName') }" | ||||
|             id="lastname" | ||||
|             v-model="lastName" | ||||
|             :placeholder="trans(PERSON_MESSAGES_PERSON_LASTNAME)" | ||||
|           /> | ||||
|           <label for="lastname">{{ | ||||
|             trans(PERSON_MESSAGES_PERSON_LASTNAME) | ||||
|           }}</label> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div | ||||
|         v-for="err in violations.violationTitles('lastName')" | ||||
|         class="invalid-feedback was-validated-force" | ||||
|       > | ||||
|         {{ err }} | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
|     <div v-if="queryItems"> | ||||
|       <ul class="list-suggest add-items inline"> | ||||
|         <li | ||||
|           v-for="(qi, i) in queryItems" | ||||
|           :key="i" | ||||
|           @click="addQueryItem('lastName', qi)" | ||||
|         > | ||||
|           <span class="person-text">{{ qi }}</span> | ||||
|         </li> | ||||
|       </ul> | ||||
|     </div> | ||||
|  | ||||
|     <div class="mb-3"> | ||||
|       <div class="input-group has-validation"> | ||||
|         <div class="form-floating"> | ||||
|           <input | ||||
|             class="form-control form-control-lg" | ||||
|             :class="{ 'is-invalid': violations.hasViolation('firstName') }" | ||||
|             id="firstname" | ||||
|             v-model="firstName" | ||||
|             :placeholder="trans(PERSON_MESSAGES_PERSON_FIRSTNAME)" | ||||
|           /> | ||||
|           <label for="firstname">{{ | ||||
|             trans(PERSON_MESSAGES_PERSON_FIRSTNAME) | ||||
|           }}</label> | ||||
|         </div> | ||||
|       </div> | ||||
|       <div | ||||
|         v-for="err in violations.violationTitles('firstName')" | ||||
|         class="invalid-feedback was-validated-force" | ||||
|       > | ||||
|         {{ err }} | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
|     <div v-if="queryItems"> | ||||
|       <ul class="list-suggest add-items inline"> | ||||
|         <li | ||||
|           v-for="(qi, i) in queryItems" | ||||
|           :key="i" | ||||
|           @click="addQueryItem('firstName', qi)" | ||||
|         > | ||||
|           <span class="person-text">{{ qi }}</span> | ||||
|         </li> | ||||
|       </ul> | ||||
|     </div> | ||||
|  | ||||
|     <template v-if="action === 'create'"> | ||||
|       <div v-for="(a, i) in config.altNames" :key="a.key" class="mb-3"> | ||||
|         <div class="input-group has-validation"> | ||||
|           <div class="form-floating"> | ||||
|             <input | ||||
|               class="form-control form-control-lg" | ||||
|               :id="a.key" | ||||
|               :name="'label_' + a.key" | ||||
|               value="" | ||||
|               @input="onAltNameInput($event, a.key)" | ||||
|             /> | ||||
|             <label :for="'label_' + a.key">{{ localizeString(a.labels) }}</label> | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|     </template> | ||||
|  | ||||
|     <template v-if="action === 'create'"> | ||||
|       <div | ||||
|         v-for="worker in config.identifiers" | ||||
|         :key="worker.definition_id" | ||||
|         class="mb-3" | ||||
|       > | ||||
|         <div class="input-group has-validation"> | ||||
|           <div class="form-floating"> | ||||
|             <input | ||||
|               class="form-control form-control-lg" | ||||
|               :class="{'is-invalid': violations.hasViolationWithParameter('identifiers', 'definition_id', worker.definition_id.toString())}" | ||||
|               type="text" | ||||
|               :name="'worker_' + worker.definition_id" | ||||
|               :placeholder="localizeString(worker.label)" | ||||
|               @input="onIdentifierInput($event, worker.definition_id)" | ||||
|             /> | ||||
|             <label :for="'worker_' + worker.definition_id">{{ | ||||
|                 localizeString(worker.label) | ||||
|               }}</label> | ||||
|           </div> | ||||
|           <div | ||||
|             v-for="err in violations.violationTitlesWithParameter('identifiers', 'definition_id', worker.definition_id.toString())" | ||||
|             class="invalid-feedback was-validated-force" | ||||
|           > | ||||
|             {{ err }} | ||||
|           </div> | ||||
|         </div> | ||||
|       </div> | ||||
|     </template> | ||||
|  | ||||
|     <div class="mb-3"> | ||||
|       <div class="input-group has-validation"> | ||||
|         <div class="form-floating"> | ||||
|           <select | ||||
|             class="form-select form-select-lg" | ||||
|             :class="{ 'is-invalid': violations.hasViolation('gender') }" | ||||
|             id="gender" | ||||
|             v-model="gender" | ||||
|           > | ||||
|             <option selected disabled> | ||||
|               {{ trans(PERSON_MESSAGES_PERSON_GENDER_PLACEHOLDER) }} | ||||
|             </option> | ||||
|             <option v-for="g in config.genders" :value="g.id" :key="g.id"> | ||||
|               {{ g.label }} | ||||
|             </option> | ||||
|           </select> | ||||
|           <label for="gender" class="form-label">{{ | ||||
|             trans(PERSON_MESSAGES_PERSON_GENDER_TITLE) | ||||
|           }}</label> | ||||
|         </div> | ||||
|         <div | ||||
|           v-for="err in violations.violationTitles('gender')" | ||||
|           class="invalid-feedback was-validated-force" | ||||
|         > | ||||
|           {{ err }} | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
|     <div class="mb-3" v-if="showCenters && config.centers.length > 1"> | ||||
|       <div class="input-group"> | ||||
|         <div class="form-floating"> | ||||
|           <select | ||||
|             class="form-select form-select-lg" | ||||
|             :class="{ 'is-invalid': violations.hasViolation('center') }" | ||||
|             id="center" | ||||
|             v-model="center" | ||||
|           > | ||||
|             <option selected disabled> | ||||
|               {{ trans(PERSON_MESSAGES_PERSON_CENTER_PLACEHOLDER) }} | ||||
|             </option> | ||||
|             <option v-for="c in config.centers" :value="c" :key="c.id"> | ||||
|               {{ c.name }} | ||||
|             </option> | ||||
|           </select> | ||||
|           <label for="center">{{ trans(PERSON_MESSAGES_PERSON_CENTER_TITLE) }}</label> | ||||
|         </div> | ||||
|         <div | ||||
|           v-for="err in violations.violationTitles('center')" | ||||
|           class="invalid-feedback was-validated-force" | ||||
|         > | ||||
|           {{ err }} | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
|     <div class="mb-3"> | ||||
|       <div class="input-group has-validation"> | ||||
|         <div class="form-floating"> | ||||
|           <select | ||||
|             class="form-select form-select-lg" | ||||
|             :class="{ 'is-invalid': violations.hasViolation('civility') }" | ||||
|             id="civility" | ||||
|             v-model="civility" | ||||
|           > | ||||
|             <option selected disabled> | ||||
|               {{ trans(PERSON_MESSAGES_PERSON_CIVILITY_PLACEHOLDER) }} | ||||
|             </option> | ||||
|             <option v-for="c in config.civilities" :value="c.id" :key="c.id"> | ||||
|               {{ localizeString(c.name) }} | ||||
|             </option> | ||||
|           </select> | ||||
|           <label for="civility">{{ trans(PERSON_MESSAGES_PERSON_CIVILITY_TITLE) }}</label> | ||||
|         </div> | ||||
|         <div | ||||
|           v-for="err in violations.violationTitles('civility')" | ||||
|           class="invalid-feedback was-validated-force" | ||||
|         > | ||||
|           {{ err }} | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
|     <div class="mb-3"> | ||||
|       <div class="input-group has-validation"> | ||||
|         <span class="input-group-text"> | ||||
|           <i class="bi bi-cake2-fill"></i> | ||||
|         </span> | ||||
|         <div class="form-floating"> | ||||
|           <input | ||||
|             class="form-control form-control-lg" | ||||
|             :class="{ 'is-invalid': violations.hasViolation('birthdate') }" | ||||
|             name="birthdate" | ||||
|             type="date" | ||||
|             v-model="birthDate" | ||||
|             :placeholder="trans(BIRTHDATE)" | ||||
|             :aria-label="trans(BIRTHDATE)" | ||||
|             /> | ||||
|           <label for="birthdate">{{ trans(BIRTHDATE) }}</label> | ||||
|         </div> | ||||
|         <div | ||||
|           v-for="err in violations.violationTitles('birthdate')" | ||||
|           class="invalid-feedback was-validated-force" | ||||
|         > | ||||
|           {{ err }} | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
|     <div class="mb-3"> | ||||
|       <div class="input-group has-validation"> | ||||
|         <span class="input-group-text" id="phonenumber"> | ||||
|           <i class="fa fa-fw fa-phone"></i> | ||||
|         </span> | ||||
|         <div class="form-floating"> | ||||
|           <input | ||||
|             class="form-control form-control-lg" | ||||
|             :class="{ 'is-invalid': violations.hasViolation('phonenumber') }" | ||||
|             v-model="phonenumber" | ||||
|             :placeholder="trans(PERSON_MESSAGES_PERSON_PHONENUMBER)" | ||||
|             :aria-label="trans(PERSON_MESSAGES_PERSON_PHONENUMBER)" | ||||
|             aria-describedby="phonenumber" | ||||
|           /> | ||||
|           <label for="phonenumber">{{ trans(PERSON_MESSAGES_PERSON_PHONENUMBER) }}</label> | ||||
|         </div> | ||||
|         <div | ||||
|           v-for="err in violations.violationTitles('phonenumber')" | ||||
|           class="invalid-feedback was-validated-force" | ||||
|         > | ||||
|           {{ err }} | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
|     <div class="mb-3"> | ||||
|       <div class="input-group has-validation"> | ||||
|         <span class="input-group-text" id="mobilenumber"> | ||||
|           <i class="fa fa-fw fa-mobile"></i> | ||||
|         </span> | ||||
|         <div class="form-floating"> | ||||
|           <input | ||||
|             class="form-control form-control-lg" | ||||
|             :class="{ 'is-invalid': violations.hasViolation('mobilenumber') }" | ||||
|             v-model="mobilenumber" | ||||
|             :placeholder="trans(PERSON_MESSAGES_PERSON_MOBILENUMBER)" | ||||
|             :aria-label="trans(PERSON_MESSAGES_PERSON_MOBILENUMBER)" | ||||
|             aria-describedby="mobilenumber" | ||||
|           /> | ||||
|           <label for="mobilenumber">{{ trans(PERSON_MESSAGES_PERSON_MOBILENUMBER) }}</label> | ||||
|         </div> | ||||
|         <div | ||||
|           v-for="err in violations.violationTitles('mobilenumber')" | ||||
|           class="invalid-feedback was-validated-force" | ||||
|         > | ||||
|           {{ err }} | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
|     <div class="mb-3"> | ||||
|       <div class="input-group has-validation"> | ||||
|         <span class="input-group-text" id="email"> | ||||
|           <i class="fa fa-fw fa-at"></i> | ||||
|         </span> | ||||
|         <div class="form-floating"> | ||||
|           <input | ||||
|             class="form-control form-control-lg" | ||||
|             :class="{ 'is-invalid': violations.hasViolation('email') }" | ||||
|             v-model="email" | ||||
|             :placeholder="trans(PERSON_MESSAGES_PERSON_EMAIL)" | ||||
|             :aria-label="trans(PERSON_MESSAGES_PERSON_EMAIL)" | ||||
|             aria-describedby="email" | ||||
|           /> | ||||
|           <label for="email">{{ trans(PERSON_MESSAGES_PERSON_EMAIL) }}</label> | ||||
|         </div> | ||||
|         <div | ||||
|           v-for="err in violations.violationTitles('email')" | ||||
|           class="invalid-feedback was-validated-force" | ||||
|         > | ||||
|           {{ err }} | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
|     <div v-if="action === 'create'" class="input-group mb-3 form-check"> | ||||
|       <input | ||||
|         class="form-check-input" | ||||
|         type="checkbox" | ||||
|         v-model="showAddressForm" | ||||
|         name="showAddressForm" | ||||
|       /> | ||||
|       <label class="form-check-label"> | ||||
|         {{ trans(PERSON_MESSAGES_PERSON_ADDRESS_SHOW_ADDRESS_FORM) }} | ||||
|       </label> | ||||
|     </div> | ||||
|  | ||||
|     <div | ||||
|       v-if="action === 'create' && showAddressFormValue" | ||||
|       class="form-floating mb-3" | ||||
|     > | ||||
|       <p>{{ trans(PERSON_MESSAGES_PERSON_ADDRESS_WARNING) }}</p> | ||||
|       <AddAddress | ||||
|         :context="addAddress.context" | ||||
|         :options="addAddress.options" | ||||
|         :addressChangedCallback="submitNewAddress" | ||||
|         ref="addAddress" | ||||
|       /> | ||||
|     </div> | ||||
|  | ||||
|   </div> | ||||
|   <div v-else> | ||||
|  | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script setup lang="ts"> | ||||
| import { ref, reactive, computed, onMounted } from "vue"; | ||||
| import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper"; | ||||
| import AddAddress from "ChillMainAssets/vuejs/Address/components/AddAddress.vue"; | ||||
| import { | ||||
|   createPerson, editPerson, | ||||
|   getCentersForPersonCreation, | ||||
|   getCivilities, | ||||
|   getGenders, | ||||
|   getPerson, | ||||
|   getPersonAltNames, | ||||
|   getPersonIdentifiers, | ||||
|   personToWritePerson, | ||||
|   WritePersonViolationMap, | ||||
| } from "../../_api/OnTheFly"; | ||||
| import { | ||||
|   trans, | ||||
|   BIRTHDATE, | ||||
|   PERSON_EDIT_ERROR_WHILE_SAVING, | ||||
|   PERSON_MESSAGES_PERSON_LASTNAME, | ||||
|   PERSON_MESSAGES_PERSON_FIRSTNAME, | ||||
|   PERSON_MESSAGES_PERSON_GENDER_PLACEHOLDER, | ||||
|   PERSON_MESSAGES_PERSON_GENDER_TITLE, | ||||
|   PERSON_MESSAGES_PERSON_CENTER_PLACEHOLDER, | ||||
|   PERSON_MESSAGES_PERSON_CENTER_TITLE, | ||||
|   PERSON_MESSAGES_PERSON_CIVILITY_PLACEHOLDER, | ||||
|   PERSON_MESSAGES_PERSON_CIVILITY_TITLE, | ||||
|   PERSON_MESSAGES_PERSON_PHONENUMBER, | ||||
|   PERSON_MESSAGES_PERSON_MOBILENUMBER, | ||||
|   PERSON_MESSAGES_PERSON_EMAIL, | ||||
|   PERSON_MESSAGES_PERSON_ADDRESS_SHOW_ADDRESS_FORM, | ||||
|   PERSON_MESSAGES_PERSON_ADDRESS_WARNING, | ||||
| } from "translator"; | ||||
| import { | ||||
|   Center, | ||||
|   Civility, | ||||
|   Gender, | ||||
| } from "ChillMainAssets/types"; | ||||
| import { | ||||
|   AltName, | ||||
|   Person, | ||||
|   PersonWrite, | ||||
|   PersonIdentifierWorker, | ||||
| } from "ChillPersonAssets/types"; | ||||
| import { | ||||
|   isValidationException, | ||||
| } from "ChillMainAssets/lib/api/apiMethods"; | ||||
| import {useToast} from "vue-toast-notification"; | ||||
| import {getTimezoneOffsetString, ISOToDate} from "ChillMainAssets/chill/js/date"; | ||||
| import {useViolationList} from "ChillMainAssets/vuejs/_composables/violationList"; | ||||
|  | ||||
| interface PersonEditComponentConfig { | ||||
|   id?: number | null; | ||||
|   action: "edit" | "create"; | ||||
|   query: string; | ||||
| } | ||||
|  | ||||
| const props = withDefaults(defineProps<PersonEditComponentConfig>(), { | ||||
|   id: null, | ||||
| }); | ||||
|  | ||||
| const emit = | ||||
|   defineEmits<(e: "onPersonCreated", payload: { person: Person }) => void>(); | ||||
|  | ||||
| defineExpose({ postPerson }); | ||||
|  | ||||
| const toast = useToast(); | ||||
|  | ||||
| const person = reactive<PersonWrite>({ | ||||
|   type: "person", | ||||
|   firstName: "", | ||||
|   lastName: "", | ||||
|   altNames: [], | ||||
|   addressId: null, | ||||
|   birthdate: null, | ||||
|   deathdate: null, | ||||
|   phonenumber: "", | ||||
|   mobilenumber: "", | ||||
|   email: "", | ||||
|   gender: null, | ||||
|   center: null, | ||||
|   civility: null, | ||||
|   identifiers: [], | ||||
| }); | ||||
|  | ||||
| const config = reactive<{ | ||||
|   altNames: AltName[]; | ||||
|   civilities: Civility[]; | ||||
|   centers: Center[]; | ||||
|   genders: Gender[]; | ||||
|   identifiers: PersonIdentifierWorker[]; | ||||
| }>({ | ||||
|   altNames: [], | ||||
|   civilities: [], | ||||
|   centers: [], | ||||
|   genders: [], | ||||
|   identifiers: [], | ||||
| }); | ||||
|  | ||||
| const showCenters = ref(false); | ||||
| const showAddressFormValue = ref(false); | ||||
|  | ||||
| const addAddress = reactive({ | ||||
|   options: { | ||||
|     button: { | ||||
|       text: { create: "person.address.create_address" }, | ||||
|       size: "btn-sm", | ||||
|     }, | ||||
|     title: { create: "person.address.create_address" }, | ||||
|   }, | ||||
|   context: { | ||||
|     target: {}, | ||||
|     edit: false, | ||||
|     addressId: null as number | null, | ||||
|     defaults: (window as any).addaddress, | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| const firstName = computed({ | ||||
|   get: () => person.firstName, | ||||
|   set: (value: string) => { | ||||
|     person.firstName = value; | ||||
|   }, | ||||
| }); | ||||
| const lastName = computed({ | ||||
|   get: () => person.lastName, | ||||
|   set: (value: string) => { | ||||
|     person.lastName = value; | ||||
|   }, | ||||
| }); | ||||
| const gender = computed({ | ||||
|   get: () => (person.gender ? person.gender.id : null), | ||||
|   set: (value: string | null) => { | ||||
|     person.gender = value | ||||
|       ? { id: Number.parseInt(value), type: "chill_main_gender" } | ||||
|       : null; | ||||
|   }, | ||||
| }); | ||||
| const civility = computed({ | ||||
|   get: () => (person.civility ? person.civility.id : null), | ||||
|   set: (value: number | null) => { | ||||
|     person.civility = | ||||
|       value !== null ? { id: value, type: "chill_main_civility" } : null; | ||||
|   }, | ||||
| }); | ||||
| const birthDate = computed({ | ||||
|   get: () => (person.birthdate ? person.birthdate.datetime.split("T")[0] : ""), | ||||
|   set: (value: string) => { | ||||
|     const date = ISOToDate(value); | ||||
|     if (null === date) { | ||||
|       person.birthdate = null; | ||||
|       return; | ||||
|     } | ||||
|     const offset = getTimezoneOffsetString(date, Intl.DateTimeFormat().resolvedOptions().timeZone); | ||||
|     if (person.birthdate) { | ||||
|       person.birthdate.datetime = value + "T00:00:00" + offset; | ||||
|     } else { | ||||
|       person.birthdate = { datetime: value + "T00:00:00" + offset }; | ||||
|     } | ||||
|   } | ||||
| }); | ||||
| const phonenumber = computed({ | ||||
|   get: () => person.phonenumber, | ||||
|   set: (value: string) => { | ||||
|     person.phonenumber = value; | ||||
|   }, | ||||
| }); | ||||
| const mobilenumber = computed({ | ||||
|   get: () => person.mobilenumber, | ||||
|   set: (value: string) => { | ||||
|     person.mobilenumber = value; | ||||
|   }, | ||||
| }); | ||||
| const email = computed({ | ||||
|   get: () => person.email, | ||||
|   set: (value: string) => { | ||||
|     person.email = value; | ||||
|   }, | ||||
| }); | ||||
| const showAddressForm = computed({ | ||||
|   get: () => showAddressFormValue.value, | ||||
|   set: (value: boolean) => { | ||||
|     showAddressFormValue.value = value; | ||||
|   }, | ||||
| }); | ||||
| const center = computed({ | ||||
|   get: () => { | ||||
|     const c = config.centers.find( | ||||
|       (c) => person.center !== null && person.center.id === c.id, | ||||
|     ); | ||||
|     return typeof c === "undefined" ? null : c; | ||||
|   }, | ||||
|   set: (value: Center | null) => { | ||||
|     if (null !== value) { | ||||
|       person.center = { | ||||
|         id: value.id, | ||||
|         type: value.type, | ||||
|       }; | ||||
|     } else { | ||||
|       person.center = null; | ||||
|     } | ||||
|   }, | ||||
| }); | ||||
|  | ||||
| /** | ||||
|  * Find the query items to display for suggestion | ||||
|  */ | ||||
| const queryItems = computed(() => { | ||||
|   const words: null | string[] = props.query ? props.query.split(" ") : null; | ||||
|  | ||||
|   if (null === words) { | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   const firstNameWords = (person.firstName || "") | ||||
|     .trim() | ||||
|     .toLowerCase() | ||||
|     .split(" "); | ||||
|   const lastNameWords = (person.lastName || "").trim().toLowerCase().split(" "); | ||||
|  | ||||
|   return words | ||||
|     .filter((word) => !firstNameWords.includes(word.toLowerCase())) | ||||
|     .filter((word) => !lastNameWords.includes(word.toLowerCase())); | ||||
| }); | ||||
|  | ||||
| const dataLoaded = ref<boolean>(false); | ||||
|  | ||||
| async function loadData() { | ||||
|   if (props.id !== undefined && props.id !== null) { | ||||
|     const p = await getPerson(props.id); | ||||
|     const w = personToWritePerson(p); | ||||
|     person.firstName = w.firstName; | ||||
|     person.lastName = w.lastName; | ||||
|     person.altNames.push(...w.altNames) | ||||
|     person.civility = w.civility; | ||||
|     person.addressId = w.addressId; | ||||
|     person.birthdate = w.birthdate; | ||||
|     person.deathdate = w.deathdate; | ||||
|     person.phonenumber = w.phonenumber; | ||||
|     person.mobilenumber = w.mobilenumber; | ||||
|     person.email = w.email; | ||||
|     person.gender = w.gender; | ||||
|     person.center = w.center; | ||||
|     person.civility = w.civility; | ||||
|     person.identifiers.push(...w.identifiers); | ||||
|     dataLoaded.value = true; | ||||
|   } | ||||
| } | ||||
|  | ||||
| function onAltNameInput(event: Event, key: string): void { | ||||
|   const target = event.target as HTMLInputElement; | ||||
|   const value = target.value; | ||||
|   const updateAltNamesKey = person.altNames.findIndex((a) => a.key === key); | ||||
|   if (-1 === updateAltNamesKey) { | ||||
|     person.altNames.push({ key, value }); | ||||
|   } else { | ||||
|     person.altNames[updateAltNamesKey].value = value; | ||||
|   } | ||||
| } | ||||
|  | ||||
| function onIdentifierInput(event: Event, definition_id: number): void { | ||||
|   const target = event.target as HTMLInputElement; | ||||
|   const value = target.value; | ||||
|   const updateIdentifierKey = person.identifiers.findIndex( | ||||
|     (w) => w.definition_id === definition_id, | ||||
|   ); | ||||
|   if (-1 === updateIdentifierKey) { | ||||
|     person.identifiers.push({ | ||||
|       type: "person_identifier", | ||||
|       definition_id, | ||||
|       value: { content: value }, | ||||
|     }); | ||||
|   } else { | ||||
|     person.identifiers[updateIdentifierKey].value = { content: value }; | ||||
|   } | ||||
| } | ||||
|  | ||||
| function addQueryItem(field: "lastName" | "firstName", queryItem: string) { | ||||
|   switch (field) { | ||||
|     case "lastName": | ||||
|       person.lastName = person.lastName | ||||
|         ? (person.lastName += ` ${queryItem}`) | ||||
|         : queryItem; | ||||
|       break; | ||||
|     case "firstName": | ||||
|       person.firstName = person.firstName | ||||
|         ? (person.firstName += ` ${queryItem}`) | ||||
|         : queryItem; | ||||
|       break; | ||||
|   } | ||||
| } | ||||
|  | ||||
| const violations = useViolationList<WritePersonViolationMap>(); | ||||
|  | ||||
| function submitNewAddress(payload: { addressId: number }) { | ||||
|   person.addressId = payload.addressId; | ||||
| } | ||||
|  | ||||
| async function postPerson(): Promise<Person> { | ||||
|   try { | ||||
|     if (props.action === 'create') { | ||||
|       const createdPerson = await createPerson(person); | ||||
|       emit("onPersonCreated", { person: createdPerson }); | ||||
|  | ||||
|       return Promise.resolve(createdPerson); | ||||
|     } else if (props.id !== null) { | ||||
|       const updatedPerson = await editPerson(person, props.id); | ||||
|       emit("onPersonCreated", { person: updatedPerson }); | ||||
|  | ||||
|       return Promise.resolve(updatedPerson); | ||||
|     } | ||||
|   } catch (e: unknown) { | ||||
|     if (isValidationException<WritePersonViolationMap>(e)) { | ||||
|       violations.setValidationException(e); | ||||
|     } else { | ||||
|       toast.error(trans(PERSON_EDIT_ERROR_WHILE_SAVING)); | ||||
|     } | ||||
|   } | ||||
|   throw "'action' is not create, or edit with a not-null id"; | ||||
| } | ||||
|  | ||||
| onMounted(() => { | ||||
|   getPersonAltNames().then((altNames) => { | ||||
|     config.altNames = altNames; | ||||
|   }); | ||||
|   getCivilities().then((civilities) => { | ||||
|     config.civilities = civilities; | ||||
|   }); | ||||
|   getGenders().then((genders) => { | ||||
|     config.genders = genders; | ||||
|   }); | ||||
|   getPersonIdentifiers().then((identifiers) => { | ||||
|     config.identifiers = identifiers.filter( | ||||
|       (w: PersonIdentifierWorker) => | ||||
|         w.presence === 'ON_CREATION' || w.presence === 'REQUIRED' | ||||
|       ); | ||||
|   }); | ||||
|   if (props.action !== "create") { | ||||
|     loadData(); | ||||
|   } else { | ||||
|     getCentersForPersonCreation().then((params) => { | ||||
|       config.centers = params.centers.filter((c: Center) => c.isActive); | ||||
|       showCenters.value = params.showCenters; | ||||
|       if (showCenters.value && config.centers.length === 1) { | ||||
|         // if there is only one center, preselect it | ||||
|         person.center = { | ||||
|           id: config.centers[0].id, | ||||
|           type: config.centers[0].type ?? "center", | ||||
|         }; | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
| .was-validated-force { | ||||
|   display: block; | ||||
| } | ||||
| </style> | ||||
| @@ -88,6 +88,15 @@ | ||||
|             <div data-suggest-container="{{ altName.vars.full_name|e('html_attr') }}" class="col-sm-8" style="margin-left: auto;"></div> | ||||
|         {% endfor %} | ||||
|     {% endif %} | ||||
|     {% if form.identifiers|length > 0 %} | ||||
|         {% for f in form.identifiers %} | ||||
|             <div class="row mb-1" style="display:flex;"> | ||||
|                 {{ form_row(f) }} | ||||
|             </div> | ||||
|         {% endfor %} | ||||
|     {% else %} | ||||
|         {{ form_widget(form.identifiers) }} | ||||
|     {% endif %} | ||||
|  | ||||
|     {{ form_row(form.gender, { 'label' : 'Gender'|trans }) }} | ||||
|  | ||||
|   | ||||
| @@ -140,9 +140,7 @@ | ||||
|         <fieldset> | ||||
|             <legend><h2>{{ 'person.Identifiers'|trans }}</h2></legend> | ||||
|             <div> | ||||
|                 {% for f in form.identifiers %} | ||||
|                     {{ form_row(f) }} | ||||
|                 {% endfor %} | ||||
|                 {{ form_widget(form.identifiers) }} | ||||
|             </div> | ||||
|         </fieldset> | ||||
|     {% else %} | ||||
|   | ||||
| @@ -0,0 +1,155 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\PersonBundle\Serializer\Normalizer; | ||||
|  | ||||
| use Chill\MainBundle\Entity\Center; | ||||
| use Chill\MainBundle\Entity\Civility; | ||||
| use Chill\MainBundle\Entity\Gender; | ||||
| use Chill\PersonBundle\Entity\Identifier\PersonIdentifier; | ||||
| use Chill\PersonBundle\Entity\Person; | ||||
| use Chill\PersonBundle\Entity\PersonAltName; | ||||
| use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface; | ||||
| use libphonenumber\PhoneNumber; | ||||
| use Symfony\Component\Serializer\Exception\UnexpectedValueException; | ||||
| use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface; | ||||
| use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait; | ||||
| use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; | ||||
| use Symfony\Component\Serializer\Normalizer\ObjectToPopulateTrait; | ||||
|  | ||||
| /** | ||||
|  * Denormalize a Person entity from a JSON-like array structure, creating or updating an existing instance. | ||||
|  * | ||||
|  * To find an existing instance by his id, see the @see{PersonJsonReadDenormalizer}. | ||||
|  */ | ||||
| final class PersonJsonDenormalizer implements DenormalizerInterface, DenormalizerAwareInterface | ||||
| { | ||||
|     use DenormalizerAwareTrait; | ||||
|     use ObjectToPopulateTrait; | ||||
|  | ||||
|     public function __construct(private readonly PersonIdentifierManagerInterface $personIdentifierManager) {} | ||||
|  | ||||
|     public function denormalize($data, string $type, ?string $format = null, array $context = []): Person | ||||
|     { | ||||
|         $person = $this->extractObjectToPopulate($type, $context); | ||||
|  | ||||
|         if (null === $person) { | ||||
|             $person = new Person(); | ||||
|         } | ||||
|  | ||||
|         // Setters applied directly per known field for readability | ||||
|         if (\array_key_exists('firstName', $data)) { | ||||
|             $person->setFirstName($data['firstName']); | ||||
|         } | ||||
|  | ||||
|         if (\array_key_exists('lastName', $data)) { | ||||
|             $person->setLastName($data['lastName']); | ||||
|         } | ||||
|  | ||||
|         if (\array_key_exists('phonenumber', $data)) { | ||||
|             $person->setPhonenumber($this->denormalizer->denormalize($data['phonenumber'], PhoneNumber::class, $format, $context)); | ||||
|         } | ||||
|  | ||||
|         if (\array_key_exists('mobilenumber', $data)) { | ||||
|             $person->setMobilenumber($this->denormalizer->denormalize($data['mobilenumber'], PhoneNumber::class, $format, $context)); | ||||
|         } | ||||
|  | ||||
|         if (\array_key_exists('gender', $data) && null !== $data['gender']) { | ||||
|             $gender = $this->denormalizer->denormalize($data['gender'], Gender::class, $format, []); | ||||
|             $person->setGender($gender); | ||||
|         } | ||||
|  | ||||
|         if (\array_key_exists('birthdate', $data)) { | ||||
|             $object = $this->denormalizer->denormalize($data['birthdate'], \DateTime::class, $format, $context); | ||||
|             $person->setBirthdate($object); | ||||
|         } | ||||
|  | ||||
|         if (\array_key_exists('deathdate', $data)) { | ||||
|             $object = $this->denormalizer->denormalize($data['deathdate'], \DateTimeImmutable::class, $format, $context); | ||||
|             $person->setDeathdate($object); | ||||
|         } | ||||
|  | ||||
|         if (\array_key_exists('center', $data)) { | ||||
|             $object = $this->denormalizer->denormalize($data['center'], Center::class, $format, $context); | ||||
|             $person->setCenter($object); | ||||
|         } | ||||
|  | ||||
|         if (\array_key_exists('altNames', $data)) { | ||||
|             foreach ($data['altNames'] as $altNameData) { | ||||
|                 if (!array_key_exists('key', $altNameData) | ||||
|                     || !array_key_exists('value', $altNameData) | ||||
|                     || '' === trim((string) $altNameData['key']) | ||||
|                 ) { | ||||
|                     throw new UnexpectedValueException('format for alt name is not correct'); | ||||
|                 } | ||||
|                 $altNameKey = $altNameData['key']; | ||||
|                 $altNameValue = $altNameData['value']; | ||||
|  | ||||
|                 $altName = $person->getAltNames()->findFirst(fn (int $key, PersonAltName $personAltName) => $personAltName->getKey() === $altNameKey); | ||||
|                 if (null === $altName) { | ||||
|                     $altName = new PersonAltName(); | ||||
|                     $person->addAltName($altName); | ||||
|                 } | ||||
|                 $altName->setKey($altNameKey)->setLabel($altNameValue); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (\array_key_exists('identifiers', $data)) { | ||||
|             foreach ($data['identifiers'] as $identifierData) { | ||||
|                 if (!array_key_exists('definition_id', $identifierData) | ||||
|                     || !array_key_exists('value', $identifierData) | ||||
|                     || !is_int($identifierData['definition_id']) | ||||
|                     || !is_array($identifierData['value']) | ||||
|                 ) { | ||||
|                     throw new UnexpectedValueException('format for identifiers is not correct'); | ||||
|                 } | ||||
|  | ||||
|                 $definitionId = $identifierData['definition_id']; | ||||
|                 $value = $identifierData['value']; | ||||
|  | ||||
|                 $worker = $this->personIdentifierManager->buildWorkerByPersonIdentifierDefinition($definitionId); | ||||
|  | ||||
|                 if (!$worker->getDefinition()->isEditableByUsers()) { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 $personIdentifier = $person->getIdentifiers()->findFirst(fn (int $key, PersonIdentifier $personIdentifier) => $personIdentifier->getDefinition()->getId() === $definitionId); | ||||
|                 if (null === $personIdentifier) { | ||||
|                     $personIdentifier = new PersonIdentifier($worker->getDefinition()); | ||||
|                     $person->addIdentifier($personIdentifier); | ||||
|                 } | ||||
|  | ||||
|                 $personIdentifier->setValue($value); | ||||
|                 $personIdentifier->setCanonical($worker->canonicalizeValue($value)); | ||||
|  | ||||
|                 if ($worker->isEmpty($personIdentifier)) { | ||||
|                     $person->removeIdentifier($personIdentifier); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (\array_key_exists('email', $data)) { | ||||
|             $person->setEmail($data['email']); | ||||
|         } | ||||
|  | ||||
|         if (\array_key_exists('civility', $data) && null !== $data['civility']) { | ||||
|             $civility = $this->denormalizer->denormalize($data['civility'], Civility::class, $format, []); | ||||
|             $person->setCivility($civility); | ||||
|         } | ||||
|  | ||||
|         return $person; | ||||
|     } | ||||
|  | ||||
|     public function supportsDenormalization($data, $type, $format = null): bool | ||||
|     { | ||||
|         return Person::class === $type && 'person' === ($data['type'] ?? null) && !isset($data['id']); | ||||
|     } | ||||
| } | ||||
| @@ -11,169 +11,33 @@ declare(strict_types=1); | ||||
|  | ||||
| namespace Chill\PersonBundle\Serializer\Normalizer; | ||||
|  | ||||
| use Chill\MainBundle\Entity\Center; | ||||
| use Chill\MainBundle\Entity\Civility; | ||||
| use Chill\MainBundle\Entity\Gender; | ||||
| use Chill\MainBundle\Phonenumber\PhoneNumberHelperInterface; | ||||
| use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface; | ||||
| use Chill\MainBundle\Templating\Entity\ChillEntityRenderExtension; | ||||
| use Chill\PersonBundle\Entity\Person; | ||||
| use Chill\PersonBundle\Entity\PersonAltName; | ||||
| use Chill\PersonBundle\Repository\PersonRepository; | ||||
| use Chill\PersonBundle\Repository\ResidentialAddressRepository; | ||||
| use Doctrine\Common\Collections\Collection; | ||||
| use libphonenumber\PhoneNumber; | ||||
| use Symfony\Component\Serializer\Exception\UnexpectedValueException; | ||||
| use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; | ||||
| use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface; | ||||
| use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait; | ||||
| use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; | ||||
| use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; | ||||
| use Symfony\Component\Serializer\Normalizer\ObjectToPopulateTrait; | ||||
| use Symfony\Component\Serializer\Normalizer\NormalizerInterface; | ||||
|  | ||||
| /** | ||||
|  * Serialize a Person entity. | ||||
|  */ | ||||
| class PersonJsonNormalizer implements DenormalizerAwareInterface, NormalizerAwareInterface, PersonJsonNormalizerInterface | ||||
| class PersonJsonNormalizer implements NormalizerAwareInterface, NormalizerInterface | ||||
| { | ||||
|     use DenormalizerAwareTrait; | ||||
|  | ||||
|     use NormalizerAwareTrait; | ||||
|  | ||||
|     use ObjectToPopulateTrait; | ||||
|  | ||||
|     public function __construct( | ||||
|         private readonly ChillEntityRenderExtension $render, | ||||
|         /* TODO: replace by PersonRenderInterface, as sthis is the only one required */ | ||||
|         private readonly PersonRepository $repository, | ||||
|         private readonly CenterResolverManagerInterface $centerResolverManager, | ||||
|         private readonly ResidentialAddressRepository $residentialAddressRepository, | ||||
|         private readonly PhoneNumberHelperInterface $phoneNumberHelper, | ||||
|         private readonly \Chill\PersonBundle\PersonIdentifier\Rendering\PersonIdRenderingInterface $personIdRendering, | ||||
|     ) {} | ||||
|  | ||||
|     public function denormalize($data, $type, $format = null, array $context = []) | ||||
|     { | ||||
|         $person = $this->extractObjectToPopulate($type, $context); | ||||
|  | ||||
|         if (\array_key_exists('id', $data) && null === $person) { | ||||
|             $person = $this->repository->find($data['id']); | ||||
|  | ||||
|             if (null === $person) { | ||||
|                 throw new UnexpectedValueException("The person with id \"{$data['id']}\" does ".'not exists'); | ||||
|             } | ||||
|  | ||||
|             // currently, not allowed to update a person through api | ||||
|             // if instantiated with id | ||||
|             return $person; | ||||
|         } | ||||
|  | ||||
|         if (null === $person) { | ||||
|             $person = new Person(); | ||||
|         } | ||||
|  | ||||
|         $fields = [ | ||||
|             'firstName', | ||||
|             'lastName', | ||||
|             'phonenumber', | ||||
|             'mobilenumber', | ||||
|             'gender', | ||||
|             'birthdate', | ||||
|             'deathdate', | ||||
|             'center', | ||||
|             'altNames', | ||||
|             'email', | ||||
|             'civility', | ||||
|         ]; | ||||
|  | ||||
|         $fields = array_filter( | ||||
|             $fields, | ||||
|             static fn (string $field): bool => \array_key_exists($field, $data) | ||||
|         ); | ||||
|  | ||||
|         foreach ($fields as $item) { | ||||
|             switch ($item) { | ||||
|                 case 'firstName': | ||||
|                     $person->setFirstName($data[$item]); | ||||
|  | ||||
|                     break; | ||||
|  | ||||
|                 case 'lastName': | ||||
|                     $person->setLastName($data[$item]); | ||||
|  | ||||
|                     break; | ||||
|  | ||||
|                 case 'phonenumber': | ||||
|                     $person->setPhonenumber($this->denormalizer->denormalize($data[$item], PhoneNumber::class, $format, $context)); | ||||
|  | ||||
|                     break; | ||||
|  | ||||
|                 case 'mobilenumber': | ||||
|                     $person->setMobilenumber($this->denormalizer->denormalize($data[$item], PhoneNumber::class, $format, $context)); | ||||
|  | ||||
|                     break; | ||||
|  | ||||
|                 case 'gender': | ||||
|                     $gender = $this->denormalizer->denormalize($data[$item], Gender::class, $format, []); | ||||
|  | ||||
|                     $person->setGender($gender); | ||||
|  | ||||
|                     break; | ||||
|  | ||||
|                 case 'birthdate': | ||||
|                     $object = $this->denormalizer->denormalize($data[$item], \DateTime::class, $format, $context); | ||||
|  | ||||
|                     $person->setBirthdate($object); | ||||
|  | ||||
|                     break; | ||||
|  | ||||
|                 case 'deathdate': | ||||
|                     $object = $this->denormalizer->denormalize($data[$item], \DateTimeImmutable::class, $format, $context); | ||||
|  | ||||
|                     $person->setDeathdate($object); | ||||
|  | ||||
|                     break; | ||||
|  | ||||
|                 case 'center': | ||||
|                     $object = $this->denormalizer->denormalize($data[$item], Center::class, $format, $context); | ||||
|                     $person->setCenter($object); | ||||
|  | ||||
|                     break; | ||||
|  | ||||
|                 case 'altNames': | ||||
|                     foreach ($data[$item] as $altName) { | ||||
|                         $oldAltName = $person | ||||
|                             ->getAltNames() | ||||
|                             ->filter(static fn (PersonAltName $n): bool => $n->getKey() === $altName['key'])->first(); | ||||
|  | ||||
|                         if (false === $oldAltName) { | ||||
|                             $newAltName = new PersonAltName(); | ||||
|                             $newAltName->setKey($altName['key']); | ||||
|                             $newAltName->setLabel($altName['label']); | ||||
|                             $person->addAltName($newAltName); | ||||
|                         } else { | ||||
|                             $oldAltName->setLabel($altName['label']); | ||||
|                         } | ||||
|                     } | ||||
|  | ||||
|                     break; | ||||
|  | ||||
|                 case 'email': | ||||
|                     $person->setEmail($data[$item]); | ||||
|  | ||||
|                     break; | ||||
|  | ||||
|                 case 'civility': | ||||
|                     $civility = $this->denormalizer->denormalize($data[$item], Civility::class, $format, []); | ||||
|  | ||||
|                     $person->setCivility($civility); | ||||
|  | ||||
|                     break; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return $person; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @param Person      $person | ||||
|      * @param string|null $format | ||||
| @@ -204,6 +68,8 @@ class PersonJsonNormalizer implements DenormalizerAwareInterface, NormalizerAwar | ||||
|             'email' => $person->getEmail(), | ||||
|             'gender' => $this->normalizer->normalize($person->getGender(), $format, $context), | ||||
|             'civility' => $this->normalizer->normalize($person->getCivility(), $format, $context), | ||||
|             'personId' => $this->personIdRendering->renderPersonId($person), | ||||
|             'identifiers' => $this->normalizer->normalize($person->getIdentifiers(), $format, $context), | ||||
|         ]; | ||||
|  | ||||
|         if (\in_array('minimal', $groups, true) && 1 === \count($groups)) { | ||||
| @@ -215,11 +81,6 @@ class PersonJsonNormalizer implements DenormalizerAwareInterface, NormalizerAwar | ||||
|             null]; | ||||
|     } | ||||
|  | ||||
|     public function supportsDenormalization($data, $type, $format = null) | ||||
|     { | ||||
|         return Person::class === $type && 'person' === ($data['type'] ?? null); | ||||
|     } | ||||
|  | ||||
|     public function supportsNormalization($data, $format = null): bool | ||||
|     { | ||||
|         return $data instanceof Person && 'json' === $format; | ||||
|   | ||||
| @@ -0,0 +1,51 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\PersonBundle\Serializer\Normalizer; | ||||
|  | ||||
| use Chill\PersonBundle\Entity\Person; | ||||
| use Chill\PersonBundle\Repository\PersonRepository; | ||||
| use Symfony\Component\Serializer\Exception\InvalidArgumentException; | ||||
| use Symfony\Component\Serializer\Exception\LogicException; | ||||
| use Symfony\Component\Serializer\Exception\UnexpectedValueException; | ||||
| use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; | ||||
|  | ||||
| /** | ||||
|  * Find a Person entity by his id during the denormalization process. | ||||
|  */ | ||||
| readonly class PersonJsonReadDenormalizer implements DenormalizerInterface | ||||
| { | ||||
|     public function __construct(private PersonRepository $repository) {} | ||||
|  | ||||
|     public function denormalize($data, string $type, ?string $format = null, array $context = []): Person | ||||
|     { | ||||
|         if (!is_array($data)) { | ||||
|             throw new InvalidArgumentException(); | ||||
|         } | ||||
|  | ||||
|         if (\array_key_exists('id', $data)) { | ||||
|             $person = $this->repository->find($data['id']); | ||||
|  | ||||
|             if (null === $person) { | ||||
|                 throw new UnexpectedValueException("The person with id \"{$data['id']}\" does ".'not exists'); | ||||
|             } | ||||
|  | ||||
|             return $person; | ||||
|         } | ||||
|  | ||||
|         throw new LogicException(); | ||||
|     } | ||||
|  | ||||
|     public function supportsDenormalization($data, string $type, ?string $format = null) | ||||
|     { | ||||
|         return is_array($data) && Person::class === $type && 'person' === ($data['type'] ?? null) && isset($data['id']); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,146 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Action\PersonEdit\Service; | ||||
|  | ||||
| use Chill\MainBundle\Entity\Civility; | ||||
| use Chill\MainBundle\Entity\Country; | ||||
| use Chill\MainBundle\Entity\Embeddable\CommentEmbeddable; | ||||
| use Chill\MainBundle\Entity\Gender; | ||||
| use Chill\MainBundle\Entity\Language; | ||||
| use Chill\PersonBundle\Actions\PersonEdit\PersonEditDTO; | ||||
| use Chill\PersonBundle\Actions\PersonEdit\Service\PersonEditDTOFactory; | ||||
| use Chill\PersonBundle\Config\ConfigPersonAltNamesHelper; | ||||
| use Chill\PersonBundle\Entity\AdministrativeStatus; | ||||
| use Chill\PersonBundle\Entity\EmploymentStatus; | ||||
| use Chill\PersonBundle\Entity\MaritalStatus; | ||||
| use Chill\PersonBundle\Entity\Person; | ||||
| use Chill\PersonBundle\Entity\PersonAltName; | ||||
| use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface; | ||||
| use Doctrine\Common\Collections\ArrayCollection; | ||||
| use libphonenumber\PhoneNumber; | ||||
| use PHPUnit\Framework\TestCase; | ||||
| use Prophecy\PhpUnit\ProphecyTrait; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
|  * | ||||
|  * @coversNothing | ||||
|  */ | ||||
| class PersonEditDTOFactoryTest extends TestCase | ||||
| { | ||||
|     use ProphecyTrait; | ||||
|  | ||||
|     public function testMapPersonEditDTOtoPersonCopiesAllFields(): void | ||||
|     { | ||||
|         $configHelper = $this->createMock(ConfigPersonAltNamesHelper::class); | ||||
|         $identifierManager = $this->createMock(PersonIdentifierManagerInterface::class); | ||||
|         $factory = new PersonEditDTOFactory($configHelper, $identifierManager); | ||||
|  | ||||
|         $dto = new PersonEditDTO(); | ||||
|         $dto->firstName = 'John'; | ||||
|         $dto->lastName = 'Doe'; | ||||
|         $dto->birthdate = new \DateTime('1980-05-10'); | ||||
|         $dto->deathdate = new \DateTimeImmutable('2050-01-01'); | ||||
|         $dto->gender = new Gender(); | ||||
|         $dto->genderComment = new CommentEmbeddable('gender comment'); | ||||
|         $dto->numberOfChildren = 2; | ||||
|         $dto->memo = 'Some memo'; | ||||
|         $dto->employmentStatus = new EmploymentStatus(); | ||||
|         $dto->administrativeStatus = new AdministrativeStatus(); | ||||
|         $dto->placeOfBirth = 'Cityville'; | ||||
|         $dto->contactInfo = 'Some contact info'; | ||||
|         $phone = new PhoneNumber(); | ||||
|         $dto->phonenumber = $phone; | ||||
|         $mobile = new PhoneNumber(); | ||||
|         $dto->mobilenumber = $mobile; | ||||
|         $dto->acceptSms = true; | ||||
|         $dto->otherPhonenumbers = new ArrayCollection(); | ||||
|         $dto->email = 'john.doe@example.org'; | ||||
|         $dto->acceptEmail = true; | ||||
|         $dto->countryOfBirth = new Country(); | ||||
|         $dto->nationality = new Country(); | ||||
|         $dto->spokenLanguages = new ArrayCollection([new Language()]); | ||||
|         $dto->civility = new Civility(); | ||||
|         $dto->maritalStatus = new MaritalStatus(); | ||||
|         $dto->maritalStatusDate = new \DateTime('2010-01-01'); | ||||
|         $dto->maritalStatusComment = new CommentEmbeddable('married'); | ||||
|         $dto->cFData = ['foo' => 'bar']; | ||||
|  | ||||
|         $person = new Person(); | ||||
|  | ||||
|         $factory->mapPersonEditDTOtoPerson($dto, $person); | ||||
|  | ||||
|         self::assertSame('John', $person->getFirstName()); | ||||
|         self::assertSame('Doe', $person->getLastName()); | ||||
|         self::assertSame($dto->birthdate, $person->getBirthdate()); | ||||
|         self::assertSame($dto->deathdate, $person->getDeathdate()); | ||||
|         self::assertSame($dto->gender, $person->getGender()); | ||||
|         self::assertSame($dto->genderComment, $person->getGenderComment()); | ||||
|         self::assertSame($dto->numberOfChildren, $person->getNumberOfChildren()); | ||||
|         self::assertSame('Some memo', $person->getMemo()); | ||||
|         self::assertSame($dto->employmentStatus, $person->getEmploymentStatus()); | ||||
|         self::assertSame($dto->administrativeStatus, $person->getAdministrativeStatus()); | ||||
|         self::assertSame('Cityville', $person->getPlaceOfBirth()); | ||||
|         self::assertSame('Some contact info', $person->getcontactInfo()); | ||||
|         self::assertSame($phone, $person->getPhonenumber()); | ||||
|         self::assertSame($mobile, $person->getMobilenumber()); | ||||
|         self::assertTrue($person->getAcceptSMS()); | ||||
|         self::assertSame($dto->otherPhonenumbers, $person->getOtherPhoneNumbers()); | ||||
|         self::assertSame('john.doe@example.org', $person->getEmail()); | ||||
|         self::assertTrue($person->getAcceptEmail()); | ||||
|         self::assertSame($dto->countryOfBirth, $person->getCountryOfBirth()); | ||||
|         self::assertSame($dto->nationality, $person->getNationality()); | ||||
|         self::assertSame($dto->spokenLanguages, $person->getSpokenLanguages()); | ||||
|         self::assertSame($dto->civility, $person->getCivility()); | ||||
|         self::assertSame($dto->maritalStatus, $person->getMaritalStatus()); | ||||
|         self::assertSame($dto->maritalStatusDate, $person->getMaritalStatusDate()); | ||||
|         self::assertSame($dto->maritalStatusComment, $person->getMaritalStatusComment()); | ||||
|         self::assertSame($dto->cFData, $person->getCFData()); | ||||
|     } | ||||
|  | ||||
|     public function testAltNamesHandlingWithConfigHelper(): void | ||||
|     { | ||||
|         $configHelper = $this->createMock(ConfigPersonAltNamesHelper::class); | ||||
|         $configHelper->method('getChoices')->willReturn([ | ||||
|             'aka' => ['en' => 'Also Known As'], | ||||
|             'nickname' => ['en' => 'Nickname'], | ||||
|         ]); | ||||
|  | ||||
|         $identifierManager = $this->createMock(PersonIdentifierManagerInterface::class); | ||||
|         $identifierManager->method('getWorkers')->willReturn([]); | ||||
|  | ||||
|         $factory = new PersonEditDTOFactory($configHelper, $identifierManager); | ||||
|         $person = new Person(); | ||||
|  | ||||
|         $dto = $factory->createPersonEditDTO($person); | ||||
|  | ||||
|         // Assert DTO has two altNames keys from helper | ||||
|         self::assertCount(2, $dto->altNames); | ||||
|         self::assertContainsOnlyInstancesOf(PersonAltName::class, $dto->altNames); | ||||
|         self::assertSame(['aka', 'nickname'], array_keys($dto->altNames)); | ||||
|         self::assertSame(['aka' => 'aka', 'nickname' => 'nickname'], array_map(fn (PersonAltName $altName) => $altName->getKey(), $dto->altNames)); | ||||
|  | ||||
|         // Fill only one label and leave the other empty | ||||
|         $dto->altNames['aka']->setLabel('The Boss'); | ||||
|         // 'nickname' remains empty by default | ||||
|  | ||||
|         // Map DTO back to person | ||||
|         $factory->mapPersonEditDTOtoPerson($dto, $person); | ||||
|  | ||||
|         // Assert only the filled alt name is persisted on the person | ||||
|         $altNames = $person->getAltNames(); | ||||
|         self::assertCount(1, $altNames); | ||||
|         $altNameArray = $altNames->toArray(); | ||||
|         self::assertSame('aka', $altNameArray[0]->getKey()); | ||||
|         self::assertSame('The Boss', $altNameArray[0]->getLabel()); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,157 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\PersonBundle\Tests\Controller; | ||||
|  | ||||
| use Chill\MainBundle\Pagination\PaginatorFactoryInterface; | ||||
| use Chill\MainBundle\Serializer\Normalizer\CollectionNormalizer; | ||||
| use Chill\PersonBundle\Controller\PersonIdentifierListApiController; | ||||
| use Chill\PersonBundle\Entity\Identifier\PersonIdentifier; | ||||
| use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition; | ||||
| use Chill\PersonBundle\PersonIdentifier\Normalizer\PersonIdentifierWorkerNormalizer; | ||||
| use Chill\PersonBundle\PersonIdentifier\PersonIdentifierEngineInterface; | ||||
| use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface; | ||||
| use Chill\PersonBundle\PersonIdentifier\PersonIdentifierWorker; | ||||
| use PHPUnit\Framework\TestCase; | ||||
| use Prophecy\PhpUnit\ProphecyTrait; | ||||
| use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
| use Symfony\Component\Serializer\Encoder\JsonEncoder; | ||||
| use Symfony\Component\Serializer\Serializer; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
|  * | ||||
|  * @coversNothing | ||||
|  */ | ||||
| class PersonIdentifierListApiControllerTest extends TestCase | ||||
| { | ||||
|     use ProphecyTrait; | ||||
|  | ||||
|     public function testListAccessDenied(): void | ||||
|     { | ||||
|         $security = $this->prophesize(Security::class); | ||||
|         $security->isGranted('ROLE_USER')->willReturn(false)->shouldBeCalledOnce(); | ||||
|  | ||||
|         $serializer = new Serializer([new PersonIdentifierWorkerNormalizer(), new CollectionNormalizer()], [new JsonEncoder()]); | ||||
|  | ||||
|         $personIdentifierManager = $this->prophesize(PersonIdentifierManagerInterface::class); | ||||
|         $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); | ||||
|  | ||||
|         $controller = new PersonIdentifierListApiController( | ||||
|             $security->reveal(), | ||||
|             $serializer, | ||||
|             $personIdentifierManager->reveal(), | ||||
|             $paginatorFactory->reveal(), | ||||
|         ); | ||||
|  | ||||
|         $this->expectException(AccessDeniedHttpException::class); | ||||
|         $controller->list(); | ||||
|     } | ||||
|  | ||||
|     public function testListSuccess(): void | ||||
|     { | ||||
|         // Build 3 workers | ||||
|         $engine = new class () implements PersonIdentifierEngineInterface { | ||||
|             public static function getName(): string | ||||
|             { | ||||
|                 return 'dummy'; | ||||
|             } | ||||
|  | ||||
|             public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string | ||||
|             { | ||||
|                 return null; | ||||
|             } | ||||
|  | ||||
|             public function buildForm(\Symfony\Component\Form\FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void {} | ||||
|  | ||||
|             public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string | ||||
|             { | ||||
|                 return ''; | ||||
|             } | ||||
|  | ||||
|             public function isEmpty(PersonIdentifier $identifier): bool | ||||
|             { | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             public function validate(PersonIdentifier $identifier, PersonIdentifierDefinition $definition): array | ||||
|             { | ||||
|                 return []; | ||||
|             } | ||||
|  | ||||
|             public function getDefaultValue(PersonIdentifierDefinition $definition): array | ||||
|             { | ||||
|                 return []; | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         $definition1 = new PersonIdentifierDefinition(['en' => 'Label 1'], 'dummy'); | ||||
|         $definition2 = new PersonIdentifierDefinition(['en' => 'Label 2'], 'dummy'); | ||||
|         $definition3 = new PersonIdentifierDefinition(['en' => 'Label 3'], 'dummy'); | ||||
|         // simulate persisted ids | ||||
|         $r = new \ReflectionProperty(PersonIdentifierDefinition::class, 'id'); | ||||
|         $r->setAccessible(true); | ||||
|         $r->setValue($definition1, 1); | ||||
|         $r->setValue($definition2, 2); | ||||
|         $r->setValue($definition3, 3); | ||||
|  | ||||
|         $workers = [ | ||||
|             new PersonIdentifierWorker($engine, $definition1), | ||||
|             new PersonIdentifierWorker($engine, $definition2), | ||||
|             new PersonIdentifierWorker($engine, $definition3), | ||||
|         ]; | ||||
|  | ||||
|         $security = $this->prophesize(Security::class); | ||||
|         $security->isGranted('ROLE_USER')->willReturn(true)->shouldBeCalledOnce(); | ||||
|  | ||||
|         $personIdentifierManager = $this->prophesize(PersonIdentifierManagerInterface::class); | ||||
|         $personIdentifierManager->getWorkers()->willReturn($workers)->shouldBeCalledOnce(); | ||||
|  | ||||
|         $paginator = $this->prophesize(\Chill\MainBundle\Pagination\PaginatorInterface::class); | ||||
|         $paginator->setItemsPerPage(3)->shouldBeCalledOnce(); | ||||
|         $paginator->getCurrentPageFirstItemNumber()->willReturn(0); | ||||
|         $paginator->getItemsPerPage()->willReturn(count($workers)); | ||||
|         $paginator->getTotalItems()->willReturn(count($workers)); | ||||
|         $paginator->hasNextPage()->willReturn(false); | ||||
|         $paginator->hasPreviousPage()->willReturn(false); | ||||
|  | ||||
|         $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); | ||||
|         $paginatorFactory->create(3)->willReturn($paginator->reveal())->shouldBeCalledOnce(); | ||||
|  | ||||
|         $serializer = new Serializer([ | ||||
|             new PersonIdentifierWorkerNormalizer(), | ||||
|             new CollectionNormalizer(), | ||||
|         ], [new JsonEncoder()]); | ||||
|  | ||||
|         $controller = new PersonIdentifierListApiController( | ||||
|             $security->reveal(), | ||||
|             $serializer, | ||||
|             $personIdentifierManager->reveal(), | ||||
|             $paginatorFactory->reveal(), | ||||
|         ); | ||||
|  | ||||
|         $response = $controller->list(); | ||||
|         self::assertSame(200, $response->getStatusCode()); | ||||
|         $body = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR); | ||||
|         self::assertIsArray($body); | ||||
|         self::assertArrayHasKey('count', $body); | ||||
|         self::assertArrayHasKey('pagination', $body); | ||||
|         self::assertArrayHasKey('results', $body); | ||||
|         self::assertSame(3, $body['count']); | ||||
|         self::assertCount(3, $body['results']); | ||||
|         // spot check one item | ||||
|         self::assertSame('person_identifier_worker', $body['results'][0]['type']); | ||||
|         self::assertSame(1, $body['results'][0]['id']); | ||||
|         self::assertSame('dummy', $body['results'][0]['engine']); | ||||
|         self::assertSame(['en' => 'Label 1'], $body['results'][0]['label']); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,111 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\PersonBundle\Tests\PersonIdentifier\Identifier; | ||||
|  | ||||
| use Chill\PersonBundle\Entity\Identifier\PersonIdentifier; | ||||
| use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition; | ||||
| use Chill\PersonBundle\PersonIdentifier\Identifier\StringIdentifier; | ||||
| use PHPUnit\Framework\TestCase; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
|  * | ||||
|  * @coversNothing | ||||
|  */ | ||||
| class StringIdentifierValidationTest extends TestCase | ||||
| { | ||||
|     private function makeDefinition(array $data = []): PersonIdentifierDefinition | ||||
|     { | ||||
|         $definition = new PersonIdentifierDefinition(label: ['en' => 'Test'], engine: StringIdentifier::NAME); | ||||
|         if ([] !== $data) { | ||||
|             $definition->setData($data); | ||||
|         } | ||||
|  | ||||
|         return $definition; | ||||
|     } | ||||
|  | ||||
|     private function makeIdentifier(PersonIdentifierDefinition $definition, ?string $content): PersonIdentifier | ||||
|     { | ||||
|         $identifier = new PersonIdentifier($definition); | ||||
|         $identifier->setValue(['content' => $content]); | ||||
|  | ||||
|         return $identifier; | ||||
|     } | ||||
|  | ||||
|     public function testValidateWithoutOptionsHasNoViolations(): void | ||||
|     { | ||||
|         $definition = $this->makeDefinition(); | ||||
|         $identifier = $this->makeIdentifier($definition, 'AB-123'); | ||||
|  | ||||
|         $engine = new StringIdentifier(); | ||||
|         $violations = $engine->validate($identifier, $definition); | ||||
|  | ||||
|         self::assertIsArray($violations); | ||||
|         self::assertCount(0, $violations); | ||||
|     } | ||||
|  | ||||
|     public function testValidateOnlyNumbersOption(): void | ||||
|     { | ||||
|         $definition = $this->makeDefinition(['only_numbers' => true]); | ||||
|         $engine = new StringIdentifier(); | ||||
|  | ||||
|         // valid numeric content | ||||
|         $identifierOk = $this->makeIdentifier($definition, '123456'); | ||||
|         $violationsOk = $engine->validate($identifierOk, $definition); | ||||
|         self::assertCount(0, $violationsOk); | ||||
|  | ||||
|         // invalid alphanumeric content | ||||
|         $identifierBad = $this->makeIdentifier($definition, '12AB'); | ||||
|         $violationsBad = $engine->validate($identifierBad, $definition); | ||||
|         self::assertCount(1, $violationsBad); | ||||
|         self::assertSame('person_identifier.only_number', $violationsBad[0]->message); | ||||
|         self::assertSame('2a3352c0-a2b9-11f0-a767-b7a3f80e52f1', $violationsBad[0]->code); | ||||
|     } | ||||
|  | ||||
|     public function testValidateFixedLengthOption(): void | ||||
|     { | ||||
|         $definition = $this->makeDefinition(['fixed_length' => 5]); | ||||
|         $engine = new StringIdentifier(); | ||||
|  | ||||
|         // valid exact length | ||||
|         $identifierOk = $this->makeIdentifier($definition, 'ABCDE'); | ||||
|         $violationsOk = $engine->validate($identifierOk, $definition); | ||||
|         self::assertCount(0, $violationsOk); | ||||
|  | ||||
|         // invalid length (too short) | ||||
|         $identifierBad = $this->makeIdentifier($definition, 'AB'); | ||||
|         $violationsBad = $engine->validate($identifierBad, $definition); | ||||
|         self::assertCount(1, $violationsBad); | ||||
|         self::assertSame('person_identifier.fixed_length', $violationsBad[0]->message); | ||||
|         self::assertSame('2b02a8fe-a2b9-11f0-bfe5-033300972783', $violationsBad[0]->code); | ||||
|         self::assertSame(['limit' => '5'], $violationsBad[0]->parameters); | ||||
|     } | ||||
|  | ||||
|     public function testValidateOnlyNumbersAndFixedLengthTogether(): void | ||||
|     { | ||||
|         $definition = $this->makeDefinition(['only_numbers' => true, 'fixed_length' => 4]); | ||||
|         $engine = new StringIdentifier(); | ||||
|  | ||||
|         // valid: numeric and correct length | ||||
|         $identifierOk = $this->makeIdentifier($definition, '1234'); | ||||
|         $violationsOk = $engine->validate($identifierOk, $definition); | ||||
|         self::assertCount(0, $violationsOk); | ||||
|  | ||||
|         // invalid: non-numeric and wrong length -> two violations expected | ||||
|         $identifierBad = $this->makeIdentifier($definition, 'AB'); | ||||
|         $violationsBad = $engine->validate($identifierBad, $definition); | ||||
|         self::assertCount(2, $violationsBad); | ||||
|         // Order is defined by implementation: numbers check first, then length | ||||
|         self::assertSame('person_identifier.only_number', $violationsBad[0]->message); | ||||
|         self::assertSame('person_identifier.fixed_length', $violationsBad[1]->message); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,133 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\PersonBundle\Tests\PersonIdentifier\Normalizer; | ||||
|  | ||||
| use Chill\PersonBundle\Entity\Identifier\PersonIdentifier; | ||||
| use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition; | ||||
| use Chill\PersonBundle\PersonIdentifier\Normalizer\PersonIdentifierWorkerNormalizer; | ||||
| use Chill\PersonBundle\PersonIdentifier\PersonIdentifierEngineInterface; | ||||
| use Chill\PersonBundle\PersonIdentifier\PersonIdentifierWorker; | ||||
| use PHPUnit\Framework\TestCase; | ||||
| use Symfony\Component\Serializer\Exception\UnexpectedValueException; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
|  * | ||||
|  * @coversNothing | ||||
|  */ | ||||
| class PersonIdentifierWorkerNormalizerTest extends TestCase | ||||
| { | ||||
|     public function testSupportsNormalization(): void | ||||
|     { | ||||
|         $engine = new class () implements PersonIdentifierEngineInterface { | ||||
|             public static function getName(): string | ||||
|             { | ||||
|                 return 'dummy'; | ||||
|             } | ||||
|  | ||||
|             public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string | ||||
|             { | ||||
|                 return null; | ||||
|             } | ||||
|  | ||||
|             public function buildForm(\Symfony\Component\Form\FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void {} | ||||
|  | ||||
|             public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string | ||||
|             { | ||||
|                 return ''; | ||||
|             } | ||||
|  | ||||
|             public function isEmpty(PersonIdentifier $identifier): bool | ||||
|             { | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             public function validate(PersonIdentifier $identifier, PersonIdentifierDefinition $definition): array | ||||
|             { | ||||
|                 return []; | ||||
|             } | ||||
|  | ||||
|             public function getDefaultValue(PersonIdentifierDefinition $definition): array | ||||
|             { | ||||
|                 return []; | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         $definition = new PersonIdentifierDefinition(label: ['en' => 'SSN'], engine: 'string'); | ||||
|         $worker = new PersonIdentifierWorker($engine, $definition); | ||||
|  | ||||
|         $normalizer = new PersonIdentifierWorkerNormalizer(); | ||||
|  | ||||
|         self::assertTrue($normalizer->supportsNormalization($worker)); | ||||
|         self::assertFalse($normalizer->supportsNormalization(new \stdClass())); | ||||
|     } | ||||
|  | ||||
|     public function testNormalizeReturnsExpectedArray(): void | ||||
|     { | ||||
|         $engine = new class () implements PersonIdentifierEngineInterface { | ||||
|             public static function getName(): string | ||||
|             { | ||||
|                 return 'dummy'; | ||||
|             } | ||||
|  | ||||
|             public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string | ||||
|             { | ||||
|                 return null; | ||||
|             } | ||||
|  | ||||
|             public function buildForm(\Symfony\Component\Form\FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void {} | ||||
|  | ||||
|             public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string | ||||
|             { | ||||
|                 return ''; | ||||
|             } | ||||
|  | ||||
|             public function isEmpty(PersonIdentifier $identifier): bool | ||||
|             { | ||||
|                 return false; | ||||
|             } | ||||
|  | ||||
|             public function getDefaultValue(PersonIdentifierDefinition $definition): array | ||||
|             { | ||||
|                 return []; | ||||
|             } | ||||
|  | ||||
|             public function validate(PersonIdentifier $identifier, PersonIdentifierDefinition $definition): array | ||||
|             { | ||||
|                 return []; | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         $definition = new PersonIdentifierDefinition(label: ['en' => 'SSN'], engine: 'string'); | ||||
|         $definition->setActive(false); | ||||
|         $worker = new PersonIdentifierWorker($engine, $definition); | ||||
|  | ||||
|         $normalizer = new PersonIdentifierWorkerNormalizer(); | ||||
|         $normalized = $normalizer->normalize($worker); | ||||
|  | ||||
|         self::assertSame([ | ||||
|             'type' => 'person_identifier_worker', | ||||
|             'definition_id' => null, | ||||
|             'engine' => 'string', | ||||
|             'label' => ['en' => 'SSN'], | ||||
|             'isActive' => false, | ||||
|             'presence' => 'ON_EDIT', | ||||
|         ], $normalized); | ||||
|     } | ||||
|  | ||||
|     public function testNormalizeThrowsOnInvalidObject(): void | ||||
|     { | ||||
|         $normalizer = new PersonIdentifierWorkerNormalizer(); | ||||
|         $this->expectException(UnexpectedValueException::class); | ||||
|         $normalizer->normalize(new \stdClass()); | ||||
|     } | ||||
| } | ||||
| @@ -71,6 +71,21 @@ class PersonIdRenderingTest extends TestCase | ||||
|                         // same behavior as StringIdentifier::renderAsString | ||||
|                         return $identifier?->getValue()['content'] ?? ''; | ||||
|                     } | ||||
|  | ||||
|                     public function isEmpty(PersonIdentifier $identifier): bool | ||||
|                     { | ||||
|                         return false; | ||||
|                     } | ||||
|  | ||||
|                     public function validate(PersonIdentifier $identifier, PersonIdentifierDefinition $definition): array | ||||
|                     { | ||||
|                         return []; | ||||
|                     } | ||||
|  | ||||
|                     public function getDefaultValue(PersonIdentifierDefinition $definition): array | ||||
|                     { | ||||
|                         return []; | ||||
|                     } | ||||
|                 }; | ||||
|  | ||||
|                 return new PersonIdentifierWorker($engine, $definition); | ||||
|   | ||||
| @@ -0,0 +1,131 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\PersonBundle\Tests\PersonIdentifier\Validator; | ||||
|  | ||||
| use Chill\PersonBundle\Entity\Identifier\IdentifierPresenceEnum; | ||||
| use Chill\PersonBundle\Entity\Identifier\PersonIdentifier; | ||||
| use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition; | ||||
| use Chill\PersonBundle\PersonIdentifier\PersonIdentifierEngineInterface; | ||||
| use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface; | ||||
| use Chill\PersonBundle\PersonIdentifier\PersonIdentifierWorker; | ||||
| use Chill\PersonBundle\PersonIdentifier\Validator\RequiredIdentifierConstraint; | ||||
| use Chill\PersonBundle\PersonIdentifier\Validator\RequiredIdentifierConstraintValidator; | ||||
| use Doctrine\Common\Collections\ArrayCollection; | ||||
| use PHPUnit\Framework\Attributes\CoversClass; | ||||
| use Symfony\Component\Validator\Constraints\NotBlank; | ||||
| use Symfony\Component\Validator\Exception\UnexpectedTypeException; | ||||
| use Symfony\Component\Validator\Exception\UnexpectedValueException; | ||||
| use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; | ||||
| use Prophecy\PhpUnit\ProphecyTrait; | ||||
| use Prophecy\Argument; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
|  */ | ||||
| #[CoversClass(RequiredIdentifierConstraintValidator::class)] | ||||
| final class RequiredIdentifierConstraintValidatorTest extends ConstraintValidatorTestCase | ||||
| { | ||||
|     use ProphecyTrait; | ||||
|  | ||||
|     private PersonIdentifierDefinition $requiredDefinition; | ||||
|  | ||||
|     protected function createValidator(): RequiredIdentifierConstraintValidator | ||||
|     { | ||||
|         $this->requiredDefinition = new PersonIdentifierDefinition( | ||||
|             label: ['fr' => 'Identifiant requis'], | ||||
|             engine: 'test.engine', | ||||
|         ); | ||||
|         $this->requiredDefinition->setPresence(IdentifierPresenceEnum::REQUIRED); | ||||
|         $reflection = new \ReflectionClass($this->requiredDefinition); | ||||
|         $id = $reflection->getProperty('id'); | ||||
|         $id->setValue($this->requiredDefinition, 1); | ||||
|  | ||||
|         // Mock only the required methods of the engine used by the validator through the worker | ||||
|         $engineProphecy = $this->prophesize(PersonIdentifierEngineInterface::class); | ||||
|         $engineProphecy->isEmpty(Argument::type(PersonIdentifier::class)) | ||||
|             ->will(function (array $args): bool { | ||||
|                 /** @var PersonIdentifier $identifier */ | ||||
|                 $identifier = $args[0]; | ||||
|  | ||||
|                 return '' === trim($identifier->getValue()['content'] ?? ''); | ||||
|             }); | ||||
|         $engineProphecy->renderAsString(Argument::any(), Argument::any()) | ||||
|             ->will(function (array $args): string { | ||||
|                 /** @var PersonIdentifier|null $identifier */ | ||||
|                 $identifier = $args[0] ?? null; | ||||
|  | ||||
|                 return $identifier?->getValue()['content'] ?? ''; | ||||
|             }); | ||||
|  | ||||
|         $worker = new PersonIdentifierWorker($engineProphecy->reveal(), $this->requiredDefinition); | ||||
|  | ||||
|         // Mock only the required method used by the validator | ||||
|         $managerProphecy = $this->prophesize(PersonIdentifierManagerInterface::class); | ||||
|         $managerProphecy->getWorkers()->willReturn([$worker]); | ||||
|  | ||||
|         return new RequiredIdentifierConstraintValidator($managerProphecy->reveal()); | ||||
|     } | ||||
|  | ||||
|     public function testThrowsOnNonCollectionValue(): void | ||||
|     { | ||||
|         $this->expectException(UnexpectedValueException::class); | ||||
|         $this->validator->validate(new \stdClass(), new RequiredIdentifierConstraint()); | ||||
|     } | ||||
|  | ||||
|     public function testThrowsOnInvalidConstraintType(): void | ||||
|     { | ||||
|         $this->expectException(UnexpectedTypeException::class); | ||||
|         // Provide a valid Collection value so the type check reaches the constraint check | ||||
|         $this->validator->validate(new ArrayCollection(), new NotBlank()); | ||||
|     } | ||||
|  | ||||
|     public function testNoViolationWhenRequiredIdentifierPresentAndNotEmpty(): void | ||||
|     { | ||||
|         $identifier = new PersonIdentifier($this->requiredDefinition); | ||||
|         $identifier->setValue(['content' => 'ABC']); | ||||
|  | ||||
|         $collection = new ArrayCollection([$identifier]); | ||||
|  | ||||
|         $this->validator->validate($collection, new RequiredIdentifierConstraint()); | ||||
|  | ||||
|         $this->assertNoViolation(); | ||||
|     } | ||||
|  | ||||
|     public function testViolationWhenRequiredIdentifierMissing(): void | ||||
|     { | ||||
|         $collection = new ArrayCollection(); | ||||
|  | ||||
|         $this->validator->validate($collection, new RequiredIdentifierConstraint()); | ||||
|  | ||||
|         $this->buildViolation('person_identifier.This identifier must be set') | ||||
|             ->setParameter('{{ value }}', '') | ||||
|             ->setParameter('definition_id', '1') | ||||
|             ->setCode('c08b7b32-947f-11f0-8608-9b8560e9bf05') | ||||
|             ->assertRaised(); | ||||
|     } | ||||
|  | ||||
|     public function testViolationWhenRequiredIdentifierIsEmpty(): void | ||||
|     { | ||||
|         $identifier = new PersonIdentifier($this->requiredDefinition); | ||||
|         $identifier->setValue(['content' => '   ']); | ||||
|  | ||||
|         $collection = new ArrayCollection([$identifier]); | ||||
|  | ||||
|         $this->validator->validate($collection, new RequiredIdentifierConstraint()); | ||||
|  | ||||
|         $this->buildViolation('person_identifier.This identifier must be set') | ||||
|             ->setParameter('{{ value }}', '   ') | ||||
|             ->setParameter('definition_id', '1') | ||||
|             ->setCode('c08b7b32-947f-11f0-8608-9b8560e9bf05') | ||||
|             ->assertRaised(); | ||||
|     } | ||||
| } | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user