mirror of
				https://gitlab.com/Chill-Projet/chill-bundles.git
				synced 2025-10-31 09:18:24 +00:00 
			
		
		
		
	Compare commits
	
		
			23 Commits
		
	
	
		
			420-locali
			...
			454-evalua
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 1ca2d4f03b | |||
| bf38ec22c9 | |||
| 9c2abb2dfa | |||
| 94744b9542 | |||
| f42bb498e4 | |||
| 01889ac671 | |||
| 62e5842311 | |||
| 8ad6f397a8 | |||
| d713704633 | |||
| b1fa9242a0 | |||
| 6ac554f93a | |||
| 372d8e5825 | |||
| 10f05e5559 | |||
| ddb2a65419 | |||
| 8d40a8089f | |||
| e1bf4a24d2 | |||
| 208a378185 | |||
| 9089c8959b | |||
| 1b9b581c31 | |||
| aa1abe4c88 | |||
| d82c9cc9a7 | |||
| a7e3b1c5d2 | |||
| 84cf11933d | 
							
								
								
									
										7
									
								
								.changes/unreleased/DX-20251027-150053.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								.changes/unreleased/DX-20251027-150053.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| kind: DX | ||||
| body: | | ||||
|     Send notifications log to dedicated channel, if it exists | ||||
| time: 2025-10-27T15:00:53.309372316+01:00 | ||||
| custom: | ||||
|     Issue: "" | ||||
|     SchemaChange: No schema change | ||||
| @@ -1,6 +0,0 @@ | ||||
| kind: Fixed | ||||
| body: Fix the rendering of list of StoredObjectVersions, where there are kept version (before converting to pdf) and intermediate versions deleted | ||||
| time: 2025-10-03T22:40:44.685474863+02:00 | ||||
| custom: | ||||
|     Issue: "" | ||||
|     SchemaChange: No schema change | ||||
| @@ -1,6 +0,0 @@ | ||||
| kind: Fixed | ||||
| body: 'Notification: fix editing of sent notification by removing form.addressesEmails, a field that no longer exists' | ||||
| time: 2025-10-06T12:13:15.45905994+02:00 | ||||
| custom: | ||||
|     Issue: "434" | ||||
|     SchemaChange: No schema change | ||||
							
								
								
									
										6
									
								
								.changes/unreleased/UX-20251030-180919.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								.changes/unreleased/UX-20251030-180919.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| kind: UX | ||||
| body: Expand timeSpent choices for evaluation document and translate them to user locale or fallback 'fr' | ||||
| time: 2025-10-30T18:09:19.373907522+01:00 | ||||
| custom: | ||||
|     Issue: "" | ||||
|     SchemaChange: No schema change | ||||
							
								
								
									
										14
									
								
								.changes/v4.6.0.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								.changes/v4.6.0.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| ## v4.6.0 - 2025-10-15 | ||||
| ### Feature | ||||
| * ([#423](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/423)) Create environment banner that can be activated and configured depending on the image deployed    | ||||
| * ([#394](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/394)) Only show active workflow on the page "my tracked workflow"    | ||||
| ### Fixed | ||||
| * Fix loading of classLists in SocialIssuesAcc.vue, ensure elements are present    | ||||
| * Fix the rendering of list of StoredObjectVersions, where there are kept version (before converting to pdf) and intermediate versions deleted    | ||||
| * ([#434](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/434)) Notification: fix editing of sent notification by removing form.addressesEmails, a field that no longer exists    | ||||
| * Fix loading of social issues and social actions within vue component    | ||||
| * ([#446](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/446)) Add unique condition on stored object filename, with cleaning step on existing duplicate filenames    | ||||
|  | ||||
|   **Schema Change**: Drop or rename table or columns, or enforce new constraint that must be manually fixed | ||||
| * [workflow] take permissions into account to delete the workflow attachment    | ||||
| * ([#448](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/448)) Fix the execution of daily cronjob notification, when the previous last execution storage was invalid    | ||||
							
								
								
									
										3
									
								
								.changes/v4.6.1.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								.changes/v4.6.1.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| ## v4.6.1 - 2025-10-27 | ||||
| ### Fixed | ||||
| * Fix export case where no 'reason' is picked within the PersonHavingActivityBetweenDateFilter.php    | ||||
| @@ -240,9 +240,6 @@ The tests are run from the project's root (not from the bundle's root). | ||||
| # Run all tests | ||||
| vendor/bin/phpunit | ||||
|  | ||||
| # Run tests for a specific bundle | ||||
| vendor/bin/phpunit --testsuite NameBundle | ||||
|  | ||||
| # Run a specific test file | ||||
| vendor/bin/phpunit path/to/TestFile.php | ||||
|  | ||||
| @@ -250,6 +247,9 @@ vendor/bin/phpunit path/to/TestFile.php | ||||
| vendor/bin/phpunit --filter methodName path/to/TestFile.php | ||||
| ``` | ||||
|  | ||||
| When writing tests, only test specific files. Do not run all tests or the full | ||||
| test suite. | ||||
|  | ||||
| #### Test Structure | ||||
|  | ||||
| Tests are organized by bundle and follow the same structure as the bundle itself: | ||||
|   | ||||
							
								
								
									
										19
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						
									
										19
									
								
								CHANGELOG.md
									
									
									
									
									
								
							| @@ -6,6 +6,25 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), | ||||
| and is generated by [Changie](https://github.com/miniscruff/changie). | ||||
|  | ||||
|  | ||||
| ## v4.6.1 - 2025-10-27 | ||||
| ### Fixed | ||||
| * Fix export case where no 'reason' is picked within the PersonHavingActivityBetweenDateFilter.php    | ||||
|  | ||||
| ## v4.6.0 - 2025-10-15 | ||||
| ### Feature | ||||
| * ([#423](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/423)) Create environment banner that can be activated and configured depending on the image deployed    | ||||
| * ([#394](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/394)) Only show active workflow on the page "my tracked workflow"    | ||||
| ### Fixed | ||||
| * Fix loading of classLists in SocialIssuesAcc.vue, ensure elements are present    | ||||
| * Fix the rendering of list of StoredObjectVersions, where there are kept version (before converting to pdf) and intermediate versions deleted    | ||||
| * ([#434](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/434)) Notification: fix editing of sent notification by removing form.addressesEmails, a field that no longer exists    | ||||
| * Fix loading of social issues and social actions within vue component    | ||||
| * ([#446](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/446)) Add unique condition on stored object filename, with cleaning step on existing duplicate filenames    | ||||
|  | ||||
|   **Schema Change**: Drop or rename table or columns, or enforce new constraint that must be manually fixed | ||||
| * [workflow] take permissions into account to delete the workflow attachment    | ||||
| * ([#448](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/448)) Fix the execution of daily cronjob notification, when the previous last execution storage was invalid    | ||||
|  | ||||
| ## v4.5.1 - 2025-10-03 | ||||
| ### Fixed | ||||
| * Add missing javascript dependency    | ||||
|   | ||||
| @@ -14,7 +14,7 @@ | ||||
|         "ext-openssl": "*", | ||||
|         "ext-redis": "*", | ||||
|         "ext-zlib": "*", | ||||
|         "champs-libres/wopi-bundle": "dev-master@dev", | ||||
|         "champs-libres/wopi-bundle": "dev-master#1be045ee95310d2037683859ecefdbf3a10f7be6 as 0.4.x-dev", | ||||
|         "champs-libres/wopi-lib": "dev-master@dev", | ||||
|         "doctrine/data-fixtures": "^1.8", | ||||
|         "doctrine/doctrine-bundle": "^2.1", | ||||
|   | ||||
| @@ -1,6 +1,13 @@ | ||||
| chill_main: | ||||
|     available_languages: [ '%env(resolve:LOCALE)%', 'en' ] | ||||
|     available_countries: ['BE', 'FR'] | ||||
|     top_banner: | ||||
|         visible: false | ||||
|         text: | ||||
|             fr: 'Vous travaillez actuellement avec la version de PRÉ-PRODUCTION.' | ||||
|             nl: 'Je werkt momenteel in de PRE-PRODUCTIE versie' | ||||
|         color: '#353535' | ||||
|         background_color: '#d8bb48' | ||||
|     notifications: | ||||
|         from_email: '%env(resolve:NOTIFICATION_FROM_EMAIL)%' | ||||
|         from_name: '%env(resolve:NOTIFICATION_FROM_NAME)%' | ||||
|   | ||||
| @@ -90,7 +90,9 @@ class ActivityReasonFilter implements ExportElementValidatedInterface, FilterInt | ||||
|  | ||||
|     public function getFormDefaultData(): array | ||||
|     { | ||||
|         return []; | ||||
|         return [ | ||||
|             'reasons' => [], | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|     public function describeAction($data, ExportGenerationContext $context): string|\Symfony\Contracts\Translation\TranslatableInterface|array | ||||
|   | ||||
| @@ -42,6 +42,8 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem | ||||
|  | ||||
|     public function alterQuery(QueryBuilder $qb, $data, ExportGenerationContext $exportGenerationContext): void | ||||
|     { | ||||
|         error_log('alterQuery called with data: '.json_encode(array_keys($data))); | ||||
|  | ||||
|         // create a subquery for activity | ||||
|         $sqb = $qb->getEntityManager()->createQueryBuilder(); | ||||
|         $sqb->select('1') | ||||
| @@ -59,7 +61,6 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem | ||||
|         if (\in_array('activity', $qb->getAllAliases(), true)) { | ||||
|             $sqb->andWhere('activity_person_having_activity.id = activity.id'); | ||||
|         } | ||||
|  | ||||
|         if (isset($data['reasons']) && [] !== $data['reasons']) { | ||||
|             // add clause activity reason | ||||
|             $sqb->join('activity_person_having_activity.reasons', 'reasons_person_having_activity'); | ||||
| @@ -124,12 +125,38 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem | ||||
|  | ||||
|     public function normalizeFormData(array $formData): array | ||||
|     { | ||||
|         return ['date_from_rolling' => $formData['date_from_rolling']->normalize(), 'date_to_rolling' => $formData['date_to_rolling']->normalize()]; | ||||
|         $normalized = [ | ||||
|             'date_from_rolling' => $formData['date_from_rolling']->normalize(), | ||||
|             'date_to_rolling' => $formData['date_to_rolling']->normalize(), | ||||
|             'reasons' => [], | ||||
|         ]; | ||||
|  | ||||
|         if (isset($formData['reasons']) && [] !== $formData['reasons']) { | ||||
|             $normalized['reasons'] = array_map( | ||||
|                 fn (ActivityReason $reason) => $reason->getId(), | ||||
|                 $formData['reasons'] | ||||
|             ); | ||||
|         } | ||||
|  | ||||
|         return $normalized; | ||||
|     } | ||||
|  | ||||
|     public function denormalizeFormData(array $formData, int $fromVersion): array | ||||
|     { | ||||
|         return ['date_from_rolling' => RollingDate::fromNormalized($formData['date_from_rolling']), 'date_to_rolling' => RollingDate::fromNormalized($formData['date_to_rolling'])]; | ||||
|         $denormalized = [ | ||||
|             'date_from_rolling' => RollingDate::fromNormalized($formData['date_from_rolling']), | ||||
|             'date_to_rolling' => RollingDate::fromNormalized($formData['date_to_rolling']), | ||||
|             'reasons' => [], | ||||
|         ]; | ||||
|  | ||||
|         if (isset($formData['reasons']) && [] !== $formData['reasons']) { | ||||
|             $denormalized['reasons'] = array_map( | ||||
|                 fn ($id) => $this->activityReasonRepository->find($id), | ||||
|                 $formData['reasons'] | ||||
|             ); | ||||
|         } | ||||
|  | ||||
|         return $denormalized; | ||||
|     } | ||||
|  | ||||
|     public function getFormDefaultData(): array | ||||
| @@ -143,10 +170,12 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem | ||||
|  | ||||
|     public function describeAction($data, ExportGenerationContext $context): array | ||||
|     { | ||||
|         $reasons = $data['reasons'] ?? []; | ||||
|  | ||||
|         return [ | ||||
|             [] === $data['reasons'] ? | ||||
|                 'export.filter.person_between_dates.describe_action_with_no_subject' | ||||
|                 : 'export.filter.person_between_dates.describe_action_with_subject', | ||||
|             [] === $reasons ? | ||||
|                 'export.filter.activity.describe_action_with_no_subject' | ||||
|                 : 'export.filter.activity.describe_action_with_subject', | ||||
|             [ | ||||
|                 'date_from' => $this->rollingDateConverter->convert($data['date_from_rolling']), | ||||
|                 'date_to' => $this->rollingDateConverter->convert($data['date_to_rolling']), | ||||
| @@ -154,7 +183,7 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem | ||||
|                     ', ', | ||||
|                     array_map( | ||||
|                         fn (ActivityReason $r): string => '"'.$this->translatableStringHelper->localize($r->getName()).'"', | ||||
|                         $data['reasons'] | ||||
|                         $reasons | ||||
|                     ) | ||||
|                 ), | ||||
|             ], | ||||
| @@ -168,6 +197,7 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem | ||||
|  | ||||
|     public function validateForm($data, ExecutionContextInterface $context): void | ||||
|     { | ||||
|         error_log('validateForm called with data: '.json_encode(array_keys($data))); | ||||
|         if ($this->rollingDateConverter->convert($data['date_from_rolling']) | ||||
|             >= $this->rollingDateConverter->convert($data['date_to_rolling'])) { | ||||
|             $context->buildViolation('export.filter.activity.person_between_dates.date mismatch') | ||||
|   | ||||
| @@ -136,8 +136,14 @@ export default { | ||||
|             issueIsLoading: false, | ||||
|             actionIsLoading: false, | ||||
|             actionAreLoaded: false, | ||||
|             socialIssuesClassList: `col-form-label ${document.querySelector("input#chill_activitybundle_activity_socialIssues").getAttribute("required") ? "required" : ""}`, | ||||
|             socialActionsClassList: `col-form-label ${document.querySelector("input#chill_activitybundle_activity_socialActions").getAttribute("required") ? "required" : ""}`, | ||||
|             socialIssuesClassList: { | ||||
|                 "col-form-label": true, | ||||
|                 required: false, | ||||
|             }, | ||||
|             socialActionsClassList: { | ||||
|                 "col-form-label": true, | ||||
|                 required: false, | ||||
|             }, | ||||
|         }; | ||||
|     }, | ||||
|     computed: { | ||||
| @@ -158,6 +164,21 @@ export default { | ||||
|         }, | ||||
|     }, | ||||
|     mounted() { | ||||
|         /* Load classNames after element is present */ | ||||
|         const socialActionsEl = document.querySelector( | ||||
|             "input#chill_activitybundle_activity_socialActions", | ||||
|         ); | ||||
|         if (socialActionsEl && socialActionsEl.hasAttribute("required")) { | ||||
|             this.socialActionsClassList.required = true; | ||||
|         } | ||||
|  | ||||
|         const socialIssuesEl = document.querySelector( | ||||
|             "input#chill_activitybundle_activity_socialIssues", | ||||
|         ); | ||||
|         if (socialIssuesEl && socialIssuesEl.hasAttribute("required")) { | ||||
|             this.socialIssuesClassList.required = true; | ||||
|         } | ||||
|  | ||||
|         /* Load other issues in multiselect */ | ||||
|         this.issueIsLoading = true; | ||||
|         this.actionAreLoaded = false; | ||||
|   | ||||
| @@ -25,8 +25,6 @@ use Symfony\Component\Mailer\MailerInterface; | ||||
| use Symfony\Component\Messenger\Event\WorkerMessageFailedEvent; | ||||
| use Symfony\Contracts\Translation\TranslatorInterface; | ||||
|  | ||||
| // use Symfony\Component\Translation\LocaleSwitcher; | ||||
|  | ||||
| /** | ||||
|  * @see OnGenerationFailsTest for test suite | ||||
|  */ | ||||
| @@ -42,7 +40,6 @@ final readonly class OnGenerationFails implements EventSubscriberInterface | ||||
|         private StoredObjectRepositoryInterface $storedObjectRepository, | ||||
|         private TranslatorInterface $translator, | ||||
|         private UserRepositoryInterface $userRepository, | ||||
|         // private LocaleSwitcher $localeSwitcher, | ||||
|     ) {} | ||||
|  | ||||
|     public static function getSubscribedEvents() | ||||
| @@ -121,25 +118,6 @@ final readonly class OnGenerationFails implements EventSubscriberInterface | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         // Implementation with LocaleSwitcher (commented out - to be activated after migration to sf7.2): | ||||
|         /* | ||||
|         $this->localeSwitcher->runWithLocale($creator->getLocale(), function () use ($message, $errors, $template, $creator) { | ||||
|             $email = (new TemplatedEmail()) | ||||
|                 ->to($message->getSendResultToEmail()) | ||||
|                 ->subject($this->translator->trans('docgen.failure_email.The generation of a document failed')) | ||||
|                 ->textTemplate('@ChillDocGenerator/Email/on_generation_failed_email.txt.twig') | ||||
|                 ->context([ | ||||
|                     'errors' => $errors, | ||||
|                     'template' => $template, | ||||
|                     'creator' => $creator, | ||||
|                     'stored_object_id' => $message->getDestinationStoredObjectId(), | ||||
|                 ]); | ||||
|  | ||||
|             $this->mailer->send($email); | ||||
|         }); | ||||
|         */ | ||||
|  | ||||
|         // Current implementation: | ||||
|         $email = (new TemplatedEmail()) | ||||
|             ->to($message->getSendResultToEmail()) | ||||
|             ->subject($this->translator->trans('docgen.failure_email.The generation of a document failed')) | ||||
|   | ||||
| @@ -27,8 +27,6 @@ use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException; | ||||
| use Symfony\Component\Messenger\Handler\MessageHandlerInterface; | ||||
| use Symfony\Contracts\Translation\TranslatorInterface; | ||||
|  | ||||
| // use Symfony\Component\Translation\LocaleSwitcher; | ||||
|  | ||||
| /** | ||||
|  * Handle the request of document generation. | ||||
|  */ | ||||
| @@ -48,7 +46,6 @@ class RequestGenerationHandler implements MessageHandlerInterface | ||||
|         private readonly MailerInterface $mailer, | ||||
|         private readonly TranslatorInterface $translator, | ||||
|         private readonly StoredObjectManagerInterface $storedObjectManager, | ||||
|         // private readonly LocaleSwitcher $localeSwitcher, | ||||
|     ) {} | ||||
|  | ||||
|     public function __invoke(RequestGenerationMessage $message) | ||||
| @@ -125,30 +122,6 @@ class RequestGenerationHandler implements MessageHandlerInterface | ||||
|  | ||||
|     private function sendDataDump(StoredObject $destinationStoredObject, RequestGenerationMessage $message): void | ||||
|     { | ||||
|         // Implementation with LocaleSwitcher (commented out - to be activated after migration to sf7.2): | ||||
|         // Note: This method sends emails to admin addresses, not user addresses, so locale switching may not be needed | ||||
|         /* | ||||
|         $this->localeSwitcher->runWithLocale('fr', function () use ($destinationStoredObject, $message) { | ||||
|             // Get the content of the document | ||||
|             $content = $this->storedObjectManager->read($destinationStoredObject); | ||||
|             $filename = $destinationStoredObject->getFilename(); | ||||
|             $contentType = $destinationStoredObject->getType(); | ||||
|  | ||||
|             // Create the email with the document as an attachment | ||||
|             $email = (new TemplatedEmail()) | ||||
|                 ->to($message->getSendResultToEmail()) | ||||
|                 ->textTemplate('@ChillDocGenerator/Email/send_data_dump_to_admin.txt.twig') | ||||
|                 ->context([ | ||||
|                     'filename' => $filename, | ||||
|                 ]) | ||||
|                 ->subject($this->translator->trans('docgen.data_dump_email.subject')) | ||||
|                 ->attach($content, $filename, $contentType); | ||||
|  | ||||
|             $this->mailer->send($email); | ||||
|         }); | ||||
|         */ | ||||
|  | ||||
|         // Current implementation: | ||||
|         // Get the content of the document | ||||
|         $content = $this->storedObjectManager->read($destinationStoredObject); | ||||
|         $filename = $destinationStoredObject->getFilename(); | ||||
|   | ||||
| @@ -23,10 +23,14 @@ use Random\RandomException; | ||||
|  * Store each version of StoredObject's. | ||||
|  * | ||||
|  * A version should not be created manually: use the method @see{StoredObject::registerVersion} instead. | ||||
|  * | ||||
|  * Each filename must be unique within the same StoredObject. We add a condition on id to apply this condition only for | ||||
|  * newly created versions when this new index is applied. | ||||
|  */ | ||||
| #[ORM\Entity] | ||||
| #[ORM\Table('chill_doc.stored_object_version')] | ||||
| #[ORM\UniqueConstraint(name: 'chill_doc_stored_object_version_unique_by_object', columns: ['stored_object_id', 'version'])] | ||||
| #[ORM\UniqueConstraint(name: 'chill_doc_stored_object_version_unique_by_filename', columns: ['filename'], options: ['where' => '(id > 0)'])] | ||||
| class StoredObjectVersion implements TrackCreationInterface | ||||
| { | ||||
|     use TrackCreationTrait; | ||||
|   | ||||
| @@ -36,6 +36,18 @@ export interface GenericDocForAccompanyingPeriod extends GenericDoc { | ||||
|     context: "accompanying-period"; | ||||
| } | ||||
|  | ||||
| export function isGenericDocForAccompanyingPeriod( | ||||
|     doc: GenericDoc, | ||||
| ): doc is GenericDocForAccompanyingPeriod { | ||||
|     return doc.context === "accompanying-period"; | ||||
| } | ||||
|  | ||||
| export function isGenericDocWithStoredObject( | ||||
|     doc: GenericDoc, | ||||
| ): doc is GenericDoc & { storedObject: StoredObject } { | ||||
|     return doc.storedObject !== null; | ||||
| } | ||||
|  | ||||
| interface BaseMetadataWithHtml extends BaseMetadata { | ||||
|     html: string; | ||||
| } | ||||
| @@ -44,28 +56,33 @@ export interface GenericDocForAccompanyingCourseDocument | ||||
|     extends GenericDocForAccompanyingPeriod { | ||||
|     key: "accompanying_course_document"; | ||||
|     metadata: BaseMetadataWithHtml; | ||||
|     storedObject: StoredObject; | ||||
| } | ||||
|  | ||||
| export interface GenericDocForAccompanyingCourseActivityDocument | ||||
|     extends GenericDocForAccompanyingPeriod { | ||||
|     key: "accompanying_course_activity_document"; | ||||
|     metadata: BaseMetadataWithHtml; | ||||
|     storedObject: StoredObject; | ||||
| } | ||||
|  | ||||
| export interface GenericDocForAccompanyingCourseCalendarDocument | ||||
|     extends GenericDocForAccompanyingPeriod { | ||||
|     key: "accompanying_course_calendar_document"; | ||||
|     metadata: BaseMetadataWithHtml; | ||||
|     storedObject: StoredObject; | ||||
| } | ||||
|  | ||||
| export interface GenericDocForAccompanyingCoursePersonDocument | ||||
|     extends GenericDocForAccompanyingPeriod { | ||||
|     key: "person_document"; | ||||
|     metadata: BaseMetadataWithHtml; | ||||
|     storedObject: StoredObject; | ||||
| } | ||||
|  | ||||
| export interface GenericDocForAccompanyingCourseWorkEvaluationDocument | ||||
|     extends GenericDocForAccompanyingPeriod { | ||||
|     key: "accompanying_period_work_evaluation_document"; | ||||
|     metadata: BaseMetadataWithHtml; | ||||
|     storedObject: StoredObject; | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,63 @@ | ||||
| <?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\Migrations\DocStore; | ||||
|  | ||||
| use Doctrine\DBAL\Schema\Schema; | ||||
| use Doctrine\Migrations\AbstractMigration; | ||||
|  | ||||
| final class Version20251013094414 extends AbstractMigration | ||||
| { | ||||
|     public function getDescription(): string | ||||
|     { | ||||
|         return 'DocStore: Enforce filename uniqueness on chill_doc.stored_object_version; clean duplicates and add partial unique index on filename (for new rows only).'; | ||||
|     } | ||||
|  | ||||
|     public function up(Schema $schema): void | ||||
|     { | ||||
|         // 1) Clean duplicates: for each (stored_object_id, filename, key, iv), keep only the last inserted row | ||||
|         //    and delete all others. Use ROW_NUMBER over id DESC to define the last one. | ||||
|         $this->addSql(<<<'SQL' | ||||
|             WITH ranked AS ( | ||||
|                 SELECT id, | ||||
|                        rank() OVER ( | ||||
|                            PARTITION BY stored_object_id, filename, "key"::jsonb, iv::jsonb | ||||
|                            ORDER BY id DESC | ||||
|                        ) AS rn | ||||
|                 FROM chill_doc.stored_object_version | ||||
|             ) | ||||
|             DELETE FROM chill_doc.stored_object_version sov | ||||
|             USING ranked r | ||||
|             WHERE sov.id = r.id | ||||
|               AND r.rn > 1 | ||||
|         SQL); | ||||
|  | ||||
|         // 2) Create a partial unique index on filename that applies only to subsequently inserted rows. | ||||
|         //    Per user's instruction, compute the cutoff using the stored_object_id sequence value. | ||||
|         $nextVal = (int) $this->connection->fetchOne("SELECT nextval('chill_doc.stored_object_version_id_seq')"); | ||||
|  | ||||
|         // Safety: if somehow sequence is not available, fallback to current max id from the table | ||||
|         if ($nextVal <= 0) { | ||||
|             $nextVal = (int) $this->connection->fetchOne('SELECT COALESCE(MAX(id), 0) FROM chill_doc.stored_object_version'); | ||||
|         } | ||||
|  | ||||
|         $this->addSql(sprintf( | ||||
|             'CREATE UNIQUE INDEX chill_doc_stored_object_version_unique_by_filename ON chill_doc.stored_object_version (filename) WHERE id > %d', | ||||
|             $nextVal | ||||
|         )); | ||||
|     } | ||||
|  | ||||
|     public function down(Schema $schema): void | ||||
|     { | ||||
|         // Drop the partial unique index; data cleanup is irreversible. | ||||
|         $this->addSql('DROP INDEX IF EXISTS chill_doc_stored_object_version_unique_by_filename'); | ||||
|     } | ||||
| } | ||||
| @@ -15,7 +15,6 @@ use Chill\MainBundle\Entity\User; | ||||
| use Chill\MainBundle\Notification\NotificationFlagManager; | ||||
| use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint; | ||||
| use libphonenumber\PhoneNumber; | ||||
| use Symfony\Component\Validator\Constraints as Assert; | ||||
|  | ||||
| final class UpdateProfileCommand | ||||
| { | ||||
| @@ -24,13 +23,11 @@ final class UpdateProfileCommand | ||||
|     public function __construct( | ||||
|         #[PhonenumberConstraint] | ||||
|         public ?PhoneNumber $phonenumber, | ||||
|         #[Assert\Choice(choices: ['fr', 'nl'], message: 'Locale must be either "fr" or "nl"')] | ||||
|         public string $locale = 'fr', | ||||
|     ) {} | ||||
|  | ||||
|     public static function create(User $user, NotificationFlagManager $flagManager): self | ||||
|     { | ||||
|         $updateProfileCommand = new self($user->getPhonenumber(), $user->getLocale()); | ||||
|         $updateProfileCommand = new self($user->getPhonenumber()); | ||||
|  | ||||
|         foreach ($flagManager->getAllNotificationFlagProviders() as $provider) { | ||||
|             $updateProfileCommand->setNotificationFlag( | ||||
|   | ||||
| @@ -18,7 +18,6 @@ final readonly class UpdateProfileCommandHandler | ||||
|     public function updateProfile(User $user, UpdateProfileCommand $command): void | ||||
|     { | ||||
|         $user->setPhonenumber($command->phonenumber); | ||||
|         $user->setLocale($command->locale); | ||||
|  | ||||
|         foreach ($command->notificationFlags as $flag => $values) { | ||||
|             $user->setNotificationImmediately($flag, $values['immediate_email']); | ||||
|   | ||||
| @@ -334,7 +334,7 @@ class ChillImportUsersCommand extends Command | ||||
|  | ||||
|     protected function loadUsers() | ||||
|     { | ||||
|         $reader = Reader::createFromPath($this->tempInput->getArgument('csvfile')); | ||||
|         $reader = Reader::from($this->tempInput->getArgument('csvfile')); | ||||
|         $reader->setHeaderOffset(0); | ||||
|  | ||||
|         foreach ($reader->getRecords() as $line => $r) { | ||||
| @@ -362,7 +362,7 @@ class ChillImportUsersCommand extends Command | ||||
|  | ||||
|     protected function prepareGroupingCenters() | ||||
|     { | ||||
|         $reader = Reader::createFromPath($this->tempInput->getOption('grouping-centers')); | ||||
|         $reader = Reader::from($this->tempInput->getOption('grouping-centers')); | ||||
|         $reader->setHeaderOffset(0); | ||||
|  | ||||
|         foreach ($reader->getRecords() as $r) { | ||||
| @@ -378,7 +378,7 @@ class ChillImportUsersCommand extends Command | ||||
|  | ||||
|     protected function prepareWriter() | ||||
|     { | ||||
|         $this->output = $output = Writer::createFromPath($this->tempInput | ||||
|         $this->output = $output = Writer::from($this->tempInput | ||||
|             ->getOption('csv-dump'), 'a+'); | ||||
|  | ||||
|         $output->insertOne([ | ||||
|   | ||||
| @@ -119,7 +119,7 @@ class ChillUserSendRenewPasswordCodeCommand extends Command | ||||
|     protected function getReader() | ||||
|     { | ||||
|         try { | ||||
|             $reader = Reader::createFromPath($this->input->getArgument('csvfile')); | ||||
|             $reader = Reader::from($this->input->getArgument('csvfile')); | ||||
|         } catch (\Exception $e) { | ||||
|             $this->logger->error('The csv file could not be read', [ | ||||
|                 'path' => $this->input->getArgument('csvfile'), | ||||
|   | ||||
| @@ -43,7 +43,7 @@ final readonly class UserExportController | ||||
|  | ||||
|         $users = $this->userRepository->findAllAsArray($request->getLocale()); | ||||
|  | ||||
|         $csv = Writer::createFromPath('php://temp', 'r+'); | ||||
|         $csv = Writer::from('php://temp', 'r+'); | ||||
|         $csv->insertOne( | ||||
|             array_map( | ||||
|                 fn (string $e) => $this->translator->trans('admin.users.export.'.$e), | ||||
| @@ -104,7 +104,7 @@ final readonly class UserExportController | ||||
|  | ||||
|         $userPermissions = $this->userRepository->findAllUserACLAsArray(); | ||||
|  | ||||
|         $csv = Writer::createFromPath('php://temp', 'r+'); | ||||
|         $csv = Writer::from('php://temp', 'r+'); | ||||
|         $csv->insertOne( | ||||
|             array_map( | ||||
|                 fn (string $e) => $this->translator->trans('admin.users.export.'.$e), | ||||
|   | ||||
| @@ -264,11 +264,12 @@ class WorkflowController extends AbstractController | ||||
|     { | ||||
|         $this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED'); | ||||
|  | ||||
|         $total = $this->entityWorkflowRepository->countBySubscriber($this->security->getUser()); | ||||
|         $total = $this->entityWorkflowRepository->countBySubscriber($this->security->getUser(), false); | ||||
|         $paginator = $this->paginatorFactory->create($total); | ||||
|  | ||||
|         $workflows = $this->entityWorkflowRepository->findBySubscriber( | ||||
|             $this->security->getUser(), | ||||
|             false, | ||||
|             ['createdAt' => 'DESC'], | ||||
|             $paginator->getItemsPerPage(), | ||||
|             $paginator->getCurrentPageFirstItemNumber() | ||||
|   | ||||
| @@ -205,6 +205,11 @@ class ChillMainExtension extends Extension implements | ||||
|                 [] | ||||
|         ); | ||||
|  | ||||
|         $container->setParameter( | ||||
|             'chill_main.top_banner', | ||||
|             $config['top_banner'] ?? [] | ||||
|         ); | ||||
|  | ||||
|         $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../config')); | ||||
|         $loader->load('services.yaml'); | ||||
|         $loader->load('services/doctrine.yaml'); | ||||
| @@ -250,6 +255,7 @@ class ChillMainExtension extends Extension implements | ||||
|                     'name' => $config['installation_name'], ], | ||||
|                 'available_languages' => $config['available_languages'], | ||||
|                 'add_address' => $config['add_address'], | ||||
|                 'chill_main_config' => $config, | ||||
|             ], | ||||
|             'form_themes' => ['@ChillMain/Form/fields.html.twig'], | ||||
|         ]; | ||||
|   | ||||
| @@ -168,6 +168,20 @@ class Configuration implements ConfigurationInterface | ||||
|             ->end() | ||||
|             ->end() | ||||
|             ->end() | ||||
|             ->arrayNode('top_banner') | ||||
|             ->canBeUnset() | ||||
|             ->children() | ||||
|             ->booleanNode('visible') | ||||
|             ->defaultFalse() | ||||
|             ->end() | ||||
|             ->arrayNode('text') | ||||
|             ->useAttributeAsKey('lang') | ||||
|             ->scalarPrototype()->end() | ||||
|             ->end() // end of text | ||||
|             ->scalarNode('color')->defaultNull()->end() | ||||
|             ->scalarNode('background_color')->defaultNull()->end() | ||||
|             ->end() // end of top_banner children | ||||
|             ->end() // end of top_banner | ||||
|             ->arrayNode('widgets') | ||||
|             ->canBeEnabled() | ||||
|             ->canBeUnset() | ||||
|   | ||||
| @@ -128,12 +128,6 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter | ||||
|     #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]', 'jsonb' => true])] | ||||
|     private array $notificationFlags = []; | ||||
|  | ||||
|     /** | ||||
|      * User's preferred locale. | ||||
|      */ | ||||
|     #[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 5, nullable: false, options: ['default' => 'fr'])] | ||||
|     private string $locale = 'fr'; | ||||
|  | ||||
|     /** | ||||
|      * User constructor. | ||||
|      */ | ||||
| @@ -722,18 +716,7 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter | ||||
|  | ||||
|     public function getLocale(): string | ||||
|     { | ||||
|         return $this->locale; | ||||
|     } | ||||
|  | ||||
|     public function setLocale(string $locale): self | ||||
|     { | ||||
|         if (!in_array($locale, ['fr', 'nl'], true)) { | ||||
|             throw new \InvalidArgumentException('Locale must be either "fr" or "nl"'); | ||||
|         } | ||||
|  | ||||
|         $this->locale = $locale; | ||||
|  | ||||
|         return $this; | ||||
|         return 'fr'; | ||||
|     } | ||||
|  | ||||
|     #[Assert\Callback] | ||||
|   | ||||
| @@ -1,38 +0,0 @@ | ||||
| <?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\MainBundle\Form\Type; | ||||
|  | ||||
| use Symfony\Component\Form\AbstractType; | ||||
| use Symfony\Component\Form\Extension\Core\Type\ChoiceType; | ||||
| use Symfony\Component\OptionsResolver\OptionsResolver; | ||||
|  | ||||
| class UserLocaleType extends AbstractType | ||||
| { | ||||
|     public function configureOptions(OptionsResolver $resolver): void | ||||
|     { | ||||
|         $resolver->setDefaults([ | ||||
|             'choices' => [ | ||||
|                 'user.locale.choice.french' => 'fr', | ||||
|                 'user.locale.choice.dutch' => 'nl', | ||||
|             ], | ||||
|             'placeholder' => 'user.locale.placeholder', | ||||
|             'required' => true, | ||||
|             'label' => 'user.locale.label', | ||||
|             'help' => 'user.locale.help', | ||||
|         ]); | ||||
|     } | ||||
|  | ||||
|     public function getParent(): string | ||||
|     { | ||||
|         return ChoiceType::class; | ||||
|     } | ||||
| } | ||||
| @@ -14,7 +14,6 @@ namespace Chill\MainBundle\Form; | ||||
| use Chill\MainBundle\Action\User\UpdateProfile\UpdateProfileCommand; | ||||
| use Chill\MainBundle\Form\Type\ChillPhoneNumberType; | ||||
| use Chill\MainBundle\Form\Type\NotificationFlagsType; | ||||
| use Chill\MainBundle\Form\Type\UserLocaleType; | ||||
| use Symfony\Component\Form\AbstractType; | ||||
| use Symfony\Component\Form\FormBuilderInterface; | ||||
| use Symfony\Component\OptionsResolver\OptionsResolver; | ||||
| @@ -27,7 +26,6 @@ class UpdateProfileType extends AbstractType | ||||
|             ->add('phonenumber', ChillPhoneNumberType::class, [ | ||||
|                 'required' => false, | ||||
|             ]) | ||||
|             ->add('locale', UserLocaleType::class) | ||||
|             ->add('notificationFlags', NotificationFlagsType::class) | ||||
|         ; | ||||
|     } | ||||
|   | ||||
| @@ -53,11 +53,16 @@ readonly class DailyNotificationDigestCronjob implements CronJobInterface | ||||
|     public function run(array $lastExecutionData): ?array | ||||
|     { | ||||
|         $now = $this->clock->now(); | ||||
|  | ||||
|         if (isset($lastExecutionData['last_execution'])) { | ||||
|             $lastExecution = \DateTimeImmutable::createFromFormat( | ||||
|                 \DateTimeImmutable::ATOM, | ||||
|                 $lastExecutionData['last_execution'] | ||||
|             ); | ||||
|  | ||||
|             if (false === $lastExecution) { | ||||
|                 $lastExecution = $now->sub(new \DateInterval('P1D')); | ||||
|             } | ||||
|         } else { | ||||
|             $lastExecution = $now->sub(new \DateInterval('P1D')); | ||||
|         } | ||||
| @@ -96,7 +101,7 @@ readonly class DailyNotificationDigestCronjob implements CronJobInterface | ||||
|         ]); | ||||
|  | ||||
|         return [ | ||||
|             'last_execution' => $now->format('Y-m-d-H:i:s.u e'), | ||||
|             'last_execution' => $now->format(\DateTimeInterface::ATOM), | ||||
|         ]; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -24,8 +24,6 @@ use Symfony\Component\Mime\Email; | ||||
| use Symfony\Component\Messenger\MessageBusInterface; | ||||
| use Symfony\Contracts\Translation\TranslatorInterface; | ||||
|  | ||||
| // use Symfony\Component\Translation\LocaleSwitcher; | ||||
|  | ||||
| readonly class NotificationMailer | ||||
| { | ||||
|     public function __construct( | ||||
| @@ -33,7 +31,6 @@ readonly class NotificationMailer | ||||
|         private LoggerInterface $logger, | ||||
|         private MessageBusInterface $messageBus, | ||||
|         private TranslatorInterface $translator, | ||||
|         // private LocaleSwitcher $localeSwitcher, | ||||
|     ) {} | ||||
|  | ||||
|     public function postPersistComment(NotificationComment $comment, PostPersistEventArgs $eventArgs): void | ||||
| @@ -59,7 +56,7 @@ readonly class NotificationMailer | ||||
|             $email | ||||
|                 ->to($dest->getEmail()) | ||||
|                 ->subject('Re: '.$comment->getNotification()->getTitle()) | ||||
|                 ->textTemplate('@ChillMain/Notification/email_notification_comment_persist.md.twig') | ||||
|                 ->textTemplate('@ChillMain/Notification/email_notification_comment_persist.fr.md.twig') | ||||
|                 ->context([ | ||||
|                     'comment' => $comment, | ||||
|                     'dest' => $dest, | ||||
| @@ -140,53 +137,13 @@ readonly class NotificationMailer | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         // Implementation with LocaleSwitcher (commented out - to be activated after migration to sf7.2): | ||||
|         /* | ||||
|         $this->localeSwitcher->runWithLocale($addressee->getLocale(), function () use ($notification, $addressee) { | ||||
|             if ($notification->isSystem()) { | ||||
|                 $email = new Email(); | ||||
|                 $email->text($notification->getMessage()); | ||||
|             } else { | ||||
|                 $email = new TemplatedEmail(); | ||||
|                 $email | ||||
|                     ->textTemplate('@ChillMain/Notification/email_non_system_notification_content.md.twig') | ||||
|                     ->context([ | ||||
|                         'notification' => $notification, | ||||
|                         'dest' => $addressee, | ||||
|                     ]); | ||||
|             } | ||||
|  | ||||
|             $email | ||||
|                 ->subject($notification->getTitle()) | ||||
|                 ->to($addressee->getEmail()); | ||||
|  | ||||
|             try { | ||||
|                 $this->mailer->send($email); | ||||
|                 $this->logger->info('[NotificationMailer] Email sent successfully', [ | ||||
|                     'notification_id' => $notification->getId(), | ||||
|                     'addressee_email' => $addressee->getEmail(), | ||||
|                     'locale' => $addressee->getLocale(), | ||||
|                 ]); | ||||
|             } catch (TransportExceptionInterface $e) { | ||||
|                 $this->logger->warning('[NotificationMailer] Could not send an email notification', [ | ||||
|                     'to' => $addressee->getEmail(), | ||||
|                     'notification_id' => $notification->getId(), | ||||
|                     'error_message' => $e->getMessage(), | ||||
|                     'error_trace' => $e->getTraceAsString(), | ||||
|                 ]); | ||||
|                 throw $e; | ||||
|             } | ||||
|         }); | ||||
|         */ | ||||
|  | ||||
|         // Current implementation: | ||||
|         if ($notification->isSystem()) { | ||||
|             $email = new Email(); | ||||
|             $email->text($notification->getMessage()); | ||||
|         } else { | ||||
|             $email = new TemplatedEmail(); | ||||
|             $email | ||||
|                 ->textTemplate('@ChillMain/Notification/email_non_system_notification_content.md.twig') | ||||
|                 ->textTemplate('@ChillMain/Notification/email_non_system_notification_content.fr.md.twig') | ||||
|                 ->context([ | ||||
|                     'notification' => $notification, | ||||
|                     'dest' => $addressee, | ||||
| @@ -225,43 +182,9 @@ readonly class NotificationMailer | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         // Implementation with LocaleSwitcher (commented out - to be activated after migration to sf7.2): | ||||
|         /* | ||||
|         $this->localeSwitcher->runWithLocale($user->getLocale(), function () use ($user, $notifications) { | ||||
|             $email = new TemplatedEmail(); | ||||
|             $email | ||||
|                 ->htmlTemplate('@ChillMain/Notification/email_daily_digest.md.twig') | ||||
|                 ->context([ | ||||
|                     'user' => $user, | ||||
|                     'notifications' => $notifications, | ||||
|                     'notification_count' => count($notifications), | ||||
|                 ]) | ||||
|                 ->subject($this->translator->trans('notification.Daily Notification Digest')) | ||||
|                 ->to($user->getEmail()); | ||||
|  | ||||
|             try { | ||||
|                 $this->mailer->send($email); | ||||
|                 $this->logger->info('[NotificationMailer] Daily digest email sent successfully', [ | ||||
|                     'user_email' => $user->getEmail(), | ||||
|                     'notification_count' => count($notifications), | ||||
|                     'locale' => $user->getLocale(), | ||||
|                 ]); | ||||
|             } catch (TransportExceptionInterface $e) { | ||||
|                 $this->logger->warning('[NotificationMailer] Could not send daily digest email', [ | ||||
|                     'to' => $user->getEmail(), | ||||
|                     'notification_count' => count($notifications), | ||||
|                     'error_message' => $e->getMessage(), | ||||
|                     'error_trace' => $e->getTraceAsString(), | ||||
|                 ]); | ||||
|                 throw $e; | ||||
|             } | ||||
|         }); | ||||
|         */ | ||||
|  | ||||
|         // Current implementation: | ||||
|         $email = new TemplatedEmail(); | ||||
|         $email | ||||
|             ->htmlTemplate('@ChillMain/Notification/email_daily_digest.md.twig') | ||||
|             ->htmlTemplate('@ChillMain/Notification/email_daily_digest.fr.md.twig') | ||||
|             ->context([ | ||||
|                 'user' => $user, | ||||
|                 'notifications' => $notifications, | ||||
| @@ -299,7 +222,7 @@ readonly class NotificationMailer | ||||
|  | ||||
|             $email = new TemplatedEmail(); | ||||
|             $email | ||||
|                 ->textTemplate('@ChillMain/Notification/email_non_system_notification_content_to_email.md.twig') | ||||
|                 ->textTemplate('@ChillMain/Notification/email_non_system_notification_content_to_email.fr.md.twig') | ||||
|                 ->context([ | ||||
|                     'notification' => $notification, | ||||
|                     'dest' => $emailAddress, | ||||
|   | ||||
| @@ -57,9 +57,15 @@ class EntityWorkflowRepository implements ObjectRepository | ||||
|         return (int) $qb->getQuery()->getSingleScalarResult(); | ||||
|     } | ||||
|  | ||||
|     public function countBySubscriber(User $user): int | ||||
|     /** | ||||
|      * @param bool|null $isFinal true to get only the entityWorkflow which is finalized, false to get the workflows that are not finalized, and null to ignore | ||||
|      * | ||||
|      * @throws \Doctrine\ORM\NoResultException | ||||
|      * @throws \Doctrine\ORM\NonUniqueResultException | ||||
|      */ | ||||
|     public function countBySubscriber(User $user, ?bool $isFinal = null): int | ||||
|     { | ||||
|         $qb = $this->buildQueryBySubscriber($user)->select('count(ew)'); | ||||
|         $qb = $this->buildQueryBySubscriber($user, $isFinal)->select('count(ew)'); | ||||
|  | ||||
|         return (int) $qb->getQuery()->getSingleScalarResult(); | ||||
|     } | ||||
| @@ -182,9 +188,14 @@ class EntityWorkflowRepository implements ObjectRepository | ||||
|         return $qb->getQuery()->getResult(); | ||||
|     } | ||||
|  | ||||
|     public function findBySubscriber(User $user, ?array $orderBy = null, $limit = null, $offset = null): array | ||||
|     /** | ||||
|      * @param bool|null  $isFinal true to get only the entityWorkflow which is finalized, false to get the workflows that are not finalized, and null to ignore | ||||
|      * @param mixed|null $limit | ||||
|      * @param mixed|null $offset | ||||
|      */ | ||||
|     public function findBySubscriber(User $user, ?bool $isFinal = null, ?array $orderBy = null, $limit = null, $offset = null): array | ||||
|     { | ||||
|         $qb = $this->buildQueryBySubscriber($user)->select('ew'); | ||||
|         $qb = $this->buildQueryBySubscriber($user, $isFinal)->select('ew'); | ||||
|  | ||||
|         foreach ($orderBy as $key => $sort) { | ||||
|             $qb->addOrderBy('ew.'.$key, $sort); | ||||
| @@ -312,7 +323,7 @@ class EntityWorkflowRepository implements ObjectRepository | ||||
|         return $qb; | ||||
|     } | ||||
|  | ||||
|     private function buildQueryBySubscriber(User $user): QueryBuilder | ||||
|     private function buildQueryBySubscriber(User $user, ?bool $isFinal): QueryBuilder | ||||
|     { | ||||
|         $qb = $this->repository->createQueryBuilder('ew'); | ||||
|  | ||||
| @@ -325,6 +336,14 @@ class EntityWorkflowRepository implements ObjectRepository | ||||
|  | ||||
|         $qb->setParameter('user', $user); | ||||
|  | ||||
|         if (null !== $isFinal) { | ||||
|             if ($isFinal) { | ||||
|                 $qb->andWhere(sprintf('EXISTS (SELECT 1 FROM %s step WHERE step.isFinal = true AND ew = step.entityWorkflow)', EntityWorkflowStep::class)); | ||||
|             } else { | ||||
|                 $qb->andWhere(sprintf('NOT EXISTS (SELECT 1 FROM %s step WHERE step.isFinal = true AND ew = step.entityWorkflow)', EntityWorkflowStep::class)); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return $qb; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,4 +1,7 @@ | ||||
| import { GenericDoc } from "ChillDocStoreAssets/types/generic_doc"; | ||||
| import { | ||||
|     GenericDoc, | ||||
|     isGenericDocWithStoredObject, | ||||
| } from "ChillDocStoreAssets/types/generic_doc"; | ||||
| import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types"; | ||||
| import { Person } from "../../../ChillPersonBundle/Resources/public/types"; | ||||
|  | ||||
| @@ -203,6 +206,25 @@ export interface WorkflowAttachment { | ||||
|     genericDoc: null | GenericDoc; | ||||
| } | ||||
|  | ||||
| export type AttachmentWithDocAndStored = WorkflowAttachment & { | ||||
|     genericDoc: GenericDoc & { storedObject: StoredObject }; | ||||
| }; | ||||
|  | ||||
| export function isAttachmentWithDocAndStored( | ||||
|     a: WorkflowAttachment, | ||||
| ): a is AttachmentWithDocAndStored { | ||||
|     return ( | ||||
|         isWorkflowAttachmentWithGenericDoc(a) && | ||||
|         isGenericDocWithStoredObject(a.genericDoc) | ||||
|     ); | ||||
| } | ||||
|  | ||||
| export function isWorkflowAttachmentWithGenericDoc( | ||||
|     attachment: WorkflowAttachment, | ||||
| ): attachment is WorkflowAttachment & { genericDoc: GenericDoc } { | ||||
|     return attachment.genericDoc !== null; | ||||
| } | ||||
|  | ||||
| export interface Workflow { | ||||
|     name: string; | ||||
|     text: string; | ||||
|   | ||||
| @@ -6,6 +6,7 @@ import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/gener | ||||
| import AttachmentList from "ChillMainAssets/vuejs/WorkflowAttachment/Component/AttachmentList.vue"; | ||||
| import { GenericDoc } from "ChillDocStoreAssets/types"; | ||||
| import { fetchWorkflow } from "ChillMainAssets/lib/workflow/api"; | ||||
| import { trans, WORKFLOW_ATTACHMENTS_ADD_AN_ATTACHMENT } from "translator"; | ||||
|  | ||||
| interface AppConfig { | ||||
|     workflowId: number; | ||||
| @@ -83,7 +84,7 @@ const canEditAttachement = computed<boolean>(() => { | ||||
|     <ul v-if="canEditAttachement" class="record_actions"> | ||||
|         <li> | ||||
|             <button type="button" class="btn btn-create" @click="openModal"> | ||||
|                 Ajouter une pièce jointe | ||||
|                 {{ trans(WORKFLOW_ATTACHMENTS_ADD_AN_ATTACHMENT) }} | ||||
|             </button> | ||||
|         </li> | ||||
|     </ul> | ||||
|   | ||||
| @@ -1,7 +1,14 @@ | ||||
| <script setup lang="ts"> | ||||
| import { EntityWorkflow, WorkflowAttachment } from "ChillMainAssets/types"; | ||||
| import { | ||||
|     AttachmentWithDocAndStored, | ||||
|     EntityWorkflow, | ||||
|     isAttachmentWithDocAndStored, | ||||
|     WorkflowAttachment, | ||||
| } from "ChillMainAssets/types"; | ||||
| import GenericDocItemBox from "ChillMainAssets/vuejs/WorkflowAttachment/Component/GenericDocItemBox.vue"; | ||||
| import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue"; | ||||
| import { computed } from "vue"; | ||||
| import { trans, WORKFLOW_ATTACHMENTS_NO_ATTACHMENT } from "translator"; | ||||
|  | ||||
| interface AttachmentListProps { | ||||
|     attachments: WorkflowAttachment[]; | ||||
| @@ -14,35 +21,43 @@ const emit = defineEmits<{ | ||||
| }>(); | ||||
|  | ||||
| const props = defineProps<AttachmentListProps>(); | ||||
|  | ||||
| const notNullAttachments = computed<AttachmentWithDocAndStored[]>(() => | ||||
|     props.attachments.filter( | ||||
|         (a: WorkflowAttachment): a is AttachmentWithDocAndStored => | ||||
|             isAttachmentWithDocAndStored(a), | ||||
|     ), | ||||
| ); | ||||
|  | ||||
| const canRemove = computed<boolean>((): boolean => { | ||||
|     if (null === props.workflow) { | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     return props.workflow._permissions.CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT; | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|     <p | ||||
|         v-if="props.attachments.length === 0" | ||||
|         v-if="notNullAttachments.length === 0" | ||||
|         class="chill-no-data-statement text-center" | ||||
|     > | ||||
|         Aucune pièce jointe | ||||
|         {{ trans(WORKFLOW_ATTACHMENTS_NO_ATTACHMENT) }} | ||||
|     </p> | ||||
|     <!-- TODO translate --> | ||||
|     <div else class="flex-table"> | ||||
|         <div v-for="a in props.attachments" :key="a.id" class="item-bloc"> | ||||
|     <div v-else class="flex-table"> | ||||
|         <div v-for="a in notNullAttachments" :key="a.id" class="item-bloc"> | ||||
|             <generic-doc-item-box | ||||
|                 v-if="a.genericDoc !== null" | ||||
|                 :generic-doc="a.genericDoc" | ||||
|             ></generic-doc-item-box> | ||||
|             <div class="item-row separator"> | ||||
|                 <ul class="record_actions"> | ||||
|                     <li v-if="a.genericDoc?.storedObject !== null"> | ||||
|                     <li> | ||||
|                         <document-action-buttons-group | ||||
|                             :stored-object="a.genericDoc.storedObject" | ||||
|                         ></document-action-buttons-group> | ||||
|                     </li> | ||||
|                     <li | ||||
|                         v-if=" | ||||
|                             !workflow?._permissions | ||||
|                                 .CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT | ||||
|                         " | ||||
|                     > | ||||
|                     <li v-if="canRemove"> | ||||
|                         <button | ||||
|                             type="button" | ||||
|                             class="btn btn-delete" | ||||
|   | ||||
| @@ -1,8 +1,8 @@ | ||||
| <script setup lang="ts"> | ||||
| import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc"; | ||||
| import { GenericDoc } from "ChillDocStoreAssets/types/generic_doc"; | ||||
|  | ||||
| interface GenericDocItemBoxProps { | ||||
|     genericDoc: GenericDocForAccompanyingPeriod; | ||||
|     genericDoc: GenericDoc; | ||||
| } | ||||
|  | ||||
| const props = defineProps<GenericDocItemBoxProps>(); | ||||
|   | ||||
| @@ -0,0 +1,17 @@ | ||||
| {% if chill_main_config.top_banner is defined and chill_main_config.top_banner.text is defined %} | ||||
|     {% set banner_text = '' %} | ||||
|     {% set current_locale = app.request.locale %} | ||||
|  | ||||
|     {% if chill_main_config.top_banner.text[current_locale] is defined %} | ||||
|         {% set banner_text = chill_main_config.top_banner.text[current_locale] %} | ||||
|     {% else %} | ||||
|         {% set banner_text = chill_main_config.top_banner.text|first %} | ||||
|     {% endif %} | ||||
|  | ||||
|     {% if banner_text %} | ||||
|         <div class="top-banner w-100 text-center py-2" | ||||
|              style="{% if chill_main_config.top_banner.color is defined %}color: {{ chill_main_config.top_banner.color }};{% endif %}{% if chill_main_config.top_banner.background_color is defined %}background-color: {{ chill_main_config.top_banner.background_color }};{% endif %}"> | ||||
|             {{ banner_text }} | ||||
|         </div> | ||||
|     {% endif %} | ||||
| {% endif %} | ||||
| @@ -14,7 +14,7 @@ | ||||
| 
 | ||||
| Vous pouvez visualiser la notification et y répondre ici: | ||||
| 
 | ||||
| {{ absolute_url(path('chill_main_notification_show', {'_locale': dest.locale, 'id': notification.id }, false)) }} | ||||
| {{ absolute_url(path('chill_main_notification_show', {'_locale': 'fr', 'id': notification.id }, false)) }} | ||||
| 
 | ||||
| -- | ||||
| Le logiciel Chill | ||||
| @@ -13,7 +13,7 @@ Commentaire: | ||||
| 
 | ||||
| Vous pouvez visualiser la notification et y répondre ici: | ||||
| 
 | ||||
| {{ absolute_url(path('chill_main_notification_show', {'_locale': dest.locale, 'id': comment.notification.id }, false)) }} | ||||
| {{ absolute_url(path('chill_main_notification_show', {'_locale': 'fr', 'id': comment.notification.id }, false)) }} | ||||
| 
 | ||||
| -- | ||||
| Le logiciel Chill | ||||
| @@ -71,8 +71,6 @@ | ||||
|                 </tbody> | ||||
|             </table> | ||||
|  | ||||
|             {{ form_row(form.locale) }} | ||||
|  | ||||
|             <ul class="record_actions"> | ||||
|                 <li> | ||||
|                     <button type="submit" class="btn btn-save">{{ 'Save'|trans }}</button> | ||||
|   | ||||
| @@ -1,16 +1,16 @@ | ||||
| {{ dest.label }}, | ||||
|  | ||||
| {{ 'workflow.notification.content.new_step_reached'|trans({'%workflow%': workflow.text}) }} | ||||
| Un suivi "{{ workflow.text }}" a atteint une nouvelle étape: {{ workflow.text }} | ||||
|  | ||||
| {{ 'workflow.notification.content.workflow_title'|trans({'%title%': title}) }} | ||||
| Titre du workflow: "{{ title }}". | ||||
| {% if is_dest %} | ||||
|  | ||||
| {{ 'workflow.notification.content.validation_needed'|trans }} | ||||
| Vous êtes invités à valider cette étape au plus tôt. | ||||
| {% endif %} | ||||
|  | ||||
|  | ||||
| {{ 'workflow.notification.content.view_workflow'|trans }} | ||||
| Vous pouvez visualiser le workflow sur cette page: | ||||
|  | ||||
| {{ absolute_url(path('chill_main_workflow_show', {'id': entity_workflow.id, '_locale': dest.locale|default('fr')})) }} | ||||
| {{ absolute_url(path('chill_main_workflow_show', {'id': entity_workflow.id, '_locale': 'fr'})) }} | ||||
|  | ||||
| {{ 'workflow.notification.content.regards'|trans }} | ||||
| Cordialement, | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| {%- if is_dest -%} | ||||
| {{ 'workflow.notification.title.attention_needed'|trans({'%workflow%': workflow.text, '%title%': title}) }} | ||||
| Un suivi {{ workflow.text }} demande votre attention: {{ title }} | ||||
| {%- else -%} | ||||
| {{ 'workflow.notification.title.new_step'|trans({'%workflow%': workflow.text, '%place%': place.text, '%title%': title}) }} | ||||
| Un suivi {{ workflow.text }} a atteint une nouvelle étape: {{ place.text }}: {{ title }} | ||||
| {%- endif -%} | ||||
|   | ||||
| @@ -26,6 +26,10 @@ | ||||
| </head> | ||||
|  | ||||
| <body> | ||||
|     {% if chill_main_config.top_banner is defined and chill_main_config.top_banner.visible is true %} | ||||
|         {{ include('@ChillMain/Layout/_top_banner.html.twig') }} | ||||
|     {% endif %} | ||||
|  | ||||
|     {% if responsive_debug is defined and responsive_debug == 1 %} | ||||
|         {{ include('@ChillMain/Layout/_debug.html.twig') }} | ||||
|     {% endif %} | ||||
|   | ||||
| @@ -16,13 +16,11 @@ use Symfony\Bridge\Twig\Mime\TemplatedEmail; | ||||
| use Symfony\Component\Mailer\MailerInterface; | ||||
| use Symfony\Component\Routing\Generator\UrlGeneratorInterface; | ||||
|  | ||||
| // use Symfony\Component\Translation\LocaleSwitcher; | ||||
|  | ||||
| class RecoverPasswordHelper | ||||
| { | ||||
|     final public const RECOVER_PASSWORD_ROUTE = 'password_recover'; | ||||
|  | ||||
|     public function __construct(private readonly TokenManager $tokenManager, private readonly UrlGeneratorInterface $urlGenerator, private readonly MailerInterface $mailer/* , private readonly LocaleSwitcher $localeSwitcher */) {} | ||||
|     public function __construct(private readonly TokenManager $tokenManager, private readonly UrlGeneratorInterface $urlGenerator, private readonly MailerInterface $mailer) {} | ||||
|  | ||||
|     /** | ||||
|      * @param bool  $absolute | ||||
| @@ -55,24 +53,6 @@ class RecoverPasswordHelper | ||||
|             throw new \UnexpectedValueException('No emaail associated to the user'); | ||||
|         } | ||||
|  | ||||
|         // Implementation with LocaleSwitcher (commented out - to be activated after migration to sf7.2): | ||||
|         /* | ||||
|         $this->localeSwitcher->runWithLocale($user->getLocale(), function () use ($user, $expiration, $template, $templateParameters, $emailSubject, $additionalUrlParameters) { | ||||
|             $email = (new TemplatedEmail()) | ||||
|                 ->subject($emailSubject) | ||||
|                 ->to($user->getEmail()) | ||||
|                 ->textTemplate($template) | ||||
|                 ->context([ | ||||
|                     'user' => $user, | ||||
|                     'url' => $this->generateUrl($user, $expiration, true, $additionalUrlParameters), | ||||
|                     ...$templateParameters, | ||||
|                 ]); | ||||
|  | ||||
|             $this->mailer->send($email); | ||||
|         }); | ||||
|         */ | ||||
|  | ||||
|         // Current implementation: | ||||
|         $email = (new TemplatedEmail()) | ||||
|             ->subject($emailSubject) | ||||
|             ->to($user->getEmail()) | ||||
|   | ||||
| @@ -64,7 +64,7 @@ class AddressReferenceBEFromBestAddress | ||||
|  | ||||
|         $uncompressedStream = gzopen($tmpname, 'r'); | ||||
|  | ||||
|         $csv = Reader::createFromStream($uncompressedStream); | ||||
|         $csv = Reader::from($uncompressedStream); | ||||
|         $csv->setDelimiter(','); | ||||
|         $csv->setHeaderOffset(0); | ||||
|  | ||||
|   | ||||
| @@ -287,7 +287,7 @@ final class AddressReferenceBaseImporter | ||||
|  | ||||
|             $filename = sprintf('%s-%s.csv', (new \DateTimeImmutable())->format('Ymd-His'), uniqid()); | ||||
|             $path = Path::normalize(sprintf('%s%s%s', sys_get_temp_dir(), DIRECTORY_SEPARATOR, $filename)); | ||||
|             $writer = Writer::createFromPath($path, 'w+'); | ||||
|             $writer = Writer::from($path, 'w+'); | ||||
|             // insert headers | ||||
|             $writer->insertOne([ | ||||
|                 'postalcode', | ||||
|   | ||||
| @@ -53,7 +53,7 @@ class AddressReferenceFromBAN | ||||
|         // re-open it to read it | ||||
|         $csvDecompressed = gzopen($path, 'r'); | ||||
|  | ||||
|         $csv = Reader::createFromStream($csvDecompressed); | ||||
|         $csv = Reader::from($csvDecompressed); | ||||
|         $csv->setDelimiter(';')->setHeaderOffset(0); | ||||
|         $stmt = new Statement(); | ||||
|         $stmt = $stmt->process($csv, [ | ||||
|   | ||||
| @@ -41,7 +41,7 @@ class AddressReferenceFromBano | ||||
|  | ||||
|         fseek($file, 0); | ||||
|  | ||||
|         $csv = Reader::createFromStream($file); | ||||
|         $csv = Reader::from($file); | ||||
|         $csv->setDelimiter(','); | ||||
|         $stmt = new Statement(); | ||||
|         $stmt = $stmt->process($csv, [ | ||||
|   | ||||
| @@ -39,7 +39,7 @@ class AddressReferenceLU | ||||
|  | ||||
|         fseek($file, 0); | ||||
|  | ||||
|         $csv = Reader::createFromStream($file); | ||||
|         $csv = Reader::from($file); | ||||
|         $csv->setDelimiter(';'); | ||||
|         $csv->setHeaderOffset(0); | ||||
|  | ||||
|   | ||||
| @@ -43,7 +43,7 @@ class PostalCodeBEFromBestAddress | ||||
|  | ||||
|         $uncompressedStream = gzopen($tmpname, 'r'); | ||||
|  | ||||
|         $csv = Reader::createFromStream($uncompressedStream); | ||||
|         $csv = Reader::from($uncompressedStream); | ||||
|         $csv->setDelimiter(','); | ||||
|         $csv->setHeaderOffset(0); | ||||
|  | ||||
|   | ||||
| @@ -47,7 +47,7 @@ class PostalCodeFRFromOpenData | ||||
|  | ||||
|         fseek($tmpfile, 0); | ||||
|  | ||||
|         $csv = Reader::createFromStream($tmpfile); | ||||
|         $csv = Reader::from($tmpfile); | ||||
|         $csv->setDelimiter(','); | ||||
|         $csv->setHeaderOffset(0); | ||||
|  | ||||
|   | ||||
| @@ -18,7 +18,7 @@ use Symfony\Component\Notifier\Event\SentMessageEvent; | ||||
| final readonly class SentMessageEventSubscriber implements EventSubscriberInterface | ||||
| { | ||||
|     public function __construct( | ||||
|         private LoggerInterface $logger, | ||||
|         private LoggerInterface $notifierLogger, // will be send to "notifierLogger" if it exists | ||||
|     ) {} | ||||
|  | ||||
|     public static function getSubscribedEvents() | ||||
| @@ -33,9 +33,9 @@ final readonly class SentMessageEventSubscriber implements EventSubscriberInterf | ||||
|         $message = $event->getMessage(); | ||||
|  | ||||
|         if (null === $message->getMessageId()) { | ||||
|             $this->logger->info('[sms] a sms message did not had any id after sending.', ['validReceiversI' => $message->getOriginalMessage()->getRecipientId()]); | ||||
|             $this->notifierLogger->info('[sms] a sms message did not had any id after sending.', ['validReceiversI' => $message->getOriginalMessage()->getRecipientId()]); | ||||
|         } else { | ||||
|             $this->logger->warning('[sms] a sms was sent', ['validReceiversI' => $message->getOriginalMessage()->getRecipientId(), 'idsI' => $message->getMessageId()]); | ||||
|             $this->notifierLogger->warning('[sms] a sms was sent', ['validReceiversI' => $message->getOriginalMessage()->getRecipientId(), 'idsI' => $message->getMessageId()]); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,98 @@ | ||||
| <?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\MainBundle\Tests\DependencyInjection; | ||||
|  | ||||
| use Chill\MainBundle\DependencyInjection\Configuration; | ||||
| use PHPUnit\Framework\TestCase; | ||||
| use Symfony\Component\Config\Definition\Processor; | ||||
| use Symfony\Component\DependencyInjection\ContainerBuilder; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
|  * | ||||
|  * @coversNothing | ||||
|  */ | ||||
| class ConfigurationTest extends TestCase | ||||
| { | ||||
|     public function testTopBannerConfiguration(): void | ||||
|     { | ||||
|         $containerBuilder = new ContainerBuilder(); | ||||
|         $configuration = new Configuration([], $containerBuilder); | ||||
|         $processor = new Processor(); | ||||
|  | ||||
|         // Test with top_banner configuration | ||||
|         $config = [ | ||||
|             'chill_main' => [ | ||||
|                 'top_banner' => [ | ||||
|                     'text' => [ | ||||
|                         'fr' => 'Vous travaillez actuellement avec la version de pré-production de Chill.', | ||||
|                         'nl' => 'Je werkte momenteel in de pré-productie versie van Chill.', | ||||
|                     ], | ||||
|                     'color' => 'white', | ||||
|                     'background-color' => 'red', | ||||
|                 ], | ||||
|             ], | ||||
|         ]; | ||||
|  | ||||
|         $processedConfig = $processor->processConfiguration($configuration, $config); | ||||
|  | ||||
|         self::assertArrayHasKey('top_banner', $processedConfig); | ||||
|         self::assertArrayHasKey('text', $processedConfig['top_banner']); | ||||
|         self::assertArrayHasKey('fr', $processedConfig['top_banner']['text']); | ||||
|         self::assertArrayHasKey('nl', $processedConfig['top_banner']['text']); | ||||
|         self::assertSame('white', $processedConfig['top_banner']['color']); | ||||
|         self::assertSame('red', $processedConfig['top_banner']['background_color']); | ||||
|     } | ||||
|  | ||||
|     public function testTopBannerConfigurationOptional(): void | ||||
|     { | ||||
|         $containerBuilder = new ContainerBuilder(); | ||||
|         $configuration = new Configuration([], $containerBuilder); | ||||
|         $processor = new Processor(); | ||||
|  | ||||
|         // Test without top_banner configuration | ||||
|         $config = [ | ||||
|             'chill_main' => [], | ||||
|         ]; | ||||
|  | ||||
|         $processedConfig = $processor->processConfiguration($configuration, $config); | ||||
|  | ||||
|         // top_banner should not be present when not configured | ||||
|         self::assertArrayNotHasKey('top_banner', $processedConfig); | ||||
|     } | ||||
|  | ||||
|     public function testTopBannerWithMinimalConfiguration(): void | ||||
|     { | ||||
|         $containerBuilder = new ContainerBuilder(); | ||||
|         $configuration = new Configuration([], $containerBuilder); | ||||
|         $processor = new Processor(); | ||||
|  | ||||
|         // Test with minimal top_banner configuration (only text) | ||||
|         $config = [ | ||||
|             'chill_main' => [ | ||||
|                 'top_banner' => [ | ||||
|                     'text' => [ | ||||
|                         'fr' => 'Test message', | ||||
|                     ], | ||||
|                 ], | ||||
|             ], | ||||
|         ]; | ||||
|  | ||||
|         $processedConfig = $processor->processConfiguration($configuration, $config); | ||||
|  | ||||
|         self::assertArrayHasKey('top_banner', $processedConfig); | ||||
|         self::assertArrayHasKey('text', $processedConfig['top_banner']); | ||||
|         self::assertSame('Test message', $processedConfig['top_banner']['text']['fr']); | ||||
|         self::assertNull($processedConfig['top_banner']['color']); | ||||
|         self::assertNull($processedConfig['top_banner']['background_color']); | ||||
|     } | ||||
| } | ||||
| @@ -37,10 +37,5 @@ class DailyNotificationDigestCronJobFunctionalTest extends KernelTestCase | ||||
|         $actual = $this->dailyNotificationDigestCronjob->run([]); | ||||
|  | ||||
|         self::assertArrayHasKey('last_execution', $actual); | ||||
|         self::assertInstanceOf( | ||||
|             \DateTimeImmutable::class, | ||||
|             \DateTimeImmutable::createFromFormat('Y-m-d-H:i:s.u e', $actual['last_execution']), | ||||
|             'test that the string can be converted to a date' | ||||
|         ); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -12,16 +12,21 @@ declare(strict_types=1); | ||||
| namespace Chill\MainBundle\Tests\Notification\Email; | ||||
|  | ||||
| use Chill\MainBundle\Notification\Email\DailyNotificationDigestCronjob; | ||||
| use Chill\MainBundle\Notification\Email\NotificationEmailMessages\ScheduleDailyNotificationDigestMessage; | ||||
| use Doctrine\DBAL\Connection; | ||||
| use Doctrine\DBAL\Result; | ||||
| use Doctrine\DBAL\Statement; | ||||
| use PHPUnit\Framework\TestCase; | ||||
| use Psr\Log\LoggerInterface; | ||||
| use Symfony\Component\Clock\ClockInterface; | ||||
| use Symfony\Component\Clock\MockClock; | ||||
| use Symfony\Component\Messenger\Envelope; | ||||
| use Symfony\Component\Messenger\MessageBusInterface; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
|  * | ||||
|  * @coversNothing | ||||
|  * @covers \DailyNotificationDigestCronjob | ||||
|  */ | ||||
| class DailyNotificationDigestCronJobTest extends TestCase | ||||
| { | ||||
| @@ -30,6 +35,7 @@ class DailyNotificationDigestCronJobTest extends TestCase | ||||
|     private MessageBusInterface $messageBus; | ||||
|     private LoggerInterface $logger; | ||||
|     private DailyNotificationDigestCronjob $cronjob; | ||||
|     private \DateTimeImmutable $firstNow; | ||||
|  | ||||
|     protected function setUp(): void | ||||
|     { | ||||
| @@ -38,6 +44,8 @@ class DailyNotificationDigestCronJobTest extends TestCase | ||||
|         $this->messageBus = $this->createMock(MessageBusInterface::class); | ||||
|         $this->logger = $this->createMock(LoggerInterface::class); | ||||
|  | ||||
|         $this->firstNow = new \DateTimeImmutable('2024-01-02T07:15:00+00:00'); | ||||
|  | ||||
|         $this->cronjob = new DailyNotificationDigestCronjob( | ||||
|             $this->clock, | ||||
|             $this->connection, | ||||
| @@ -78,4 +86,129 @@ class DailyNotificationDigestCronJobTest extends TestCase | ||||
|             'hour 23 - should not run' => [23, false], | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|     public function testRunFirstExecutionReturnsStateAndDispatches(): array | ||||
|     { | ||||
|         // Use MockClock for deterministic time | ||||
|         $firstNow = $this->firstNow; | ||||
|         $clock = new MockClock($firstNow); | ||||
|  | ||||
|         // Mock DBAL statement/result | ||||
|         $statement = $this->createMock(Statement::class); | ||||
|         $result = $this->createMock(Result::class); | ||||
|  | ||||
|         $this->connection->method('prepare')->willReturn($statement); | ||||
|         $statement->method('bindValue')->willReturnSelf(); | ||||
|         $statement->method('executeQuery')->willReturn($result); | ||||
|  | ||||
|         $rows = [ | ||||
|             ['user_id' => 10], | ||||
|             ['user_id' => 42], | ||||
|         ]; | ||||
|         $result->method('fetchAllAssociative')->willReturn($rows); | ||||
|  | ||||
|         $dispatched = []; | ||||
|         $this->messageBus->method('dispatch')->willReturnCallback(function ($message) use (&$dispatched) { | ||||
|             $dispatched[] = $message; | ||||
|  | ||||
|             return new Envelope($message); | ||||
|         }); | ||||
|  | ||||
|         $cron = new DailyNotificationDigestCronjob($clock, $this->connection, $this->messageBus, $this->logger); | ||||
|         $state = $cron->run([]); | ||||
|  | ||||
|         // Assert dispatch count and message contents | ||||
|         self::assertCount(2, $dispatched); | ||||
|         $expectedLast = $firstNow->sub(new \DateInterval('P1D')); | ||||
|         foreach ($dispatched as $i => $msg) { | ||||
|             self::assertInstanceOf(ScheduleDailyNotificationDigestMessage::class, $msg); | ||||
|             self::assertTrue(in_array($msg->getUserId(), [10, 42], true)); | ||||
|             self::assertEquals($firstNow, $msg->getCurrentDateTime(), 'compare the current date'); | ||||
|             self::assertEquals($expectedLast, $msg->getLastExecutionDateTime(), 'compare the last execution date'); | ||||
|         } | ||||
|  | ||||
|         // Assert returned state | ||||
|         self::assertIsArray($state); | ||||
|         self::assertArrayHasKey('last_execution', $state); | ||||
|         self::assertSame($firstNow->format(\DateTimeInterface::ATOM), $state['last_execution']); | ||||
|  | ||||
|         return $state; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @depends testRunFirstExecutionReturnsStateAndDispatches | ||||
|      */ | ||||
|     public function testRunSecondExecutionUsesPreviousState(array $previousState): void | ||||
|     { | ||||
|         $firstNow = $this->firstNow; | ||||
|         $secondNow = $firstNow->add(new \DateInterval('P1D')); | ||||
|         $clock = new MockClock($secondNow); | ||||
|  | ||||
|         // Mock DBAL for a single user this time | ||||
|         $statement = $this->createMock(Statement::class); | ||||
|         $result = $this->createMock(Result::class); | ||||
|  | ||||
|         $this->connection->method('prepare')->willReturn($statement); | ||||
|         $statement->method('bindValue')->willReturnSelf(); | ||||
|         $statement->method('executeQuery')->willReturn($result); | ||||
|  | ||||
|         $rows = [ | ||||
|             ['user_id' => 7], | ||||
|         ]; | ||||
|         $result->method('fetchAllAssociative')->willReturn($rows); | ||||
|  | ||||
|         $captured = []; | ||||
|         $this->messageBus->method('dispatch')->willReturnCallback(function ($message) use (&$captured) { | ||||
|             $captured[] = $message; | ||||
|  | ||||
|             return new Envelope($message); | ||||
|         }); | ||||
|  | ||||
|         $cron = new DailyNotificationDigestCronjob($clock, $this->connection, $this->messageBus, $this->logger); | ||||
|         $cron->run($previousState); | ||||
|  | ||||
|         self::assertCount(1, $captured); | ||||
|         $msg = $captured[0]; | ||||
|         self::assertInstanceOf(ScheduleDailyNotificationDigestMessage::class, $msg); | ||||
|         self::assertEquals(7, $msg->getUserId()); | ||||
|         self::assertEquals($secondNow, $msg->getCurrentDateTime(), 'compare the current date'); | ||||
|         self::assertEquals($firstNow, $msg->getLastExecutionDateTime(), 'compare the last execution date'); | ||||
|     } | ||||
|  | ||||
|     public function testRunWithInvalidExecutionState(): void | ||||
|     { | ||||
|         $firstNow = new \DateTimeImmutable('2025-10-14T10:30:00 Europe/Brussels'); | ||||
|         $previousExpected = $firstNow->sub(new \DateInterval('P1D')); | ||||
|         $clock = new MockClock($firstNow); | ||||
|  | ||||
|         // Mock DBAL for a single user this time | ||||
|         $statement = $this->createMock(Statement::class); | ||||
|         $result = $this->createMock(Result::class); | ||||
|  | ||||
|         $this->connection->method('prepare')->willReturn($statement); | ||||
|         $statement->method('bindValue')->willReturnSelf(); | ||||
|         $statement->method('executeQuery')->willReturn($result); | ||||
|  | ||||
|         $rows = [ | ||||
|             ['user_id' => 7], | ||||
|         ]; | ||||
|         $result->method('fetchAllAssociative')->willReturn($rows); | ||||
|  | ||||
|         $captured = []; | ||||
|         $this->messageBus->method('dispatch')->willReturnCallback(function ($message) use (&$captured) { | ||||
|             $captured[] = $message; | ||||
|  | ||||
|             return new Envelope($message); | ||||
|         }); | ||||
|  | ||||
|         $cron = new DailyNotificationDigestCronjob($clock, $this->connection, $this->messageBus, $this->logger); | ||||
|         $cron->run(['last_execution' => 'invalid data']); | ||||
|  | ||||
|         self::assertCount(1, $captured); | ||||
|         $msg = $captured[0]; | ||||
|         self::assertInstanceOf(ScheduleDailyNotificationDigestMessage::class, $msg); | ||||
|         self::assertEquals(7, $msg->getUserId()); | ||||
|         self::assertEquals($firstNow, $msg->getCurrentDateTime(), 'compare the current date'); | ||||
|         self::assertEquals($previousExpected, $msg->getLastExecutionDateTime(), 'compare the last execution date'); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -22,8 +22,6 @@ use Symfony\Component\Mailer\MailerInterface; | ||||
| use Symfony\Component\Workflow\Event\Event; | ||||
| use Symfony\Component\Workflow\Registry; | ||||
|  | ||||
| // use Symfony\Component\Translation\LocaleSwitcher; | ||||
|  | ||||
| final readonly class NotificationToUserGroupsOnTransition implements EventSubscriberInterface | ||||
| { | ||||
|     public function __construct( | ||||
| @@ -33,7 +31,6 @@ final readonly class NotificationToUserGroupsOnTransition implements EventSubscr | ||||
|         private MailerInterface $mailer, | ||||
|         private EntityManagerInterface $entityManager, | ||||
|         private EntityWorkflowManager $entityWorkflowManager, | ||||
|         // private LocaleSwitcher $localeSwitcher, | ||||
|     ) {} | ||||
|  | ||||
|     public static function getSubscribedEvents(): array | ||||
| @@ -90,24 +87,6 @@ final readonly class NotificationToUserGroupsOnTransition implements EventSubscr | ||||
|                 'title' => $title, | ||||
|             ]; | ||||
|  | ||||
|             // Implementation with LocaleSwitcher (commented out - to be activated after migration to sf7.2): | ||||
|             // Note: This sends emails to user groups, not individual users, so locale switching may use default locale | ||||
|             /* | ||||
|             $this->localeSwitcher->runWithLocale('fr', function () use ($context, $userGroup) { | ||||
|                 $email = new TemplatedEmail(); | ||||
|                 $email | ||||
|                     ->htmlTemplate('@ChillMain/Workflow/workflow_notification_on_transition_completed_content_to_user_group.fr.txt.twig') | ||||
|                     ->context($context) | ||||
|                     ->subject( | ||||
|                         $this->engine->render('@ChillMain/Workflow/workflow_notification_on_transition_completed_title.fr.txt.twig', $context) | ||||
|                     ) | ||||
|                     ->to($userGroup->getEmail()); | ||||
|  | ||||
|                 $this->mailer->send($email); | ||||
|             }); | ||||
|             */ | ||||
|  | ||||
|             // Current implementation: | ||||
|             $email = new TemplatedEmail(); | ||||
|             $email | ||||
|                 ->htmlTemplate('@ChillMain/Workflow/workflow_notification_on_transition_completed_content_to_user_group.fr.txt.twig') | ||||
|   | ||||
| @@ -1,33 +0,0 @@ | ||||
| <?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\Migrations\Main; | ||||
|  | ||||
| use Doctrine\DBAL\Schema\Schema; | ||||
| use Doctrine\Migrations\AbstractMigration; | ||||
|  | ||||
| final class Version20251022140718 extends AbstractMigration | ||||
| { | ||||
|     public function getDescription(): string | ||||
|     { | ||||
|         return 'Add locale field to users table for user language preferences'; | ||||
|     } | ||||
|  | ||||
|     public function up(Schema $schema): void | ||||
|     { | ||||
|         $this->addSql('ALTER TABLE users ADD locale VARCHAR(5) DEFAULT \'fr\' NOT NULL'); | ||||
|     } | ||||
|  | ||||
|     public function down(Schema $schema): void | ||||
|     { | ||||
|         $this->addSql('ALTER TABLE users DROP locale'); | ||||
|     } | ||||
| } | ||||
| @@ -127,6 +127,20 @@ duration: | ||||
|             few {# minutes} | ||||
|             other {# minutes} | ||||
|         } | ||||
|     hour: >- | ||||
|         {h, plural, | ||||
|             =0 {Aucune durée} | ||||
|             one {# heure} | ||||
|             few {# heures} | ||||
|             other {# heures} | ||||
|         } | ||||
|     day: >- | ||||
|         {d, plural, | ||||
|             =0 {Aucune durée} | ||||
|             one {# jour} | ||||
|             few {# jours} | ||||
|             other {# jours} | ||||
|         } | ||||
|  | ||||
| filter_order: | ||||
|     by_date: | ||||
|   | ||||
| @@ -56,13 +56,6 @@ user: | ||||
|         no job: Pas de métier assigné | ||||
|         no scope: Pas de cercle assigné | ||||
|         notification_preferences: Préférences pour mes notifications | ||||
|     locale: | ||||
|         label: Langue de communication | ||||
|         help: Langue utilisée pour les notifications par email et autres communications. | ||||
|         placeholder: Choisissez une langue | ||||
|         choice: | ||||
|             french: Français | ||||
|             dutch: Nederlands | ||||
|  | ||||
| user_group: | ||||
|     inactive: Inactif | ||||
| @@ -675,19 +668,10 @@ workflow: | ||||
|         reject_are_you_sure: Êtes-vous sûr de vouloir rejeter la signature de %signer% | ||||
|         waiting_for: En attente de modification de l'état de la signature | ||||
|  | ||||
|     notification: | ||||
|         title: | ||||
|             attention_needed: "Attention requise dans le workflow %workflow% pour %title%" | ||||
|             new_step: "Nouvelle étape dans le workflow %workflow% (%place%) pour %title%" | ||||
|         content: | ||||
|             new_step_reached: "Une nouvelle étape a été atteinte dans le workflow %workflow%." | ||||
|             workflow_title: "Titre du workflow : %title%" | ||||
|             validation_needed: "Votre validation est nécessaire pour cette étape." | ||||
|             view_workflow: "Vous pouvez consulter le workflow ici :" | ||||
|             regards: "Cordialement," | ||||
|  | ||||
|     attachments: | ||||
|         title: Pièces jointes | ||||
|         no_attachment: Aucune pièce jointe | ||||
|         Add_an_attachment: Ajouter une pièce jointe | ||||
|  | ||||
|     wait: | ||||
|         title: En attente de traitement | ||||
| @@ -763,22 +747,7 @@ notification: | ||||
|         greeting: "Bonjour %user%" | ||||
|         intro: "Vous avez reçu %notification_count% nouvelle(s) notification(s)." | ||||
|         view_notification: "Vous pouvez visualiser la notification et y répondre ici:" | ||||
|         signature: "L'équipe Chill" | ||||
|  | ||||
| daily_notifications: "{1}Vous avez 1 nouvelle notification.|]1,Inf[Vous avez %notification_count% nouvelles notifications." | ||||
|  | ||||
| docgen: | ||||
|     failure_email: | ||||
|         "The generation of a document failed": "La génération d'un document a échoué" | ||||
|         "The generation of the document %template_name% failed": "La génération du document %template_name% a échoué" | ||||
|         "Forward this email to your administrator for solving": "Transmettez cet email à votre administrateur pour résolution" | ||||
|         "References": "Références" | ||||
|         "The following errors were encoutered": "Les erreurs suivantes ont été rencontrées" | ||||
|     data_dump_email: | ||||
|         subject: "Export de données disponible" | ||||
|         "Dear": "Cher utilisateur," | ||||
|         "data_dump_ready_and_attached": "Votre export de données est prêt et joint à cet email." | ||||
|         "filename": "Nom du fichier : %filename%" | ||||
|         signature: "Le logiciel Chill" | ||||
|  | ||||
| CHILL_MAIN_COMPOSE_EXPORT: Exécuter des exports et les sauvegarder | ||||
| CHILL_MAIN_GENERATE_SAVED_EXPORT: Exécuter et modifier des exports préalablement sauvegardés | ||||
|   | ||||
| @@ -46,14 +46,6 @@ No title: Geen titel | ||||
| User profile: Mijn gebruikersprofiel | ||||
| Phonenumber successfully updated!: Telefoonnummer bijgewerkt! | ||||
|  | ||||
| user: | ||||
|     locale: | ||||
|         label: Communicatietaal | ||||
|         help: Taal gebruikt voor e-mailmeldingen en andere communicatie. | ||||
|         placeholder: Kies een taal | ||||
|         choice: | ||||
|             french: Français | ||||
|             dutch: Nederlands | ||||
|  | ||||
| Edit: Bewerken | ||||
| Update: Updaten | ||||
| @@ -431,17 +423,6 @@ workflow: | ||||
|     For: Pour | ||||
|     Cc: Cc | ||||
|  | ||||
|     notification: | ||||
|         title: | ||||
|             attention_needed: "Aandacht vereist in workflow %workflow% voor %title%" | ||||
|             new_step: "Nieuwe stap in workflow %workflow% (%place%) voor %title%" | ||||
|         content: | ||||
|             new_step_reached: "Een nieuwe stap is bereikt in workflow %workflow%." | ||||
|             workflow_title: "Workflow titel: %title%" | ||||
|             validation_needed: "Uw validatie is nodig voor deze stap." | ||||
|             view_workflow: "U kunt de workflow hier bekijken:" | ||||
|             regards: "Met vriendelijke groeten," | ||||
|  | ||||
|  | ||||
| Subscribe final: Recevoir une notification à l'étape finale | ||||
| Subscribe all steps: Recevoir une notification à chaque étape | ||||
|   | ||||
| @@ -49,7 +49,7 @@ final class ImportSocialWorkMetadata extends Command | ||||
|         $filepath = $input->getOption('filepath'); | ||||
|  | ||||
|         try { | ||||
|             $csv = Reader::createFromPath($filepath); | ||||
|             $csv = Reader::from($filepath); | ||||
|         } catch (\Throwable $e) { | ||||
|             throw new \Exception('Error while loading CSV.', 0, $e); | ||||
|         } | ||||
|   | ||||
| @@ -29,7 +29,7 @@ class LoadSocialWorkMetadata extends Fixture implements OrderedFixtureInterface | ||||
|     public function load(ObjectManager $manager): void | ||||
|     { | ||||
|         try { | ||||
|             $csv = Reader::createFromPath(__DIR__.'/data/social_work_metadata.csv'); | ||||
|             $csv = Reader::from(__DIR__.'/data/social_work_metadata.csv'); | ||||
|         } catch (\Throwable $e) { | ||||
|             throw new \Exception('Error while loading CSV.', 0, $e); | ||||
|         } | ||||
|   | ||||
| @@ -60,43 +60,124 @@ import { | ||||
|     EVALUATION_DOCUMENT_MOVE_SUCCESS, | ||||
| } from "translator"; | ||||
| import { useToast } from "vue-toast-notification"; | ||||
| import { buildLinkCreate as buildLinkCreateNotification } from "ChillMainAssets/lib/entity-notification/api"; | ||||
|  | ||||
| const props = defineProps(["evaluation", "docAnchorId"]); | ||||
| const store = useStore(); | ||||
|  | ||||
| const $toast = useToast(); | ||||
|  | ||||
| const timeSpentChoices = [ | ||||
|     { text: "1 minute", value: 60 }, | ||||
|     { text: "2 minutes", value: 120 }, | ||||
|     { text: "3 minutes", value: 180 }, | ||||
|     { text: "4 minutes", value: 240 }, | ||||
|     { text: "5 minutes", value: 300 }, | ||||
|     { text: "10 minutes", value: 600 }, | ||||
|     { text: "15 minutes", value: 900 }, | ||||
|     { text: "20 minutes", value: 1200 }, | ||||
|     { text: "25 minutes", value: 1500 }, | ||||
|     { text: "30 minutes", value: 1800 }, | ||||
|     { text: "45 minutes", value: 2700 }, | ||||
|     { text: "1 hour", value: 3600 }, | ||||
|     { text: "1 hour 15 minutes", value: 4500 }, | ||||
|     { text: "1 hour 30 minutes", value: 5400 }, | ||||
|     { text: "1 hour 45 minutes", value: 6300 }, | ||||
|     { text: "2 hours", value: 7200 }, | ||||
|     { text: "2 hours 30 minutes", value: 9000 }, | ||||
|     { text: "3 hours", value: 10800 }, | ||||
|     { text: "3 hours 30 minutes", value: 12600 }, | ||||
|     { text: "4 hours", value: 14400 }, | ||||
|     { text: "4 hours 30 minutes", value: 16200 }, | ||||
|     { text: "5 hours", value: 18000 }, | ||||
|     { text: "5 hours 30 minutes", value: 19800 }, | ||||
|     { text: "6 hours", value: 21600 }, | ||||
|     { text: "6 hours 30 minutes", value: 23400 }, | ||||
|     { text: "7 hours", value: 25200 }, | ||||
|     { text: "7 hours 30 minutes", value: 27000 }, | ||||
|     { text: "8 hours", value: 28800 }, | ||||
| const timeSpentValues = [ | ||||
|     60, | ||||
|     120, | ||||
|     180, | ||||
|     240, | ||||
|     300, | ||||
|     600, | ||||
|     900, | ||||
|     1200, | ||||
|     1500, | ||||
|     1800, | ||||
|     2700, | ||||
|     3600, | ||||
|     4500, | ||||
|     5400, | ||||
|     6300, | ||||
|     7200, | ||||
|     9000, | ||||
|     10800, | ||||
|     12600, | ||||
|     14400, | ||||
|     16200, | ||||
|     18000, | ||||
|     19800, | ||||
|     21600, | ||||
|     23400, | ||||
|     25200, | ||||
|     27000, | ||||
|     28800, | ||||
|     43200, | ||||
|     57600, | ||||
|     72000, | ||||
|     86400, | ||||
|     100800, | ||||
|     115200, | ||||
|     129600, | ||||
|     144000, // goes from 1 minute to 40 hours | ||||
| ]; | ||||
|  | ||||
| const formatDuration = (seconds, locale) => { | ||||
|     const currentLocale = locale || navigator.language || "fr"; | ||||
|  | ||||
|     const totalHours = Math.floor(seconds / 3600); | ||||
|     const remainingMinutes = Math.floor((seconds % 3600) / 60); | ||||
|  | ||||
|     if (totalHours >= 8) { | ||||
|         const days = Math.floor(totalHours / 8); | ||||
|         const remainingHours = totalHours % 8; | ||||
|  | ||||
|         const parts = []; | ||||
|  | ||||
|         if (days > 0) { | ||||
|             parts.push( | ||||
|                 new Intl.NumberFormat(currentLocale, { | ||||
|                     style: "unit", | ||||
|                     unit: "day", | ||||
|                     unitDisplay: "long", | ||||
|                 }).format(days), | ||||
|             ); | ||||
|         } | ||||
|  | ||||
|         if (remainingHours > 0) { | ||||
|             parts.push( | ||||
|                 new Intl.NumberFormat(currentLocale, { | ||||
|                     style: "unit", | ||||
|                     unit: "hour", | ||||
|                     unitDisplay: "long", | ||||
|                 }).format(remainingHours), | ||||
|             ); | ||||
|         } | ||||
|  | ||||
|         return parts.join(" "); | ||||
|     } | ||||
|  | ||||
|     // For less than 8 hours, use hour and minute format | ||||
|     const parts = []; | ||||
|  | ||||
|     if (totalHours > 0) { | ||||
|         parts.push( | ||||
|             new Intl.NumberFormat(currentLocale, { | ||||
|                 style: "unit", | ||||
|                 unit: "hour", | ||||
|                 unitDisplay: "long", | ||||
|             }).format(totalHours), | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     if (remainingMinutes > 0) { | ||||
|         parts.push( | ||||
|             new Intl.NumberFormat(currentLocale, { | ||||
|                 style: "unit", | ||||
|                 unit: "minute", | ||||
|                 unitDisplay: "long", | ||||
|             }).format(remainingMinutes), | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     console.log(parts); | ||||
|     console.log(parts.join(" ")); | ||||
|  | ||||
|     return parts.join(" "); | ||||
| }; | ||||
|  | ||||
| const timeSpentChoices = computed(() => { | ||||
|     const locale = "fr"; | ||||
|     return timeSpentValues.map((value) => ({ | ||||
|         text: formatDuration(value, locale), | ||||
|         value: parseInt(value), | ||||
|     })); | ||||
| }); | ||||
|  | ||||
| const startDate = computed({ | ||||
|     get() { | ||||
|         return props.evaluation.startDate; | ||||
| @@ -193,7 +274,7 @@ function updateWarningInterval(value) { | ||||
| } | ||||
|  | ||||
| function updateTimeSpent(value) { | ||||
|     timeSpent.value = value; | ||||
|     timeSpent.value = parseInt(value); | ||||
| } | ||||
|  | ||||
| function updateComment(value) { | ||||
|   | ||||
| @@ -216,9 +216,29 @@ | ||||
|  | ||||
|                                     {% if e.timeSpent is not null and e.timeSpent > 0 %} | ||||
|                                         <li> | ||||
|                                             {% set minutes = (e.timeSpent / 60) %} | ||||
|                                             <span | ||||
|                                                 class="item-key">{{ 'accompanying_course_work.timeSpent'|trans ~ ' : ' }}</span> {{ 'duration.minute'|trans({ '{m}' : minutes }) }} | ||||
|                                             {% set totalHours = (e.timeSpent / 3600)|round(0, 'floor') %} | ||||
|                                             {% set totalMinutes = ((e.timeSpent % 3600) / 60)|round(0, 'floor') %} | ||||
|  | ||||
|                                             <span class="item-key">{{ 'accompanying_course_work.timeSpent'|trans ~ ' : ' }}</span> | ||||
|  | ||||
|                                             {% if totalHours >= 8 %} | ||||
|                                                 {% set days = (totalHours / 8)|round(0, 'floor') %} | ||||
|                                                 {% set remainingHours = totalHours % 8 %} | ||||
|  | ||||
|                                                 {% if days > 0 %} | ||||
|                                                     {{ 'duration.day'|trans({ '{d}' : days }) }} | ||||
|                                                 {% endif %} | ||||
|                                                 {% if remainingHours > 0 %} | ||||
|                                                     {{ 'duration.hour'|trans({ '{h}' : remainingHours }) }} | ||||
|                                                 {% endif %} | ||||
|                                             {% else %} | ||||
|                                                 {% if totalHours > 0 %} | ||||
|                                                     {{ 'duration.hour'|trans({ '{h}' : totalHours }) }} | ||||
|                                                 {% endif %} | ||||
|                                                 {% if totalMinutes > 0 %} | ||||
|                                                     {{ 'duration.minute'|trans({ '{m}' : totalMinutes }) }} | ||||
|                                                 {% endif %} | ||||
|                                             {% endif %} | ||||
|                                         </li> | ||||
|                                     {% elseif displayContent is defined and displayContent == 'long' %} | ||||
|                                         <li> | ||||
|   | ||||
| @@ -38,7 +38,7 @@ final readonly class SocialActionCSVExportService | ||||
|             array_keys($this->formatRow(new SocialAction())) | ||||
|         ); | ||||
|  | ||||
|         $csv = Writer::createFromPath('php://temp', 'w+'); | ||||
|         $csv = Writer::from('php://temp', 'w+'); | ||||
|         $csv->insertOne($headers); | ||||
|  | ||||
|         foreach ($actions as $action) { | ||||
|   | ||||
| @@ -36,7 +36,7 @@ readonly class SocialIssueCSVExportService | ||||
|     public function generateCsv(array $issues): Writer | ||||
|     { | ||||
|         // CSV headers | ||||
|         $csv = Writer::createFromPath('php://temp', 'r+'); | ||||
|         $csv = Writer::from('php://temp', 'r+'); | ||||
|         $csv->insertOne( | ||||
|             array_map( | ||||
|                 fn (string $e) => $this->translator->trans($e), | ||||
|   | ||||
| @@ -52,7 +52,7 @@ class ThirdpartyCSVExportController extends AbstractController | ||||
|             fwrite($output, "\xEF\xBB\xBF"); | ||||
|  | ||||
|             // Create CSV writer | ||||
|             $csv = Writer::createFromStream($output); | ||||
|             $csv = Writer::from($output); | ||||
|  | ||||
|             // Write header row | ||||
|             $header = array_map( | ||||
|   | ||||
		Reference in New Issue
	
	Block a user