diff --git a/.changes/unreleased/DX-20251027-150053.yaml b/.changes/unreleased/DX-20251027-150053.yaml deleted file mode 100644 index 880894bd4..000000000 --- a/.changes/unreleased/DX-20251027-150053.yaml +++ /dev/null @@ -1,7 +0,0 @@ -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 diff --git a/.changes/unreleased/Fixed-20251119-133324.yaml b/.changes/unreleased/Fixed-20251119-133324.yaml new file mode 100644 index 000000000..408863dfe --- /dev/null +++ b/.changes/unreleased/Fixed-20251119-133324.yaml @@ -0,0 +1,6 @@ +kind: Fixed +body: Insert name of file as the document title when uploading +time: 2025-11-19T13:33:24.778116633+01:00 +custom: + Issue: "" + SchemaChange: No schema change diff --git a/.changes/unreleased/Fixed-20251119-134802.yaml b/.changes/unreleased/Fixed-20251119-134802.yaml new file mode 100644 index 000000000..8c9250c40 --- /dev/null +++ b/.changes/unreleased/Fixed-20251119-134802.yaml @@ -0,0 +1,6 @@ +kind: Fixed +body: Add missing path paramater 'id' for editing multiple participations +time: 2025-11-19T13:48:02.078949572+01:00 +custom: + Issue: "" + SchemaChange: No schema change diff --git a/.changes/v4.7.0.md b/.changes/v4.7.0.md new file mode 100644 index 000000000..48e6095fc --- /dev/null +++ b/.changes/v4.7.0.md @@ -0,0 +1,21 @@ +## v4.7.0 - 2025-11-10 +### Feature +* ([#385](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/385)) Create invitation list in user menu +* ([#404](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/404)) Add columns for comments linked to an activity in the activity list export +### Fixed +* ([#451](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/451)) Fix: display also social actions linked to parents of the selected social issue +* ([#453](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/453)) Fix: export actions and their results in csv even when action does not have any goals attached to it. +* Fix the possibility to delete a workflow + + **Schema Change**: Drop or rename table or columns, or enforce new constraint that must be manually fixed +* ([#457](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/457)) Fix the fusion of thirdparty properties that are located in another schema than public for TO_ONE relations + add extra loop for MANY_TO_MANY relations where thirdparty is the source instead of the target +* ([#428](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/428)) Fix suggestion of referrer when creating notification for accompanyingPeriodWorkDocument +### DX +* Send notifications log to dedicated channel, if it exists + +### UX +* ([#425](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/425)) Change the terms 'cercle' and 'centre' to 'service', and 'territoire' respectively. +* ([#542](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/542)) Improve the ux for selecting whether user wants to be notified of the final step of a workflow or all steps +* Expand timeSpent choices for evaluation document and translate them to user locale or fallback 'fr' +* ([#455](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/455)) Change the order of display for results and objectives in the social work/action form +* Wrap text when it is too long within badges diff --git a/.changes/v4.8.0.md b/.changes/v4.8.0.md new file mode 100644 index 000000000..48583b6a2 --- /dev/null +++ b/.changes/v4.8.0.md @@ -0,0 +1,9 @@ +## v4.8.0 - 2025-11-17 +### Feature +* ([#461](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/461)) Make a calendar item on the 'mes rendez-vous' page clickable. Clicking will navigate to the edit page of the calendar item. +### Fixed +* ([#463](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/463)) Display calendar items for which an invite was accepted on the mes rendez-vous page +* Improve accessibility on login page + +### UX +* ([#449](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/449)) Remove the label if there is only one scope and no scope picking field is displayed. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4573e5ba2..2cc2b5aa5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,38 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), and is generated by [Changie](https://github.com/miniscruff/changie). +## v4.8.0 - 2025-11-17 +### Feature +* ([#461](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/461)) Make a calendar item on the 'mes rendez-vous' page clickable. Clicking will navigate to the edit page of the calendar item. +### Fixed +* ([#463](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/463)) Display calendar items for which an invite was accepted on the mes rendez-vous page +* Improve accessibility on login page + +### UX +* ([#449](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/449)) Remove the label if there is only one scope and no scope picking field is displayed. + +## v4.7.0 - 2025-11-10 +### Feature +* ([#385](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/385)) Create invitation list in user menu +* ([#404](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/404)) Add columns for comments linked to an activity in the activity list export +### Fixed +* ([#451](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/451)) Fix: display also social actions linked to parents of the selected social issue +* ([#453](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/453)) Fix: export actions and their results in csv even when action does not have any goals attached to it. +* Fix the possibility to delete a workflow + + **Schema Change**: Drop or rename table or columns, or enforce new constraint that must be manually fixed +* ([#457](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/457)) Fix the fusion of thirdparty properties that are located in another schema than public for TO_ONE relations + add extra loop for MANY_TO_MANY relations where thirdparty is the source instead of the target +* ([#428](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/428)) Fix suggestion of referrer when creating notification for accompanyingPeriodWorkDocument +### DX +* Send notifications log to dedicated channel, if it exists + +### UX +* ([#425](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/425)) Change the terms 'cercle' and 'centre' to 'service', and 'territoire' respectively. +* ([#542](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/542)) Improve the ux for selecting whether user wants to be notified of the final step of a workflow or all steps +* Expand timeSpent choices for evaluation document and translate them to user locale or fallback 'fr' +* ([#455](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/455)) Change the order of display for results and objectives in the social work/action form +* Wrap text when it is too long within badges + ## v4.6.1 - 2025-10-27 ### Fixed * Fix export case where no 'reason' is picked within the PersonHavingActivityBetweenDateFilter.php diff --git a/composer.json b/composer.json index 6740b8538..c1e235670 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,7 @@ "ext-openssl": "*", "ext-redis": "*", "ext-zlib": "*", - "champs-libres/wopi-bundle": "dev-master#1be045ee95310d2037683859ecefdbf3a10f7be6 as 0.4.x-dev", + "champs-libres/wopi-bundle": "dev-symfony-v5@dev", "champs-libres/wopi-lib": "dev-master@dev", "doctrine/data-fixtures": "^1.8", "doctrine/doctrine-bundle": "^2.1", diff --git a/config/bundles.php b/config/bundles.php index ec11bc0b6..72b5e22f5 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -2,7 +2,6 @@ return [ Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true], - loophp\PsrHttpMessageBridgeBundle\PsrHttpMessageBridgeBundle::class => ['all' => true], ChampsLibres\WopiBundle\WopiBundle::class => ['all' => true], Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], @@ -37,4 +36,5 @@ return [ Chill\WopiBundle\ChillWopiBundle::class => ['all' => true], Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], Symfony\UX\Translator\UxTranslatorBundle::class => ['all' => true], + loophp\PsrHttpMessageBridgeBundle\PsrHttpMessageBridgeBundle::class => ['all' => true], ]; diff --git a/config/packages/chill.yaml b/config/packages/chill.yaml index 0f5aa08b7..26f91feb5 100644 --- a/config/packages/chill.yaml +++ b/config/packages/chill.yaml @@ -1,5 +1,5 @@ chill_main: - available_languages: [ '%env(resolve:LOCALE)%', 'en' ] + available_languages: [ '%env(resolve:LOCALE)%', 'en', 'nl' ] available_countries: ['BE', 'FR'] top_banner: visible: false diff --git a/config/packages/chill_aside_activity.yaml b/config/packages/chill_aside_activity.yaml new file mode 100644 index 000000000..eb5c2d70e --- /dev/null +++ b/config/packages/chill_aside_activity.yaml @@ -0,0 +1,2 @@ +chill_aside_activity: + show_concerned_persons_count: hidden diff --git a/docs/source/_static/bundles/docStore/doc_store_classes.puml b/docs/source/_static/bundles/docStore/doc_store_classes.puml index 431d268c0..f0c61f44c 100644 --- a/docs/source/_static/bundles/docStore/doc_store_classes.puml +++ b/docs/source/_static/bundles/docStore/doc_store_classes.puml @@ -23,8 +23,8 @@ class "Document" { - text description - ArrayCollection_DocumentCategory categories - varchar_150 content #link to openstack - - Center center - - Cercle cercle + - Territoire territoire + - Service service - User user - DateTime date # Creation date } diff --git a/docs/source/development/database-principles.rst b/docs/source/development/database-principles.rst index 455354934..3b528ae22 100644 --- a/docs/source/development/database-principles.rst +++ b/docs/source/development/database-principles.rst @@ -38,7 +38,7 @@ Certaines données sont historisées: - les référents d'un parcours; - les statuts d'un parcours; -- la liaison entre les centres et les usagers; +- la liaison entre les territoires et les usagers; - etc. Dans ces cas-là, Chill crée généralement deux colonnes, qui sont habituellement nommées :code:`startDate` et :code:`endDate`. Lorsque la colonne :code:`endDate` est à :code:`NULL`, cela signifie que la période n'est pas "fermée". La colonne :code:`startDate` n'est pas nullable. diff --git a/docs/source/development/database/table_list.csv b/docs/source/development/database/table_list.csv index fe688318d..be72a52ab 100644 --- a/docs/source/development/database/table_list.csv +++ b/docs/source/development/database/table_list.csv @@ -1,6 +1,6 @@ order,table_schema,table_name,commentaire 1,chill_3party,party_category,Catégorie de tiers -2,chill_3party,party_center,Association entre les tiers et les centres (déprécié) +2,chill_3party,party_center,Association entre les tiers et les territoires (déprécié) 3,chill_3party,party_profession,Profession du tiers (déprécié) 4,chill_3party,third_party,Tiers 5,chill_3party,thirdparty_category,association tiers - catégories @@ -54,7 +54,7 @@ order,table_schema,table_name,commentaire 53,public,activitytpresence,Présence aux échanges 54,public,activitytype,Types d'échanges 55,public,activitytypecategory,Catégories de types d'échanges -56,public,centers,"Centres (territoires, agences, etc.)" +56,public,centers,"Territoires (territoires, agences, etc.)" 57,public,chill_activity_activity_chill_person_socialaction, 58,public,chill_activity_activity_chill_person_socialissue 59,public,chill_docgen_template,Gabarits de documents @@ -111,7 +111,7 @@ order,table_schema,table_name,commentaire 110,public,chill_person_marital_status,Etats civils 111,public,chill_person_not_duplicate, 112,public,chill_person_person,Usagers -113,public,chill_person_person_center_history,Historique des centres d'un usagers +113,public,chill_person_person_center_history,Historique des territoires d'un usagers 114,public,chill_person_persons_to_addresses,Déprécié 115,public,chill_person_phone,Numéros d etéléphone supplémentaires d'un usager 116,public,chill_person_relations,Types de relations de filiation @@ -142,7 +142,7 @@ order,table_schema,table_name,commentaire 141,public,permission_groups 142,public,permissionsgroup_rolescope 143,public,persons_spoken_languages -144,public,regroupment,Regroupement de centres +144,public,regroupment,Regroupement de territoires 145,public,regroupment_center, 146,public,role_scopes, 147,public,scopes,Services diff --git a/src/Bundle/ChillActivityBundle/Export/Export/ListActivityHelper.php b/src/Bundle/ChillActivityBundle/Export/Export/ListActivityHelper.php index dd817e7ab..c3a4428aa 100644 --- a/src/Bundle/ChillActivityBundle/Export/Export/ListActivityHelper.php +++ b/src/Bundle/ChillActivityBundle/Export/Export/ListActivityHelper.php @@ -66,6 +66,9 @@ class ListActivityHelper ->leftJoin('activity.location', 'location') ->addSelect('location.name AS locationName') ->addSelect('activity.sentReceived') + ->addSelect('activity.comment.comment AS commentText') + ->addSelect('activity.comment.date AS commentDate') + ->addSelect('JSON_BUILD_OBJECT(\'uid\', activity.comment.userId, \'d\', activity.comment.date) AS commentUser') ->addSelect('JSON_BUILD_OBJECT(\'uid\', IDENTITY(activity.createdBy), \'d\', activity.createdAt) AS createdBy') ->addSelect('activity.createdAt') ->addSelect('JSON_BUILD_OBJECT(\'uid\', IDENTITY(activity.updatedBy), \'d\', activity.updatedAt) AS updatedBy') @@ -87,6 +90,8 @@ class ListActivityHelper 'createdAt', 'updatedAt' => $this->dateTimeHelper->getLabel($key), 'createdBy', 'updatedBy' => $this->userHelper->getLabel($key, $values, $key), 'date' => $this->dateTimeHelper->getLabel(self::MSG_KEY.$key), + 'commentDate' => $this->dateTimeHelper->getLabel(self::MSG_KEY.'comment_date'), + 'commentUser' => $this->userHelper->getLabel($key, $values, self::MSG_KEY.'comment_user'), 'attendeeName' => function ($value) { if ('_header' === $value) { return 'Attendee'; @@ -176,6 +181,9 @@ class ListActivityHelper 'usersNames', 'thirdPartiesIds', 'thirdPartiesNames', + 'commentText', + 'commentDate', + 'commentUser', 'createdBy', 'createdAt', 'updatedBy', diff --git a/src/Bundle/ChillActivityBundle/Form/ActivityType.php b/src/Bundle/ChillActivityBundle/Form/ActivityType.php index 28c607acd..3b8f53303 100644 --- a/src/Bundle/ChillActivityBundle/Form/ActivityType.php +++ b/src/Bundle/ChillActivityBundle/Form/ActivityType.php @@ -88,8 +88,8 @@ class ActivityType extends AbstractType if (null !== $options['data']->getPerson()) { $builder->add('scope', ScopePickerType::class, [ - 'center' => $options['center'], 'role' => ActivityVoter::CREATE === (string) $options['role'] ? ActivityVoter::CREATE_PERSON : (string) $options['role'], + 'center' => $options['center'], 'required' => true, ]); } diff --git a/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/SocialIssuesAcc/CheckSocialAction.vue b/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/SocialIssuesAcc/CheckSocialAction.vue index 8172b2b6f..503977dbd 100644 --- a/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/SocialIssuesAcc/CheckSocialAction.vue +++ b/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/SocialIssuesAcc/CheckSocialAction.vue @@ -43,11 +43,23 @@ export default { span.badge { @include badge_social($social-action-color); font-size: 95%; + white-space: normal; + word-wrap: break-word; + word-break: break-word; + display: inline-block; + max-width: 100%; margin-bottom: 5px; margin-right: 1em; - max-width: 100%; /* Adjust as needed */ - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + text-align: left; + line-height: 1.2em; + + &::before { + position: absolute; + left: 11px; + top: 0; + margin: 0 0.3em 0 -0.75em; + } + position: relative; + padding-left: 1.5em; } diff --git a/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/SocialIssuesAcc/CheckSocialIssue.vue b/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/SocialIssuesAcc/CheckSocialIssue.vue index 9dbedf2ea..ef1c82a35 100644 --- a/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/SocialIssuesAcc/CheckSocialIssue.vue +++ b/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/SocialIssuesAcc/CheckSocialIssue.vue @@ -43,7 +43,22 @@ export default { span.badge { @include badge_social($social-issue-color); font-size: 95%; + white-space: normal; + word-wrap: break-word; + word-break: break-word; + display: inline-block; + max-width: 100%; margin-bottom: 5px; margin-right: 1em; + text-align: left; + + &::before { + position: absolute; + left: 11px; + top: 0; + margin: 0 0.3em 0 -0.75em; + } + position: relative; + padding-left: 1.5em; } diff --git a/src/Bundle/ChillActivityBundle/translations/messages.fr.yml b/src/Bundle/ChillActivityBundle/translations/messages.fr.yml index 883310df9..63c61d8e4 100644 --- a/src/Bundle/ChillActivityBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillActivityBundle/translations/messages.fr.yml @@ -10,7 +10,7 @@ Attendee: Présence de l'usager attendee: présence de l'usager list_reasons: liste des sujets user_username: nom de l'utilisateur -circle_name: nom du cercle +circle_name: nom du service Remark: Commentaire No comments: Aucun commentaire Add a new activity: Ajouter une nouvel échange @@ -20,7 +20,7 @@ not present: absent Delete: Supprimer Update: Mettre à jour Update activity: Modifier l'échange -Scope: Cercle +Scope: Service Activity data: Données de l'échange Activity location: Localisation de l'échange No reason associated: Aucun sujet @@ -398,13 +398,15 @@ export: sent received: Envoyé ou reçu emergency: Urgence accompanying course id: Identifiant du parcours - course circles: Cercles du parcours + course circles: Services du parcours travelTime: Durée de déplacement durationTime: Durée id: Identifiant List activities linked to an accompanying course: Liste les échanges liés à un parcours en fonction de différents filtres. List activity linked to a course: Liste des échanges liés à un parcours - + commentText: Commentaire + comment_date: Date de la dernière édition du commentaire + comment_user: Dernière édition par filter: activity: diff --git a/src/Bundle/ChillAsideActivityBundle/src/DependencyInjection/ChillAsideActivityExtension.php b/src/Bundle/ChillAsideActivityBundle/src/DependencyInjection/ChillAsideActivityExtension.php index 056f29ba1..6fa123146 100644 --- a/src/Bundle/ChillAsideActivityBundle/src/DependencyInjection/ChillAsideActivityExtension.php +++ b/src/Bundle/ChillAsideActivityBundle/src/DependencyInjection/ChillAsideActivityExtension.php @@ -25,6 +25,7 @@ final class ChillAsideActivityExtension extends Extension implements PrependExte $config = $this->processConfiguration($configuration, $configs); $container->setParameter('chill_aside_activity.form.time_duration', $config['form']['time_duration']); + $container->setParameter('chill_aside_activity.show_concerned_persons_count', 'visible' === $config['show_concerned_persons_count']); $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../config')); $loader->load('services.yaml'); @@ -38,6 +39,24 @@ final class ChillAsideActivityExtension extends Extension implements PrependExte { $this->prependRoute($container); $this->prependCruds($container); + $this->prependTwigConfig($container); + } + + protected function prependTwigConfig(ContainerBuilder $container) + { + // Get the configuration for this bundle + $chillAsideActivityConfig = $container->getExtensionConfig($this->getAlias()); + $config = $this->processConfiguration($this->getConfiguration($chillAsideActivityConfig, $container), $chillAsideActivityConfig); + + // Add configuration to twig globals + $twigConfig = [ + 'globals' => [ + 'chill_aside_activity_config' => [ + 'show_concerned_persons_count' => 'visible' === $config['show_concerned_persons_count'], + ], + ], + ]; + $container->prependExtensionConfig('twig', $twigConfig); } protected function prependCruds(ContainerBuilder $container) diff --git a/src/Bundle/ChillAsideActivityBundle/src/DependencyInjection/Configuration.php b/src/Bundle/ChillAsideActivityBundle/src/DependencyInjection/Configuration.php index 241a545a8..66f3d3c86 100644 --- a/src/Bundle/ChillAsideActivityBundle/src/DependencyInjection/Configuration.php +++ b/src/Bundle/ChillAsideActivityBundle/src/DependencyInjection/Configuration.php @@ -141,6 +141,12 @@ class Configuration implements ConfigurationInterface ->end() ->end() ->end() + ->end() + ->enumNode('show_concerned_persons_count') + ->values(['hidden', 'visible']) + ->defaultValue('hidden') + ->info('Show the concerned persons count field in aside activity forms and views') + ->end() ->end(); return $treeBuilder; diff --git a/src/Bundle/ChillAsideActivityBundle/src/Entity/AsideActivity.php b/src/Bundle/ChillAsideActivityBundle/src/Entity/AsideActivity.php index b7671c61a..0082deaf9 100644 --- a/src/Bundle/ChillAsideActivityBundle/src/Entity/AsideActivity.php +++ b/src/Bundle/ChillAsideActivityBundle/src/Entity/AsideActivity.php @@ -62,6 +62,10 @@ class AsideActivity implements TrackCreationInterface, TrackUpdateInterface #[ORM\ManyToOne(targetEntity: User::class)] private User $updatedBy; + #[Assert\GreaterThanOrEqual(0)] + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: true)] + private ?int $concernedPersonsCount = 0; + public function getAgent(): ?User { return $this->agent; @@ -186,4 +190,16 @@ class AsideActivity implements TrackCreationInterface, TrackUpdateInterface return $this; } + + public function getConcernedPersonsCount(): ?int + { + return $this->concernedPersonsCount; + } + + public function setConcernedPersonsCount(?int $concernedPersonsCount): self + { + $this->concernedPersonsCount = $concernedPersonsCount; + + return $this; + } } diff --git a/src/Bundle/ChillAsideActivityBundle/src/Export/Aggregator/ByConcernedPersonsCountAggregator.php b/src/Bundle/ChillAsideActivityBundle/src/Export/Aggregator/ByConcernedPersonsCountAggregator.php new file mode 100644 index 000000000..444c49269 --- /dev/null +++ b/src/Bundle/ChillAsideActivityBundle/src/Export/Aggregator/ByConcernedPersonsCountAggregator.php @@ -0,0 +1,86 @@ +addSelect('aside.concernedPersonsCount AS by_concerned_persons_count_aggregator') + ->addGroupBy('by_concerned_persons_count_aggregator'); + } + + public function applyOn(): string + { + return Declarations::ASIDE_ACTIVITY_TYPE; + } + + public function buildForm(FormBuilderInterface $builder): void + { + // No form needed + } + + public function getNormalizationVersion(): int + { + return 1; + } + + public function normalizeFormData(array $formData): array + { + return []; + } + + public function denormalizeFormData(array $formData, int $fromVersion): array + { + return []; + } + + public function getFormDefaultData(): array + { + return []; + } + + public function getLabels($key, array $values, $data): callable + { + return function ($value): string { + if ('_header' === $value) { + return 'export.aggregator.Concerned persons count'; + } + + if (null === $value) { + return 'export.aggregator.No concerned persons count specified'; + } + + return (string) $value; + }; + } + + public function getQueryKeys($data): array + { + return ['by_concerned_persons_count_aggregator']; + } + + public function getTitle(): string + { + return 'export.aggregator.Group by concerned persons count'; + } +} diff --git a/src/Bundle/ChillAsideActivityBundle/src/Export/Export/SumConcernedPersonsCountAsideActivity.php b/src/Bundle/ChillAsideActivityBundle/src/Export/Export/SumConcernedPersonsCountAsideActivity.php new file mode 100644 index 000000000..ab22302c2 --- /dev/null +++ b/src/Bundle/ChillAsideActivityBundle/src/Export/Export/SumConcernedPersonsCountAsideActivity.php @@ -0,0 +1,116 @@ +getTitle(); + + return static fn ($value) => $labels[$value]; + } + + public function getQueryKeys($data): array + { + return ['export_sum_concerned_persons_count']; + } + + public function getResult($query, $data, \Chill\MainBundle\Export\ExportGenerationContext $context): array + { + return $query->getQuery()->getResult(Query::HYDRATE_SCALAR); + } + + public function getTitle(): string + { + return 'export.Sum concerned persons count for aside activities'; + } + + public function getType(): string + { + return Declarations::ASIDE_ACTIVITY_TYPE; + } + + public function initiateQuery(array $requiredModifiers, array $acl, array $data, \Chill\MainBundle\Export\ExportGenerationContext $context): \Doctrine\ORM\QueryBuilder + { + $qb = $this->repository->createQueryBuilder('aside'); + + $qb->select('SUM(COALESCE(aside.concernedPersonsCount, 0)) as export_sum_concerned_persons_count'); + + return $qb; + } + + public function requiredRole(): string + { + return AsideActivityVoter::STATS; + } + + public function supportsModifiers(): array + { + return [ + Declarations::ASIDE_ACTIVITY_TYPE, + ]; + } +} diff --git a/src/Bundle/ChillAsideActivityBundle/src/Form/AsideActivityFormType.php b/src/Bundle/ChillAsideActivityBundle/src/Form/AsideActivityFormType.php index d6fcb821c..ea04fdc42 100644 --- a/src/Bundle/ChillAsideActivityBundle/src/Form/AsideActivityFormType.php +++ b/src/Bundle/ChillAsideActivityBundle/src/Form/AsideActivityFormType.php @@ -21,6 +21,7 @@ use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeToTimestampTransformer; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\IntegerType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvents; @@ -29,11 +30,13 @@ use Symfony\Component\OptionsResolver\OptionsResolver; final class AsideActivityFormType extends AbstractType { private readonly array $timeChoices; + private readonly bool $showConcernedPersonsCount; public function __construct( ParameterBagInterface $parameterBag, ) { $this->timeChoices = $parameterBag->get('chill_aside_activity.form.time_duration'); + $this->showConcernedPersonsCount = $parameterBag->get('chill_aside_activity.show_concerned_persons_count'); } public function buildForm(FormBuilderInterface $builder, array $options) @@ -76,6 +79,16 @@ final class AsideActivityFormType extends AbstractType ->add('location', PickUserLocationType::class) ; + if ($this->showConcernedPersonsCount) { + $builder->add('concernedPersonsCount', IntegerType::class, [ + 'label' => 'Concerned persons count', + 'required' => false, + 'attr' => [ + 'min' => 0, + ], + ]); + } + foreach (['duration'] as $fieldName) { $builder->get($fieldName) ->addModelTransformer($durationTimeTransformer); diff --git a/src/Bundle/ChillAsideActivityBundle/src/Resources/views/asideActivity/index.html.twig b/src/Bundle/ChillAsideActivityBundle/src/Resources/views/asideActivity/index.html.twig index 0a8648749..0d06e5ba9 100644 --- a/src/Bundle/ChillAsideActivityBundle/src/Resources/views/asideActivity/index.html.twig +++ b/src/Bundle/ChillAsideActivityBundle/src/Resources/views/asideActivity/index.html.twig @@ -42,6 +42,11 @@ {%- if entity.location.name is defined -%}
{{ entity.location.name }}
{%- endif -%} + + {%- if entity.concernedPersonsCount > 0 -%} +
{{ entity.concernedPersonsCount }}
+ {%- endif -%} +
diff --git a/src/Bundle/ChillAsideActivityBundle/src/Resources/views/asideActivity/view.html.twig b/src/Bundle/ChillAsideActivityBundle/src/Resources/views/asideActivity/view.html.twig index ef6faa9c6..330a2ab13 100644 --- a/src/Bundle/ChillAsideActivityBundle/src/Resources/views/asideActivity/view.html.twig +++ b/src/Bundle/ChillAsideActivityBundle/src/Resources/views/asideActivity/view.html.twig @@ -38,6 +38,11 @@
{{ 'Duration'|trans }}
{{ entity.duration|date('H:i') }}
+ {% if chill_aside_activity_config.show_concerned_persons_count == 'visible' %} +
{{ 'Concerned persons count'|trans }}
+
{{ entity.concernedPersonsCount }}
+ {% endif %} +
{{ 'Remark'|trans }}
{%- if entity.note is empty -%}
diff --git a/src/Bundle/ChillAsideActivityBundle/src/Tests/Export/Aggregator/ByConcernedPersonsCountAggregatorTest.php b/src/Bundle/ChillAsideActivityBundle/src/Tests/Export/Aggregator/ByConcernedPersonsCountAggregatorTest.php new file mode 100644 index 000000000..a1d133d8c --- /dev/null +++ b/src/Bundle/ChillAsideActivityBundle/src/Tests/Export/Aggregator/ByConcernedPersonsCountAggregatorTest.php @@ -0,0 +1,49 @@ +get(EntityManagerInterface::class); + + return [ + $em->createQueryBuilder() + ->select('count(aside.id)') + ->from(AsideActivity::class, 'aside'), + ]; + } +} diff --git a/src/Bundle/ChillAsideActivityBundle/src/Tests/Export/Export/SumConcernedPersonsCountAsideActivityTest.php b/src/Bundle/ChillAsideActivityBundle/src/Tests/Export/Export/SumConcernedPersonsCountAsideActivityTest.php new file mode 100644 index 000000000..499986ac1 --- /dev/null +++ b/src/Bundle/ChillAsideActivityBundle/src/Tests/Export/Export/SumConcernedPersonsCountAsideActivityTest.php @@ -0,0 +1,50 @@ +get(AsideActivityRepository::class); + + yield new SumConcernedPersonsCountAsideActivity($repository); + } + + public static function getFormData(): array + { + return [ + [], + ]; + } + + public static function getModifiersCombination(): array + { + return [ + ['aside_activity'], + ]; + } +} diff --git a/src/Bundle/ChillAsideActivityBundle/src/config/services/export.yaml b/src/Bundle/ChillAsideActivityBundle/src/config/services/export.yaml index 40cb120da..efba7a8af 100644 --- a/src/Bundle/ChillAsideActivityBundle/src/config/services/export.yaml +++ b/src/Bundle/ChillAsideActivityBundle/src/config/services/export.yaml @@ -20,6 +20,10 @@ services: tags: - { name: chill.export, alias: 'avg_aside_activity_duration' } + Chill\AsideActivityBundle\Export\Export\SumConcernedPersonsCountAsideActivity: + tags: + - { name: chill.export, alias: 'sum_aside_activity_concerned_persons_count' } + ## Filters chill.aside_activity.export.date_filter: class: Chill\AsideActivityBundle\Export\Filter\ByDateFilter @@ -70,3 +74,7 @@ services: Chill\AsideActivityBundle\Export\Aggregator\ByLocationAggregator: tags: - { name: chill.export_aggregator, alias: 'aside_activity_location_aggregator' } + + Chill\AsideActivityBundle\Export\Aggregator\ByConcernedPersonsCountAggregator: + tags: + - { name: chill.export_aggregator, alias: 'aside_activity_concerned_persons_count_aggregator' } diff --git a/src/Bundle/ChillAsideActivityBundle/src/migrations/Version20251006113048.php b/src/Bundle/ChillAsideActivityBundle/src/migrations/Version20251006113048.php new file mode 100644 index 000000000..8ea1dbf4c --- /dev/null +++ b/src/Bundle/ChillAsideActivityBundle/src/migrations/Version20251006113048.php @@ -0,0 +1,33 @@ +addSql('ALTER TABLE chill_asideactivity.asideactivity ADD concernedPersonsCount INT DEFAULT 0'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_asideactivity.AsideActivity DROP concernedPersonsCount'); + } +} diff --git a/src/Bundle/ChillAsideActivityBundle/src/translations/messages.fr.yml b/src/Bundle/ChillAsideActivityBundle/src/translations/messages.fr.yml index 7d3c7a20e..1b0e39e1b 100644 --- a/src/Bundle/ChillAsideActivityBundle/src/translations/messages.fr.yml +++ b/src/Bundle/ChillAsideActivityBundle/src/translations/messages.fr.yml @@ -27,6 +27,7 @@ Emergency: Urgent by: "Par " location: Lieu Asideactivity location: Localisation de l'activité +Concerned persons count: Nombre d'usager concernés # Crud crud: @@ -177,7 +178,7 @@ export: agent_id: Utilisateur creator_id: Créateur main_scope: Service principal de l'utilisateur - main_center: Centre principal de l'utilisateur + main_center: Territoire principal de l'utilisateur aside_activity_type: Catégorie d'activité annexe date: Date duration: Durée @@ -190,6 +191,7 @@ export: Count aside activities by various parameters.: Compte le nombre d'activités annexes selon divers critères Average aside activities duration: Durée moyenne des activités annexes Sum aside activities duration: Durée des activités annexes + Sum concerned persons count for aside activities: Nombre d'usager concernés par les activités annexes filter: Filter by aside activity date: Filtrer les activités annexes par date Filter by aside activity type: Filtrer les activités annexes par type d'activité @@ -210,6 +212,8 @@ export: 'Filtered by aside activity location: only %location%': "Filtré par localisation: uniquement %location%" aggregator: Group by aside activity type: Grouper les activités annexes par type d'activité + Group by concerned persons count: Grouper les activités annexes par nombre d'usagers conernés + Concerned persons count: Nombre d'usagers concernés Aside activity type: Type d'activité annexe by_user_job: Aggregate by user job: Grouper les activités annexes par métier des utilisateurs diff --git a/src/Bundle/ChillCalendarBundle/Controller/CalendarAPIController.php b/src/Bundle/ChillCalendarBundle/Controller/CalendarAPIController.php index eb5812b4d..d3919d3a3 100644 --- a/src/Bundle/ChillCalendarBundle/Controller/CalendarAPIController.php +++ b/src/Bundle/ChillCalendarBundle/Controller/CalendarAPIController.php @@ -12,6 +12,7 @@ declare(strict_types=1); namespace Chill\CalendarBundle\Controller; use Chill\CalendarBundle\Repository\CalendarRepository; +use Chill\CalendarBundle\Repository\InviteRepository; use Chill\MainBundle\CRUD\Controller\ApiController; use Chill\MainBundle\Entity\User; use Chill\MainBundle\Serializer\Model\Collection; @@ -23,7 +24,10 @@ use Symfony\Component\Routing\Annotation\Route; class CalendarAPIController extends ApiController { - public function __construct(private readonly CalendarRepository $calendarRepository) {} + public function __construct( + private readonly CalendarRepository $calendarRepository, + private readonly InviteRepository $inviteRepository, + ) {} #[Route(path: '/api/1.0/calendar/calendar/by-user/{id}.{_format}', name: 'chill_api_single_calendar_list_by-user', requirements: ['_format' => 'json'])] public function listByUser(User $user, Request $request, string $_format): JsonResponse @@ -52,16 +56,37 @@ class CalendarAPIController extends ApiController throw new BadRequestHttpException('dateTo not parsable'); } - $total = $this->calendarRepository->countByUser($user, $dateFrom, $dateTo); - $paginator = $this->getPaginatorFactory()->create($total); - $ranges = $this->calendarRepository->findByUser( + // Get calendar items where user is the main user + $ownCalendars = $this->calendarRepository->findByUser( $user, $dateFrom, - $dateTo, - $paginator->getItemsPerPage(), - $paginator->getCurrentPageFirstItemNumber() + $dateTo ); + // Get calendar items from accepted invites + $acceptedInvites = $this->inviteRepository->findAcceptedInvitesByUserAndDateRange($user, $dateFrom, $dateTo); + $inviteCalendars = array_map(fn ($invite) => $invite->getCalendar(), $acceptedInvites); + + // Merge + $allCalendars = array_merge($ownCalendars, $inviteCalendars); + $uniqueCalendars = []; + $seenIds = []; + + foreach ($allCalendars as $calendar) { + $id = $calendar->getId(); + if (!in_array($id, $seenIds, true)) { + $seenIds[] = $id; + $uniqueCalendars[] = $calendar; + } + } + + $total = count($uniqueCalendars); + $paginator = $this->getPaginatorFactory()->create($total); + + $offset = $paginator->getCurrentPageFirstItemNumber(); + $limit = $paginator->getItemsPerPage(); + $ranges = array_slice($uniqueCalendars, $offset, $limit); + $collection = new Collection($ranges, $paginator); return $this->json($collection, Response::HTTP_OK, [], ['groups' => ['calendar:light']]); diff --git a/src/Bundle/ChillCalendarBundle/Controller/CalendarController.php b/src/Bundle/ChillCalendarBundle/Controller/CalendarController.php index 94db03b1f..6705d7bb7 100644 --- a/src/Bundle/ChillCalendarBundle/Controller/CalendarController.php +++ b/src/Bundle/ChillCalendarBundle/Controller/CalendarController.php @@ -13,6 +13,7 @@ namespace Chill\CalendarBundle\Controller; use Chill\CalendarBundle\Entity\Calendar; use Chill\CalendarBundle\Form\CalendarType; +use Chill\CalendarBundle\Form\CancelType; use Chill\CalendarBundle\RemoteCalendar\Connector\RemoteCalendarConnectorInterface; use Chill\CalendarBundle\Repository\CalendarACLAwareRepositoryInterface; use Chill\CalendarBundle\Security\Voter\CalendarVoter; @@ -30,6 +31,7 @@ use Chill\PersonBundle\Repository\PersonRepository; use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter; use Chill\PersonBundle\Security\Authorization\PersonVoter; use Chill\ThirdPartyBundle\Entity\ThirdParty; +use Doctrine\ORM\EntityManagerInterface; use http\Exception\UnexpectedValueException; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -60,6 +62,7 @@ class CalendarController extends AbstractController private readonly UserRepositoryInterface $userRepository, private readonly TranslatorInterface $translator, private readonly \Doctrine\Persistence\ManagerRegistry $managerRegistry, + private readonly EntityManagerInterface $em, ) {} /** @@ -111,6 +114,55 @@ class CalendarController extends AbstractController ]); } + #[Route(path: '/{_locale}/calendar/calendar/{id}/cancel', name: 'chill_calendar_calendar_cancel')] + public function cancelAction(Calendar $calendar, Request $request): Response + { + // Deal with sms being sent or not + // Communicate cancellation with the remote calendar. + + $this->denyAccessUnlessGranted(CalendarVoter::EDIT, $calendar); + + [$person, $accompanyingPeriod] = [$calendar->getPerson(), $calendar->getAccompanyingPeriod()]; + + $form = $this->createForm(CancelType::class, $calendar); + $form->add('submit', SubmitType::class); + + if ($accompanyingPeriod instanceof AccompanyingPeriod) { + $view = '@ChillCalendar/Calendar/cancelCalendarByAccompanyingCourse.html.twig'; + $redirectRoute = $this->generateUrl('chill_calendar_calendar_list_by_period', ['id' => $accompanyingPeriod->getId()]); + } elseif ($person instanceof Person) { + $view = '@ChillCalendar/Calendar/cancelCalendarByPerson.html.twig'; + $redirectRoute = $this->generateUrl('chill_calendar_calendar_list_by_person', ['id' => $person->getId()]); + } else { + throw new \RuntimeException('nor person or accompanying period'); + } + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + + $this->logger->notice('A calendar event has been cancelled', [ + 'by_user' => $this->getUser()->getUsername(), + 'calendar_id' => $calendar->getId(), + ]); + + $calendar->setStatus($calendar::STATUS_CANCELED); + $calendar->setSmsStatus($calendar::SMS_CANCEL_PENDING); + $this->em->flush(); + + $this->addFlash('success', $this->translator->trans('chill_calendar.calendar_canceled')); + + return new RedirectResponse($redirectRoute); + } + + return $this->render($view, [ + 'calendar' => $calendar, + 'form' => $form->createView(), + 'accompanyingCourse' => $accompanyingPeriod, + 'person' => $person, + ]); + } + /** * Edit a calendar item. */ @@ -266,7 +318,7 @@ class CalendarController extends AbstractController } if (!$this->getUser() instanceof User) { - throw new UnauthorizedHttpException('you are not an user'); + throw new UnauthorizedHttpException('you are not a user'); } $view = '@ChillCalendar/Calendar/listByUser.html.twig'; diff --git a/src/Bundle/ChillCalendarBundle/Controller/MyInvitationsController.php b/src/Bundle/ChillCalendarBundle/Controller/MyInvitationsController.php new file mode 100644 index 000000000..7af5ac18f --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Controller/MyInvitationsController.php @@ -0,0 +1,58 @@ +denyAccessUnlessGranted('ROLE_USER'); + + $user = $this->getUser(); + + if (!$user instanceof User) { + throw new UnauthorizedHttpException('you are not a user'); + } + + $total = count($this->inviteRepository->findBy(['user' => $user])); + $paginator = $this->paginator->create($total); + + $invitations = $this->inviteRepository->findBy( + ['user' => $user], + ['createdAt' => 'DESC'], + $paginator->getItemsPerPage(), + $paginator->getCurrentPageFirstItemNumber() + ); + + $view = '@ChillCalendar/Invitations/listByUser.html.twig'; + + return $this->render($view, [ + 'invitations' => $invitations, + 'paginator' => $paginator, + 'templates' => $this->docGeneratorTemplateRepository->findByEntity(Calendar::class), + ]); + } +} diff --git a/src/Bundle/ChillCalendarBundle/DataFixtures/ORM/LoadCancelReason.php b/src/Bundle/ChillCalendarBundle/DataFixtures/ORM/LoadCancelReason.php index d7e552d5d..2a8e371e0 100644 --- a/src/Bundle/ChillCalendarBundle/DataFixtures/ORM/LoadCancelReason.php +++ b/src/Bundle/ChillCalendarBundle/DataFixtures/ORM/LoadCancelReason.php @@ -35,7 +35,7 @@ class LoadCancelReason extends Fixture implements FixtureGroupInterface $arr = [ ['name' => CancelReason::CANCELEDBY_USER], ['name' => CancelReason::CANCELEDBY_PERSON], - ['name' => CancelReason::CANCELEDBY_DONOTCOUNT], + ['name' => CancelReason::CANCELEDBY_OTHER], ]; foreach ($arr as $a) { diff --git a/src/Bundle/ChillCalendarBundle/Entity/Calendar.php b/src/Bundle/ChillCalendarBundle/Entity/Calendar.php index dad302193..c194c247e 100644 --- a/src/Bundle/ChillCalendarBundle/Entity/Calendar.php +++ b/src/Bundle/ChillCalendarBundle/Entity/Calendar.php @@ -269,6 +269,11 @@ class Calendar implements TrackCreationInterface, TrackUpdateInterface, HasCente return $this->cancelReason; } + public function isCanceled(): bool + { + return null !== $this->cancelReason; + } + public function getCenters(): ?iterable { return match ($this->getContext()) { diff --git a/src/Bundle/ChillCalendarBundle/Entity/CancelReason.php b/src/Bundle/ChillCalendarBundle/Entity/CancelReason.php index d4a2ed9a9..04192133c 100644 --- a/src/Bundle/ChillCalendarBundle/Entity/CancelReason.php +++ b/src/Bundle/ChillCalendarBundle/Entity/CancelReason.php @@ -18,14 +18,14 @@ use Doctrine\ORM\Mapping as ORM; #[ORM\Table(name: 'chill_calendar.cancel_reason')] class CancelReason { - final public const CANCELEDBY_DONOTCOUNT = 'CANCELEDBY_DONOTCOUNT'; + final public const CANCELEDBY_OTHER = 'CANCELEDBY_OTHER'; final public const CANCELEDBY_PERSON = 'CANCELEDBY_PERSON'; final public const CANCELEDBY_USER = 'CANCELEDBY_USER'; - #[ORM\Column(type: \Doctrine\DBAL\Types\Types::BOOLEAN)] - private ?bool $active = null; + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::BOOLEAN, options: ['default' => true])] + private bool $active = true; #[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 255)] private ?string $canceledBy = null; diff --git a/src/Bundle/ChillCalendarBundle/Form/CancelReasonType.php b/src/Bundle/ChillCalendarBundle/Form/CancelReasonType.php index c0ac4ddb0..311d3ac02 100644 --- a/src/Bundle/ChillCalendarBundle/Form/CancelReasonType.php +++ b/src/Bundle/ChillCalendarBundle/Form/CancelReasonType.php @@ -15,7 +15,7 @@ use Chill\CalendarBundle\Entity\CancelReason; use Chill\MainBundle\Form\Type\TranslatableStringFormType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; -use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -28,7 +28,14 @@ class CancelReasonType extends AbstractType ->add('active', CheckboxType::class, [ 'required' => false, ]) - ->add('canceledBy', TextType::class); + ->add('canceledBy', ChoiceType::class, [ + 'choices' => [ + 'chill_calendar.canceled_by.user' => CancelReason::CANCELEDBY_USER, + 'chill_calendar.canceled_by.person' => CancelReason::CANCELEDBY_PERSON, + 'chill_calendar.canceled_by.other' => CancelReason::CANCELEDBY_OTHER, + ], + 'required' => true, + ]); } public function configureOptions(OptionsResolver $resolver) diff --git a/src/Bundle/ChillCalendarBundle/Form/CancelType.php b/src/Bundle/ChillCalendarBundle/Form/CancelType.php new file mode 100644 index 000000000..ad41a6105 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Form/CancelType.php @@ -0,0 +1,42 @@ +add('cancelReason', EntityType::class, [ + 'class' => CancelReason::class, + 'required' => true, + 'choice_label' => fn (CancelReason $cancelReason) => $this->translatableStringHelper->localize($cancelReason->getName()), + ]); + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => Calendar::class, + + ]); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Menu/UserMenuBuilder.php b/src/Bundle/ChillCalendarBundle/Menu/UserMenuBuilder.php index 3a062f7b8..672b53460 100644 --- a/src/Bundle/ChillCalendarBundle/Menu/UserMenuBuilder.php +++ b/src/Bundle/ChillCalendarBundle/Menu/UserMenuBuilder.php @@ -25,6 +25,13 @@ class UserMenuBuilder implements LocalMenuBuilderInterface if ($this->security->isGranted('ROLE_USER')) { $menu->addChild('My calendar list', [ 'route' => 'chill_calendar_calendar_list_my', + ]) + ->setExtras([ + 'order' => 8, + 'icon' => 'tasks', + ]); + $menu->addChild('invite.list.title', [ + 'route' => 'chill_calendar_invitations_list_my', ]) ->setExtras([ 'order' => 9, diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Doctrine/CalendarEntityListener.php b/src/Bundle/ChillCalendarBundle/Messenger/Doctrine/CalendarEntityListener.php index 8f62fdcdb..2319fde99 100644 --- a/src/Bundle/ChillCalendarBundle/Messenger/Doctrine/CalendarEntityListener.php +++ b/src/Bundle/ChillCalendarBundle/Messenger/Doctrine/CalendarEntityListener.php @@ -21,6 +21,7 @@ namespace Chill\CalendarBundle\Messenger\Doctrine; use Chill\CalendarBundle\Entity\Calendar; use Chill\CalendarBundle\Messenger\Message\CalendarMessage; use Chill\CalendarBundle\Messenger\Message\CalendarRemovedMessage; +use Chill\MainBundle\Entity\User; use Doctrine\ORM\Event\PostPersistEventArgs; use Doctrine\ORM\Event\PostRemoveEventArgs; use Doctrine\ORM\Event\PostUpdateEventArgs; @@ -31,6 +32,17 @@ class CalendarEntityListener { public function __construct(private readonly MessageBusInterface $messageBus, private readonly Security $security) {} + private function getAuthenticatedUser(): User + { + $user = $this->security->getUser(); + + if (!$user instanceof User) { + throw new \LogicException('Expected an instance of User.'); + } + + return $user; + } + public function postPersist(Calendar $calendar, PostPersistEventArgs $args): void { if (!$calendar->preventEnqueueChanges) { @@ -38,7 +50,7 @@ class CalendarEntityListener new CalendarMessage( $calendar, CalendarMessage::CALENDAR_PERSIST, - $this->security->getUser() + $this->getAuthenticatedUser() ) ); } @@ -50,7 +62,7 @@ class CalendarEntityListener $this->messageBus->dispatch( new CalendarRemovedMessage( $calendar, - $this->security->getUser() + $this->getAuthenticatedUser() ) ); } @@ -58,12 +70,19 @@ class CalendarEntityListener public function postUpdate(Calendar $calendar, PostUpdateEventArgs $args): void { - if (!$calendar->preventEnqueueChanges) { + if ($calendar->getStatus() === $calendar::STATUS_CANCELED) { + $this->messageBus->dispatch( + new CalendarRemovedMessage( + $calendar, + $this->getAuthenticatedUser() + ) + ); + } elseif (!$calendar->preventEnqueueChanges) { $this->messageBus->dispatch( new CalendarMessage( $calendar, CalendarMessage::CALENDAR_UPDATE, - $this->security->getUser() + $this->getAuthenticatedUser() ) ); } diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Message/CalendarRemovedMessage.php b/src/Bundle/ChillCalendarBundle/Messenger/Message/CalendarRemovedMessage.php index 53dcea28c..7d2e88fef 100644 --- a/src/Bundle/ChillCalendarBundle/Messenger/Message/CalendarRemovedMessage.php +++ b/src/Bundle/ChillCalendarBundle/Messenger/Message/CalendarRemovedMessage.php @@ -70,6 +70,8 @@ class CalendarRemovedMessage public function getRemoteId(): string { + dump($this->remoteId); + return $this->remoteId; } } diff --git a/src/Bundle/ChillCalendarBundle/Repository/CalendarRepository.php b/src/Bundle/ChillCalendarBundle/Repository/CalendarRepository.php index 3121854e5..e6f90f279 100644 --- a/src/Bundle/ChillCalendarBundle/Repository/CalendarRepository.php +++ b/src/Bundle/ChillCalendarBundle/Repository/CalendarRepository.php @@ -191,6 +191,7 @@ class CalendarRepository implements ObjectRepository $qb->expr()->eq('c.mainUser', ':user'), $qb->expr()->gte('c.startDate', ':startDate'), $qb->expr()->lte('c.endDate', ':endDate'), + $qb->expr()->isNull('c.cancelReason'), ) ) ->setParameters([ diff --git a/src/Bundle/ChillCalendarBundle/Repository/InviteRepository.php b/src/Bundle/ChillCalendarBundle/Repository/InviteRepository.php index 8778330f8..8fe28f500 100644 --- a/src/Bundle/ChillCalendarBundle/Repository/InviteRepository.php +++ b/src/Bundle/ChillCalendarBundle/Repository/InviteRepository.php @@ -12,6 +12,7 @@ declare(strict_types=1); namespace Chill\CalendarBundle\Repository; use Chill\CalendarBundle\Entity\Invite; +use Chill\MainBundle\Entity\User; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; use Doctrine\Persistence\ObjectRepository; @@ -41,7 +42,7 @@ class InviteRepository implements ObjectRepository /** * @return array|Invite[] */ - public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null) + public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array { return $this->entityRepository->findBy($criteria, $orderBy, $limit, $offset); } @@ -51,6 +52,52 @@ class InviteRepository implements ObjectRepository return $this->entityRepository->findOneBy($criteria); } + /** + * Find accepted invites for a user within a date range. + * + * @return array|Invite[] + */ + public function findAcceptedInvitesByUserAndDateRange(User $user, \DateTimeImmutable $from, \DateTimeImmutable $to): array + { + return $this->buildAcceptedInviteByUserAndDateRangeQuery($user, $from, $to) + ->getQuery() + ->getResult(); + } + + /** + * Count accepted invites for a user within a date range. + */ + public function countAcceptedInvitesByUserAndDateRange(User $user, \DateTimeImmutable $from, \DateTimeImmutable $to): int + { + return $this->buildAcceptedInviteByUserAndDateRangeQuery($user, $from, $to) + ->select('COUNT(c)') + ->getQuery() + ->getSingleScalarResult(); + } + + public function buildAcceptedInviteByUserAndDateRangeQuery(User $user, \DateTimeImmutable $from, \DateTimeImmutable $to) + { + $qb = $this->entityRepository->createQueryBuilder('i'); + + return $qb + ->join('i.calendar', 'c') + ->where( + $qb->expr()->andX( + $qb->expr()->eq('i.user', ':user'), + $qb->expr()->eq('i.status', ':status'), + $qb->expr()->gte('c.startDate', ':startDate'), + $qb->expr()->lte('c.endDate', ':endDate'), + $qb->expr()->isNull('c.cancelReason') + ) + ) + ->setParameters([ + 'user' => $user, + 'status' => Invite::ACCEPTED, + 'startDate' => $from, + 'endDate' => $to, + ]); + } + public function getClassName(): string { return Invite::class; diff --git a/src/Bundle/ChillCalendarBundle/Resources/config/services/controller.yml b/src/Bundle/ChillCalendarBundle/Resources/config/services/controller.yml index a0457c5a8..cce562d7d 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/config/services/controller.yml +++ b/src/Bundle/ChillCalendarBundle/Resources/config/services/controller.yml @@ -1,5 +1,6 @@ services: Chill\CalendarBundle\Controller\: autowire: true + autoconfigure: true resource: '../../../Controller' tags: ['controller.service_arguments'] diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/App2.vue b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/App2.vue index 6e3d5b61c..40c3200b9 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/App2.vue +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/App2.vue @@ -108,9 +108,12 @@ {{ formatDate(event.endStr, "time") }}: {{ event.extendedProps.locationName }} - {{ - event.title - }} + + {{ event.title }} + no 'is' { + const idStr = calendarId.match(/_(\d+)$/)?.[1]; + + return `/fr/calendar/calendar/${idStr}/edit`; +}; + onMounted(() => { copyFromWeek.value = dateToISO(getMonday(0)); copyToWeek.value = dateToISO(getMonday(1)); diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/_list.html.twig b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/_list.html.twig index cff5c00cc..c827ca4f4 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/_list.html.twig +++ b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/_list.html.twig @@ -1,17 +1,23 @@ -{# list used in context of person or accompanyingPeriod #} +{# list used in context of person, accompanyingPeriod or user #} -{% if calendarItems|length > 0 %} -
- - {% for calendar in calendarItems %} - -
-
-
-
+ - {% if calendar.comment.comment is not empty +
+
    + {% if calendar.mainUser is not empty %} + {{ calendar.mainUser|chill_entity_render_box({'at_date': calendar.startDate}) }} + {% endif %} +
+
+ +
+
+ + {% if calendar.comment.comment is not empty or calendar.users|length > 0 or calendar.thirdParties|length > 0 or calendar.users|length > 0 %} @@ -76,131 +87,133 @@ } %}
-
- {% endif %} +
+ {% endif %} + + {% if calendar.comment.comment is not empty %} +
+
+ {{ calendar.comment|chill_entity_render_box( { 'limit_lines': 3, 'metadata': false } ) }} +
+
+ {% endif %} + + {% if calendar.location is not empty %} +
+
+ {% if calendar.location.address is not same as(null) and calendar.location.name is not empty %} + {% endif %} + {% if calendar.location.name is not empty %}{{ calendar.location.name }}{% endif %} + {% if calendar.location.address is not same as(null) %}{{ calendar.location.address|chill_entity_render_box({'multiline': false, 'with_picto': (calendar.location.name is empty)}) }}{% else %} + {% endif %} + {% if calendar.location.phonenumber1 is not empty %} {{ calendar.location.phonenumber1|chill_format_phonenumber }}{% endif %} + {% if calendar.location.phonenumber2 is not empty %} {{ calendar.location.phonenumber2|chill_format_phonenumber }}{% endif %} +
+
+ {% endif %} + + {% if calendar.documents is not empty %} +
+
+ {{ include('@ChillCalendar/Calendar/_documents.twig.html') }} +
+
+ {% endif %} + + {% if calendar.activity is not null %} +
+
+
+
+

{{ 'Activity'|trans }}

+
+

+ + + {{ calendar.activity.type.name | localize_translatable_string }} + + {% if calendar.activity.emergency %} + {{ 'Emergency'|trans|upper }} + {% endif %} + +

+ +
- {% if calendar.comment.comment is not empty %} -
-
- {{ calendar.comment|chill_entity_render_box( { 'limit_lines': 3, 'metadata': false } ) }}
- {% endif %} - - {% if calendar.location is not empty %} -
-
- {% if calendar.location.address is not same as(null) and calendar.location.name is not empty %} - {% endif %} - {% if calendar.location.name is not empty %}{{ calendar.location.name }}{% endif %} - {% if calendar.location.address is not same as(null) %}{{ calendar.location.address|chill_entity_render_box({'multiline': false, 'with_picto': (calendar.location.name is empty)}) }}{% else %} - {% endif %} - {% if calendar.location.phonenumber1 is not empty %} {{ calendar.location.phonenumber1|chill_format_phonenumber }}{% endif %} - {% if calendar.location.phonenumber2 is not empty %} {{ calendar.location.phonenumber2|chill_format_phonenumber }}{% endif %} -
-
- {% endif %} - -
-
- - {{ include('@ChillCalendar/Calendar/_documents.twig.html') }} -
+
+
+ {% endif %} - {% if calendar.activity is not null %} -
-
-
-
-

{{ 'Activity'|trans }}

-
-

- - - {{ calendar.activity.type.name | localize_translatable_string }} - - {% if calendar.activity.emergency %} - {{ 'Emergency'|trans|upper }} - {% endif %} - -

- -
    -
  • - - {{ 'Created by'|trans }} - {{ calendar.activity.createdBy|chill_entity_render_string({'at_date': calendar.activity.createdAt}) }}, {{ 'on'|trans }} {{ calendar.activity.createdAt|format_datetime('short', 'short') }} - -
  • - {% if is_granted('CHILL_ACTIVITY_SEE', calendar.activity) %} -
  • - -
  • - {% endif %} -
- -
-
+
+
-
+ + {% endif %} {% endif %} + {% if calendar.activity is null and ( + (calendar.context == 'accompanying_period' and is_granted('CHILL_ACTIVITY_CREATE', calendar.accompanyingPeriod)) + or + (calendar.context == 'person' and is_granted('CHILL_ACTIVITY_CREATE', calendar.person)) + ) + and calendar.status is not constant('STATUS_CANCELED', calendar) + %} +
  • + + {{ 'Transform to activity'|trans }} + +
  • + {% endif %} -
    -
      - {% if is_granted('CHILL_CALENDAR_DOC_EDIT', calendar) %} - {% if templates|length == 0 %} -
    • - - {{ 'chill_calendar.Add a document'|trans }} - -
    • - {% else %} -
    • - -
    • - {% endif %} - {% endif %} - {% if calendar.activity is null and ( - (calendar.context == 'accompanying_period' and is_granted('CHILL_ACTIVITY_CREATE', calendar.accompanyingPeriod)) - or - (calendar.context == 'person' and is_granted('CHILL_ACTIVITY_CREATE', calendar.person)) - ) - %} -
    • - - {{ 'Transform to activity'|trans }} - -
    • - {% endif %} - - {% if (calendar.isInvited(app.user)) %} + {% if calendar.isInvited(app.user) and not calendar.isCanceled %} {% set invite = calendar.inviteForUser(app.user) %}
    • {% endif %} - {% if is_granted('CHILL_CALENDAR_CALENDAR_EDIT', calendar) %} + + {% if is_granted('CHILL_CALENDAR_CALENDAR_EDIT', calendar) and calendar.status is not constant('STATUS_CANCELED', calendar) %}
    • +
    • + {{ 'Cancel'|trans }} +
    • {% endif %} + {% if is_granted('CHILL_CALENDAR_CALENDAR_DELETE', calendar) %}
    • -
    - -
    - {% endfor %} - - {% if calendarItems|length < paginator.getTotalItems %} - {{ chill_pagination(paginator) }} - {% endif %} -
    -{% endif %} + +
    + + diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/cancelCalendarByAccompanyingCourse.html.twig b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/cancelCalendarByAccompanyingCourse.html.twig new file mode 100644 index 000000000..2f6759725 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/cancelCalendarByAccompanyingCourse.html.twig @@ -0,0 +1,29 @@ +{% extends "@ChillPerson/AccompanyingCourse/layout.html.twig" %} + +{% set activeRouteKey = 'chill_calendar_calendar_list' %} + +{% block title 'chill_calendar.cancel_calendar_item'|trans %} + +{% block content %} + + {{ form_start(form) }} + + {{ form_row(form.cancelReason) }} + + + + {{ form_end(form) }} + +{% endblock %} diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/cancelCalendarByPerson.html.twig b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/cancelCalendarByPerson.html.twig new file mode 100644 index 000000000..76196b23e --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/cancelCalendarByPerson.html.twig @@ -0,0 +1,29 @@ +{% extends "@ChillPerson/Person/layout.html.twig" %} + +{% set activeRouteKey = 'chill_calendar_calendar_list' %} + +{% block title 'chill_calendar.cancel_calendar_item'|trans %} + +{% block content %} + + {{ form_start(form) }} + + {{ form_row(form.cancelReason) }} + + + + {{ form_end(form) }} + +{% endblock %} diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/listByAccompanyingCourse.html.twig b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/listByAccompanyingCourse.html.twig index 7ce1003bc..96ddb3388 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/listByAccompanyingCourse.html.twig +++ b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/listByAccompanyingCourse.html.twig @@ -34,7 +34,18 @@ {% endif %}

    {% else %} - {{ include('@ChillCalendar/Calendar/_list.html.twig', {context: 'accompanying_course'}) }} + {% if calendarItems|length > 0 %} +
    + {% for calendar in calendarItems %} + {{ include('@ChillCalendar/Calendar/_list.html.twig', {context: 'accompanying_course'}) }} + {% endfor %} +
    + + {% if calendarItems|length < paginator.getTotalItems %} + {{ chill_pagination(paginator) }} + {% endif %} + + {% endif %} {% endif %}
      diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/listByPerson.html.twig b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/listByPerson.html.twig index 9e3b59d2a..dc44b721c 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/listByPerson.html.twig +++ b/src/Bundle/ChillCalendarBundle/Resources/views/Calendar/listByPerson.html.twig @@ -33,7 +33,17 @@ {% endif %}

      {% else %} - {{ include ('@ChillCalendar/Calendar/_list.html.twig', {context: 'person'}) }} + {% if calendarItems|length > 0 %} +
      + {% for calendar in calendarItems %} + {{ include ('@ChillCalendar/Calendar/_list.html.twig', {context: 'person'}) }} + {% endfor %} +
      + + {% if calendarItems|length < paginator.getTotalItems %} + {{ chill_pagination(paginator) }} + {% endif %} + {% endif %} {% endif %}
        diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/CalendarShortMessage/short_message_canceled.twig b/src/Bundle/ChillCalendarBundle/Resources/views/CalendarShortMessage/short_message_canceled.txt.twig similarity index 100% rename from src/Bundle/ChillCalendarBundle/Resources/views/CalendarShortMessage/short_message_canceled.twig rename to src/Bundle/ChillCalendarBundle/Resources/views/CalendarShortMessage/short_message_canceled.txt.twig diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/CancelReason/index.html.twig b/src/Bundle/ChillCalendarBundle/Resources/views/CancelReason/index.html.twig index 0668d8db5..bd99d6e6f 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/views/CancelReason/index.html.twig +++ b/src/Bundle/ChillCalendarBundle/Resources/views/CancelReason/index.html.twig @@ -5,7 +5,7 @@ {% block table_entities_thead_tr %} {{ 'Id'|trans }} {{ 'Name'|trans }} - {{ 'canceledBy'|trans }} + {{ 'Canceled by'|trans }} {{ 'active'|trans }}   {% endblock %} @@ -40,4 +40,4 @@ {% endblock %} {% endembed %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/src/Bundle/ChillCalendarBundle/Resources/views/Invitations/listByUser.html.twig b/src/Bundle/ChillCalendarBundle/Resources/views/Invitations/listByUser.html.twig new file mode 100644 index 000000000..c7b7ecc86 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Resources/views/Invitations/listByUser.html.twig @@ -0,0 +1,40 @@ +{% extends "@ChillMain/layout.html.twig" %} + +{% set activeRouteKey = 'chill_calendar_invitations_list' %} + +{% block title %}{{ 'invite.list.title'|trans }}{% endblock title %} + +{% block content %} + +

        {{ 'invite.list.title'|trans }}

        + + {% if invitations|length == 0 %} +

        + {{ "invite.list.none"|trans }} +

        + {% else %} +
        + {% for invitation in invitations %} + {% set calendar = invitation.getCalendar %} + {{ include('@ChillCalendar/Calendar/_list.html.twig', {context: 'user'}) }} + {% endfor %} +
        + + {% if invitations|length < paginator.getTotalItems %} + {{ chill_pagination(paginator) }} + {% endif %} + {% endif %} + +{% endblock %} + +{% block js %} + {{ parent() }} + {{ encore_entry_script_tags('mod_answer') }} + {{ encore_entry_script_tags('mod_document_action_buttons_group') }} +{% endblock %} + +{% block css %} + {{ parent() }} + {{ encore_entry_link_tags('mod_answer') }} + {{ encore_entry_link_tags('mod_document_action_buttons_group') }} +{% endblock %} diff --git a/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/DefaultShortMessageForCalendarBuilder.php b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/DefaultShortMessageForCalendarBuilder.php index dfce0548c..bce9b1487 100644 --- a/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/DefaultShortMessageForCalendarBuilder.php +++ b/src/Bundle/ChillCalendarBundle/Service/ShortMessageNotification/DefaultShortMessageForCalendarBuilder.php @@ -19,6 +19,7 @@ declare(strict_types=1); namespace Chill\CalendarBundle\Service\ShortMessageNotification; use Chill\CalendarBundle\Entity\Calendar; +use Chill\CalendarBundle\Entity\CancelReason; use libphonenumber\PhoneNumberFormat; use libphonenumber\PhoneNumberUtil; use Symfony\Component\Notifier\Message\SmsMessage; @@ -57,7 +58,7 @@ class DefaultShortMessageForCalendarBuilder implements ShortMessageForCalendarBu $this->phoneUtil->format($person->getMobilenumber(), PhoneNumberFormat::E164), $this->engine->render('@ChillCalendar/CalendarShortMessage/short_message.txt.twig', ['calendar' => $calendar]), ); - } elseif (Calendar::SMS_CANCEL_PENDING === $calendar->getSmsStatus()) { + } elseif (Calendar::SMS_CANCEL_PENDING === $calendar->getSmsStatus() && (null === $calendar->getCancelReason() || CancelReason::CANCELEDBY_PERSON !== $calendar->getCancelReason()->getCanceledBy())) { $toUsers[] = new SmsMessage( $this->phoneUtil->format($person->getMobilenumber(), PhoneNumberFormat::E164), $this->engine->render('@ChillCalendar/CalendarShortMessage/short_message_canceled.txt.twig', ['calendar' => $calendar]), diff --git a/src/Bundle/ChillCalendarBundle/Tests/Controller/MyInvitationsControllerTest.php b/src/Bundle/ChillCalendarBundle/Tests/Controller/MyInvitationsControllerTest.php new file mode 100644 index 000000000..32ed70456 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Tests/Controller/MyInvitationsControllerTest.php @@ -0,0 +1,292 @@ +prophesize(InviteRepository::class); + $paginatorFactory = $this->prophesize(PaginatorFactory::class); + $docGeneratorTemplateRepository = $this->prophesize(DocGeneratorTemplateRepositoryInterface::class); + + // Create controller instance + $this->controller = new MyInvitationsController( + $inviteRepository->reveal(), + $paginatorFactory->reveal(), + $docGeneratorTemplateRepository->reveal() + ); + + // Set up necessary services for AbstractController + $authorizationChecker = $this->prophesize(AuthorizationCheckerInterface::class); + $tokenStorage = $this->prophesize(TokenStorageInterface::class); + $twig = $this->prophesize(Environment::class); + + // Use reflection to set the container + $reflection = new \ReflectionClass($this->controller); + $containerProperty = $reflection->getParentClass()->getProperty('container'); + $containerProperty->setAccessible(true); + + // Create a mock container + $container = $this->prophesize(\Psr\Container\ContainerInterface::class); + $container->has('security.authorization_checker')->willReturn(true); + $container->get('security.authorization_checker')->willReturn($authorizationChecker->reveal()); + $container->has('security.token_storage')->willReturn(true); + $container->get('security.token_storage')->willReturn($tokenStorage->reveal()); + $container->has('twig')->willReturn(true); + $container->get('twig')->willReturn($twig->reveal()); + + $containerProperty->setValue($this->controller, $container->reveal()); + } + + public function testMyInvitationsReturnsCorrectAmountOfInvitations(): void + { + // Create test user + $user = new User(); + $user->setUsername('testuser'); + + // Create test invitations + $invite1 = new Invite(); + $invite1->setUser($user); + $invite1->setStatus(Invite::PENDING); + + $invite2 = new Invite(); + $invite2->setUser($user); + $invite2->setStatus(Invite::ACCEPTED); + + $invite3 = new Invite(); + $invite3->setUser($user); + $invite3->setStatus(Invite::DECLINED); + + $allInvitations = [$invite1, $invite2, $invite3]; + $paginatedInvitations = [$invite1, $invite2]; // First page with 2 items per page + + // Set up repository prophecies + $inviteRepository = $this->prophesize(InviteRepository::class); + $inviteRepository->findBy(['user' => $user])->willReturn($allInvitations); + $inviteRepository->findBy( + ['user' => $user], + ['createdAt' => 'DESC'], + 2, // items per page + 0 // offset + )->willReturn($paginatedInvitations); + + // Set up paginator prophecies + $paginator = $this->prophesize(PaginatorInterface::class); + $paginator->getItemsPerPage()->willReturn(2); + $paginator->getCurrentPageFirstItemNumber()->willReturn(0); + + $paginatorFactory = $this->prophesize(PaginatorFactory::class); + $paginatorFactory->create(3)->willReturn($paginator->reveal()); + + // Set up doc generator repository + $docGeneratorTemplateRepository = $this->prophesize(DocGeneratorTemplateRepositoryInterface::class); + $docGeneratorTemplateRepository->findByEntity(Calendar::class)->willReturn([]); + + // Create controller with mocked dependencies + $controller = new MyInvitationsController( + $inviteRepository->reveal(), + $paginatorFactory->reveal(), + $docGeneratorTemplateRepository->reveal() + ); + + // Set up authorization checker to return true for ROLE_USER + $authorizationChecker = $this->prophesize(AuthorizationCheckerInterface::class); + $authorizationChecker->isGranted('ROLE_USER', null)->willReturn(true); + + // Set up token storage to return user + $token = $this->prophesize(TokenInterface::class); + $token->getUser()->willReturn($user); + $tokenStorage = $this->prophesize(TokenStorageInterface::class); + $tokenStorage->getToken()->willReturn($token->reveal()); + + // Set up twig to return a response + $twig = $this->prophesize(Environment::class); + $twig->render('@ChillCalendar/Invitations/listByUser.html.twig', [ + 'invitations' => $paginatedInvitations, + 'paginator' => $paginator->reveal(), + 'templates' => [], + ])->willReturn('rendered content'); + + // Set up container + $container = $this->prophesize(\Psr\Container\ContainerInterface::class); + $container->has('security.authorization_checker')->willReturn(true); + $container->get('security.authorization_checker')->willReturn($authorizationChecker->reveal()); + $container->has('security.token_storage')->willReturn(true); + $container->get('security.token_storage')->willReturn($tokenStorage->reveal()); + $container->has('twig')->willReturn(true); + $container->get('twig')->willReturn($twig->reveal()); + + // Use reflection to set the container + $reflection = new \ReflectionClass($controller); + $containerProperty = $reflection->getParentClass()->getProperty('container'); + $containerProperty->setAccessible(true); + $containerProperty->setValue($controller, $container->reveal()); + + // Create request + $request = new Request(); + + // Execute the action + $response = $controller->myInvitations($request); + + // Assert that response is successful + self::assertInstanceOf(Response::class, $response); + self::assertSame(200, $response->getStatusCode()); + self::assertSame('rendered content', $response->getContent()); + } + + public function testMyInvitationsPageLoads(): void + { + // Create test user + $user = new User(); + $user->setUsername('testuser'); + + // Set up repository prophecies - no invitations + $inviteRepository = $this->prophesize(InviteRepository::class); + $inviteRepository->findBy(['user' => $user])->willReturn([]); + $inviteRepository->findBy( + ['user' => $user], + ['createdAt' => 'DESC'], + 20, // default items per page + 0 // offset + )->willReturn([]); + + // Set up paginator prophecies + $paginator = $this->prophesize(PaginatorInterface::class); + $paginator->getItemsPerPage()->willReturn(20); + $paginator->getCurrentPageFirstItemNumber()->willReturn(0); + + $paginatorFactory = $this->prophesize(PaginatorFactory::class); + $paginatorFactory->create(0)->willReturn($paginator->reveal()); + + // Set up doc generator repository + $docGeneratorTemplateRepository = $this->prophesize(DocGeneratorTemplateRepositoryInterface::class); + $docGeneratorTemplateRepository->findByEntity(Calendar::class)->willReturn([]); + + // Create controller with mocked dependencies + $controller = new MyInvitationsController( + $inviteRepository->reveal(), + $paginatorFactory->reveal(), + $docGeneratorTemplateRepository->reveal() + ); + + // Set up authorization checker to return true for ROLE_USER + $authorizationChecker = $this->prophesize(AuthorizationCheckerInterface::class); + $authorizationChecker->isGranted('ROLE_USER', null)->willReturn(true); + + // Set up token storage to return user + $token = $this->prophesize(TokenInterface::class); + $token->getUser()->willReturn($user); + $tokenStorage = $this->prophesize(TokenStorageInterface::class); + $tokenStorage->getToken()->willReturn($token->reveal()); + + // Set up twig to return a response + $twig = $this->prophesize(Environment::class); + $twig->render('@ChillCalendar/Invitations/listByUser.html.twig', [ + 'invitations' => [], + 'paginator' => $paginator->reveal(), + 'templates' => [], + ])->willReturn('empty page content'); + + // Set up container + $container = $this->prophesize(\Psr\Container\ContainerInterface::class); + $container->has('security.authorization_checker')->willReturn(true); + $container->get('security.authorization_checker')->willReturn($authorizationChecker->reveal()); + $container->has('security.token_storage')->willReturn(true); + $container->get('security.token_storage')->willReturn($tokenStorage->reveal()); + $container->has('twig')->willReturn(true); + $container->get('twig')->willReturn($twig->reveal()); + + // Use reflection to set the container + $reflection = new \ReflectionClass($controller); + $containerProperty = $reflection->getParentClass()->getProperty('container'); + $containerProperty->setAccessible(true); + $containerProperty->setValue($controller, $container->reveal()); + + // Create request + $request = new Request(); + + // Execute the action + $response = $controller->myInvitations($request); + + // Assert that page loads successfully + self::assertInstanceOf(Response::class, $response); + self::assertSame(200, $response->getStatusCode()); + self::assertSame('empty page content', $response->getContent()); + } + + public function testMyInvitationsRequiresAuthentication(): void + { + // Create controller with minimal dependencies + $inviteRepository = $this->prophesize(InviteRepository::class); + $paginatorFactory = $this->prophesize(PaginatorFactory::class); + $docGeneratorTemplateRepository = $this->prophesize(DocGeneratorTemplateRepositoryInterface::class); + + $controller = new MyInvitationsController( + $inviteRepository->reveal(), + $paginatorFactory->reveal(), + $docGeneratorTemplateRepository->reveal() + ); + + // Set up authorization checker to return false for ROLE_USER + $authorizationChecker = $this->prophesize(AuthorizationCheckerInterface::class); + $authorizationChecker->isGranted('ROLE_USER')->willReturn(false); + $authorizationChecker->isGranted('ROLE_USER', null)->willReturn(false); + + // Set up container + $container = $this->prophesize(\Psr\Container\ContainerInterface::class); + $container->has('security.authorization_checker')->willReturn(true); + $container->get('security.authorization_checker')->willReturn($authorizationChecker->reveal()); + + // Use reflection to set the container + $reflection = new \ReflectionClass($controller); + $containerProperty = $reflection->getParentClass()->getProperty('container'); + $containerProperty->setAccessible(true); + $containerProperty->setValue($controller, $container->reveal()); + + // Create request + $request = new Request(); + + // Expect AccessDeniedException + $this->expectException(\Symfony\Component\Security\Core\Exception\AccessDeniedException::class); + + // Execute the action + $controller->myInvitations($request); + } +} diff --git a/src/Bundle/ChillCalendarBundle/translations/messages.fr.yml b/src/Bundle/ChillCalendarBundle/translations/messages.fr.yml index cfb2fe057..8717dcd9f 100644 --- a/src/Bundle/ChillCalendarBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillCalendarBundle/translations/messages.fr.yml @@ -31,8 +31,7 @@ Will send SMS: Un SMS de rappel sera envoyé Will not send SMS: Aucun SMS de rappel ne sera envoyé SMS already sent: Un SMS a été envoyé -canceledBy: supprimé par -Canceled by: supprimé par +Canceled by: Annulé par Calendar configuration: Gestion des rendez-vous crud: @@ -44,6 +43,14 @@ crud: title_edit: Modifier le motif d'annulation chill_calendar: + canceled: Annulé + cancel_reason: Raison d'annulation + cancel_calendar_item: Annuler rendez-vous + calendar_canceled: Le rendez-vous a été annulé + canceled_by: + user: Utilisateur + person: Usager + other: Autre Document: Document d'un rendez-vous form: The main user is mandatory. He will organize the appointment.: L'utilisateur principal est obligatoire. Il est l'organisateur de l'événement. @@ -86,6 +93,9 @@ invite: declined: Refusé pending: En attente tentative: Accepté provisoirement + list: + none: Il n'y aucun invitation + title: Mes invitations # exports Exports of calendar: Exports des rendez-vous diff --git a/src/Bundle/ChillDocGeneratorBundle/Repository/DocGeneratorTemplateRepositoryInterface.php b/src/Bundle/ChillDocGeneratorBundle/Repository/DocGeneratorTemplateRepositoryInterface.php index e5071e76a..f2e3cf629 100644 --- a/src/Bundle/ChillDocGeneratorBundle/Repository/DocGeneratorTemplateRepositoryInterface.php +++ b/src/Bundle/ChillDocGeneratorBundle/Repository/DocGeneratorTemplateRepositoryInterface.php @@ -20,4 +20,9 @@ use Doctrine\Persistence\ObjectRepository; interface DocGeneratorTemplateRepositoryInterface extends ObjectRepository { public function countByEntity(string $entity): int; + + /** + * @return array|DocGeneratorTemplate[] + */ + public function findByEntity(string $entity, ?int $start = 0, ?int $limit = 50): array; } diff --git a/src/Bundle/ChillDocGeneratorBundle/Service/Messenger/OnGenerationFails.php b/src/Bundle/ChillDocGeneratorBundle/Service/Messenger/OnGenerationFails.php index b9d538a63..a950b88c0 100644 --- a/src/Bundle/ChillDocGeneratorBundle/Service/Messenger/OnGenerationFails.php +++ b/src/Bundle/ChillDocGeneratorBundle/Service/Messenger/OnGenerationFails.php @@ -25,6 +25,8 @@ 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 */ @@ -40,6 +42,7 @@ final readonly class OnGenerationFails implements EventSubscriberInterface private StoredObjectRepositoryInterface $storedObjectRepository, private TranslatorInterface $translator, private UserRepositoryInterface $userRepository, + // private LocaleSwitcher $localeSwitcher, ) {} public static function getSubscribedEvents() @@ -118,6 +121,25 @@ 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')) diff --git a/src/Bundle/ChillDocGeneratorBundle/Service/Messenger/RequestGenerationHandler.php b/src/Bundle/ChillDocGeneratorBundle/Service/Messenger/RequestGenerationHandler.php index 9dd20af91..a67f5a68f 100644 --- a/src/Bundle/ChillDocGeneratorBundle/Service/Messenger/RequestGenerationHandler.php +++ b/src/Bundle/ChillDocGeneratorBundle/Service/Messenger/RequestGenerationHandler.php @@ -27,6 +27,8 @@ 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. */ @@ -46,6 +48,7 @@ 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) @@ -122,6 +125,30 @@ 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(); diff --git a/src/Bundle/ChillDocStoreBundle/Form/PersonDocumentType.php b/src/Bundle/ChillDocStoreBundle/Form/PersonDocumentType.php index 0addd53cc..6e201165d 100644 --- a/src/Bundle/ChillDocStoreBundle/Form/PersonDocumentType.php +++ b/src/Bundle/ChillDocStoreBundle/Form/PersonDocumentType.php @@ -17,7 +17,6 @@ use Chill\DocStoreBundle\Entity\PersonDocument; use Chill\MainBundle\Form\Type\ChillDateType; use Chill\MainBundle\Form\Type\ChillTextareaType; use Chill\MainBundle\Form\Type\ScopePickerType; -use Chill\MainBundle\Security\Resolver\CenterResolverDispatcher; use Chill\MainBundle\Security\Resolver\ScopeResolverDispatcher; use Chill\MainBundle\Templating\TranslatableStringHelperInterface; use Doctrine\ORM\EntityRepository; @@ -30,7 +29,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver; class PersonDocumentType extends AbstractType { - public function __construct(private readonly TranslatableStringHelperInterface $translatableStringHelper, private readonly ScopeResolverDispatcher $scopeResolverDispatcher, private readonly ParameterBagInterface $parameterBag, private readonly CenterResolverDispatcher $centerResolverDispatcher) {} + public function __construct(private readonly TranslatableStringHelperInterface $translatableStringHelper, private readonly ScopeResolverDispatcher $scopeResolverDispatcher, private readonly ParameterBagInterface $parameterBag) {} public function buildForm(FormBuilderInterface $builder, array $options) { @@ -57,8 +56,8 @@ class PersonDocumentType extends AbstractType if ($isScopeConcerned && $this->parameterBag->get('chill_main')['acl']['form_show_scopes']) { $builder->add('scope', ScopePickerType::class, [ - 'center' => $this->centerResolverDispatcher->resolveCenter($document), 'role' => $options['role'], + 'subject' => $document, ]); } } diff --git a/src/Bundle/ChillEventBundle/Controller/EventController.php b/src/Bundle/ChillEventBundle/Controller/EventController.php index 7bcc8dbe6..b52697868 100644 --- a/src/Bundle/ChillEventBundle/Controller/EventController.php +++ b/src/Bundle/ChillEventBundle/Controller/EventController.php @@ -246,7 +246,7 @@ final class EventController extends AbstractController 'class' => Center::class, 'choices' => $centers, 'placeholder' => $this->translator->trans('Pick a center'), - 'label' => 'To which centre should the event be associated ?', + 'label' => 'To which territory should the event be associated ?', ]) ->add('submit', SubmitType::class, [ 'label' => 'Next step', diff --git a/src/Bundle/ChillEventBundle/Resources/views/Participation/edit-multiple.html.twig b/src/Bundle/ChillEventBundle/Resources/views/Participation/edit-multiple.html.twig index a01c51e49..a2cef3e53 100644 --- a/src/Bundle/ChillEventBundle/Resources/views/Participation/edit-multiple.html.twig +++ b/src/Bundle/ChillEventBundle/Resources/views/Participation/edit-multiple.html.twig @@ -50,7 +50,7 @@
        • - + {{ 'Back to the event'|trans }}
        • diff --git a/src/Bundle/ChillEventBundle/translations/messages.fr.yml b/src/Bundle/ChillEventBundle/translations/messages.fr.yml index 2251dbd6c..45bee74e4 100644 --- a/src/Bundle/ChillEventBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillEventBundle/translations/messages.fr.yml @@ -64,7 +64,7 @@ CHILL_EVENT_PARTICIPATION_SEE_DETAILS: Voir le détail d'une participation # TODO check place to put this Next step: Étape suivante -To which centre should the event be associated ?: À quel centre doit être associé l'événement ? +To which territory should the event be associated ?: À quel territoire doit être associé l'événement ? # timeline past: passé @@ -151,7 +151,7 @@ event: filter: event_types: Par types d'événement event_dates: Par date d'événement - center: Par centre + center: Par territoire by_responsable: Par responsable pick_responsable: Filtrer par responsables budget: @@ -188,7 +188,7 @@ event_id: Identifiant event_name: Nom event_date: Date event_type: Type d'évenement -event_center: Centre +event_center: Territoire event_moderator: Responsable event_participants_count: Nombre de participants event_location: Localisation diff --git a/src/Bundle/ChillMainBundle/Action/User/UpdateProfile/UpdateProfileCommand.php b/src/Bundle/ChillMainBundle/Action/User/UpdateProfile/UpdateProfileCommand.php index f0751cb58..061f8e55a 100644 --- a/src/Bundle/ChillMainBundle/Action/User/UpdateProfile/UpdateProfileCommand.php +++ b/src/Bundle/ChillMainBundle/Action/User/UpdateProfile/UpdateProfileCommand.php @@ -15,6 +15,7 @@ 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 { @@ -23,11 +24,13 @@ 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()); + $updateProfileCommand = new self($user->getPhonenumber(), $user->getLocale()); foreach ($flagManager->getAllNotificationFlagProviders() as $provider) { $updateProfileCommand->setNotificationFlag( diff --git a/src/Bundle/ChillMainBundle/Action/User/UpdateProfile/UpdateProfileCommandHandler.php b/src/Bundle/ChillMainBundle/Action/User/UpdateProfile/UpdateProfileCommandHandler.php index 4c46e686e..08684b8c6 100644 --- a/src/Bundle/ChillMainBundle/Action/User/UpdateProfile/UpdateProfileCommandHandler.php +++ b/src/Bundle/ChillMainBundle/Action/User/UpdateProfile/UpdateProfileCommandHandler.php @@ -18,6 +18,7 @@ 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']); diff --git a/src/Bundle/ChillMainBundle/CRUD/Controller/CRUDController.php b/src/Bundle/ChillMainBundle/CRUD/Controller/CRUDController.php index 19e9014da..ccba39848 100644 --- a/src/Bundle/ChillMainBundle/CRUD/Controller/CRUDController.php +++ b/src/Bundle/ChillMainBundle/CRUD/Controller/CRUDController.php @@ -102,7 +102,6 @@ class CRUDController extends AbstractController Resolver::class => Resolver::class, SerializerInterface::class => SerializerInterface::class, FilterOrderHelperFactoryInterface::class => FilterOrderHelperFactoryInterface::class, - ManagerRegistry::class => ManagerRegistry::class, ] ); } @@ -674,7 +673,7 @@ class CRUDController extends AbstractController protected function getManagerRegistry(): ManagerRegistry { - return $this->container->get(ManagerRegistry::class); + return $this->container->get('doctrine'); } /** diff --git a/src/Bundle/ChillMainBundle/Entity/User.php b/src/Bundle/ChillMainBundle/Entity/User.php index 70b032238..b5539aa83 100644 --- a/src/Bundle/ChillMainBundle/Entity/User.php +++ b/src/Bundle/ChillMainBundle/Entity/User.php @@ -128,6 +128,12 @@ 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. */ @@ -716,7 +722,14 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter public function getLocale(): string { - return 'fr'; + return $this->locale; + } + + public function setLocale(string $locale): self + { + $this->locale = $locale; + + return $this; } #[Assert\Callback] diff --git a/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStep.php b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStep.php index 24acc4f78..13978493a 100644 --- a/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStep.php +++ b/src/Bundle/ChillMainBundle/Entity/Workflow/EntityWorkflowStep.php @@ -123,7 +123,7 @@ class EntityWorkflowStep /** * @var Collection */ - #[ORM\OneToMany(mappedBy: 'step', targetEntity: EntityWorkflowStepHold::class)] + #[ORM\OneToMany(mappedBy: 'step', targetEntity: EntityWorkflowStepHold::class, cascade: ['remove'])] private Collection $holdsOnStep; /** diff --git a/src/Bundle/ChillMainBundle/Form/Type/ScopePickerType.php b/src/Bundle/ChillMainBundle/Form/Type/ScopePickerType.php index 6f10626c6..2367bc565 100644 --- a/src/Bundle/ChillMainBundle/Form/Type/ScopePickerType.php +++ b/src/Bundle/ChillMainBundle/Form/Type/ScopePickerType.php @@ -16,6 +16,7 @@ use Chill\MainBundle\Entity\Scope; use Chill\MainBundle\Entity\User; use Chill\MainBundle\Form\DataMapper\ScopePickerDataMapper; use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface; +use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface; use Chill\MainBundle\Templating\TranslatableStringHelperInterface; use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Component\Form\AbstractType; @@ -32,65 +33,84 @@ use Symfony\Component\Security\Core\Security; * Allow to pick amongst available scope for the current * user. * - * options : - * - * - `center`: the center of the entity - * - `role` : the role of the user + * Options: + * - `role`: string, the role to check permissions for + * - Either `subject`: object, entity to resolve centers from + * - Or `center`: Center|array|null, the center(s) to check */ class ScopePickerType extends AbstractType { public function __construct( + private readonly TranslatableStringHelperInterface $translatableStringHelper, private readonly AuthorizationHelperInterface $authorizationHelper, private readonly Security $security, - private readonly TranslatableStringHelperInterface $translatableStringHelper, + private readonly CenterResolverManagerInterface $centerResolverManager, ) {} public function buildForm(FormBuilderInterface $builder, array $options) { - $items = array_values( + // Compute centers from subject + $centers = $options['center'] ?? null; + if (null === $centers && isset($options['subject'])) { + $centers = $this->centerResolverManager->resolveCenters($options['subject']); + } + + if (null === $centers) { + throw new \RuntimeException('Either "center" or "subject" must be provided'); + } + + $reachableScopes = array_values( array_filter( $this->authorizationHelper->getReachableScopes( $this->security->getUser(), $options['role'], - $options['center'] + $centers ), static fn (Scope $s) => $s->isActive() ) ); - if (0 === \count($items)) { - throw new \RuntimeException('no scopes are reachable. This form should not be shown to user'); + $builder->setAttribute('reachable_scopes_count', count($reachableScopes)); + + if (0 === count($reachableScopes)) { + $builder->setAttribute('has_scopes', false); + + return; } - if (1 !== \count($items)) { + $builder->setAttribute('has_scopes', true); + + if (1 !== count($reachableScopes)) { $builder->add('scope', EntityType::class, [ 'class' => Scope::class, 'placeholder' => 'Choose the circle', 'choice_label' => fn (Scope $c) => $this->translatableStringHelper->localize($c->getName()), - 'choices' => $items, + 'choices' => $reachableScopes, ]); $builder->setDataMapper(new ScopePickerDataMapper()); } else { $builder->add('scope', HiddenType::class, [ - 'data' => $items[0]->getId(), + 'data' => $reachableScopes[0]->getId(), ]); - $builder->setDataMapper(new ScopePickerDataMapper($items[0])); + $builder->setDataMapper(new ScopePickerDataMapper($reachableScopes[0])); } } public function buildView(FormView $view, FormInterface $form, array $options) { $view->vars['fullWidth'] = true; + // display of label is handled by the EntityType + $view->vars['label'] = false; } public function configureOptions(OptionsResolver $resolver) { $resolver - // create `center` option - ->setRequired('center') - ->setAllowedTypes('center', [Center::class, 'array', 'null']) - // create ``role` option ->setRequired('role') - ->setAllowedTypes('role', ['string']); + ->setAllowedTypes('role', ['string']) + ->setDefined('subject') + ->setAllowedTypes('subject', ['object']) + ->setDefined('center') + ->setAllowedTypes('center', [Center::class, 'array', 'null']); } } diff --git a/src/Bundle/ChillMainBundle/Form/Type/UserLocaleType.php b/src/Bundle/ChillMainBundle/Form/Type/UserLocaleType.php new file mode 100644 index 000000000..8e9c7fa62 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Form/Type/UserLocaleType.php @@ -0,0 +1,43 @@ +availableLanguages as $languageCode) { + $choices[Languages::getName($languageCode)] = $languageCode; + } + + $resolver->setDefaults([ + 'choices' => $choices, + 'placeholder' => 'user.locale.placeholder', + 'required' => true, + 'label' => 'user.locale.label', + 'help' => 'user.locale.help', + ]); + } + + public function getParent(): string + { + return ChoiceType::class; + } +} diff --git a/src/Bundle/ChillMainBundle/Form/UpdateProfileType.php b/src/Bundle/ChillMainBundle/Form/UpdateProfileType.php index 6aa8f0943..3806facb9 100644 --- a/src/Bundle/ChillMainBundle/Form/UpdateProfileType.php +++ b/src/Bundle/ChillMainBundle/Form/UpdateProfileType.php @@ -14,6 +14,7 @@ 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; @@ -26,6 +27,7 @@ class UpdateProfileType extends AbstractType ->add('phonenumber', ChillPhoneNumberType::class, [ 'required' => false, ]) + ->add('locale', UserLocaleType::class) ->add('notificationFlags', NotificationFlagsType::class) ; } diff --git a/src/Bundle/ChillMainBundle/Notification/Email/NotificationMailer.php b/src/Bundle/ChillMainBundle/Notification/Email/NotificationMailer.php index 2f888ffd5..1cb64ff4a 100644 --- a/src/Bundle/ChillMainBundle/Notification/Email/NotificationMailer.php +++ b/src/Bundle/ChillMainBundle/Notification/Email/NotificationMailer.php @@ -24,6 +24,8 @@ 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( @@ -31,6 +33,7 @@ readonly class NotificationMailer private LoggerInterface $logger, private MessageBusInterface $messageBus, private TranslatorInterface $translator, + // private LocaleSwitcher $localeSwitcher, ) {} public function postPersistComment(NotificationComment $comment, PostPersistEventArgs $eventArgs): void @@ -56,7 +59,7 @@ readonly class NotificationMailer $email ->to($dest->getEmail()) ->subject('Re: '.$comment->getNotification()->getTitle()) - ->textTemplate('@ChillMain/Notification/email_notification_comment_persist.fr.md.twig') + ->textTemplate('@ChillMain/Notification/email_notification_comment_persist.md.twig') ->context([ 'comment' => $comment, 'dest' => $dest, @@ -137,13 +140,53 @@ 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.fr.md.twig') + ->textTemplate('@ChillMain/Notification/email_non_system_notification_content.md.twig') ->context([ 'notification' => $notification, 'dest' => $addressee, @@ -182,9 +225,43 @@ 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.fr.md.twig') + ->htmlTemplate('@ChillMain/Notification/email_daily_digest.md.twig') ->context([ 'user' => $user, 'notifications' => $notifications, @@ -222,7 +299,7 @@ readonly class NotificationMailer $email = new TemplatedEmail(); $email - ->textTemplate('@ChillMain/Notification/email_non_system_notification_content_to_email.fr.md.twig') + ->textTemplate('@ChillMain/Notification/email_non_system_notification_content_to_email.md.twig') ->context([ 'notification' => $notification, 'dest' => $emailAddress, diff --git a/src/Bundle/ChillMainBundle/Pagination/PaginatorFactory.php b/src/Bundle/ChillMainBundle/Pagination/PaginatorFactory.php index 9a809287b..fa4897224 100644 --- a/src/Bundle/ChillMainBundle/Pagination/PaginatorFactory.php +++ b/src/Bundle/ChillMainBundle/Pagination/PaginatorFactory.php @@ -17,7 +17,7 @@ use Symfony\Component\Routing\RouterInterface; /** * Create paginator instances. */ -final readonly class PaginatorFactory implements PaginatorFactoryInterface +class PaginatorFactory implements PaginatorFactoryInterface { final public const DEFAULT_CURRENT_PAGE_KEY = 'page'; @@ -29,16 +29,16 @@ final readonly class PaginatorFactory implements PaginatorFactoryInterface /** * the request stack. */ - private RequestStack $requestStack, + private readonly RequestStack $requestStack, /** * the router and generator for url. */ - private RouterInterface $router, + private readonly RouterInterface $router, /** * the default item per page. This may be overriden by * the request or inside the paginator. */ - private int $itemPerPage = 20, + private readonly int $itemPerPage = 20, ) {} /** diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/EntityWorkflow/EntityWorkflowVueSubscriber.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/EntityWorkflow/EntityWorkflowVueSubscriber.vue index 332849ef5..a9e5348ea 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/EntityWorkflow/EntityWorkflowVueSubscriber.vue +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/EntityWorkflow/EntityWorkflowVueSubscriber.vue @@ -1,40 +1,38 @@ @@ -45,9 +43,7 @@ import { defineProps, defineEmits } from "vue"; import { trans, WORKFLOW_SUBSCRIBE_FINAL, - WORKFLOW_UNSUBSCRIBE_FINAL, WORKFLOW_SUBSCRIBE_ALL_STEPS, - WORKFLOW_UNSUBSCRIBE_ALL_STEPS, } from "translator"; // props diff --git a/src/Bundle/ChillMainBundle/Resources/views/Login/_login-logo.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Login/_login-logo.html.twig index 751165357..6c5d06de7 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Login/_login-logo.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Login/_login-logo.html.twig @@ -1 +1 @@ - \ No newline at end of file + diff --git a/src/Bundle/ChillMainBundle/Resources/views/Login/login.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Login/login.html.twig index 8a636a8cd..4edbc19f5 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Login/login.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Login/login.html.twig @@ -16,7 +16,7 @@ * along with this program. If not, see . #} - + @@ -35,10 +35,10 @@ <form method="POST" action="{{ path('login_check') }}"> <label for="_username">{{ 'Username'|trans }}</label> - <input type="text" name="_username" value="{{ last_username }}" /> + <input type="text" name="_username" value="{{ last_username }}" id="_username" /> <br/> <label for="_password">{{ 'Password'|trans }}</label> - <input type="password" name="_password" /> + <input type="password" name="_password" id="_password" /> <input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}" /> <br/> <button type="submit" name="login">{{ 'Login'|trans }}</button> diff --git a/src/Bundle/ChillMainBundle/Resources/views/Notification/email_daily_digest.fr.md.twig b/src/Bundle/ChillMainBundle/Resources/views/Notification/email_daily_digest.md.twig similarity index 100% rename from src/Bundle/ChillMainBundle/Resources/views/Notification/email_daily_digest.fr.md.twig rename to src/Bundle/ChillMainBundle/Resources/views/Notification/email_daily_digest.md.twig diff --git a/src/Bundle/ChillMainBundle/Resources/views/Notification/email_non_system_notification_content.fr.md.twig b/src/Bundle/ChillMainBundle/Resources/views/Notification/email_non_system_notification_content.md.twig similarity index 88% rename from src/Bundle/ChillMainBundle/Resources/views/Notification/email_non_system_notification_content.fr.md.twig rename to src/Bundle/ChillMainBundle/Resources/views/Notification/email_non_system_notification_content.md.twig index 62e41860b..023f2901a 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Notification/email_non_system_notification_content.fr.md.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Notification/email_non_system_notification_content.md.twig @@ -14,7 +14,7 @@ Vous pouvez visualiser la notification et y répondre ici: -{{ absolute_url(path('chill_main_notification_show', {'_locale': 'fr', 'id': notification.id }, false)) }} +{{ absolute_url(path('chill_main_notification_show', {'_locale': dest.locale, 'id': notification.id }, false)) }} -- Le logiciel Chill diff --git a/src/Bundle/ChillMainBundle/Resources/views/Notification/email_non_system_notification_content_to_email.fr.md.twig b/src/Bundle/ChillMainBundle/Resources/views/Notification/email_non_system_notification_content_to_email.md.twig similarity index 100% rename from src/Bundle/ChillMainBundle/Resources/views/Notification/email_non_system_notification_content_to_email.fr.md.twig rename to src/Bundle/ChillMainBundle/Resources/views/Notification/email_non_system_notification_content_to_email.md.twig diff --git a/src/Bundle/ChillMainBundle/Resources/views/Notification/email_notification_comment_persist.fr.md.twig b/src/Bundle/ChillMainBundle/Resources/views/Notification/email_notification_comment_persist.md.twig similarity index 87% rename from src/Bundle/ChillMainBundle/Resources/views/Notification/email_notification_comment_persist.fr.md.twig rename to src/Bundle/ChillMainBundle/Resources/views/Notification/email_notification_comment_persist.md.twig index b1244da39..e7e212492 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Notification/email_notification_comment_persist.fr.md.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Notification/email_notification_comment_persist.md.twig @@ -13,7 +13,7 @@ Commentaire: Vous pouvez visualiser la notification et y répondre ici: -{{ absolute_url(path('chill_main_notification_show', {'_locale': 'fr', 'id': comment.notification.id }, false)) }} +{{ absolute_url(path('chill_main_notification_show', {'_locale': dest.locale, 'id': comment.notification.id }, false)) }} -- Le logiciel Chill diff --git a/src/Bundle/ChillMainBundle/Resources/views/User/index.html.twig b/src/Bundle/ChillMainBundle/Resources/views/User/index.html.twig index 5393f09c8..66ffc030b 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/User/index.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/User/index.html.twig @@ -61,7 +61,7 @@ {% endif %} </li> <li> - <span class="dt">cercle/centre:</span> + <span class="dt">{{ 'Scope'|trans }}/{{ 'center'|trans }}:</span> {% if entity.mainScope %} {{ entity.mainScope.name|localize_translatable_string }} {% endif %} diff --git a/src/Bundle/ChillMainBundle/Resources/views/User/profile.html.twig b/src/Bundle/ChillMainBundle/Resources/views/User/profile.html.twig index 0b268af03..266f75115 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/User/profile.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/User/profile.html.twig @@ -44,6 +44,7 @@ <div> {{ form_start(form) }} {{ form_row(form.phonenumber) }} + {{ form_row(form.locale) }} <h2 class="mb-4">{{ 'user.profile.notification_preferences'|trans }}</h2> <table class="table table-striped align-middle"> diff --git a/src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_notification_on_transition_completed_content.fr.txt.twig b/src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_notification_on_transition_completed_content.fr.txt.twig index 03ddad84f..2b4beb42b 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_notification_on_transition_completed_content.fr.txt.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_notification_on_transition_completed_content.fr.txt.twig @@ -1,16 +1,16 @@ {{ dest.label }}, -Un suivi "{{ workflow.text }}" a atteint une nouvelle étape: {{ workflow.text }} +{{ 'workflow.notification.content.new_step_reached'|trans({'%workflow%': workflow.text}) }} -Titre du workflow: "{{ title }}". +{{ 'workflow.notification.content.workflow_title'|trans({'%title%': title}) }} {% if is_dest %} -Vous êtes invités à valider cette étape au plus tôt. +{{ 'workflow.notification.content.validation_needed'|trans }} {% endif %} -Vous pouvez visualiser le workflow sur cette page: +{{ 'workflow.notification.content.view_workflow'|trans }} -{{ absolute_url(path('chill_main_workflow_show', {'id': entity_workflow.id, '_locale': 'fr'})) }} +{{ absolute_url(path('chill_main_workflow_show', {'id': entity_workflow.id, '_locale': dest.locale|default('fr')})) }} -Cordialement, +{{ 'workflow.notification.content.regards'|trans }} diff --git a/src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_notification_on_transition_completed_title.fr.txt.twig b/src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_notification_on_transition_completed_title.fr.txt.twig index 9a6bc70a0..0d266cbac 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_notification_on_transition_completed_title.fr.txt.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_notification_on_transition_completed_title.fr.txt.twig @@ -1,5 +1,5 @@ {%- if is_dest -%} -Un suivi {{ workflow.text }} demande votre attention: {{ title }} +{{ 'workflow.notification.title.attention_needed'|trans({'%workflow%': workflow.text, '%title%': title}) }} {%- else -%} -Un suivi {{ workflow.text }} a atteint une nouvelle étape: {{ place.text }}: {{ title }} +{{ 'workflow.notification.title.new_step'|trans({'%workflow%': workflow.text, '%place%': place.text, '%title%': title}) }} {%- endif -%} diff --git a/src/Bundle/ChillMainBundle/Routing/MenuBuilder/AdminUserMenuBuilder.php b/src/Bundle/ChillMainBundle/Routing/MenuBuilder/AdminUserMenuBuilder.php index 6c2baf0fd..045c9d47f 100644 --- a/src/Bundle/ChillMainBundle/Routing/MenuBuilder/AdminUserMenuBuilder.php +++ b/src/Bundle/ChillMainBundle/Routing/MenuBuilder/AdminUserMenuBuilder.php @@ -49,7 +49,7 @@ class AdminUserMenuBuilder implements LocalMenuBuilderInterface 'route' => 'chill_crud_center_index', ])->setExtras(['order' => 1010]); - $menu->addChild('Regroupements des centres', [ + $menu->addChild('Regroupements des territoires', [ 'route' => 'chill_crud_regroupment_index', ])->setExtras(['order' => 1015]); diff --git a/src/Bundle/ChillMainBundle/Security/PasswordRecover/RecoverPasswordHelper.php b/src/Bundle/ChillMainBundle/Security/PasswordRecover/RecoverPasswordHelper.php index b325a9345..7ef9eadb2 100644 --- a/src/Bundle/ChillMainBundle/Security/PasswordRecover/RecoverPasswordHelper.php +++ b/src/Bundle/ChillMainBundle/Security/PasswordRecover/RecoverPasswordHelper.php @@ -16,11 +16,13 @@ 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) {} + public function __construct(private readonly TokenManager $tokenManager, private readonly UrlGeneratorInterface $urlGenerator, private readonly MailerInterface $mailer/* , private readonly LocaleSwitcher $localeSwitcher */) {} /** * @param bool $absolute @@ -53,6 +55,24 @@ 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()) diff --git a/src/Bundle/ChillMainBundle/Tests/Controller/ScopeControllerTest.php b/src/Bundle/ChillMainBundle/Tests/Controller/ScopeControllerTest.php index b88b0a852..ffc832c37 100644 --- a/src/Bundle/ChillMainBundle/Tests/Controller/ScopeControllerTest.php +++ b/src/Bundle/ChillMainBundle/Tests/Controller/ScopeControllerTest.php @@ -35,7 +35,7 @@ final class ScopeControllerTest extends WebTestCase $client->getResponse()->getStatusCode(), 'Unexpected HTTP status code for GET /fr/admin/scope/' ); - $crawler = $client->click($crawler->selectLink('Créer un nouveau cercle')->link()); + $crawler = $client->click($crawler->selectLink('Créer un nouveau service')->link()); // Fill in the form and submit it $form = $crawler->selectButton('Créer')->form([ 'chill_mainbundle_scope[name][fr]' => 'Test en fr', diff --git a/src/Bundle/ChillMainBundle/Tests/Form/Type/ScopePickerTypeTest.php b/src/Bundle/ChillMainBundle/Tests/Form/Type/ScopePickerTypeTest.php index 3d3813748..53ce413d2 100644 --- a/src/Bundle/ChillMainBundle/Tests/Form/Type/ScopePickerTypeTest.php +++ b/src/Bundle/ChillMainBundle/Tests/Form/Type/ScopePickerTypeTest.php @@ -11,11 +11,11 @@ declare(strict_types=1); namespace Form\Type; -use Chill\MainBundle\Entity\Center; use Chill\MainBundle\Entity\Scope; use Chill\MainBundle\Entity\User; use Chill\MainBundle\Form\Type\ScopePickerType; use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface; +use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface; use Chill\MainBundle\Templating\TranslatableStringHelperInterface; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder; @@ -39,11 +39,11 @@ final class ScopePickerTypeTest extends TypeTestCase { use ProphecyTrait; - public function estBuildOneScopeIsSuccessful() + public function testBuildOneScopeIsSuccessful() { $form = $this->factory->create(ScopePickerType::class, null, [ - 'center' => new Center(), 'role' => 'ONE_SCOPE', + 'center' => [], ]); $view = $form->createView(); @@ -54,8 +54,8 @@ final class ScopePickerTypeTest extends TypeTestCase public function testBuildThreeScopesIsSuccessful() { $form = $this->factory->create(ScopePickerType::class, null, [ - 'center' => new Center(), 'role' => 'THREE_SCOPE', + 'center' => [], ]); $view = $form->createView(); @@ -66,8 +66,8 @@ final class ScopePickerTypeTest extends TypeTestCase public function testBuildTwoScopesIsSuccessful() { $form = $this->factory->create(ScopePickerType::class, null, [ - 'center' => new Center(), 'role' => 'TWO_SCOPE', + 'center' => [], ]); $view = $form->createView(); @@ -101,10 +101,13 @@ final class ScopePickerTypeTest extends TypeTestCase static fn ($args) => $args[0]['fr'] ); + $centerResolverManager = $this->prophesize(CenterResolverManagerInterface::class); + $type = new ScopePickerType( + $translatableStringHelper->reveal(), $authorizationHelper->reveal(), $security->reveal(), - $translatableStringHelper->reveal() + $centerResolverManager->reveal() ); // add the mocks for creating EntityType diff --git a/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/NotificationToUserGroupsOnTransition.php b/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/NotificationToUserGroupsOnTransition.php index 1eebb03a6..dad18a30c 100644 --- a/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/NotificationToUserGroupsOnTransition.php +++ b/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/NotificationToUserGroupsOnTransition.php @@ -22,6 +22,8 @@ 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( @@ -31,6 +33,7 @@ final readonly class NotificationToUserGroupsOnTransition implements EventSubscr private MailerInterface $mailer, private EntityManagerInterface $entityManager, private EntityWorkflowManager $entityWorkflowManager, + // private LocaleSwitcher $localeSwitcher, ) {} public static function getSubscribedEvents(): array @@ -87,6 +90,24 @@ 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') diff --git a/src/Bundle/ChillMainBundle/config/services/form.yaml b/src/Bundle/ChillMainBundle/config/services/form.yaml index e917b37c9..ab7a49c68 100644 --- a/src/Bundle/ChillMainBundle/config/services/form.yaml +++ b/src/Bundle/ChillMainBundle/config/services/form.yaml @@ -12,6 +12,12 @@ services: tags: - { name: form.type, alias: translatable_string } + Chill\MainBundle\Form\Type\UserLocaleType: + arguments: + - "%chill_main.available_languages%" + tags: + - { name: form.type } + chill.main.form.type.select2choice: class: Chill\MainBundle\Form\Type\Select2ChoiceType tags: diff --git a/src/Bundle/ChillMainBundle/migrations/Version20251022140718.php b/src/Bundle/ChillMainBundle/migrations/Version20251022140718.php new file mode 100644 index 000000000..733bf5da1 --- /dev/null +++ b/src/Bundle/ChillMainBundle/migrations/Version20251022140718.php @@ -0,0 +1,33 @@ +<?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'); + } +} diff --git a/src/Bundle/ChillMainBundle/migrations/Version20251104124123.php b/src/Bundle/ChillMainBundle/migrations/Version20251104124123.php new file mode 100644 index 000000000..9ba086f36 --- /dev/null +++ b/src/Bundle/ChillMainBundle/migrations/Version20251104124123.php @@ -0,0 +1,46 @@ +<?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 Version20251104124123 extends AbstractMigration +{ + public function getDescription(): string + { + return 'Delete on cascade EntityWorkflowStepHold'; + } + + public function up(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_main_workflow_entity_step_hold + DROP CONSTRAINT fk_1be2e7c73b21e9c'); + + $this->addSql('ALTER TABLE chill_main_workflow_entity_step_hold + ADD CONSTRAINT fk_1be2e7c73b21e9c + FOREIGN KEY (step_id) + REFERENCES chill_main_workflow_entity_step (id) + ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_main_workflow_entity_step_hold + DROP CONSTRAINT fk_1be2e7c73b21e9c'); + + $this->addSql('ALTER TABLE chill_main_workflow_entity_step_hold + ADD CONSTRAINT fk_1be2e7c73b21e9c + FOREIGN KEY (step_id) + REFERENCES chill_main_workflow_entity_step (id)'); + } +} diff --git a/src/Bundle/ChillMainBundle/translations/messages+intl-icu.fr.yaml b/src/Bundle/ChillMainBundle/translations/messages+intl-icu.fr.yaml index 580910b2c..5b0841238 100644 --- a/src/Bundle/ChillMainBundle/translations/messages+intl-icu.fr.yaml +++ b/src/Bundle/ChillMainBundle/translations/messages+intl-icu.fr.yaml @@ -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: diff --git a/src/Bundle/ChillMainBundle/translations/messages.fr.yml b/src/Bundle/ChillMainBundle/translations/messages.fr.yml index 676f9cdea..b6343312e 100644 --- a/src/Bundle/ChillMainBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillMainBundle/translations/messages.fr.yml @@ -54,8 +54,12 @@ user: title: Mon profil Profile successfully updated!: Votre profil a été mis à jour! no job: Pas de métier assigné - no scope: Pas de cercle assigné + no scope: Pas de service 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 user_group: inactive: Inactif @@ -102,9 +106,9 @@ createdAt: Créé le createdBy: Créé par #elements used in software -centers: centres -Centers: Centres -center: centre +centers: territoires +Centers: Territoires +center: territoire comment: commentaire Comment: Commentaire Comments: Commentaires @@ -227,12 +231,12 @@ Location Menu: Localisations et types de localisation Management of location: Gestion des localisations et types de localisation #admin section for center's administration -Create a new center: Créer un nouveau centre -Center list: Liste des centres -Center edit: Édition d'un centre -Center creation: Création d'un centre -New center: Nouveau centre -Center: Centre +Create a new center: Créer une nouveau territoire +Center list: Liste des territoires +Center edit: Édition d'un territoire +Center creation: Création d'un territoire +New center: Nouveau territoire +Center: Territoire #admin section for permissions group Permissions group list: Groupes de permissions @@ -246,15 +250,15 @@ New permission group: Nouveau groupe de permissions PermissionsGroup "%name%" edit: Modification du groupe de permission '%name%' Role: Rôle Choose amongst roles: Choisir un rôle -Choose amongst scopes: Choisir un cercle +Choose amongst scopes: Choisir un service Add permission: Ajouter les permissions This group does not provide any permission: Ce groupe n'attribue aucune permission The role '%role%' has been removed: Le rôle "%role%" a été enlevé de ce groupe de permission -The role '%role%' on circle '%scope%' has been removed: Le rôle "%role%" sur le cercle "%scope%" a été enlevé de ce groupe de permission +The role '%role%' on circle '%scope%' has been removed: Le rôle "%role%" sur le service "%scope%" a été enlevé de ce groupe de permission Unclassified: Non classifié -Help to pick role and scope: Certains rôles ne nécessitent pas de cercle. -The role need scope: Ce rôle nécessite un cercle. -The role does not need scope: Ce rôle ne nécessite pas de cercle ! +Help to pick role and scope: Certains rôles ne nécessitent pas de service. +The role need scope: Ce rôle nécessite un service. +The role does not need scope: Ce rôle ne nécessite pas de service ! #admin section for users User configuration: Gestion des utilisateurs @@ -270,7 +274,7 @@ Grant new permissions: Ajout de permissions Add a new groupCenter: Ajout de permissions The permissions have been successfully added to the user: Les permissions ont été accordées à l'utilisateur The permissions where removed.: Les permissions ont été enlevées. -Center & groups: Centre et groupes +Center & groups: Territoire et groupes User %username%: Utilisateur %username% Add a new user: Ajouter un nouvel utilisateur The permissions have been added: Les permissions ont été ajoutées @@ -280,13 +284,13 @@ Back to the user edition: Retour au formulaire d'édition Password successfully updated!: Mot de passe mis à jour Flags: Drapeaux Main location: Localisation principale -Main scope: Cercle -Main center: Centre +Main scope: Service +Main center: Territoire user job: Métier de l'utilisateur Job: Métier Jobs: Métiers -Choose a main center: Choisir un centre -Choose a main scope: Choisir un cercle +Choose a main center: Choisir un territoire +Choose a main scope: Choisir un service choose a job: Choisir un métier choose a location: Choisir une localisation @@ -302,12 +306,12 @@ Current location successfully updated: Localisation actuelle mise à jour Pick a location: Choisir un lieu #admin section for circles (old: scopes) -List circles: Cercles -New circle: Nouveau cercle -Circle: Cercle -Circle edit: Modification du cercle -Circle creation: Création d'un cercle -Create a new circle: Créer un nouveau cercle +List circles: Services +New circle: Nouveau service +Circle: Service +Circle edit: Modification du service +Circle creation: Création d'un service +Create a new circle: Créer un nouveau service #admin section for location Location: Localisation @@ -347,9 +351,9 @@ Country list: Liste des pays Country code: Code du pays # circles / scopes -Choose the circle: Choisir le cercle -Scope: Cercle -Scopes: Cercles +Choose the circle: Choisir le service +Scope: Service +Scopes: Services #export @@ -357,14 +361,14 @@ Scopes: Cercles Exports list: Liste des exports Create an export: Créer un export #export creation step 'center' : pick a center -Pick centers: Choisir les centres -Pick a center: Choisir un centre -The export will contains only data from the picked centers.: L'export ne contiendra que les données des centres choisis. -This will eventually restrict your possibilities in filtering the data.: Les possibilités de filtrages seront adaptées aux droits de consultation pour les centres choisis. +Pick centers: Choisir les territoires +Pick a center: Choisir un territoire +The export will contains only data from the picked centers.: L'export ne contiendra que les données des territoires choisis. +This will eventually restrict your possibilities in filtering the data.: Les possibilités de filtrages seront adaptées aux droits de consultation pour les territoires choisis. Go to export options: Vers la préparation de l'export -Pick aggregated centers: Regroupement de centres -uncheck all centers: Désélectionner tous les centres -check all centers: Sélectionner tous les centres +Pick aggregated centers: Regroupement de territoires +uncheck all centers: Désélectionner tous les territoires +check all centers: Sélectionner tous les territoires # export creation step 'export' : choose aggregators, filtering and formatter Formatter: Mise en forme Choose the formatter: Choisissez le format d'export voulu. @@ -510,10 +514,10 @@ crud: title_edit: Modifier un regroupement center: index: - title: Liste des centres - add_new: Ajouter un centre - title_new: Nouveau centre - title_edit: Modifier un centre + title: Liste des territoires + add_new: Ajouter un territoire + title_new: Nouveau territoire + title_edit: Modifier un territoire news_item: index: title: Liste des actualités @@ -668,6 +672,17 @@ 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 @@ -747,7 +762,22 @@ 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: "Le logiciel Chill" + 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%" 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 @@ -862,7 +892,7 @@ absence: admin: users: export_list_csv: Liste des utilisateurs (format CSV) - export_permissions_csv: Association utilisateurs - groupes de permissions - centre (format CSV) + export_permissions_csv: Association utilisateurs - groupes de permissions - territoire (format CSV) export: id: Identifiant username: Nom d'utilisateur @@ -872,8 +902,8 @@ admin: civility_abbreviation: Abbréviation civilité civility_name: Civilité label: Label - mainCenter_id: Identifiant centre principal - mainCenter_name: Centre principal + mainCenter_id: Identifiant territoire principal + mainCenter_name: Territoire principal mainScope_id: Identifiant service principal mainScope_name: Service principal userJob_id: Identifiant métier @@ -883,8 +913,8 @@ admin: mainLocation_id: Identifiant localisation principale mainLocation_name: Localisation principale absenceStart: Absent à partir du - center_id: Identifiant du centre - center_name: Centre + center_id: Identifiant du territoire + center_name: Territoire permissionsGroup_id: Identifiant du groupe de permissions permissionsGroup_name: Groupe de permissions job_scope_histories: @@ -975,3 +1005,6 @@ multiselect: editor: switch_to_simple: Éditeur simple switch_to_complex: Éditeur riche + +login_page: + logo_alt: "Logo de Chill" diff --git a/src/Bundle/ChillMainBundle/translations/messages.nl.yml b/src/Bundle/ChillMainBundle/translations/messages.nl.yml index 22bb6a50e..f545f7396 100644 --- a/src/Bundle/ChillMainBundle/translations/messages.nl.yml +++ b/src/Bundle/ChillMainBundle/translations/messages.nl.yml @@ -46,6 +46,14 @@ 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 @@ -423,6 +431,17 @@ 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 diff --git a/src/Bundle/ChillMainBundle/translations/validators.fr.yml b/src/Bundle/ChillMainBundle/translations/validators.fr.yml index ce84efc9f..54083cf7c 100644 --- a/src/Bundle/ChillMainBundle/translations/validators.fr.yml +++ b/src/Bundle/ChillMainBundle/translations/validators.fr.yml @@ -1,15 +1,15 @@ # role_scope constraint # scope presence -The role "%role%" require to be associated with a scope.: Le rôle "%role%" doit être associé à un cercle. -The role "%role%" should not be associated with a scope.: Le rôle "%role%" ne doit pas être associé à un cercle. +The role "%role%" require to be associated with a scope.: Le rôle "%role%" doit être associé à un service. +The role "%role%" should not be associated with a scope.: Le rôle "%role%" ne doit pas être associé à un service. "The password must contains one letter, one capitalized letter, one number and one special character as *[@#$%!,;:+\"'-/{}~=µ()£]). Other characters are allowed.": "Le mot de passe doit contenir une majuscule, une minuscule, et au moins un caractère spécial parmi *[@#$%!,;:+\"'-/{}~=µ()£]). Les autres caractères sont autorisés." The password fields must match: Les mots de passe doivent correspondre The password must be greater than {{ limit }} characters: "[1,Inf] Le mot de passe doit contenir au moins {{ limit }} caractères" -A permission is already present for the same role and scope: Une permission est déjà présente pour le même rôle et cercle. +A permission is already present for the same role and scope: Une permission est déjà présente pour le même rôle et service. #UserCircleConsistency -"{{ username }} is not allowed to see entities published in this circle": "{{ username }} n'est pas autorisé à voir l'élément publié dans ce cercle." +"{{ username }} is not allowed to see entities published in this circle": "{{ username }} n'est pas autorisé à voir l'élément publié dans ce service." The user in cc cannot be a dest user in the same workflow step: Un utilisateur en Cc ne peut pas être un utilisateur qui valide. diff --git a/src/Bundle/ChillPersonBundle/Entity/SocialWork/SocialIssue.php b/src/Bundle/ChillPersonBundle/Entity/SocialWork/SocialIssue.php index 02c38a60a..66189642e 100644 --- a/src/Bundle/ChillPersonBundle/Entity/SocialWork/SocialIssue.php +++ b/src/Bundle/ChillPersonBundle/Entity/SocialWork/SocialIssue.php @@ -122,6 +122,8 @@ class SocialIssue * get all the ancestors of the social issue. * * @param bool $includeThis if the array in the result must include the present SocialIssue + * + * @return list<SocialIssue> */ public function getAncestors(bool $includeThis = true): array { @@ -176,7 +178,7 @@ class SocialIssue } /** - * @return Collection|SocialAction[] All the descendant social actions of the entity + * @return Collection<int, SocialAction> All the descendant social actions of the entity */ public function getDescendantsSocialActions(): Collection { @@ -239,18 +241,23 @@ class SocialIssue } /** - * @return Collection<SocialAction> All the descendant social actions of all - * the descendants of the entity + * @return Collection<int, SocialAction> All the social actions of the entity, it's + * the descendants and it's parents */ public function getRecursiveSocialActions(): Collection { $recursiveSocialActions = new ArrayCollection(); + // Get social actions from parent issues + foreach ($this->getAncestors(false) as $ancestor) { + foreach ($ancestor->getDescendantsSocialActions() as $descendant) { + $recursiveSocialActions->add($descendant); + } + } + foreach ($this->getDescendantsWithThis() as $socialIssue) { foreach ($socialIssue->getDescendantsSocialActions() as $descendant) { - if (!$recursiveSocialActions->contains($descendant)) { - $recursiveSocialActions->add($descendant); - } + $recursiveSocialActions->add($descendant); } } diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/js/i18n.js b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/js/i18n.js index 6756381de..9d00136ea 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/js/i18n.js +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/js/i18n.js @@ -80,7 +80,7 @@ const appMessages = { firstName: "Prénom", lastName: "Nom", birthdate: "Date de naissance", - center: "Centre", + center: "Territoire", phonenumber: "Téléphone", mobilenumber: "Mobile", altNames: "Autres noms", diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/App.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/App.vue index bf403291e..bd888dd3c 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/App.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/App.vue @@ -52,20 +52,7 @@ </div> </div> - <!-- results which are not attached to an objective --> - <div v-if="hasResultsForAction"> - <div class="results_without_objective"> - {{ $t("results_without_objective") }} - </div> - <div> - <add-result - :availableResults="resultsForAction" - destination="action" - ></add-result> - </div> - </div> - - <!-- results which **are** attached to an objective --> + <!-- 1. Goals with results that were already selected/saved to the entity --> <div v-for="g in goalsPicked" :key="g.goal.id"> <div class="item-title" @click="removeGoal(g)"> <span class="removable">{{ @@ -76,6 +63,32 @@ <add-result :goal="g.goal" destination="goal"></add-result> </div> </div> + + <!-- 2. Results without objectives that were already selected/saved to the entity --> + <div v-if="hasResultsForAction"> + <div + class="results_without_objective" + style=" + background: repeating-linear-gradient( + 45deg, + #e6e6e6, + #e6e6e6 10px, + #f3f3f3 0, + #f3f3f3 20px + ); + " + > + {{ $t("results_without_objective") }} + </div> + <div> + <add-result + :availableResults="resultsForAction" + destination="action" + ></add-result> + </div> + </div> + + <!-- 3. Selector for objectives with results --> <div class="accordion" id="expandedSuggestions"> <div v-if="availableForCheckGoal.length > 0" @@ -138,6 +151,8 @@ }}</span> </div> </div> + + <!-- 4. Selector for results without objectives is already included above in section 2 --> </div> <div id="evaluations" class="action-row"> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/DocumentsList.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/DocumentsList.vue index 77fa443d4..7c9892de9 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/DocumentsList.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/DocumentsList.vue @@ -97,7 +97,7 @@ @click=" goToGenerateDocumentNotification( d, - false, + true, ) " > diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/FormEvaluation.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/FormEvaluation.vue index 7bdde14e9..6f459daec 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/FormEvaluation.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/FormEvaluation.vue @@ -67,37 +67,117 @@ 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; @@ -194,7 +274,7 @@ function updateWarningInterval(value) { } function updateTimeSpent(value) { - timeSpent.value = value; + timeSpent.value = parseInt(value); } function updateComment(value) { @@ -213,11 +293,11 @@ function onInputDocumentTitle(event) { }); } -function addDocument({ stored_object, stored_object_version }) { +function addDocument({ stored_object, stored_object_version, file_name }) { let document = { type: "accompanying_period_work_evaluation_document", storedObject: stored_object, - title: "Nouveau document", + title: file_name, }; store.commit("addDocument", { key: props.evaluation.key, diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/VisGraph/i18n.js b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/VisGraph/i18n.js index 289a68a34..a24e63015 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/VisGraph/i18n.js +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/VisGraph/i18n.js @@ -50,8 +50,8 @@ const visMessages = { return "Né·e le"; } }, - center_id: "Identifiant du centre", - center_type: "Type de centre", + center_id: "Identifiant du territoire", + center_type: "Type de territoire", center_name: "Territoire", // vendée phonenumber: "Téléphone", mobilenumber: "Mobile", diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_js/i18n.ts b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_js/i18n.ts index ba7637544..26d905a97 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_js/i18n.ts +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_js/i18n.ts @@ -25,8 +25,8 @@ const personMessages = { return "Né·e le"; } }, - center_id: "Identifiant du centre", - center_type: "Type de centre", + center_id: "Identifiant du territoire", + center_type: "Type de territoire", center_name: "Territoire", // vendée phonenumber: "Téléphone", mobilenumber: "Mobile", @@ -53,8 +53,8 @@ const personMessages = { "Un nouveau ménage va être créé. L'usager sera membre de ce ménage.", }, center: { - placeholder: "Choisissez un centre", - title: "Centre", + placeholder: "Choisissez un territoire", + title: "territoire", }, }, error_only_one_person: "Une seule personne peut être sélectionnée !", diff --git a/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingCourseWork/_objectifs_results_evaluations.html.twig b/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingCourseWork/_objectifs_results_evaluations.html.twig index f35310503..9adba79ff 100644 --- a/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingCourseWork/_objectifs_results_evaluations.html.twig +++ b/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingCourseWork/_objectifs_results_evaluations.html.twig @@ -2,30 +2,6 @@ # OPTIONS # - displayContent: [short|long] default: short #} -{% if w.results|length > 0 %} - - <table class="obj-res-eval"> - <thead> - <th class="obj"><h4 class="title_label">{{ 'accompanying_course_work.goal'|trans }}</h4></th> - <th class="res"><h4 class="title_label">{{ 'accompanying_course_work.results'|trans }}</h4></th> - </thead> - <tbody> - <tr> - <td class="obj"> - <p class="chill-no-data-statement">{{ 'accompanying_course_work.results without objective'|trans }}</p> - </td> - <td class="res"> - <ul class="result_list"> - {% for r in w.results %} - <li>{{ r.title|localize_translatable_string }}</li> - {% endfor %} - </ul> - </td> - </tr> - </tbody> - </table> -{% endif %} - {% if w.goals|length > 0 %} <table class="obj-res-eval"> <thead> @@ -57,6 +33,31 @@ </table> {% endif %} +{% if w.results|length > 0 %} + + <table class="obj-res-eval"> + <thead> + <th class="obj"><h4 class="title_label">{{ 'accompanying_course_work.goal'|trans }}</h4></th> + <th class="res"><h4 class="title_label">{{ 'accompanying_course_work.results'|trans }}</h4></th> + </thead> + <tbody> + <tr> + <td class="obj"> + <p class="chill-no-data-statement">{{ 'accompanying_course_work.results without objective'|trans }}</p> + </td> + <td class="res"> + <ul class="result_list"> + {% for r in w.results %} + <li>{{ r.title|localize_translatable_string }}</li> + {% endfor %} + </ul> + </td> + </tr> + </tbody> + </table> +{% endif %} + + {% if w.accompanyingPeriodWorkEvaluations|length > 0 %} <table class="obj-res-eval"> <thead> @@ -216,9 +217,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> diff --git a/src/Bundle/ChillPersonBundle/Service/DocGenerator/PersonContext.php b/src/Bundle/ChillPersonBundle/Service/DocGenerator/PersonContext.php index eb588cdaf..0ac921277 100644 --- a/src/Bundle/ChillPersonBundle/Service/DocGenerator/PersonContext.php +++ b/src/Bundle/ChillPersonBundle/Service/DocGenerator/PersonContext.php @@ -168,9 +168,8 @@ final readonly class PersonContext implements PersonContextInterface if ($this->isScopeNecessary($entity)) { $builder->add('scope', ScopePickerType::class, [ - 'center' => $this->centerResolverManager->resolveCenters($entity), 'role' => PersonDocumentVoter::CREATE, - 'label' => 'Scope', + 'subject' => $entity, ]); } } diff --git a/src/Bundle/ChillPersonBundle/Service/SocialWork/SocialActionCSVExportService.php b/src/Bundle/ChillPersonBundle/Service/SocialWork/SocialActionCSVExportService.php index 09b4442b2..71c548dd1 100644 --- a/src/Bundle/ChillPersonBundle/Service/SocialWork/SocialActionCSVExportService.php +++ b/src/Bundle/ChillPersonBundle/Service/SocialWork/SocialActionCSVExportService.php @@ -42,28 +42,41 @@ final readonly class SocialActionCSVExportService $csv->insertOne($headers); foreach ($actions as $action) { - if ($action->getGoals()->isEmpty() && $action->getResults()->isEmpty() && $action->getEvaluations()->isEmpty()) { + $hasGoals = !$action->getGoals()->isEmpty(); + $hasResults = !$action->getResults()->isEmpty(); + $hasEvaluations = !$action->getEvaluations()->isEmpty(); + + // If action has no goals, results, or evaluations, insert a single row + if (!$hasGoals && !$hasResults && !$hasEvaluations) { $csv->insertOne($this->formatRow($action)); + continue; } - foreach ($action->getGoals() as $goal) { - if ($goal->getResults()->isEmpty()) { - $csv->insertOne($this->formatRow($action, $goal)); - } - - foreach ($goal->getResults() as $goalResult) { - $csv->insertOne($this->formatRow($action, $goal, $goalResult)); + // Process goals and their results + if ($hasGoals) { + foreach ($action->getGoals() as $goal) { + if ($goal->getResults()->isEmpty()) { + $csv->insertOne($this->formatRow($action, $goal)); + } else { + foreach ($goal->getResults() as $goalResult) { + $csv->insertOne($this->formatRow($action, $goal, $goalResult)); + } + } } } - foreach ($action->getResults() as $result) { - if ($result->getGoals()->isEmpty()) { + // Process results that are linked to this action (regardless of whether they have goals elsewhere) + if ($hasResults && !$hasGoals) { + foreach ($action->getResults() as $result) { $csv->insertOne($this->formatRow($action, null, null, $result)); } } - foreach ($action->getEvaluations() as $evaluation) { - $csv->insertOne($this->formatRow($action, evaluation: $evaluation)); + // Process evaluations + if ($hasEvaluations) { + foreach ($action->getEvaluations() as $evaluation) { + $csv->insertOne($this->formatRow($action, evaluation: $evaluation)); + } } } diff --git a/src/Bundle/ChillPersonBundle/translations/messages.fr.yml b/src/Bundle/ChillPersonBundle/translations/messages.fr.yml index cde9a6add..88b12f7bd 100644 --- a/src/Bundle/ChillPersonBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillPersonBundle/translations/messages.fr.yml @@ -376,7 +376,7 @@ Create a list of people according to various filters.: Crée une liste d'usagers Fields to include in export: Champs à inclure dans l'export Address valid at this date: Addresse valide à cette date Data valid at this date: Données valides à cette date -Data regarding center, addresses, and so on will be computed at this date: Les données concernant le centre, l'adresse, le ménage, sera calculé à cette date. +Data regarding center, addresses, and so on will be computed at this date: Les données concernant le territoire, l'adresse, le ménage, sera calculé à cette date. List duplicates: Liste des doublons Create a list of duplicate people: Créer la liste des usagers détectés comme doublons. Count people participating in an accompanying course: Nombre d'usagers concernés par un parcours @@ -1110,9 +1110,9 @@ export: Group course by household composition: Grouper les usagers par composition familiale Calc date: Date de calcul de la composition du ménage by_center: - title: Grouper les usagers par centre - at_date: Date de calcul du centre - center: Centre de l'usager + title: Grouper les usagers par territoire + at_date: Date de calcul du territoire + center: Territoire de l'usager by_postal_code: title: Grouper les usagers par code postal de l'adresse at_date: Date de calcul de l'adresse @@ -1437,7 +1437,7 @@ export: acpParticipantPersons: Usagers concernés acpParticipantPersonsIds: Usagers concernés (identifiants) duration: Durée du parcours (en jours) - centers: Centres des usagers + centers: Territoires des usagers eval: List of evaluations: Liste des évaluations diff --git a/src/Bundle/ChillPersonBundle/translations/validators.fr.yml b/src/Bundle/ChillPersonBundle/translations/validators.fr.yml index 691fef833..c6fe0f912 100644 --- a/src/Bundle/ChillPersonBundle/translations/validators.fr.yml +++ b/src/Bundle/ChillPersonBundle/translations/validators.fr.yml @@ -23,7 +23,7 @@ The gender must be set: Le genre doit être renseigné You are not allowed to perform this action: Vous n'avez pas le droit de changer cette valeur. Sorry, but someone else has already changed this entity. Please refresh the page and apply the changes again: Désolé, mais quelqu'un d'autre a déjà modifié cette entité. Veuillez actualiser la page et appliquer à nouveau les modifications -A center is required: Un centre est requis +A center is required: Un territoire est requis #export list You must select at least one element: Vous devez sélectionner au moins un élément diff --git a/src/Bundle/ChillReportBundle/translations/messages.fr.yml b/src/Bundle/ChillReportBundle/translations/messages.fr.yml index c22ed17fb..a8d5ed979 100644 --- a/src/Bundle/ChillReportBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillReportBundle/translations/messages.fr.yml @@ -9,7 +9,7 @@ 'Report list': 'Liste des rapports' Details: Détails Person: Usager -Scope: Cercle +Scope: Service Date: Date User: Utilisateur 'Report type': 'Type de rapport' diff --git a/src/Bundle/ChillTaskBundle/Form/SingleTaskType.php b/src/Bundle/ChillTaskBundle/Form/SingleTaskType.php index 7e572e078..1883263a1 100644 --- a/src/Bundle/ChillTaskBundle/Form/SingleTaskType.php +++ b/src/Bundle/ChillTaskBundle/Form/SingleTaskType.php @@ -27,7 +27,11 @@ use Symfony\Component\OptionsResolver\OptionsResolver; class SingleTaskType extends AbstractType { - public function __construct(private readonly ParameterBagInterface $parameterBag, private readonly CenterResolverDispatcherInterface $centerResolverDispatcher, private readonly ScopeResolverDispatcher $scopeResolverDispatcher) {} + public function __construct( + private readonly ParameterBagInterface $parameterBag, + private readonly CenterResolverDispatcherInterface $centerResolverDispatcher, + private readonly ScopeResolverDispatcher $scopeResolverDispatcher, + ) {} public function buildForm(FormBuilderInterface $builder, array $options) { @@ -64,8 +68,8 @@ class SingleTaskType extends AbstractType if ($isScopeConcerned && $this->parameterBag->get('chill_main')['acl']['form_show_scopes']) { $builder ->add('circle', ScopePickerType::class, [ - 'center' => $center, 'role' => $options['role'], + 'subject' => $task, 'required' => true, ]); } diff --git a/src/Bundle/ChillTaskBundle/Resources/views/SingleTask/Person/list.html.twig b/src/Bundle/ChillTaskBundle/Resources/views/SingleTask/Person/list.html.twig index ffdd17a39..a4d5d81c9 100644 --- a/src/Bundle/ChillTaskBundle/Resources/views/SingleTask/Person/list.html.twig +++ b/src/Bundle/ChillTaskBundle/Resources/views/SingleTask/Person/list.html.twig @@ -5,7 +5,7 @@ {% block title 'Tasks for {{ name }}'|trans({ '{{ name }}' : person|chill_entity_render_string }) %} {% block content %} - <div class="col-md-10 col-xxl"> + <div class="task-list""> <h1>{{ block('title') }}</h1> diff --git a/src/Bundle/ChillTaskBundle/Resources/views/SingleTask/index.html.twig b/src/Bundle/ChillTaskBundle/Resources/views/SingleTask/index.html.twig index 02ab79664..658d9cba1 100644 --- a/src/Bundle/ChillTaskBundle/Resources/views/SingleTask/index.html.twig +++ b/src/Bundle/ChillTaskBundle/Resources/views/SingleTask/index.html.twig @@ -37,7 +37,7 @@ {% endblock %} {% else %} {% block content %} - <div class="col-md-10 col-xxl tasks"> + <div class="col-md-9 col-xxl tasks"> {% include '@ChillTask/SingleTask/AccompanyingCourse/list.html.twig' %} </div> diff --git a/src/Bundle/ChillTaskBundle/translations/messages.fr.yml b/src/Bundle/ChillTaskBundle/translations/messages.fr.yml index b0d155f59..341e889ec 100644 --- a/src/Bundle/ChillTaskBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillTaskBundle/translations/messages.fr.yml @@ -4,7 +4,7 @@ Tasks: "Tâches" Title: Titre Description: Description Assignee: "Personne assignée" -Scope: Cercle +Scope: Service "Start date": "Date de début" "End date": "Date d'échéance" "Warning date": "Date d'avertissement" @@ -106,7 +106,7 @@ My tasks over deadline: Mes tâches à échéance dépassée #transition page Apply transition on task <em>%title%</em>: Appliquer la transition sur la tâche <em>%title%</em> -All centers: Tous les centres +All centers: Tous les territoires # ROLES CHILL_TASK_TASK_CREATE: Ajouter une tâche diff --git a/src/Bundle/ChillThirdPartyBundle/Service/ThirdpartyMergeService.php b/src/Bundle/ChillThirdPartyBundle/Service/ThirdpartyMergeService.php index 4eecb09b4..a5aecea45 100644 --- a/src/Bundle/ChillThirdPartyBundle/Service/ThirdpartyMergeService.php +++ b/src/Bundle/ChillThirdPartyBundle/Service/ThirdpartyMergeService.php @@ -69,10 +69,11 @@ readonly class ThirdpartyMergeService if (($assoc['type'] & ClassMetadata::TO_ONE) !== 0) { $joinColumn = $meta->getSingleAssociationJoinColumnName($assoc['fieldName']); - $suffix = (ThirdParty::class === $assoc['sourceEntity']) ? 'chill_3party.' : ''; + $schema = $meta->getSchemaName(); + $prefix = null !== $schema && '' !== $schema ? $schema.'.' : ''; $queries[] = [ - 'sql' => "UPDATE {$suffix}{$tableName} SET {$joinColumn} = :toKeep WHERE {$joinColumn} = :toDelete", + 'sql' => "UPDATE {$prefix}{$tableName} SET {$joinColumn} = :toKeep WHERE {$joinColumn} = :toDelete", 'params' => ['toKeep' => $toKeep->getId(), 'toDelete' => $toDelete->getId()], ]; } elseif (ClassMetadata::MANY_TO_MANY === $assoc['type'] && isset($assoc['joinTable'])) { @@ -85,13 +86,36 @@ readonly class ThirdpartyMergeService ]; $queries[] = [ - 'sql' => "DELETE FROM {$joinTable} WHERE {$joinColumn} = :toDelete", + 'sql' => "DELETE FROM {$prefix}{$joinTable} WHERE {$joinColumn} = :toDelete", 'params' => ['toDelete' => $toDelete->getId()], ]; } } } + // Also handle many-to-many where ThirdParty is the source + $thirdPartyMeta = $this->em->getClassMetadata(ThirdParty::class); + foreach ($thirdPartyMeta->getAssociationMappings() as $assoc) { + if (ClassMetadata::MANY_TO_MANY === $assoc['type'] && isset($assoc['joinTable'])) { + $joinTable = $assoc['joinTable']['name']; + $prefix = null !== ($assoc['joinTable']['schema'] ?? null) ? $assoc['joinTable']['schema'].'.' : ''; + $joinColumn = $assoc['joinTable']['joinColumns'][0]['name']; // Note: joinColumns, not inverseJoinColumns + + // Get the other column name to build proper duplicate check + $otherColumn = $assoc['joinTable']['inverseJoinColumns'][0]['name']; + + $queries[] = [ + 'sql' => "UPDATE {$prefix}{$joinTable} SET {$joinColumn} = :toKeep WHERE {$joinColumn} = :toDelete AND NOT EXISTS (SELECT 1 FROM {$prefix}{$joinTable} AS t2 WHERE t2.{$joinColumn} = :toKeep AND t2.{$otherColumn} = {$prefix}{$joinTable}.{$otherColumn})", + 'params' => ['toKeep' => $toKeep->getId(), 'toDelete' => $toDelete->getId()], + ]; + + $queries[] = [ + 'sql' => "DELETE FROM {$prefix}{$joinTable} WHERE {$joinColumn} = :toDelete", + 'params' => ['toDelete' => $toDelete->getId()], + ]; + } + } + return $queries; } @@ -102,10 +126,6 @@ readonly class ThirdpartyMergeService 'sql' => 'UPDATE chill_3party.third_party SET parent_id = :toKeep WHERE parent_id = :toDelete', 'params' => ['toKeep' => $toKeep->getId(), 'toDelete' => $toDelete->getId()], ], - [ - 'sql' => 'UPDATE chill_3party.thirdparty_category SET thirdparty_id = :toKeep WHERE thirdparty_id = :toDelete AND NOT EXISTS (SELECT 1 FROM chill_3party.thirdparty_category WHERE thirdparty_id = :toKeep)', - 'params' => ['toKeep' => $toKeep->getId(), 'toDelete' => $toDelete->getId()], - ], [ 'sql' => 'DELETE FROM chill_3party.third_party WHERE id = :toDelete', 'params' => ['toDelete' => $toDelete->getId()], diff --git a/src/Bundle/ChillThirdPartyBundle/Tests/Service/ThirdpartyMergeServiceTest.php b/src/Bundle/ChillThirdPartyBundle/Tests/Service/ThirdpartyMergeServiceTest.php index 4b2819751..4d8afbb7d 100644 --- a/src/Bundle/ChillThirdPartyBundle/Tests/Service/ThirdpartyMergeServiceTest.php +++ b/src/Bundle/ChillThirdPartyBundle/Tests/Service/ThirdpartyMergeServiceTest.php @@ -12,6 +12,7 @@ declare(strict_types=1); namespace Chill\ThirdPartyBundle\Tests\Service; use Chill\ActivityBundle\Entity\Activity; +use Chill\MainBundle\Entity\Center; use Chill\ThirdPartyBundle\Entity\ThirdParty; use Chill\ThirdPartyBundle\Entity\ThirdPartyCategory; use Chill\ThirdPartyBundle\Service\ThirdpartyMergeService; @@ -47,19 +48,20 @@ class ThirdpartyMergeServiceTest extends KernelTestCase $toDelete->setName('Thirdparty to delete'); $this->em->persist($toDelete); - // Create a related entity with TO_ONE relation (thirdparty parent) + // Create a related entity with TO_ONE relation (thirdparty parent) - tests schema handling for TO_ONE $relatedToOneEntity = new ThirdParty(); $relatedToOneEntity->setName('RelatedToOne thirdparty'); $relatedToOneEntity->setParent($toDelete); $this->em->persist($relatedToOneEntity); - // Create a related entity with TO_MANY relation (thirdparty category) + // Create a related entity with MANY_TO_MANY relation (thirdparty category) - tests schema handling for MANY_TO_MANY where ThirdParty is target $thirdpartyCategory = new ThirdPartyCategory(); $thirdpartyCategory->setName(['fr' => 'Thirdparty category']); $this->em->persist($thirdpartyCategory); $toDelete->addCategory($thirdpartyCategory); $this->em->persist($toDelete); + // Test MANY_TO_MANY relation from another bundle (Activity) - tests cross-bundle schema handling $activity = new Activity(); $activity->setDate(new \DateTime()); $activity->addThirdParty($toDelete); @@ -73,14 +75,55 @@ class ThirdpartyMergeServiceTest extends KernelTestCase $this->em->refresh($relatedToOneEntity); // Check that references were updated - $this->assertEquals($toKeep->getId(), $relatedToOneEntity->getParent()->getId(), 'The parent thirdparty was succesfully merged'); + // Test TO_ONE relation in chill_3party schema was properly handled + $this->assertEquals($toKeep->getId(), $relatedToOneEntity->getParent()->getId(), 'The parent thirdparty in chill_3party schema was successfully merged'); + + // Test MANY_TO_MANY relation in chill_3party schema was properly handled $updatedRelatedManyEntity = $this->em->find(ThirdPartyCategory::class, $thirdpartyCategory->getId()); - $this->assertContains($updatedRelatedManyEntity, $toKeep->getCategories(), 'The thirdparty category was found in the toKeep entity'); + $this->assertContains($updatedRelatedManyEntity, $toKeep->getCategories(), 'The thirdparty category in chill_3party schema was found in the toKeep entity'); + + // Test MANY_TO_MANY relation from different schema (Activity bundle) was properly handled + $this->em->refresh($activity); + $this->assertContains($toKeep, $activity->getThirdParties(), 'The activity relation from different schema was successfully merged'); + $this->assertNotContains($toDelete, $activity->getThirdParties(), 'The toDelete thirdparty was removed from activity relation'); // Check that toDelete was removed $this->em->clear(); $deletedThirdParty = $this->em->find(ThirdParty::class, $toDelete->getId()); - $this->assertNull($deletedThirdParty); + $this->assertNull($deletedThirdParty, 'The toDelete thirdparty was successfully removed'); + } + + public function testMergeWithSharedCenterDoesNotCauseUniqueConstraintViolation(): void + { + // Create a center that will be shared by both thirdparties + $sharedCenter = new Center(); + $sharedCenter->setName('Shared Center'); + $this->em->persist($sharedCenter); + + // Create ThirdParty entities + $toKeep = new ThirdParty(); + $toKeep->setName('Thirdparty to keep'); + $toKeep->addCenter($sharedCenter); // Both thirdparties linked to same center + $this->em->persist($toKeep); + + $toDelete = new ThirdParty(); + $toDelete->setName('Thirdparty to delete'); + $toDelete->addCenter($sharedCenter); // Both thirdparties linked to same center + $this->em->persist($toDelete); + + $this->em->flush(); + + // This should not throw a unique constraint violation + $this->service->merge($toKeep, $toDelete); + + // Verify that toKeep still has the shared center + $this->em->refresh($toKeep); + $this->assertContains($sharedCenter, $toKeep->getCenters(), 'The shared center is still linked to the kept thirdparty'); + + // Verify that toDelete was removed + $this->em->clear(); + $deletedThirdParty = $this->em->find(ThirdParty::class, $toDelete->getId()); + $this->assertNull($deletedThirdParty, 'The toDelete thirdparty was successfully removed'); } } diff --git a/src/Bundle/ChillThirdPartyBundle/translations/messages.fr.yml b/src/Bundle/ChillThirdPartyBundle/translations/messages.fr.yml index 2c5e8f262..bdd9420f7 100644 --- a/src/Bundle/ChillThirdPartyBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillThirdPartyBundle/translations/messages.fr.yml @@ -73,8 +73,8 @@ No acronym given: Aucun sigle renseigné No phone given: Aucun téléphone renseigné No email given: Aucune adresse courriel renseignée -The party is visible in those centers: Le tiers est visible dans ces centres -The party is not visible in any center: Le tiers n'est associé à aucun centre +The party is visible in those centers: Le tiers est visible dans ces territoires +The party is not visible in any center: Le tiers n'est associé à aucun territoire No third parties: Aucun tiers Any third party selected: Aucun tiers sélectionné