Merge branch 'master' into 456-doc-generation-active-status

This commit is contained in:
2025-11-19 16:25:44 +01:00
117 changed files with 2326 additions and 495 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

21
.changes/v4.7.0.md Normal file
View File

@@ -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

9
.changes/v4.8.0.md Normal file
View File

@@ -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.

View File

@@ -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). 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 ## v4.6.1 - 2025-10-27
### Fixed ### Fixed
* Fix export case where no 'reason' is picked within the PersonHavingActivityBetweenDateFilter.php * Fix export case where no 'reason' is picked within the PersonHavingActivityBetweenDateFilter.php

View File

@@ -14,7 +14,7 @@
"ext-openssl": "*", "ext-openssl": "*",
"ext-redis": "*", "ext-redis": "*",
"ext-zlib": "*", "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", "champs-libres/wopi-lib": "dev-master@dev",
"doctrine/data-fixtures": "^1.8", "doctrine/data-fixtures": "^1.8",
"doctrine/doctrine-bundle": "^2.1", "doctrine/doctrine-bundle": "^2.1",

View File

@@ -2,7 +2,6 @@
return [ return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true], Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
loophp\PsrHttpMessageBridgeBundle\PsrHttpMessageBridgeBundle::class => ['all' => true],
ChampsLibres\WopiBundle\WopiBundle::class => ['all' => true], ChampsLibres\WopiBundle\WopiBundle::class => ['all' => true],
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
@@ -37,4 +36,5 @@ return [
Chill\WopiBundle\ChillWopiBundle::class => ['all' => true], Chill\WopiBundle\ChillWopiBundle::class => ['all' => true],
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
Symfony\UX\Translator\UxTranslatorBundle::class => ['all' => true], Symfony\UX\Translator\UxTranslatorBundle::class => ['all' => true],
loophp\PsrHttpMessageBridgeBundle\PsrHttpMessageBridgeBundle::class => ['all' => true],
]; ];

View File

@@ -1,5 +1,5 @@
chill_main: chill_main:
available_languages: [ '%env(resolve:LOCALE)%', 'en' ] available_languages: [ '%env(resolve:LOCALE)%', 'en', 'nl' ]
available_countries: ['BE', 'FR'] available_countries: ['BE', 'FR']
top_banner: top_banner:
visible: false visible: false

View File

@@ -0,0 +1,2 @@
chill_aside_activity:
show_concerned_persons_count: hidden

View File

@@ -23,8 +23,8 @@ class "Document" {
- text description - text description
- ArrayCollection_DocumentCategory categories - ArrayCollection_DocumentCategory categories
- varchar_150 content #link to openstack - varchar_150 content #link to openstack
- Center center - Territoire territoire
- Cercle cercle - Service service
- User user - User user
- DateTime date # Creation date - DateTime date # Creation date
} }

View File

@@ -38,7 +38,7 @@ Certaines données sont historisées:
- les référents d'un parcours; - les référents d'un parcours;
- les statuts 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. - 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. 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.

View File

@@ -1,6 +1,6 @@
order,table_schema,table_name,commentaire order,table_schema,table_name,commentaire
1,chill_3party,party_category,Catégorie de tiers 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é) 3,chill_3party,party_profession,Profession du tiers (déprécié)
4,chill_3party,third_party,Tiers 4,chill_3party,third_party,Tiers
5,chill_3party,thirdparty_category,association tiers - catégories 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 53,public,activitytpresence,Présence aux échanges
54,public,activitytype,Types d'échanges 54,public,activitytype,Types d'échanges
55,public,activitytypecategory,Catégories de 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, 57,public,chill_activity_activity_chill_person_socialaction,
58,public,chill_activity_activity_chill_person_socialissue 58,public,chill_activity_activity_chill_person_socialissue
59,public,chill_docgen_template,Gabarits de documents 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 110,public,chill_person_marital_status,Etats civils
111,public,chill_person_not_duplicate, 111,public,chill_person_not_duplicate,
112,public,chill_person_person,Usagers 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é 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 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 116,public,chill_person_relations,Types de relations de filiation
@@ -142,7 +142,7 @@ order,table_schema,table_name,commentaire
141,public,permission_groups 141,public,permission_groups
142,public,permissionsgroup_rolescope 142,public,permissionsgroup_rolescope
143,public,persons_spoken_languages 143,public,persons_spoken_languages
144,public,regroupment,Regroupement de centres 144,public,regroupment,Regroupement de territoires
145,public,regroupment_center, 145,public,regroupment_center,
146,public,role_scopes, 146,public,role_scopes,
147,public,scopes,Services 147,public,scopes,Services
1 order table_schema table_name commentaire
2 1 chill_3party party_category Catégorie de tiers
3 2 chill_3party party_center Association entre les tiers et les centres (déprécié) Association entre les tiers et les territoires (déprécié)
4 3 chill_3party party_profession Profession du tiers (déprécié)
5 4 chill_3party third_party Tiers
6 5 chill_3party thirdparty_category association tiers - catégories
54 53 public activitytpresence Présence aux échanges
55 54 public activitytype Types d'échanges
56 55 public activitytypecategory Catégories de types d'échanges
57 56 public centers Centres (territoires, agences, etc.) Territoires (territoires, agences, etc.)
58 57 public chill_activity_activity_chill_person_socialaction
59 58 public chill_activity_activity_chill_person_socialissue
60 59 public chill_docgen_template Gabarits de documents
111 110 public chill_person_marital_status Etats civils
112 111 public chill_person_not_duplicate
113 112 public chill_person_person Usagers
114 113 public chill_person_person_center_history Historique des centres d'un usagers Historique des territoires d'un usagers
115 114 public chill_person_persons_to_addresses Déprécié
116 115 public chill_person_phone Numéros d etéléphone supplémentaires d'un usager
117 116 public chill_person_relations Types de relations de filiation
142 141 public permission_groups
143 142 public permissionsgroup_rolescope
144 143 public persons_spoken_languages
145 144 public regroupment Regroupement de centres Regroupement de territoires
146 145 public regroupment_center
147 146 public role_scopes
148 147 public scopes Services

View File

@@ -66,6 +66,9 @@ class ListActivityHelper
->leftJoin('activity.location', 'location') ->leftJoin('activity.location', 'location')
->addSelect('location.name AS locationName') ->addSelect('location.name AS locationName')
->addSelect('activity.sentReceived') ->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('JSON_BUILD_OBJECT(\'uid\', IDENTITY(activity.createdBy), \'d\', activity.createdAt) AS createdBy')
->addSelect('activity.createdAt') ->addSelect('activity.createdAt')
->addSelect('JSON_BUILD_OBJECT(\'uid\', IDENTITY(activity.updatedBy), \'d\', activity.updatedAt) AS updatedBy') ->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), 'createdAt', 'updatedAt' => $this->dateTimeHelper->getLabel($key),
'createdBy', 'updatedBy' => $this->userHelper->getLabel($key, $values, $key), 'createdBy', 'updatedBy' => $this->userHelper->getLabel($key, $values, $key),
'date' => $this->dateTimeHelper->getLabel(self::MSG_KEY.$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) { 'attendeeName' => function ($value) {
if ('_header' === $value) { if ('_header' === $value) {
return 'Attendee'; return 'Attendee';
@@ -176,6 +181,9 @@ class ListActivityHelper
'usersNames', 'usersNames',
'thirdPartiesIds', 'thirdPartiesIds',
'thirdPartiesNames', 'thirdPartiesNames',
'commentText',
'commentDate',
'commentUser',
'createdBy', 'createdBy',
'createdAt', 'createdAt',
'updatedBy', 'updatedBy',

View File

@@ -88,8 +88,8 @@ class ActivityType extends AbstractType
if (null !== $options['data']->getPerson()) { if (null !== $options['data']->getPerson()) {
$builder->add('scope', ScopePickerType::class, [ $builder->add('scope', ScopePickerType::class, [
'center' => $options['center'],
'role' => ActivityVoter::CREATE === (string) $options['role'] ? ActivityVoter::CREATE_PERSON : (string) $options['role'], 'role' => ActivityVoter::CREATE === (string) $options['role'] ? ActivityVoter::CREATE_PERSON : (string) $options['role'],
'center' => $options['center'],
'required' => true, 'required' => true,
]); ]);
} }

View File

@@ -43,11 +43,23 @@ export default {
span.badge { span.badge {
@include badge_social($social-action-color); @include badge_social($social-action-color);
font-size: 95%; font-size: 95%;
white-space: normal;
word-wrap: break-word;
word-break: break-word;
display: inline-block;
max-width: 100%;
margin-bottom: 5px; margin-bottom: 5px;
margin-right: 1em; margin-right: 1em;
max-width: 100%; /* Adjust as needed */ text-align: left;
overflow: hidden; line-height: 1.2em;
text-overflow: ellipsis;
white-space: nowrap; &::before {
position: absolute;
left: 11px;
top: 0;
margin: 0 0.3em 0 -0.75em;
}
position: relative;
padding-left: 1.5em;
} }
</style> </style>

View File

@@ -43,7 +43,22 @@ export default {
span.badge { span.badge {
@include badge_social($social-issue-color); @include badge_social($social-issue-color);
font-size: 95%; font-size: 95%;
white-space: normal;
word-wrap: break-word;
word-break: break-word;
display: inline-block;
max-width: 100%;
margin-bottom: 5px; margin-bottom: 5px;
margin-right: 1em; 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;
} }
</style> </style>

View File

@@ -10,7 +10,7 @@ Attendee: Présence de l'usager
attendee: présence de l'usager attendee: présence de l'usager
list_reasons: liste des sujets list_reasons: liste des sujets
user_username: nom de l'utilisateur user_username: nom de l'utilisateur
circle_name: nom du cercle circle_name: nom du service
Remark: Commentaire Remark: Commentaire
No comments: Aucun commentaire No comments: Aucun commentaire
Add a new activity: Ajouter une nouvel échange Add a new activity: Ajouter une nouvel échange
@@ -20,7 +20,7 @@ not present: absent
Delete: Supprimer Delete: Supprimer
Update: Mettre à jour Update: Mettre à jour
Update activity: Modifier l'échange Update activity: Modifier l'échange
Scope: Cercle Scope: Service
Activity data: Données de l'échange Activity data: Données de l'échange
Activity location: Localisation de l'échange Activity location: Localisation de l'échange
No reason associated: Aucun sujet No reason associated: Aucun sujet
@@ -398,13 +398,15 @@ export:
sent received: Envoyé ou reçu sent received: Envoyé ou reçu
emergency: Urgence emergency: Urgence
accompanying course id: Identifiant du parcours accompanying course id: Identifiant du parcours
course circles: Cercles du parcours course circles: Services du parcours
travelTime: Durée de déplacement travelTime: Durée de déplacement
durationTime: Durée durationTime: Durée
id: Identifiant id: Identifiant
List activities linked to an accompanying course: Liste les échanges liés à un parcours en fonction de différents filtres. 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 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: filter:
activity: activity:

View File

@@ -25,6 +25,7 @@ final class ChillAsideActivityExtension extends Extension implements PrependExte
$config = $this->processConfiguration($configuration, $configs); $config = $this->processConfiguration($configuration, $configs);
$container->setParameter('chill_aside_activity.form.time_duration', $config['form']['time_duration']); $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 = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../config'));
$loader->load('services.yaml'); $loader->load('services.yaml');
@@ -38,6 +39,24 @@ final class ChillAsideActivityExtension extends Extension implements PrependExte
{ {
$this->prependRoute($container); $this->prependRoute($container);
$this->prependCruds($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) protected function prependCruds(ContainerBuilder $container)

View File

@@ -141,6 +141,12 @@ class Configuration implements ConfigurationInterface
->end() ->end()
->end() ->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(); ->end();
return $treeBuilder; return $treeBuilder;

View File

@@ -62,6 +62,10 @@ class AsideActivity implements TrackCreationInterface, TrackUpdateInterface
#[ORM\ManyToOne(targetEntity: User::class)] #[ORM\ManyToOne(targetEntity: User::class)]
private User $updatedBy; private User $updatedBy;
#[Assert\GreaterThanOrEqual(0)]
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: true)]
private ?int $concernedPersonsCount = 0;
public function getAgent(): ?User public function getAgent(): ?User
{ {
return $this->agent; return $this->agent;
@@ -186,4 +190,16 @@ class AsideActivity implements TrackCreationInterface, TrackUpdateInterface
return $this; return $this;
} }
public function getConcernedPersonsCount(): ?int
{
return $this->concernedPersonsCount;
}
public function setConcernedPersonsCount(?int $concernedPersonsCount): self
{
$this->concernedPersonsCount = $concernedPersonsCount;
return $this;
}
} }

View File

@@ -0,0 +1,86 @@
<?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\AsideActivityBundle\Export\Aggregator;
use Chill\AsideActivityBundle\Export\Declarations;
use Chill\MainBundle\Export\AggregatorInterface;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Form\FormBuilderInterface;
class ByConcernedPersonsCountAggregator implements AggregatorInterface
{
public function addRole(): ?string
{
return null;
}
public function alterQuery(QueryBuilder $qb, $data, \Chill\MainBundle\Export\ExportGenerationContext $exportGenerationContext): void
{
$qb->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';
}
}

View File

@@ -0,0 +1,116 @@
<?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\AsideActivityBundle\Export\Export;
use Chill\AsideActivityBundle\Export\Declarations;
use Chill\AsideActivityBundle\Repository\AsideActivityRepository;
use Chill\AsideActivityBundle\Security\AsideActivityVoter;
use Chill\MainBundle\Export\ExportInterface;
use Chill\MainBundle\Export\FormatterInterface;
use Chill\MainBundle\Export\GroupedExportInterface;
use Doctrine\ORM\Query;
use Symfony\Component\Form\FormBuilderInterface;
class SumConcernedPersonsCountAsideActivity implements ExportInterface, GroupedExportInterface
{
public function __construct(private readonly AsideActivityRepository $repository) {}
public function buildForm(FormBuilderInterface $builder) {}
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 getAllowedFormattersTypes(): array
{
return [FormatterInterface::TYPE_TABULAR];
}
public function getDescription(): string
{
return 'export.Sum concerned persons count for aside activities';
}
public function getGroup(): string
{
return 'export.Exports of aside activities';
}
public function getLabels($key, array $values, $data)
{
if ('export_sum_concerned_persons_count' !== $key) {
throw new \LogicException("the key {$key} is not used by this export");
}
$labels = array_combine($values, $values);
$labels['_header'] = $this->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,
];
}
}

View File

@@ -21,6 +21,7 @@ use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeToTimestampTransformer; use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeToTimestampTransformer;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType; 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\FormBuilderInterface;
use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents; use Symfony\Component\Form\FormEvents;
@@ -29,11 +30,13 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
final class AsideActivityFormType extends AbstractType final class AsideActivityFormType extends AbstractType
{ {
private readonly array $timeChoices; private readonly array $timeChoices;
private readonly bool $showConcernedPersonsCount;
public function __construct( public function __construct(
ParameterBagInterface $parameterBag, ParameterBagInterface $parameterBag,
) { ) {
$this->timeChoices = $parameterBag->get('chill_aside_activity.form.time_duration'); $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) public function buildForm(FormBuilderInterface $builder, array $options)
@@ -76,6 +79,16 @@ final class AsideActivityFormType extends AbstractType
->add('location', PickUserLocationType::class) ->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) { foreach (['duration'] as $fieldName) {
$builder->get($fieldName) $builder->get($fieldName)
->addModelTransformer($durationTimeTransformer); ->addModelTransformer($durationTimeTransformer);

View File

@@ -42,6 +42,11 @@
{%- if entity.location.name is defined -%} {%- if entity.location.name is defined -%}
<div><i class="fa fa-fw fa-map-marker"></i>{{ entity.location.name }}</div> <div><i class="fa fa-fw fa-map-marker"></i>{{ entity.location.name }}</div>
{%- endif -%} {%- endif -%}
{%- if entity.concernedPersonsCount > 0 -%}
<div><i class="fa fa-fw fa-user"></i>{{ entity.concernedPersonsCount }}</div>
{%- endif -%}
</div> </div>
<div class="item-col" style="justify-content: flex-end;"> <div class="item-col" style="justify-content: flex-end;">
<div class="box"> <div class="box">

View File

@@ -38,6 +38,11 @@
<dt class="inline">{{ 'Duration'|trans }}</dt> <dt class="inline">{{ 'Duration'|trans }}</dt>
<dd>{{ entity.duration|date('H:i') }}</dd> <dd>{{ entity.duration|date('H:i') }}</dd>
{% if chill_aside_activity_config.show_concerned_persons_count == 'visible' %}
<dt class="inline">{{ 'Concerned persons count'|trans }}</dt>
<dd>{{ entity.concernedPersonsCount }}</dd>
{% endif %}
<dt class="inline">{{ 'Remark'|trans }}</dt> <dt class="inline">{{ 'Remark'|trans }}</dt>
{%- if entity.note is empty -%} {%- if entity.note is empty -%}
<dd> <dd>

View File

@@ -0,0 +1,49 @@
<?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\AsideActivityBundle\Tests\Export\Aggregator;
use Chill\AsideActivityBundle\Entity\AsideActivity;
use Chill\AsideActivityBundle\Export\Aggregator\ByConcernedPersonsCountAggregator;
use Chill\MainBundle\Test\Export\AbstractAggregatorTest;
use Doctrine\ORM\EntityManagerInterface;
/**
* @internal
*
* @coversNothing
*/
class ByConcernedPersonsCountAggregatorTest extends AbstractAggregatorTest
{
public function getAggregator()
{
return new ByConcernedPersonsCountAggregator();
}
public static function getFormData(): array
{
return [
[],
];
}
public static function getQueryBuilders(): iterable
{
self::bootKernel();
$em = self::getContainer()->get(EntityManagerInterface::class);
return [
$em->createQueryBuilder()
->select('count(aside.id)')
->from(AsideActivity::class, 'aside'),
];
}
}

View File

@@ -0,0 +1,50 @@
<?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\AsideActivityBundle\Tests\Export\Export;
use Chill\AsideActivityBundle\Export\Export\SumConcernedPersonsCountAsideActivity;
use Chill\AsideActivityBundle\Repository\AsideActivityRepository;
use Chill\MainBundle\Test\Export\AbstractExportTest;
/**
* @internal
*
* @coversNothing
*/
final class SumConcernedPersonsCountAsideActivityTest extends AbstractExportTest
{
protected function setUp(): void
{
self::bootKernel();
}
public function getExport()
{
$repository = self::getContainer()->get(AsideActivityRepository::class);
yield new SumConcernedPersonsCountAsideActivity($repository);
}
public static function getFormData(): array
{
return [
[],
];
}
public static function getModifiersCombination(): array
{
return [
['aside_activity'],
];
}
}

View File

@@ -20,6 +20,10 @@ services:
tags: tags:
- { name: chill.export, alias: 'avg_aside_activity_duration' } - { 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 ## Filters
chill.aside_activity.export.date_filter: chill.aside_activity.export.date_filter:
class: Chill\AsideActivityBundle\Export\Filter\ByDateFilter class: Chill\AsideActivityBundle\Export\Filter\ByDateFilter
@@ -70,3 +74,7 @@ services:
Chill\AsideActivityBundle\Export\Aggregator\ByLocationAggregator: Chill\AsideActivityBundle\Export\Aggregator\ByLocationAggregator:
tags: tags:
- { name: chill.export_aggregator, alias: 'aside_activity_location_aggregator' } - { 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' }

View File

@@ -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\AsideActivity;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20251006113048 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add concernedPersonsCount property to AsideActivity entity';
}
public function up(Schema $schema): void
{
$this->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');
}
}

View File

@@ -27,6 +27,7 @@ Emergency: Urgent
by: "Par " by: "Par "
location: Lieu location: Lieu
Asideactivity location: Localisation de l'activité Asideactivity location: Localisation de l'activité
Concerned persons count: Nombre d'usager concernés
# Crud # Crud
crud: crud:
@@ -177,7 +178,7 @@ export:
agent_id: Utilisateur agent_id: Utilisateur
creator_id: Créateur creator_id: Créateur
main_scope: Service principal de l'utilisateur 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 aside_activity_type: Catégorie d'activité annexe
date: Date date: Date
duration: Durée 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 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 Average aside activities duration: Durée moyenne des activités annexes
Sum aside activities duration: Durée 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:
Filter by aside activity date: Filtrer les activités annexes par date 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é 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%" 'Filtered by aside activity location: only %location%': "Filtré par localisation: uniquement %location%"
aggregator: aggregator:
Group by aside activity type: Grouper les activités annexes par type d'activité 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 Aside activity type: Type d'activité annexe
by_user_job: by_user_job:
Aggregate by user job: Grouper les activités annexes par métier des utilisateurs Aggregate by user job: Grouper les activités annexes par métier des utilisateurs

View File

@@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\CalendarBundle\Controller; namespace Chill\CalendarBundle\Controller;
use Chill\CalendarBundle\Repository\CalendarRepository; use Chill\CalendarBundle\Repository\CalendarRepository;
use Chill\CalendarBundle\Repository\InviteRepository;
use Chill\MainBundle\CRUD\Controller\ApiController; use Chill\MainBundle\CRUD\Controller\ApiController;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Serializer\Model\Collection; use Chill\MainBundle\Serializer\Model\Collection;
@@ -23,7 +24,10 @@ use Symfony\Component\Routing\Annotation\Route;
class CalendarAPIController extends ApiController 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'])] #[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 public function listByUser(User $user, Request $request, string $_format): JsonResponse
@@ -52,16 +56,37 @@ class CalendarAPIController extends ApiController
throw new BadRequestHttpException('dateTo not parsable'); throw new BadRequestHttpException('dateTo not parsable');
} }
$total = $this->calendarRepository->countByUser($user, $dateFrom, $dateTo); // Get calendar items where user is the main user
$paginator = $this->getPaginatorFactory()->create($total); $ownCalendars = $this->calendarRepository->findByUser(
$ranges = $this->calendarRepository->findByUser(
$user, $user,
$dateFrom, $dateFrom,
$dateTo, $dateTo
$paginator->getItemsPerPage(),
$paginator->getCurrentPageFirstItemNumber()
); );
// 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); $collection = new Collection($ranges, $paginator);
return $this->json($collection, Response::HTTP_OK, [], ['groups' => ['calendar:light']]); return $this->json($collection, Response::HTTP_OK, [], ['groups' => ['calendar:light']]);

View File

@@ -13,6 +13,7 @@ namespace Chill\CalendarBundle\Controller;
use Chill\CalendarBundle\Entity\Calendar; use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Form\CalendarType; use Chill\CalendarBundle\Form\CalendarType;
use Chill\CalendarBundle\Form\CancelType;
use Chill\CalendarBundle\RemoteCalendar\Connector\RemoteCalendarConnectorInterface; use Chill\CalendarBundle\RemoteCalendar\Connector\RemoteCalendarConnectorInterface;
use Chill\CalendarBundle\Repository\CalendarACLAwareRepositoryInterface; use Chill\CalendarBundle\Repository\CalendarACLAwareRepositoryInterface;
use Chill\CalendarBundle\Security\Voter\CalendarVoter; 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\AccompanyingPeriodVoter;
use Chill\PersonBundle\Security\Authorization\PersonVoter; use Chill\PersonBundle\Security\Authorization\PersonVoter;
use Chill\ThirdPartyBundle\Entity\ThirdParty; use Chill\ThirdPartyBundle\Entity\ThirdParty;
use Doctrine\ORM\EntityManagerInterface;
use http\Exception\UnexpectedValueException; use http\Exception\UnexpectedValueException;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -60,6 +62,7 @@ class CalendarController extends AbstractController
private readonly UserRepositoryInterface $userRepository, private readonly UserRepositoryInterface $userRepository,
private readonly TranslatorInterface $translator, private readonly TranslatorInterface $translator,
private readonly \Doctrine\Persistence\ManagerRegistry $managerRegistry, 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. * Edit a calendar item.
*/ */
@@ -266,7 +318,7 @@ class CalendarController extends AbstractController
} }
if (!$this->getUser() instanceof User) { 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'; $view = '@ChillCalendar/Calendar/listByUser.html.twig';

View File

@@ -0,0 +1,58 @@
<?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\CalendarBundle\Controller;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Repository\InviteRepository;
use Chill\DocGeneratorBundle\Repository\DocGeneratorTemplateRepositoryInterface;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Routing\Annotation\Route;
class MyInvitationsController extends AbstractController
{
public function __construct(private readonly InviteRepository $inviteRepository, private readonly PaginatorFactory $paginator, private readonly DocGeneratorTemplateRepositoryInterface $docGeneratorTemplateRepository) {}
#[Route(path: '/{_locale}/calendar/invitations/my', name: 'chill_calendar_invitations_list_my')]
public function myInvitations(Request $request): Response
{
$this->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),
]);
}
}

View File

@@ -35,7 +35,7 @@ class LoadCancelReason extends Fixture implements FixtureGroupInterface
$arr = [ $arr = [
['name' => CancelReason::CANCELEDBY_USER], ['name' => CancelReason::CANCELEDBY_USER],
['name' => CancelReason::CANCELEDBY_PERSON], ['name' => CancelReason::CANCELEDBY_PERSON],
['name' => CancelReason::CANCELEDBY_DONOTCOUNT], ['name' => CancelReason::CANCELEDBY_OTHER],
]; ];
foreach ($arr as $a) { foreach ($arr as $a) {

View File

@@ -269,6 +269,11 @@ class Calendar implements TrackCreationInterface, TrackUpdateInterface, HasCente
return $this->cancelReason; return $this->cancelReason;
} }
public function isCanceled(): bool
{
return null !== $this->cancelReason;
}
public function getCenters(): ?iterable public function getCenters(): ?iterable
{ {
return match ($this->getContext()) { return match ($this->getContext()) {

View File

@@ -18,14 +18,14 @@ use Doctrine\ORM\Mapping as ORM;
#[ORM\Table(name: 'chill_calendar.cancel_reason')] #[ORM\Table(name: 'chill_calendar.cancel_reason')]
class CancelReason 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_PERSON = 'CANCELEDBY_PERSON';
final public const CANCELEDBY_USER = 'CANCELEDBY_USER'; final public const CANCELEDBY_USER = 'CANCELEDBY_USER';
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::BOOLEAN)] #[ORM\Column(type: \Doctrine\DBAL\Types\Types::BOOLEAN, options: ['default' => true])]
private ?bool $active = null; private bool $active = true;
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 255)] #[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 255)]
private ?string $canceledBy = null; private ?string $canceledBy = null;

View File

@@ -15,7 +15,7 @@ use Chill\CalendarBundle\Entity\CancelReason;
use Chill\MainBundle\Form\Type\TranslatableStringFormType; use Chill\MainBundle\Form\Type\TranslatableStringFormType;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType; 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\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -28,7 +28,14 @@ class CancelReasonType extends AbstractType
->add('active', CheckboxType::class, [ ->add('active', CheckboxType::class, [
'required' => false, '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) public function configureOptions(OptionsResolver $resolver)

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\CalendarBundle\Form;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Entity\CancelReason;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class CancelType extends AbstractType
{
public function __construct(private readonly TranslatableStringHelperInterface $translatableStringHelper) {}
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder->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,
]);
}
}

View File

@@ -25,6 +25,13 @@ class UserMenuBuilder implements LocalMenuBuilderInterface
if ($this->security->isGranted('ROLE_USER')) { if ($this->security->isGranted('ROLE_USER')) {
$menu->addChild('My calendar list', [ $menu->addChild('My calendar list', [
'route' => 'chill_calendar_calendar_list_my', 'route' => 'chill_calendar_calendar_list_my',
])
->setExtras([
'order' => 8,
'icon' => 'tasks',
]);
$menu->addChild('invite.list.title', [
'route' => 'chill_calendar_invitations_list_my',
]) ])
->setExtras([ ->setExtras([
'order' => 9, 'order' => 9,

View File

@@ -21,6 +21,7 @@ namespace Chill\CalendarBundle\Messenger\Doctrine;
use Chill\CalendarBundle\Entity\Calendar; use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Messenger\Message\CalendarMessage; use Chill\CalendarBundle\Messenger\Message\CalendarMessage;
use Chill\CalendarBundle\Messenger\Message\CalendarRemovedMessage; use Chill\CalendarBundle\Messenger\Message\CalendarRemovedMessage;
use Chill\MainBundle\Entity\User;
use Doctrine\ORM\Event\PostPersistEventArgs; use Doctrine\ORM\Event\PostPersistEventArgs;
use Doctrine\ORM\Event\PostRemoveEventArgs; use Doctrine\ORM\Event\PostRemoveEventArgs;
use Doctrine\ORM\Event\PostUpdateEventArgs; use Doctrine\ORM\Event\PostUpdateEventArgs;
@@ -31,6 +32,17 @@ class CalendarEntityListener
{ {
public function __construct(private readonly MessageBusInterface $messageBus, private readonly Security $security) {} 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 public function postPersist(Calendar $calendar, PostPersistEventArgs $args): void
{ {
if (!$calendar->preventEnqueueChanges) { if (!$calendar->preventEnqueueChanges) {
@@ -38,7 +50,7 @@ class CalendarEntityListener
new CalendarMessage( new CalendarMessage(
$calendar, $calendar,
CalendarMessage::CALENDAR_PERSIST, CalendarMessage::CALENDAR_PERSIST,
$this->security->getUser() $this->getAuthenticatedUser()
) )
); );
} }
@@ -50,7 +62,7 @@ class CalendarEntityListener
$this->messageBus->dispatch( $this->messageBus->dispatch(
new CalendarRemovedMessage( new CalendarRemovedMessage(
$calendar, $calendar,
$this->security->getUser() $this->getAuthenticatedUser()
) )
); );
} }
@@ -58,12 +70,19 @@ class CalendarEntityListener
public function postUpdate(Calendar $calendar, PostUpdateEventArgs $args): void 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( $this->messageBus->dispatch(
new CalendarMessage( new CalendarMessage(
$calendar, $calendar,
CalendarMessage::CALENDAR_UPDATE, CalendarMessage::CALENDAR_UPDATE,
$this->security->getUser() $this->getAuthenticatedUser()
) )
); );
} }

View File

@@ -70,6 +70,8 @@ class CalendarRemovedMessage
public function getRemoteId(): string public function getRemoteId(): string
{ {
dump($this->remoteId);
return $this->remoteId; return $this->remoteId;
} }
} }

View File

@@ -191,6 +191,7 @@ class CalendarRepository implements ObjectRepository
$qb->expr()->eq('c.mainUser', ':user'), $qb->expr()->eq('c.mainUser', ':user'),
$qb->expr()->gte('c.startDate', ':startDate'), $qb->expr()->gte('c.startDate', ':startDate'),
$qb->expr()->lte('c.endDate', ':endDate'), $qb->expr()->lte('c.endDate', ':endDate'),
$qb->expr()->isNull('c.cancelReason'),
) )
) )
->setParameters([ ->setParameters([

View File

@@ -12,6 +12,7 @@ declare(strict_types=1);
namespace Chill\CalendarBundle\Repository; namespace Chill\CalendarBundle\Repository;
use Chill\CalendarBundle\Entity\Invite; use Chill\CalendarBundle\Entity\Invite;
use Chill\MainBundle\Entity\User;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository; use Doctrine\ORM\EntityRepository;
use Doctrine\Persistence\ObjectRepository; use Doctrine\Persistence\ObjectRepository;
@@ -41,7 +42,7 @@ class InviteRepository implements ObjectRepository
/** /**
* @return array|Invite[] * @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); return $this->entityRepository->findBy($criteria, $orderBy, $limit, $offset);
} }
@@ -51,6 +52,52 @@ class InviteRepository implements ObjectRepository
return $this->entityRepository->findOneBy($criteria); 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 public function getClassName(): string
{ {
return Invite::class; return Invite::class;

View File

@@ -1,5 +1,6 @@
services: services:
Chill\CalendarBundle\Controller\: Chill\CalendarBundle\Controller\:
autowire: true autowire: true
autoconfigure: true
resource: '../../../Controller' resource: '../../../Controller'
tags: ['controller.service_arguments'] tags: ['controller.service_arguments']

View File

@@ -108,9 +108,12 @@
{{ formatDate(event.endStr, "time") }}: {{ formatDate(event.endStr, "time") }}:
{{ event.extendedProps.locationName }}</b {{ event.extendedProps.locationName }}</b
> >
<b v-else-if="event.extendedProps.is === 'local'">{{ <a
event.title :href="calendarLink(event.id)"
}}</b> v-else-if="event.extendedProps.is === 'local'"
>
<b>{{ event.title }}</b>
</a>
<b v-else>no 'is'</b> <b v-else>no 'is'</b>
<a <a
v-if="event.extendedProps.is === 'range'" v-if="event.extendedProps.is === 'range'"
@@ -486,6 +489,12 @@ function copyWeek() {
}); });
} }
const calendarLink = (calendarId: string) => {
const idStr = calendarId.match(/_(\d+)$/)?.[1];
return `/fr/calendar/calendar/${idStr}/edit`;
};
onMounted(() => { onMounted(() => {
copyFromWeek.value = dateToISO(getMonday(0)); copyFromWeek.value = dateToISO(getMonday(0));
copyToWeek.value = dateToISO(getMonday(1)); copyToWeek.value = dateToISO(getMonday(1));

View File

@@ -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 %} <div class="item-bloc">
<div class="flex-table list-records context-accompanyingCourse"> <div class="item-row main">
<div class="item-col">
{% for calendar in calendarItems %} <div class="wrap-header">
<div class="wl-row">
<div class="item-bloc"> {% if calendar.status == 'canceled' %}
<div class="item-row main"> <div class="badge rounded-pill bg-danger">
<div class="item-col"> <span>{{ 'chill_calendar.canceled'|trans }}: </span>
<div class="wrap-header"> <span>{{ calendar.cancelReason.name|localize_translatable_string }}</span>
</div>
{% endif %}
</div>
<div class="wl-row"> <div class="wl-row">
<div class="wl-col title"> <div class="wl-col title">
<p class="date-label"> <p class="date-label">
{% if calendar.status == 'canceled' %}
<del>
{% endif %}
{% if context == 'person' and calendar.context == 'accompanying_period' %} {% if context == 'person' and calendar.context == 'accompanying_period' %}
<a href="{{ chill_path_add_return_path('chill_person_accompanying_course_index', {'accompanying_period_id': calendar.accompanyingPeriod.id}) }}" style="text-decoration: none;"> <a href="{{ chill_path_add_return_path('chill_person_accompanying_course_index', {'accompanying_period_id': calendar.accompanyingPeriod.id}) }}" style="text-decoration: none;">
<span class="badge bg-primary"> <span class="badge bg-primary">
@@ -19,6 +25,9 @@
</span> </span>
</a> </a>
{% endif %} {% endif %}
{% if calendar.status == 'canceled' %}
<del>
{% endif %}
{% if calendar.endDate.diff(calendar.startDate).days >= 1 %} {% if calendar.endDate.diff(calendar.startDate).days >= 1 %}
{{ calendar.startDate|format_datetime('short', 'short') }} {{ calendar.startDate|format_datetime('short', 'short') }}
- {{ calendar.endDate|format_datetime('short', 'short') }} - {{ calendar.endDate|format_datetime('short', 'short') }}
@@ -26,44 +35,46 @@
{{ calendar.startDate|format_datetime('short', 'short') }} {{ calendar.startDate|format_datetime('short', 'short') }}
- {{ calendar.endDate|format_datetime('none', 'short') }} - {{ calendar.endDate|format_datetime('none', 'short') }}
{% endif %} {% endif %}
</p> {% if calendar.status == 'canceled' %}
</del>
<div class="duration short-message">
<i class="fa fa-fw fa-hourglass-end"></i>
{{ calendar.duration|date('%H:%I') }}
{% if false == calendar.sendSMS or null == calendar.sendSMS %}
<!-- no sms will be send -->
{% else %}
{% if calendar.smsStatus == 'sms_sent' %}
<span title="{{ 'SMS already sent'|trans }}" class="badge bg-info">
<i class="fa fa-check "></i>
<i class="fa fa-envelope "></i>
</span>
{% else %}
<span title="{{ 'Will send SMS'|trans }}" class="badge bg-info">
<i class="fa fa-envelope "></i>
<i class="fa fa-hourglass-end "></i>
</span>
{% endif %}
{% endif %} {% endif %}
</div>
</div> <div class="duration short-message">
</div> <i class="fa fa-fw fa-hourglass-end"></i>
</div> {{ calendar.duration|date('%H:%I') }}
{% if false == calendar.sendSMS or null == calendar.sendSMS %}
<div class="item-col"> <!-- no sms will be sent -->
<ul class="list-content"> {% else %}
{% if calendar.mainUser is not empty %} {% if calendar.smsStatus == 'sms_sent' %}
<span class="badge-user">{{ calendar.mainUser|chill_entity_render_box({'at_date': calendar.startDate}) }}</span> <span title="{{ 'SMS already sent'|trans }}" class="badge bg-info">
<i class="fa fa-check "></i>
<i class="fa fa-envelope "></i>
</span>
{% else %}
<span title="{{ 'Will send SMS'|trans }}" class="badge bg-info">
<i class="fa fa-envelope "></i>
<i class="fa fa-hourglass-end "></i>
</span>
{% endif %} {% endif %}
</ul> {% endif %}
</div> </div>
</div> </div>
</div> </div>
</div>
{% if calendar.comment.comment is not empty <div class="item-col">
<ul class="list-content">
{% if calendar.mainUser is not empty %}
<span class="badge-user">{{ calendar.mainUser|chill_entity_render_box({'at_date': calendar.startDate}) }}</span>
{% endif %}
</ul>
</div>
</div>
</div>
{% if calendar.comment.comment is not empty
or calendar.users|length > 0 or calendar.users|length > 0
or calendar.thirdParties|length > 0 or calendar.thirdParties|length > 0
or calendar.users|length > 0 %} or calendar.users|length > 0 %}
@@ -76,131 +87,133 @@
} %} } %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% if calendar.comment.comment is not empty %}
<div class="item-row details separator">
<div class="item-col comment">
{{ calendar.comment|chill_entity_render_box( { 'limit_lines': 3, 'metadata': false } ) }}
</div>
</div>
{% endif %}
{% if calendar.location is not empty %}
<div class="item-row separator">
<div>
{% if calendar.location.address is not same as(null) and calendar.location.name is not empty %}
<i class="fa fa-map-marker"></i>{% 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 %}
<i class="fa fa-map-marker"></i>{% endif %}
{% if calendar.location.phonenumber1 is not empty %}<i
class="fa fa-phone"></i> {{ calendar.location.phonenumber1|chill_format_phonenumber }}{% endif %}
{% if calendar.location.phonenumber2 is not empty %}<i
class="fa fa-phone"></i> {{ calendar.location.phonenumber2|chill_format_phonenumber }}{% endif %}
</div>
</div>
{% endif %}
{% if calendar.documents is not empty %}
<div class="item-row separator column">
<div>
{{ include('@ChillCalendar/Calendar/_documents.twig.html') }}
</div>
</div>
{% endif %}
{% if calendar.activity is not null %}
<div class="item-row separator">
<div class="item-col">
<div class="wrap-list">
<div class="wl-row">
<div class="wl-col title"><h3>{{ 'Activity'|trans }}</h3></div>
<div class="wl-col list activity-linked">
<h2 class="badge-title">
<span class="title_label"></span>
<span class="title_action">
{{ calendar.activity.type.name | localize_translatable_string }}
{% if calendar.activity.emergency %}
<span class="badge bg-danger rounded-pill fs-6 float-end">{{ 'Emergency'|trans|upper }}</span>
{% endif %}
</span>
</h2>
<ul class="record_actions">
<li class="cancel">
<span class="createdBy">
{{ 'Created by'|trans }}
<b>{{ calendar.activity.createdBy|chill_entity_render_string({'at_date': calendar.activity.createdAt}) }}</b>, {{ 'on'|trans }} {{ calendar.activity.createdAt|format_datetime('short', 'short') }}
</span>
</li>
{% if is_granted('CHILL_ACTIVITY_SEE', calendar.activity) %}
<li>
<a href="{{ chill_path_add_return_path('chill_activity_activity_show', {'id': calendar.activity.id}) }}" class="btn btn-sm btn-show" ></a>
</li>
{% endif %}
</ul>
{% if calendar.comment.comment is not empty %}
<div class="item-row details separator">
<div class="item-col comment">
{{ calendar.comment|chill_entity_render_box( { 'limit_lines': 3, 'metadata': false } ) }}
</div> </div>
</div> </div>
{% endif %}
{% if calendar.location is not empty %}
<div class="item-row separator">
<div>
{% if calendar.location.address is not same as(null) and calendar.location.name is not empty %}
<i class="fa fa-map-marker"></i>{% 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 %}
<i class="fa fa-map-marker"></i>{% endif %}
{% if calendar.location.phonenumber1 is not empty %}<i
class="fa fa-phone"></i> {{ calendar.location.phonenumber1|chill_format_phonenumber }}{% endif %}
{% if calendar.location.phonenumber2 is not empty %}<i
class="fa fa-phone"></i> {{ calendar.location.phonenumber2|chill_format_phonenumber }}{% endif %}
</div>
</div>
{% endif %}
<div class="item-row separator column">
<div>
{{ include('@ChillCalendar/Calendar/_documents.twig.html') }}
</div>
</div> </div>
</div>
</div>
{% endif %}
{% if calendar.activity is not null %} <div class="item-row separator">
<div class="item-row separator"> <ul class="record_actions">
<div class="item-col"> {% if is_granted('CHILL_CALENDAR_DOC_EDIT', calendar) and calendar.status is not constant('STATUS_CANCELED', calendar) %}
<div class="wrap-list"> {% if templates|length == 0 %}
<div class="wl-row"> <li>
<div class="wl-col title"><h3>{{ 'Activity'|trans }}</h3></div> <a class="btn btn-create"
<div class="wl-col list activity-linked"> href="{{ chill_path_add_return_path('chill_calendar_calendardoc_new', {'id': calendar.id }) }}">
<h2 class="badge-title"> {{ 'chill_calendar.Add a document'|trans }}
<span class="title_label"></span> </a>
<span class="title_action"> </li>
{{ calendar.activity.type.name | localize_translatable_string }} {% else %}
<li>
{% if calendar.activity.emergency %} <div class="dropdown">
<span class="badge bg-danger rounded-pill fs-6 float-end">{{ 'Emergency'|trans|upper }}</span> <button class="btn btn-create dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
{% endif %} {{ 'chill_calendar.Add a document'|trans }}
</span> </button>
</h2> <ul class="dropdown-menu">
<li>
<ul class="record_actions"> <a class="dropdown-item"
<li class="cancel"> href="{{ chill_path_add_return_path('chill_calendar_calendardoc_new', {'id': calendar.id }) }}">
<span class="createdBy"> {{ 'chill_calendar.Upload a document'|trans }}
{{ 'Created by'|trans }} </a>
<b>{{ calendar.activity.createdBy|chill_entity_render_string({'at_date': calendar.activity.createdAt}) }}</b>, {{ 'on'|trans }} {{ calendar.activity.createdAt|format_datetime('short', 'short') }} </li>
</span> {% for template in templates %}
</li> <li>
{% if is_granted('CHILL_ACTIVITY_SEE', calendar.activity) %} <a class="dropdown-item"
<li> href="{{ chill_path_add_return_path('chill_docgenerator_generate_from_template', {'template': template.id, 'entityClassName': 'Chill\\CalendarBundle\\Entity\\Calendar', 'entityId': calendar.id}) }}"
<a href="{{ chill_path_add_return_path('chill_activity_activity_show', {'id': calendar.activity.id}) }}" class="btn btn-sm btn-show" ></a> >
</li> {{ template.name|localize_translatable_string }}
{% endif %} </a>
</ul> </li>
{% endfor %}
</div> </ul>
</div>
</div> </div>
</div> </li>
</div> {% 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)
%}
<li>
<a class="btn btn-create"
href="{{ chill_path_add_return_path('chill_calendar_calendar_to_activity', { 'id': calendar.id }) }}">
{{ 'Transform to activity'|trans }}
</a>
</li>
{% endif %}
<div class="item-row separator"> {% if calendar.isInvited(app.user) and not calendar.isCanceled %}
<ul class="record_actions">
{% if is_granted('CHILL_CALENDAR_DOC_EDIT', calendar) %}
{% if templates|length == 0 %}
<li>
<a class="btn btn-create"
href="{{ chill_path_add_return_path('chill_calendar_calendardoc_new', {'id': calendar.id }) }}">
{{ 'chill_calendar.Add a document'|trans }}
</a>
</li>
{% else %}
<li>
<div class="dropdown">
<button class="btn btn-create dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
{{ 'chill_calendar.Add a document'|trans }}
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item"
href="{{ chill_path_add_return_path('chill_calendar_calendardoc_new', {'id': calendar.id }) }}">
{{ 'chill_calendar.Upload a document'|trans }}
</a>
</li>
{% for template in templates %}
<li>
<a class="dropdown-item"
href="{{ chill_path_add_return_path('chill_docgenerator_generate_from_template', {'template': template.id, 'entityClassName': 'Chill\\CalendarBundle\\Entity\\Calendar', 'entityId': calendar.id}) }}"
>
{{ template.name|localize_translatable_string }}
</a>
</li>
{% endfor %}
</ul>
</div>
</li>
{% 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))
)
%}
<li>
<a class="btn btn-create"
href="{{ chill_path_add_return_path('chill_calendar_calendar_to_activity', { 'id': calendar.id }) }}">
{{ 'Transform to activity'|trans }}
</a>
</li>
{% endif %}
{% if (calendar.isInvited(app.user)) %}
{% set invite = calendar.inviteForUser(app.user) %} {% set invite = calendar.inviteForUser(app.user) %}
<li> <li>
<div invite-answer data-status="{{ invite.status|e('html_attr') }}" <div invite-answer data-status="{{ invite.status|e('html_attr') }}"
@@ -213,12 +226,18 @@
class="btn btn-show "></a> class="btn btn-show "></a>
</li> </li>
{% endif %} {% 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) %}
<li> <li>
<a href="{{ chill_path_add_return_path('chill_calendar_calendar_edit', { 'id': calendar.id }) }}" <a href="{{ chill_path_add_return_path('chill_calendar_calendar_edit', { 'id': calendar.id }) }}"
class="btn btn-update "></a> class="btn btn-update "></a>
</li> </li>
<li>
<a href="{{ chill_path_add_return_path('chill_calendar_calendar_cancel', { 'id': calendar.id } ) }}"
class="btn btn-action"><i class="bi bi-x-circle"></i> {{ 'Cancel'|trans }}</a>
</li>
{% endif %} {% endif %}
{% if is_granted('CHILL_CALENDAR_CALENDAR_DELETE', calendar) %} {% if is_granted('CHILL_CALENDAR_CALENDAR_DELETE', calendar) %}
<li> <li>
<a href="{{ chill_path_add_return_path('chill_calendar_calendar_delete', { 'id': calendar.id } ) }}" <a href="{{ chill_path_add_return_path('chill_calendar_calendar_delete', { 'id': calendar.id } ) }}"
@@ -227,14 +246,8 @@
{% endif %} {% endif %}
</ul> </ul>
</div>
</div>
{% endfor %}
{% if calendarItems|length < paginator.getTotalItems %}
{{ chill_pagination(paginator) }}
{% endif %}
</div> </div>
{% endif %}
</div>

View File

@@ -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) }}
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
<a
class="btn btn-cancel"
href="{{ chill_return_path_or('chill_calendar_calendar_list', { 'id': accompanyingCourse.id } )}}"
>
{{ 'Cancel'|trans|chill_return_path_label }}
</a>
</li>
<li>
{{ form_widget(form.submit, { 'attr' : { 'class': 'btn btn-save' }, 'label': 'Save' } ) }}
</li>
</ul>
{{ form_end(form) }}
{% endblock %}

View File

@@ -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) }}
<ul class="record_actions sticky-form-buttons">
<li class="cancel">
<a
class="btn btn-cancel"
href="{{ chill_return_path_or('chill_calendar_calendar_list', { 'id': person.id } )}}"
>
{{ 'Cancel'|trans|chill_return_path_label }}
</a>
</li>
<li>
{{ form_widget(form.submit, { 'attr' : { 'class': 'btn btn-save' }, 'label': 'Save' } ) }}
</li>
</ul>
{{ form_end(form) }}
{% endblock %}

View File

@@ -34,7 +34,18 @@
{% endif %} {% endif %}
</p> </p>
{% else %} {% else %}
{{ include('@ChillCalendar/Calendar/_list.html.twig', {context: 'accompanying_course'}) }} {% if calendarItems|length > 0 %}
<div class="flex-table list-records context-accompanyingCourse">
{% for calendar in calendarItems %}
{{ include('@ChillCalendar/Calendar/_list.html.twig', {context: 'accompanying_course'}) }}
{% endfor %}
</div>
{% if calendarItems|length < paginator.getTotalItems %}
{{ chill_pagination(paginator) }}
{% endif %}
{% endif %}
{% endif %} {% endif %}
<ul class="record_actions sticky-form-buttons"> <ul class="record_actions sticky-form-buttons">

View File

@@ -33,7 +33,17 @@
{% endif %} {% endif %}
</p> </p>
{% else %} {% else %}
{{ include ('@ChillCalendar/Calendar/_list.html.twig', {context: 'person'}) }} {% if calendarItems|length > 0 %}
<div class="flex-table list-records context-person">
{% for calendar in calendarItems %}
{{ include ('@ChillCalendar/Calendar/_list.html.twig', {context: 'person'}) }}
{% endfor %}
</div>
{% if calendarItems|length < paginator.getTotalItems %}
{{ chill_pagination(paginator) }}
{% endif %}
{% endif %}
{% endif %} {% endif %}
<ul class="record_actions sticky-form-buttons"> <ul class="record_actions sticky-form-buttons">

View File

@@ -5,7 +5,7 @@
{% block table_entities_thead_tr %} {% block table_entities_thead_tr %}
<th>{{ 'Id'|trans }}</th> <th>{{ 'Id'|trans }}</th>
<th>{{ 'Name'|trans }}</th> <th>{{ 'Name'|trans }}</th>
<th>{{ 'canceledBy'|trans }}</th> <th>{{ 'Canceled by'|trans }}</th>
<th>{{ 'active'|trans }}</th> <th>{{ 'active'|trans }}</th>
<th>&nbsp;</th> <th>&nbsp;</th>
{% endblock %} {% endblock %}
@@ -40,4 +40,4 @@
</li> </li>
{% endblock %} {% endblock %}
{% endembed %} {% endembed %}
{% endblock %} {% endblock %}

View File

@@ -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 %}
<h1>{{ 'invite.list.title'|trans }}</h1>
{% if invitations|length == 0 %}
<p class="chill-no-data-statement">
{{ "invite.list.none"|trans }}
</p>
{% else %}
<div class="flex-table list-records">
{% for invitation in invitations %}
{% set calendar = invitation.getCalendar %}
{{ include('@ChillCalendar/Calendar/_list.html.twig', {context: 'user'}) }}
{% endfor %}
</div>
{% 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 %}

View File

@@ -19,6 +19,7 @@ declare(strict_types=1);
namespace Chill\CalendarBundle\Service\ShortMessageNotification; namespace Chill\CalendarBundle\Service\ShortMessageNotification;
use Chill\CalendarBundle\Entity\Calendar; use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Entity\CancelReason;
use libphonenumber\PhoneNumberFormat; use libphonenumber\PhoneNumberFormat;
use libphonenumber\PhoneNumberUtil; use libphonenumber\PhoneNumberUtil;
use Symfony\Component\Notifier\Message\SmsMessage; use Symfony\Component\Notifier\Message\SmsMessage;
@@ -57,7 +58,7 @@ class DefaultShortMessageForCalendarBuilder implements ShortMessageForCalendarBu
$this->phoneUtil->format($person->getMobilenumber(), PhoneNumberFormat::E164), $this->phoneUtil->format($person->getMobilenumber(), PhoneNumberFormat::E164),
$this->engine->render('@ChillCalendar/CalendarShortMessage/short_message.txt.twig', ['calendar' => $calendar]), $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( $toUsers[] = new SmsMessage(
$this->phoneUtil->format($person->getMobilenumber(), PhoneNumberFormat::E164), $this->phoneUtil->format($person->getMobilenumber(), PhoneNumberFormat::E164),
$this->engine->render('@ChillCalendar/CalendarShortMessage/short_message_canceled.txt.twig', ['calendar' => $calendar]), $this->engine->render('@ChillCalendar/CalendarShortMessage/short_message_canceled.txt.twig', ['calendar' => $calendar]),

View File

@@ -0,0 +1,292 @@
<?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\CalendarBundle\Tests\Controller;
use Chill\CalendarBundle\Controller\MyInvitationsController;
use Chill\CalendarBundle\Entity\Calendar;
use Chill\CalendarBundle\Entity\Invite;
use Chill\CalendarBundle\Repository\InviteRepository;
use Chill\DocGeneratorBundle\Repository\DocGeneratorTemplateRepositoryInterface;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Pagination\PaginatorFactory;
use Chill\MainBundle\Pagination\PaginatorInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Twig\Environment;
/**
* @internal
*
* @coversNothing
*/
final class MyInvitationsControllerTest extends TestCase
{
use ProphecyTrait;
private MyInvitationsController $controller;
protected function setUp(): void
{
// Create prophecies for dependencies
$inviteRepository = $this->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);
}
}

View File

@@ -31,8 +31,7 @@ Will send SMS: Un SMS de rappel sera envoyé
Will not send SMS: Aucun SMS de rappel ne sera envoyé Will not send SMS: Aucun SMS de rappel ne sera envoyé
SMS already sent: Un SMS a été envoyé SMS already sent: Un SMS a été envoyé
canceledBy: supprimé par Canceled by: Annulé par
Canceled by: supprimé par
Calendar configuration: Gestion des rendez-vous Calendar configuration: Gestion des rendez-vous
crud: crud:
@@ -44,6 +43,14 @@ crud:
title_edit: Modifier le motif d'annulation title_edit: Modifier le motif d'annulation
chill_calendar: 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 Document: Document d'un rendez-vous
form: form:
The main user is mandatory. He will organize the appointment.: L'utilisateur principal est obligatoire. Il est l'organisateur de l'événement. 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é declined: Refusé
pending: En attente pending: En attente
tentative: Accepté provisoirement tentative: Accepté provisoirement
list:
none: Il n'y aucun invitation
title: Mes invitations
# exports # exports
Exports of calendar: Exports des rendez-vous Exports of calendar: Exports des rendez-vous

View File

@@ -20,4 +20,9 @@ use Doctrine\Persistence\ObjectRepository;
interface DocGeneratorTemplateRepositoryInterface extends ObjectRepository interface DocGeneratorTemplateRepositoryInterface extends ObjectRepository
{ {
public function countByEntity(string $entity): int; public function countByEntity(string $entity): int;
/**
* @return array|DocGeneratorTemplate[]
*/
public function findByEntity(string $entity, ?int $start = 0, ?int $limit = 50): array;
} }

View File

@@ -25,6 +25,8 @@ use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Event\WorkerMessageFailedEvent; use Symfony\Component\Messenger\Event\WorkerMessageFailedEvent;
use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Contracts\Translation\TranslatorInterface;
// use Symfony\Component\Translation\LocaleSwitcher;
/** /**
* @see OnGenerationFailsTest for test suite * @see OnGenerationFailsTest for test suite
*/ */
@@ -40,6 +42,7 @@ final readonly class OnGenerationFails implements EventSubscriberInterface
private StoredObjectRepositoryInterface $storedObjectRepository, private StoredObjectRepositoryInterface $storedObjectRepository,
private TranslatorInterface $translator, private TranslatorInterface $translator,
private UserRepositoryInterface $userRepository, private UserRepositoryInterface $userRepository,
// private LocaleSwitcher $localeSwitcher,
) {} ) {}
public static function getSubscribedEvents() public static function getSubscribedEvents()
@@ -118,6 +121,25 @@ final readonly class OnGenerationFails implements EventSubscriberInterface
return; 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()) $email = (new TemplatedEmail())
->to($message->getSendResultToEmail()) ->to($message->getSendResultToEmail())
->subject($this->translator->trans('docgen.failure_email.The generation of a document failed')) ->subject($this->translator->trans('docgen.failure_email.The generation of a document failed'))

View File

@@ -27,6 +27,8 @@ use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException;
use Symfony\Component\Messenger\Handler\MessageHandlerInterface; use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Contracts\Translation\TranslatorInterface;
// use Symfony\Component\Translation\LocaleSwitcher;
/** /**
* Handle the request of document generation. * Handle the request of document generation.
*/ */
@@ -46,6 +48,7 @@ class RequestGenerationHandler implements MessageHandlerInterface
private readonly MailerInterface $mailer, private readonly MailerInterface $mailer,
private readonly TranslatorInterface $translator, private readonly TranslatorInterface $translator,
private readonly StoredObjectManagerInterface $storedObjectManager, private readonly StoredObjectManagerInterface $storedObjectManager,
// private readonly LocaleSwitcher $localeSwitcher,
) {} ) {}
public function __invoke(RequestGenerationMessage $message) public function __invoke(RequestGenerationMessage $message)
@@ -122,6 +125,30 @@ class RequestGenerationHandler implements MessageHandlerInterface
private function sendDataDump(StoredObject $destinationStoredObject, RequestGenerationMessage $message): void 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 // Get the content of the document
$content = $this->storedObjectManager->read($destinationStoredObject); $content = $this->storedObjectManager->read($destinationStoredObject);
$filename = $destinationStoredObject->getFilename(); $filename = $destinationStoredObject->getFilename();

View File

@@ -17,7 +17,6 @@ use Chill\DocStoreBundle\Entity\PersonDocument;
use Chill\MainBundle\Form\Type\ChillDateType; use Chill\MainBundle\Form\Type\ChillDateType;
use Chill\MainBundle\Form\Type\ChillTextareaType; use Chill\MainBundle\Form\Type\ChillTextareaType;
use Chill\MainBundle\Form\Type\ScopePickerType; use Chill\MainBundle\Form\Type\ScopePickerType;
use Chill\MainBundle\Security\Resolver\CenterResolverDispatcher;
use Chill\MainBundle\Security\Resolver\ScopeResolverDispatcher; use Chill\MainBundle\Security\Resolver\ScopeResolverDispatcher;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface; use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Doctrine\ORM\EntityRepository; use Doctrine\ORM\EntityRepository;
@@ -30,7 +29,7 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class PersonDocumentType extends AbstractType 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) 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']) { if ($isScopeConcerned && $this->parameterBag->get('chill_main')['acl']['form_show_scopes']) {
$builder->add('scope', ScopePickerType::class, [ $builder->add('scope', ScopePickerType::class, [
'center' => $this->centerResolverDispatcher->resolveCenter($document),
'role' => $options['role'], 'role' => $options['role'],
'subject' => $document,
]); ]);
} }
} }

View File

@@ -246,7 +246,7 @@ final class EventController extends AbstractController
'class' => Center::class, 'class' => Center::class,
'choices' => $centers, 'choices' => $centers,
'placeholder' => $this->translator->trans('Pick a center'), '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, [ ->add('submit', SubmitType::class, [
'label' => 'Next step', 'label' => 'Next step',

View File

@@ -50,7 +50,7 @@
<ul class="record_actions"> <ul class="record_actions">
<li> <li>
<a href="{{ path('chill_event__event_show', { 'event_id' : event.id } ) }}" class="btn btn-cancel"> <a href="{{ path('chill_event__event_show', { 'id' : event.id } ) }}" class="btn btn-cancel">
{{ 'Back to the event'|trans }} {{ 'Back to the event'|trans }}
</a> </a>
</li> </li>

View File

@@ -64,7 +64,7 @@ CHILL_EVENT_PARTICIPATION_SEE_DETAILS: Voir le détail d'une participation
# TODO check place to put this # TODO check place to put this
Next step: Étape suivante 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 # timeline
past: passé past: passé
@@ -151,7 +151,7 @@ event:
filter: filter:
event_types: Par types d'événement event_types: Par types d'événement
event_dates: Par date d'événement event_dates: Par date d'événement
center: Par centre center: Par territoire
by_responsable: Par responsable by_responsable: Par responsable
pick_responsable: Filtrer par responsables pick_responsable: Filtrer par responsables
budget: budget:
@@ -188,7 +188,7 @@ event_id: Identifiant
event_name: Nom event_name: Nom
event_date: Date event_date: Date
event_type: Type d'évenement event_type: Type d'évenement
event_center: Centre event_center: Territoire
event_moderator: Responsable event_moderator: Responsable
event_participants_count: Nombre de participants event_participants_count: Nombre de participants
event_location: Localisation event_location: Localisation

View File

@@ -15,6 +15,7 @@ use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Notification\NotificationFlagManager; use Chill\MainBundle\Notification\NotificationFlagManager;
use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint; use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint;
use libphonenumber\PhoneNumber; use libphonenumber\PhoneNumber;
use Symfony\Component\Validator\Constraints as Assert;
final class UpdateProfileCommand final class UpdateProfileCommand
{ {
@@ -23,11 +24,13 @@ final class UpdateProfileCommand
public function __construct( public function __construct(
#[PhonenumberConstraint] #[PhonenumberConstraint]
public ?PhoneNumber $phonenumber, 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 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) { foreach ($flagManager->getAllNotificationFlagProviders() as $provider) {
$updateProfileCommand->setNotificationFlag( $updateProfileCommand->setNotificationFlag(

View File

@@ -18,6 +18,7 @@ final readonly class UpdateProfileCommandHandler
public function updateProfile(User $user, UpdateProfileCommand $command): void public function updateProfile(User $user, UpdateProfileCommand $command): void
{ {
$user->setPhonenumber($command->phonenumber); $user->setPhonenumber($command->phonenumber);
$user->setLocale($command->locale);
foreach ($command->notificationFlags as $flag => $values) { foreach ($command->notificationFlags as $flag => $values) {
$user->setNotificationImmediately($flag, $values['immediate_email']); $user->setNotificationImmediately($flag, $values['immediate_email']);

View File

@@ -102,7 +102,6 @@ class CRUDController extends AbstractController
Resolver::class => Resolver::class, Resolver::class => Resolver::class,
SerializerInterface::class => SerializerInterface::class, SerializerInterface::class => SerializerInterface::class,
FilterOrderHelperFactoryInterface::class => FilterOrderHelperFactoryInterface::class, FilterOrderHelperFactoryInterface::class => FilterOrderHelperFactoryInterface::class,
ManagerRegistry::class => ManagerRegistry::class,
] ]
); );
} }
@@ -674,7 +673,7 @@ class CRUDController extends AbstractController
protected function getManagerRegistry(): ManagerRegistry protected function getManagerRegistry(): ManagerRegistry
{ {
return $this->container->get(ManagerRegistry::class); return $this->container->get('doctrine');
} }
/** /**

View File

@@ -128,6 +128,12 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]', 'jsonb' => true])] #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]', 'jsonb' => true])]
private array $notificationFlags = []; 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. * User constructor.
*/ */
@@ -716,7 +722,14 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
public function getLocale(): string public function getLocale(): string
{ {
return 'fr'; return $this->locale;
}
public function setLocale(string $locale): self
{
$this->locale = $locale;
return $this;
} }
#[Assert\Callback] #[Assert\Callback]

View File

@@ -123,7 +123,7 @@ class EntityWorkflowStep
/** /**
* @var Collection<int, EntityWorkflowStepHold> * @var Collection<int, EntityWorkflowStepHold>
*/ */
#[ORM\OneToMany(mappedBy: 'step', targetEntity: EntityWorkflowStepHold::class)] #[ORM\OneToMany(mappedBy: 'step', targetEntity: EntityWorkflowStepHold::class, cascade: ['remove'])]
private Collection $holdsOnStep; private Collection $holdsOnStep;
/** /**

View File

@@ -16,6 +16,7 @@ use Chill\MainBundle\Entity\Scope;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Form\DataMapper\ScopePickerDataMapper; use Chill\MainBundle\Form\DataMapper\ScopePickerDataMapper;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface; use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface;
use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface; use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
@@ -32,65 +33,84 @@ use Symfony\Component\Security\Core\Security;
* Allow to pick amongst available scope for the current * Allow to pick amongst available scope for the current
* user. * user.
* *
* options : * Options:
* * - `role`: string, the role to check permissions for
* - `center`: the center of the entity * - Either `subject`: object, entity to resolve centers from
* - `role` : the role of the user * - Or `center`: Center|array|null, the center(s) to check
*/ */
class ScopePickerType extends AbstractType class ScopePickerType extends AbstractType
{ {
public function __construct( public function __construct(
private readonly TranslatableStringHelperInterface $translatableStringHelper,
private readonly AuthorizationHelperInterface $authorizationHelper, private readonly AuthorizationHelperInterface $authorizationHelper,
private readonly Security $security, private readonly Security $security,
private readonly TranslatableStringHelperInterface $translatableStringHelper, private readonly CenterResolverManagerInterface $centerResolverManager,
) {} ) {}
public function buildForm(FormBuilderInterface $builder, array $options) 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( array_filter(
$this->authorizationHelper->getReachableScopes( $this->authorizationHelper->getReachableScopes(
$this->security->getUser(), $this->security->getUser(),
$options['role'], $options['role'],
$options['center'] $centers
), ),
static fn (Scope $s) => $s->isActive() static fn (Scope $s) => $s->isActive()
) )
); );
if (0 === \count($items)) { $builder->setAttribute('reachable_scopes_count', count($reachableScopes));
throw new \RuntimeException('no scopes are reachable. This form should not be shown to user');
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, [ $builder->add('scope', EntityType::class, [
'class' => Scope::class, 'class' => Scope::class,
'placeholder' => 'Choose the circle', 'placeholder' => 'Choose the circle',
'choice_label' => fn (Scope $c) => $this->translatableStringHelper->localize($c->getName()), 'choice_label' => fn (Scope $c) => $this->translatableStringHelper->localize($c->getName()),
'choices' => $items, 'choices' => $reachableScopes,
]); ]);
$builder->setDataMapper(new ScopePickerDataMapper()); $builder->setDataMapper(new ScopePickerDataMapper());
} else { } else {
$builder->add('scope', HiddenType::class, [ $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) public function buildView(FormView $view, FormInterface $form, array $options)
{ {
$view->vars['fullWidth'] = true; $view->vars['fullWidth'] = true;
// display of label is handled by the EntityType
$view->vars['label'] = false;
} }
public function configureOptions(OptionsResolver $resolver) public function configureOptions(OptionsResolver $resolver)
{ {
$resolver $resolver
// create `center` option
->setRequired('center')
->setAllowedTypes('center', [Center::class, 'array', 'null'])
// create ``role` option
->setRequired('role') ->setRequired('role')
->setAllowedTypes('role', ['string']); ->setAllowedTypes('role', ['string'])
->setDefined('subject')
->setAllowedTypes('subject', ['object'])
->setDefined('center')
->setAllowedTypes('center', [Center::class, 'array', 'null']);
} }
} }

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Intl\Languages;
use Symfony\Component\OptionsResolver\OptionsResolver;
class UserLocaleType extends AbstractType
{
public function __construct(private readonly array $availableLanguages) {}
public function configureOptions(OptionsResolver $resolver): void
{
$choices = [];
foreach ($this->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;
}
}

View File

@@ -14,6 +14,7 @@ namespace Chill\MainBundle\Form;
use Chill\MainBundle\Action\User\UpdateProfile\UpdateProfileCommand; use Chill\MainBundle\Action\User\UpdateProfile\UpdateProfileCommand;
use Chill\MainBundle\Form\Type\ChillPhoneNumberType; use Chill\MainBundle\Form\Type\ChillPhoneNumberType;
use Chill\MainBundle\Form\Type\NotificationFlagsType; use Chill\MainBundle\Form\Type\NotificationFlagsType;
use Chill\MainBundle\Form\Type\UserLocaleType;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -26,6 +27,7 @@ class UpdateProfileType extends AbstractType
->add('phonenumber', ChillPhoneNumberType::class, [ ->add('phonenumber', ChillPhoneNumberType::class, [
'required' => false, 'required' => false,
]) ])
->add('locale', UserLocaleType::class)
->add('notificationFlags', NotificationFlagsType::class) ->add('notificationFlags', NotificationFlagsType::class)
; ;
} }

View File

@@ -24,6 +24,8 @@ use Symfony\Component\Mime\Email;
use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Contracts\Translation\TranslatorInterface;
// use Symfony\Component\Translation\LocaleSwitcher;
readonly class NotificationMailer readonly class NotificationMailer
{ {
public function __construct( public function __construct(
@@ -31,6 +33,7 @@ readonly class NotificationMailer
private LoggerInterface $logger, private LoggerInterface $logger,
private MessageBusInterface $messageBus, private MessageBusInterface $messageBus,
private TranslatorInterface $translator, private TranslatorInterface $translator,
// private LocaleSwitcher $localeSwitcher,
) {} ) {}
public function postPersistComment(NotificationComment $comment, PostPersistEventArgs $eventArgs): void public function postPersistComment(NotificationComment $comment, PostPersistEventArgs $eventArgs): void
@@ -56,7 +59,7 @@ readonly class NotificationMailer
$email $email
->to($dest->getEmail()) ->to($dest->getEmail())
->subject('Re: '.$comment->getNotification()->getTitle()) ->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([ ->context([
'comment' => $comment, 'comment' => $comment,
'dest' => $dest, 'dest' => $dest,
@@ -137,13 +140,53 @@ readonly class NotificationMailer
return; 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()) { if ($notification->isSystem()) {
$email = new Email(); $email = new Email();
$email->text($notification->getMessage()); $email->text($notification->getMessage());
} else { } else {
$email = new TemplatedEmail(); $email = new TemplatedEmail();
$email $email
->textTemplate('@ChillMain/Notification/email_non_system_notification_content.fr.md.twig') ->textTemplate('@ChillMain/Notification/email_non_system_notification_content.md.twig')
->context([ ->context([
'notification' => $notification, 'notification' => $notification,
'dest' => $addressee, 'dest' => $addressee,
@@ -182,9 +225,43 @@ readonly class NotificationMailer
return; 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 = new TemplatedEmail();
$email $email
->htmlTemplate('@ChillMain/Notification/email_daily_digest.fr.md.twig') ->htmlTemplate('@ChillMain/Notification/email_daily_digest.md.twig')
->context([ ->context([
'user' => $user, 'user' => $user,
'notifications' => $notifications, 'notifications' => $notifications,
@@ -222,7 +299,7 @@ readonly class NotificationMailer
$email = new TemplatedEmail(); $email = new TemplatedEmail();
$email $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([ ->context([
'notification' => $notification, 'notification' => $notification,
'dest' => $emailAddress, 'dest' => $emailAddress,

View File

@@ -17,7 +17,7 @@ use Symfony\Component\Routing\RouterInterface;
/** /**
* Create paginator instances. * Create paginator instances.
*/ */
final readonly class PaginatorFactory implements PaginatorFactoryInterface class PaginatorFactory implements PaginatorFactoryInterface
{ {
final public const DEFAULT_CURRENT_PAGE_KEY = 'page'; final public const DEFAULT_CURRENT_PAGE_KEY = 'page';
@@ -29,16 +29,16 @@ final readonly class PaginatorFactory implements PaginatorFactoryInterface
/** /**
* the request stack. * the request stack.
*/ */
private RequestStack $requestStack, private readonly RequestStack $requestStack,
/** /**
* the router and generator for url. * the router and generator for url.
*/ */
private RouterInterface $router, private readonly RouterInterface $router,
/** /**
* the default item per page. This may be overriden by * the default item per page. This may be overriden by
* the request or inside the paginator. * the request or inside the paginator.
*/ */
private int $itemPerPage = 20, private readonly int $itemPerPage = 20,
) {} ) {}
/** /**

View File

@@ -1,40 +1,38 @@
<template> <template>
<div class="d-grid gap-2 my-3"> <div class="d-grid gap-2 my-3">
<button <button
class="btn btn-misc" class="btn btn-outline-primary text-start d-flex align-items-center"
:class="{ active: subscriberFinal }"
type="button" type="button"
v-if="!subscriberFinal" @click="
@click="subscribeTo('subscribe', 'final')" subscribeTo(
subscriberFinal ? 'unsubscribe' : 'subscribe',
'final',
)
"
> >
<i class="fa fa-check fa-fw"></i> <i
{{ trans(WORKFLOW_SUBSCRIBE_FINAL) }} class="fa fa-fw me-2"
:class="subscriberFinal ? 'fa-check-square-o' : 'fa-square-o'"
></i>
<span>{{ trans(WORKFLOW_SUBSCRIBE_FINAL) }}</span>
</button> </button>
<button <button
class="btn btn-misc" class="btn btn-outline-primary text-start d-flex align-items-center"
:class="{ active: subscriberStep }"
type="button" type="button"
v-if="subscriberFinal" @click="
@click="subscribeTo('unsubscribe', 'final')" subscribeTo(
subscriberStep ? 'unsubscribe' : 'subscribe',
'step',
)
"
> >
<i class="fa fa-times fa-fw"></i> <i
{{ trans(WORKFLOW_UNSUBSCRIBE_FINAL) }} class="fa fa-fw me-2"
</button> :class="subscriberStep ? 'fa-check-square-o' : 'fa-square-o'"
<button ></i>
class="btn btn-misc" <span>{{ trans(WORKFLOW_SUBSCRIBE_ALL_STEPS) }}</span>
type="button"
v-if="!subscriberStep"
@click="subscribeTo('subscribe', 'step')"
>
<i class="fa fa-check fa-fw"></i>
{{ trans(WORKFLOW_SUBSCRIBE_ALL_STEPS) }}
</button>
<button
class="btn btn-misc"
type="button"
v-if="subscriberStep"
@click="subscribeTo('unsubscribe', 'step')"
>
<i class="fa fa-times fa-fw"></i>
{{ trans(WORKFLOW_UNSUBSCRIBE_ALL_STEPS) }}
</button> </button>
</div> </div>
</template> </template>
@@ -45,9 +43,7 @@ import { defineProps, defineEmits } from "vue";
import { import {
trans, trans,
WORKFLOW_SUBSCRIBE_FINAL, WORKFLOW_SUBSCRIBE_FINAL,
WORKFLOW_UNSUBSCRIBE_FINAL,
WORKFLOW_SUBSCRIBE_ALL_STEPS, WORKFLOW_SUBSCRIBE_ALL_STEPS,
WORKFLOW_UNSUBSCRIBE_ALL_STEPS,
} from "translator"; } from "translator";
// props // props

View File

@@ -1 +1 @@
<img class="logo" src="{{ asset('build/images/logo-chill-outil-accompagnement_white.png') }}"> <img class="logo" alt="{{ 'login_page.logo_alt'|trans }}" src="{{ asset('build/images/logo-chill-outil-accompagnement_white.png') }}">

View File

@@ -16,7 +16,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
#} #}
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="{{ app.request.locale }}">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title> <title>
@@ -35,10 +35,10 @@
<form method="POST" action="{{ path('login_check') }}"> <form method="POST" action="{{ path('login_check') }}">
<label for="_username">{{ 'Username'|trans }}</label> <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/> <br/>
<label for="_password">{{ 'Password'|trans }}</label> <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') }}" /> <input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}" />
<br/> <br/>
<button type="submit" name="login">{{ 'Login'|trans }}</button> <button type="submit" name="login">{{ 'Login'|trans }}</button>

View File

@@ -14,7 +14,7 @@
Vous pouvez visualiser la notification et y répondre ici: 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 Le logiciel Chill

View File

@@ -13,7 +13,7 @@ Commentaire:
Vous pouvez visualiser la notification et y répondre ici: 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 Le logiciel Chill

View File

@@ -61,7 +61,7 @@
{% endif %} {% endif %}
</li> </li>
<li> <li>
<span class="dt">cercle/centre:</span> <span class="dt">{{ 'Scope'|trans }}/{{ 'center'|trans }}:</span>
{% if entity.mainScope %} {% if entity.mainScope %}
{{ entity.mainScope.name|localize_translatable_string }} {{ entity.mainScope.name|localize_translatable_string }}
{% endif %} {% endif %}

View File

@@ -44,6 +44,7 @@
<div> <div>
{{ form_start(form) }} {{ form_start(form) }}
{{ form_row(form.phonenumber) }} {{ form_row(form.phonenumber) }}
{{ form_row(form.locale) }}
<h2 class="mb-4">{{ 'user.profile.notification_preferences'|trans }}</h2> <h2 class="mb-4">{{ 'user.profile.notification_preferences'|trans }}</h2>
<table class="table table-striped align-middle"> <table class="table table-striped align-middle">

View File

@@ -1,16 +1,16 @@
{{ dest.label }}, {{ 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 %} {% if is_dest %}
Vous êtes invités à valider cette étape au plus tôt. {{ 'workflow.notification.content.validation_needed'|trans }}
{% endif %} {% 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 }}

View File

@@ -1,5 +1,5 @@
{%- if is_dest -%} {%- if is_dest -%}
Un suivi {{ workflow.text }} demande votre attention: {{ title }} {{ 'workflow.notification.title.attention_needed'|trans({'%workflow%': workflow.text, '%title%': title}) }}
{%- else -%} {%- 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 -%} {%- endif -%}

View File

@@ -49,7 +49,7 @@ class AdminUserMenuBuilder implements LocalMenuBuilderInterface
'route' => 'chill_crud_center_index', 'route' => 'chill_crud_center_index',
])->setExtras(['order' => 1010]); ])->setExtras(['order' => 1010]);
$menu->addChild('Regroupements des centres', [ $menu->addChild('Regroupements des territoires', [
'route' => 'chill_crud_regroupment_index', 'route' => 'chill_crud_regroupment_index',
])->setExtras(['order' => 1015]); ])->setExtras(['order' => 1015]);

View File

@@ -16,11 +16,13 @@ use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
// use Symfony\Component\Translation\LocaleSwitcher;
class RecoverPasswordHelper class RecoverPasswordHelper
{ {
final public const RECOVER_PASSWORD_ROUTE = 'password_recover'; 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 * @param bool $absolute
@@ -53,6 +55,24 @@ class RecoverPasswordHelper
throw new \UnexpectedValueException('No emaail associated to the user'); 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()) $email = (new TemplatedEmail())
->subject($emailSubject) ->subject($emailSubject)
->to($user->getEmail()) ->to($user->getEmail())

View File

@@ -35,7 +35,7 @@ final class ScopeControllerTest extends WebTestCase
$client->getResponse()->getStatusCode(), $client->getResponse()->getStatusCode(),
'Unexpected HTTP status code for GET /fr/admin/scope/' '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 // Fill in the form and submit it
$form = $crawler->selectButton('Créer')->form([ $form = $crawler->selectButton('Créer')->form([
'chill_mainbundle_scope[name][fr]' => 'Test en fr', 'chill_mainbundle_scope[name][fr]' => 'Test en fr',

View File

@@ -11,11 +11,11 @@ declare(strict_types=1);
namespace Form\Type; namespace Form\Type;
use Chill\MainBundle\Entity\Center;
use Chill\MainBundle\Entity\Scope; use Chill\MainBundle\Entity\Scope;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Form\Type\ScopePickerType; use Chill\MainBundle\Form\Type\ScopePickerType;
use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface; use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface;
use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface; use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder; use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
@@ -39,11 +39,11 @@ final class ScopePickerTypeTest extends TypeTestCase
{ {
use ProphecyTrait; use ProphecyTrait;
public function estBuildOneScopeIsSuccessful() public function testBuildOneScopeIsSuccessful()
{ {
$form = $this->factory->create(ScopePickerType::class, null, [ $form = $this->factory->create(ScopePickerType::class, null, [
'center' => new Center(),
'role' => 'ONE_SCOPE', 'role' => 'ONE_SCOPE',
'center' => [],
]); ]);
$view = $form->createView(); $view = $form->createView();
@@ -54,8 +54,8 @@ final class ScopePickerTypeTest extends TypeTestCase
public function testBuildThreeScopesIsSuccessful() public function testBuildThreeScopesIsSuccessful()
{ {
$form = $this->factory->create(ScopePickerType::class, null, [ $form = $this->factory->create(ScopePickerType::class, null, [
'center' => new Center(),
'role' => 'THREE_SCOPE', 'role' => 'THREE_SCOPE',
'center' => [],
]); ]);
$view = $form->createView(); $view = $form->createView();
@@ -66,8 +66,8 @@ final class ScopePickerTypeTest extends TypeTestCase
public function testBuildTwoScopesIsSuccessful() public function testBuildTwoScopesIsSuccessful()
{ {
$form = $this->factory->create(ScopePickerType::class, null, [ $form = $this->factory->create(ScopePickerType::class, null, [
'center' => new Center(),
'role' => 'TWO_SCOPE', 'role' => 'TWO_SCOPE',
'center' => [],
]); ]);
$view = $form->createView(); $view = $form->createView();
@@ -101,10 +101,13 @@ final class ScopePickerTypeTest extends TypeTestCase
static fn ($args) => $args[0]['fr'] static fn ($args) => $args[0]['fr']
); );
$centerResolverManager = $this->prophesize(CenterResolverManagerInterface::class);
$type = new ScopePickerType( $type = new ScopePickerType(
$translatableStringHelper->reveal(),
$authorizationHelper->reveal(), $authorizationHelper->reveal(),
$security->reveal(), $security->reveal(),
$translatableStringHelper->reveal() $centerResolverManager->reveal()
); );
// add the mocks for creating EntityType // add the mocks for creating EntityType

View File

@@ -22,6 +22,8 @@ use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Workflow\Event\Event; use Symfony\Component\Workflow\Event\Event;
use Symfony\Component\Workflow\Registry; use Symfony\Component\Workflow\Registry;
// use Symfony\Component\Translation\LocaleSwitcher;
final readonly class NotificationToUserGroupsOnTransition implements EventSubscriberInterface final readonly class NotificationToUserGroupsOnTransition implements EventSubscriberInterface
{ {
public function __construct( public function __construct(
@@ -31,6 +33,7 @@ final readonly class NotificationToUserGroupsOnTransition implements EventSubscr
private MailerInterface $mailer, private MailerInterface $mailer,
private EntityManagerInterface $entityManager, private EntityManagerInterface $entityManager,
private EntityWorkflowManager $entityWorkflowManager, private EntityWorkflowManager $entityWorkflowManager,
// private LocaleSwitcher $localeSwitcher,
) {} ) {}
public static function getSubscribedEvents(): array public static function getSubscribedEvents(): array
@@ -87,6 +90,24 @@ final readonly class NotificationToUserGroupsOnTransition implements EventSubscr
'title' => $title, '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 = new TemplatedEmail();
$email $email
->htmlTemplate('@ChillMain/Workflow/workflow_notification_on_transition_completed_content_to_user_group.fr.txt.twig') ->htmlTemplate('@ChillMain/Workflow/workflow_notification_on_transition_completed_content_to_user_group.fr.txt.twig')

View File

@@ -12,6 +12,12 @@ services:
tags: tags:
- { name: form.type, alias: translatable_string } - { 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: chill.main.form.type.select2choice:
class: Chill\MainBundle\Form\Type\Select2ChoiceType class: Chill\MainBundle\Form\Type\Select2ChoiceType
tags: tags:

View File

@@ -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');
}
}

View File

@@ -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)');
}
}

View File

@@ -127,6 +127,20 @@ duration:
few {# minutes} few {# minutes}
other {# 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: filter_order:
by_date: by_date:

View File

@@ -54,8 +54,12 @@ user:
title: Mon profil title: Mon profil
Profile successfully updated!: Votre profil a été mis à jour! Profile successfully updated!: Votre profil a été mis à jour!
no job: Pas de métier assigné 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 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: user_group:
inactive: Inactif inactive: Inactif
@@ -102,9 +106,9 @@ createdAt: Créé le
createdBy: Créé par createdBy: Créé par
#elements used in software #elements used in software
centers: centres centers: territoires
Centers: Centres Centers: Territoires
center: centre center: territoire
comment: commentaire comment: commentaire
Comment: Commentaire Comment: Commentaire
Comments: Commentaires Comments: Commentaires
@@ -227,12 +231,12 @@ Location Menu: Localisations et types de localisation
Management of location: Gestion des localisations et types de localisation Management of location: Gestion des localisations et types de localisation
#admin section for center's administration #admin section for center's administration
Create a new center: Créer un nouveau centre Create a new center: Créer une nouveau territoire
Center list: Liste des centres Center list: Liste des territoires
Center edit: Édition d'un centre Center edit: Édition d'un territoire
Center creation: Création d'un centre Center creation: Création d'un territoire
New center: Nouveau centre New center: Nouveau territoire
Center: Centre Center: Territoire
#admin section for permissions group #admin section for permissions group
Permissions group list: Groupes de permissions 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%' PermissionsGroup "%name%" edit: Modification du groupe de permission '%name%'
Role: Rôle Role: Rôle
Choose amongst roles: Choisir un 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 Add permission: Ajouter les permissions
This group does not provide any permission: Ce groupe n'attribue aucune permission 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%' 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é Unclassified: Non classifié
Help to pick role and scope: Certains rôles ne nécessitent 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 cercle. 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 cercle ! The role does not need scope: Ce rôle ne nécessite pas de service !
#admin section for users #admin section for users
User configuration: Gestion des utilisateurs User configuration: Gestion des utilisateurs
@@ -270,7 +274,7 @@ Grant new permissions: Ajout de permissions
Add a new groupCenter: 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 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. The permissions where removed.: Les permissions ont été enlevées.
Center & groups: Centre et groupes Center & groups: Territoire et groupes
User %username%: Utilisateur %username% User %username%: Utilisateur %username%
Add a new user: Ajouter un nouvel utilisateur Add a new user: Ajouter un nouvel utilisateur
The permissions have been added: Les permissions ont été ajoutées 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 Password successfully updated!: Mot de passe mis à jour
Flags: Drapeaux Flags: Drapeaux
Main location: Localisation principale Main location: Localisation principale
Main scope: Cercle Main scope: Service
Main center: Centre Main center: Territoire
user job: Métier de l'utilisateur user job: Métier de l'utilisateur
Job: Métier Job: Métier
Jobs: Métiers Jobs: Métiers
Choose a main center: Choisir un centre Choose a main center: Choisir un territoire
Choose a main scope: Choisir un cercle Choose a main scope: Choisir un service
choose a job: Choisir un métier choose a job: Choisir un métier
choose a location: Choisir une localisation choose a location: Choisir une localisation
@@ -302,12 +306,12 @@ Current location successfully updated: Localisation actuelle mise à jour
Pick a location: Choisir un lieu Pick a location: Choisir un lieu
#admin section for circles (old: scopes) #admin section for circles (old: scopes)
List circles: Cercles List circles: Services
New circle: Nouveau cercle New circle: Nouveau service
Circle: Cercle Circle: Service
Circle edit: Modification du cercle Circle edit: Modification du service
Circle creation: Création d'un cercle Circle creation: Création d'un service
Create a new circle: Créer un nouveau cercle Create a new circle: Créer un nouveau service
#admin section for location #admin section for location
Location: Localisation Location: Localisation
@@ -347,9 +351,9 @@ Country list: Liste des pays
Country code: Code du pays Country code: Code du pays
# circles / scopes # circles / scopes
Choose the circle: Choisir le cercle Choose the circle: Choisir le service
Scope: Cercle Scope: Service
Scopes: Cercles Scopes: Services
#export #export
@@ -357,14 +361,14 @@ Scopes: Cercles
Exports list: Liste des exports Exports list: Liste des exports
Create an export: Créer un export Create an export: Créer un export
#export creation step 'center' : pick a center #export creation step 'center' : pick a center
Pick centers: Choisir les centres Pick centers: Choisir les territoires
Pick a center: Choisir un centre 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 centres choisis. 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 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 territoires choisis.
Go to export options: Vers la préparation de l'export Go to export options: Vers la préparation de l'export
Pick aggregated centers: Regroupement de centres Pick aggregated centers: Regroupement de territoires
uncheck all centers: Désélectionner tous les centres uncheck all centers: Désélectionner tous les territoires
check all centers: Sélectionner tous les centres check all centers: Sélectionner tous les territoires
# export creation step 'export' : choose aggregators, filtering and formatter # export creation step 'export' : choose aggregators, filtering and formatter
Formatter: Mise en forme Formatter: Mise en forme
Choose the formatter: Choisissez le format d'export voulu. Choose the formatter: Choisissez le format d'export voulu.
@@ -510,10 +514,10 @@ crud:
title_edit: Modifier un regroupement title_edit: Modifier un regroupement
center: center:
index: index:
title: Liste des centres title: Liste des territoires
add_new: Ajouter un centre add_new: Ajouter un territoire
title_new: Nouveau centre title_new: Nouveau territoire
title_edit: Modifier un centre title_edit: Modifier un territoire
news_item: news_item:
index: index:
title: Liste des actualités 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% 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 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: attachments:
title: Pièces jointes title: Pièces jointes
no_attachment: Aucune pièce jointe no_attachment: Aucune pièce jointe
@@ -747,7 +762,22 @@ notification:
greeting: "Bonjour %user%" greeting: "Bonjour %user%"
intro: "Vous avez reçu %notification_count% nouvelle(s) notification(s)." intro: "Vous avez reçu %notification_count% nouvelle(s) notification(s)."
view_notification: "Vous pouvez visualiser la notification et y répondre ici:" 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_COMPOSE_EXPORT: Exécuter des exports et les sauvegarder
CHILL_MAIN_GENERATE_SAVED_EXPORT: Exécuter et modifier des exports préalablement sauvegardés CHILL_MAIN_GENERATE_SAVED_EXPORT: Exécuter et modifier des exports préalablement sauvegardés
@@ -862,7 +892,7 @@ absence:
admin: admin:
users: users:
export_list_csv: Liste des utilisateurs (format CSV) 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: export:
id: Identifiant id: Identifiant
username: Nom d'utilisateur username: Nom d'utilisateur
@@ -872,8 +902,8 @@ admin:
civility_abbreviation: Abbréviation civilité civility_abbreviation: Abbréviation civilité
civility_name: Civilité civility_name: Civilité
label: Label label: Label
mainCenter_id: Identifiant centre principal mainCenter_id: Identifiant territoire principal
mainCenter_name: Centre principal mainCenter_name: Territoire principal
mainScope_id: Identifiant service principal mainScope_id: Identifiant service principal
mainScope_name: Service principal mainScope_name: Service principal
userJob_id: Identifiant métier userJob_id: Identifiant métier
@@ -883,8 +913,8 @@ admin:
mainLocation_id: Identifiant localisation principale mainLocation_id: Identifiant localisation principale
mainLocation_name: Localisation principale mainLocation_name: Localisation principale
absenceStart: Absent à partir du absenceStart: Absent à partir du
center_id: Identifiant du centre center_id: Identifiant du territoire
center_name: Centre center_name: Territoire
permissionsGroup_id: Identifiant du groupe de permissions permissionsGroup_id: Identifiant du groupe de permissions
permissionsGroup_name: Groupe de permissions permissionsGroup_name: Groupe de permissions
job_scope_histories: job_scope_histories:
@@ -975,3 +1005,6 @@ multiselect:
editor: editor:
switch_to_simple: Éditeur simple switch_to_simple: Éditeur simple
switch_to_complex: Éditeur riche switch_to_complex: Éditeur riche
login_page:
logo_alt: "Logo de Chill"

View File

@@ -46,6 +46,14 @@ No title: Geen titel
User profile: Mijn gebruikersprofiel User profile: Mijn gebruikersprofiel
Phonenumber successfully updated!: Telefoonnummer bijgewerkt! 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 Edit: Bewerken
Update: Updaten Update: Updaten
@@ -423,6 +431,17 @@ workflow:
For: Pour For: Pour
Cc: Cc 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 final: Recevoir une notification à l'étape finale
Subscribe all steps: Recevoir une notification à chaque étape Subscribe all steps: Recevoir une notification à chaque étape

View File

@@ -1,15 +1,15 @@
# role_scope constraint # role_scope constraint
# scope presence # scope presence
The role "%role%" require to be associated with a scope.: Le rôle "%role%" doit ê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 cercle. 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 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 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" 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 #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. 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.

View File

@@ -122,6 +122,8 @@ class SocialIssue
* get all the ancestors of the social issue. * get all the ancestors of the social issue.
* *
* @param bool $includeThis if the array in the result must include the present SocialIssue * @param bool $includeThis if the array in the result must include the present SocialIssue
*
* @return list<SocialIssue>
*/ */
public function getAncestors(bool $includeThis = true): array 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 public function getDescendantsSocialActions(): Collection
{ {
@@ -239,18 +241,23 @@ class SocialIssue
} }
/** /**
* @return Collection<SocialAction> All the descendant social actions of all * @return Collection<int, SocialAction> All the social actions of the entity, it's
* the descendants of the entity * the descendants and it's parents
*/ */
public function getRecursiveSocialActions(): Collection public function getRecursiveSocialActions(): Collection
{ {
$recursiveSocialActions = new ArrayCollection(); $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 ($this->getDescendantsWithThis() as $socialIssue) {
foreach ($socialIssue->getDescendantsSocialActions() as $descendant) { foreach ($socialIssue->getDescendantsSocialActions() as $descendant) {
if (!$recursiveSocialActions->contains($descendant)) { $recursiveSocialActions->add($descendant);
$recursiveSocialActions->add($descendant);
}
} }
} }

View File

@@ -80,7 +80,7 @@ const appMessages = {
firstName: "Prénom", firstName: "Prénom",
lastName: "Nom", lastName: "Nom",
birthdate: "Date de naissance", birthdate: "Date de naissance",
center: "Centre", center: "Territoire",
phonenumber: "Téléphone", phonenumber: "Téléphone",
mobilenumber: "Mobile", mobilenumber: "Mobile",
altNames: "Autres noms", altNames: "Autres noms",

View File

@@ -52,20 +52,7 @@
</div> </div>
</div> </div>
<!-- results which are not attached to an objective --> <!-- 1. Goals with results that were already selected/saved to the entity -->
<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 -->
<div v-for="g in goalsPicked" :key="g.goal.id"> <div v-for="g in goalsPicked" :key="g.goal.id">
<div class="item-title" @click="removeGoal(g)"> <div class="item-title" @click="removeGoal(g)">
<span class="removable">{{ <span class="removable">{{
@@ -76,6 +63,32 @@
<add-result :goal="g.goal" destination="goal"></add-result> <add-result :goal="g.goal" destination="goal"></add-result>
</div> </div>
</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 class="accordion" id="expandedSuggestions">
<div <div
v-if="availableForCheckGoal.length > 0" v-if="availableForCheckGoal.length > 0"
@@ -138,6 +151,8 @@
}}</span> }}</span>
</div> </div>
</div> </div>
<!-- 4. Selector for results without objectives is already included above in section 2 -->
</div> </div>
<div id="evaluations" class="action-row"> <div id="evaluations" class="action-row">

Some files were not shown because too many files have changed in this diff Show More