Merge remote-tracking branch 'origin/upgrade-sf5' into signature-app-master

This commit is contained in:
Julien Fastré 2024-09-16 11:51:33 +02:00
commit 45323e9136
Signed by: julienfastre
GPG Key ID: BDE2190974723FCB
46 changed files with 1787 additions and 628 deletions

View File

@ -1,11 +1,30 @@
## v2.23.0 - 2024-07-23 ## v2.23.0 - 2024-07-23 & 2024-07-19
### Feature ### Feature
* ([#221](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/221)) [DX] move async-upload-bundle features into chill-bundles * ([#221](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/221)) [DX] move async-upload-bundle features into chill-bundles
* Add job bundle (module emploi) * Add job bundle (module emploi)
* Upgrade import of address list to the last version of compiled addresses of belgian-best-address * Upgrade import of address list to the last version of compiled addresses of belgian-best-address
* Upgrade CKEditor and refactor configuration with use of typescript * Upgrade CKEditor and refactor configuration with use of typescript
* ([#123](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/123)) Add a button to duplicate calendar ranges from a week to another one
* [admin] filter users by active / inactive in the admin user's list
* ([#273](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/273)) Add the possibility to mark all notifications as read
* Handle duplicate reference id in the import of reference addresses
* Do not update the "createdAt" column when importing postal code which does not change
* Display filename on file upload within the UI interface
### Fixed ### Fixed
* Fix resolving of centers for an household, which will fix in turn the access control * Fix resolving of centers for an household, which will fix in turn the access control
* Resolved type hinting error in activity list export * Resolved type hinting error in activity list export
* ([#271](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/271)) Take into account the acp closing date in the acp works date filter
### Traduction française des principaux changements
- Ajout d'un bouton pour dupliquer les périodes de disponibilités d'une semaine à une autre;
- dans l'interface d'administration, filtre sur les utilisateurs actifs. Par défaut, seul les utilisateurs
actifs sont affichés;
- Nouveau bouton pour indiquer toutes les notifications comme lues;
- Améliorations sur l'import des adresses et des codes postaux;
- Affiche le nom du fichier déposé quand on téléverse un fichier depuis le poste de travail local;
- Agrandit l'icône du type de fichier dans l'interface de dépôt de fichier;
- correction: tient compte de la date de fermeture du parcours dans les filtres sur les actions d'accompagnement.

3
.changes/v2.24.0.md Normal file
View File

@ -0,0 +1,3 @@
## v2.24.0 - 2024-09-11
### Feature
* ([#306](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/306)) When a document is converted or downloaded in the browser, this document is removed from the browser memory after 45s. Future click on the button re-download the document.

3
.changes/v3.1.0.md Normal file
View File

@ -0,0 +1,3 @@
## v3.1.0 - 2024-08-30
### Feature
* Add export aggregator to aggregate activities by household + filter persons that are not part of an accompanyingperiod during a certain timeframe.

View File

@ -6,33 +6,62 @@ 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).
## v3.1.0 - 2024-08-30
### Feature
* Add export aggregator to aggregate activities by household + filter persons that are not part of an accompanyingperiod during a certain timeframe.
## v3.0.0 - 2024-08-26 ## v3.0.0 - 2024-08-26
### Fixed ### Fixed
* Fix delete action for accompanying periods in draft state * Fix delete action for accompanying periods in draft state
* Fix connection to azure when making an calendar event in chill * Fix connection to azure when making an calendar event in chill
* CollectionType js fixes for remove button and adding multiple entries * CollectionType js fixes for remove button and adding multiple entries
## v2.23.0 - 2024-07-23 ## v2.24.0 - 2024-09-11
### Feature ### Feature
* ([#221](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/221)) [DX] move async-upload-bundle features into chill-bundles * ([#306](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/306)) When a document is converted or downloaded in the browser, this document is removed from the browser memory after 45s. Future click on the button re-download the document.
* Add job bundle (module emploi)
* Upgrade import of address list to the last version of compiled addresses of belgian-best-address ## v2.23.0 - 2024-07-19 & 2024-07-23
### Feature
* Upgrade CKEditor and refactor configuration with use of typescript * ([#123](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/123)) Add a button to duplicate calendar ranges from a week to another one
* [admin] filter users by active / inactive in the admin user's list
* ([#273](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/273)) Add the possibility to mark all notifications as read
* Handle duplicate reference id in the import of reference addresses
* Do not update the "createdAt" column when importing postal code which does not change
* Display filename on file upload within the UI interface
### Fixed ### Fixed
* Fix resolving of centers for an household, which will fix in turn the access control * ([#271](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/271)) Take into account the acp closing date in the acp works date filter
* Resolved type hinting error in activity list export
### Traduction française des principaux changements
- Ajout d'un bouton pour dupliquer les périodes de disponibilités d'une semaine à une autre;
- dans l'interface d'administration, filtre sur les utilisateurs actifs. Par défaut, seul les utilisateurs
actifs sont affichés;
- Nouveau bouton pour indiquer toutes les notifications comme lues;
- Améliorations sur l'import des adresses et des codes postaux;
- Affiche le nom du fichier déposé quand on téléverse un fichier depuis le poste de travail local;
- Agrandit l'icône du type de fichier dans l'interface de dépôt de fichier;
- correction: tient compte de la date de fermeture du parcours dans les filtres sur les actions d'accompagnement.
* ([#221](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/221)) [DX] move async-upload-bundle features into chill-bundles
* Add job bundle (module emploi)
* Upgrade import of address list to the last version of compiled addresses of belgian-best-address
* Upgrade CKEditor and refactor configuration with use of typescript
### Fixed
* Fix resolving of centers for an household, which will fix in turn the access control
* Resolved type hinting error in activity list export
## v2.22.2 - 2024-07-03 ## v2.22.2 - 2024-07-03
### Fixed ### Fixed
* Remove scope required for event participation stats * Remove scope required for event participation stats
## v2.22.1 - 2024-07-01 ## v2.22.1 - 2024-07-01
### Fixed ### Fixed
* Remove debug word * Remove debug word
### DX ### DX
* Add a command for reading official address DB from Luxembourg and update chill addresses * Add a command for reading official address DB from Luxembourg and update chill addresses
## v2.22.0 - 2024-06-25 ## v2.22.0 - 2024-06-25
### Feature ### Feature
@ -75,7 +104,7 @@ and is generated by [Changie](https://github.com/miniscruff/changie).
## v2.20.1 - 2024-06-05 ## v2.20.1 - 2024-06-05
### Fixed ### Fixed
* Do not allow StoredObjectCreated for edit and convert buttons * Do not allow StoredObjectCreated for edit and convert buttons
## v2.20.0 - 2024-06-05 ## v2.20.0 - 2024-06-05
### Fixed ### Fixed
@ -122,96 +151,96 @@ and is generated by [Changie](https://github.com/miniscruff/changie).
## v2.18.2 - 2024-04-12 ## v2.18.2 - 2024-04-12
### Fixed ### Fixed
* ([#250](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/250)) Postal codes import : fix the source URL and the keys to handle each record * ([#250](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/250)) Postal codes import : fix the source URL and the keys to handle each record
## v2.18.1 - 2024-03-26 ## v2.18.1 - 2024-03-26
### Fixed ### Fixed
* Fix layout issue in document generation for admin (minor) * Fix layout issue in document generation for admin (minor)
## v2.18.0 - 2024-03-26 ## v2.18.0 - 2024-03-26
### Feature ### Feature
* ([#268](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/268)) Improve admin UX to configure document templates for document generation * ([#268](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/268)) Improve admin UX to configure document templates for document generation
### Fixed ### Fixed
* ([#267](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/267)) Fix the join between job and user in the user list (admin): show only the current user job * ([#267](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/267)) Fix the join between job and user in the user list (admin): show only the current user job
## v2.17.0 - 2024-03-19 ## v2.17.0 - 2024-03-19
### Feature ### Feature
* ([#237](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/237)) New export filter for social actions with an evaluation created between two dates * ([#237](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/237)) New export filter for social actions with an evaluation created between two dates
* ([#258](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/258)) In the list of accompangying period, add the list of person's centers and the duration of the course * ([#258](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/258)) In the list of accompangying period, add the list of person's centers and the duration of the course
* ([#238](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/238)) Allow to customize list person with new fields * ([#238](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/238)) Allow to customize list person with new fields
* ([#159](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/159)) Admin can publish news on the homepage * ([#159](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/159)) Admin can publish news on the homepage
### Fixed ### Fixed
* ([#264](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/264)) Fix languages: load the languages in all availables languages configured for Chill * ([#264](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/264)) Fix languages: load the languages in all availables languages configured for Chill
* ([#259](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/259)) Keep a consistent behaviour between the filtering of activities within the document generation (model "accompanying period with activities"), and the same filter in the list of activities for an accompanying period * ([#259](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/259)) Keep a consistent behaviour between the filtering of activities within the document generation (model "accompanying period with activities"), and the same filter in the list of activities for an accompanying period
## v2.16.3 - 2024-02-26 ## v2.16.3 - 2024-02-26
### Fixed ### Fixed
* ([#236](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/236)) Fix translation of user job -> 'service' must be 'métier' * ([#236](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/236)) Fix translation of user job -> 'service' must be 'métier'
### UX ### UX
* ([#232](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/232)) Order user jobs and services alphabetically in export filters * ([#232](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/232)) Order user jobs and services alphabetically in export filters
## v2.16.2 - 2024-02-21 ## v2.16.2 - 2024-02-21
### Fixed ### Fixed
* Check for null values in closing motive of parcours d'accompagnement for correct rendering of template * Check for null values in closing motive of parcours d'accompagnement for correct rendering of template
## v2.16.1 - 2024-02-09 ## v2.16.1 - 2024-02-09
### Fixed ### Fixed
* Force bootstrap version to avoid error in builds with newer version * Force bootstrap version to avoid error in builds with newer version
## v2.16.0 - 2024-02-08 ## v2.16.0 - 2024-02-08
### Feature ### Feature
* ([#231](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/231)) Create new filter for persons having a participation in an accompanying period during a certain time span * ([#231](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/231)) Create new filter for persons having a participation in an accompanying period during a certain time span
* ([#241](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/241)) [Export][List of accompanyign period] Add two columns: the list of persons participating to the period, and their ids * ([#241](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/241)) [Export][List of accompanyign period] Add two columns: the list of persons participating to the period, and their ids
* ([#244](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/244)) Add capability to generate export about change of steps of accompanying period, and generate exports for this * ([#244](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/244)) Add capability to generate export about change of steps of accompanying period, and generate exports for this
* ([#253](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/253)) Export: group accompanying period by person participating * ([#253](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/253)) Export: group accompanying period by person participating
* ([#243](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/243)) Export: add filter for courses not linked to a reference address * ([#243](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/243)) Export: add filter for courses not linked to a reference address
* ([#229](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/229)) Allow to group activities linked with accompanying period by reason * ([#229](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/229)) Allow to group activities linked with accompanying period by reason
* ([#115](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/115)) Prevent social work to be saved when another user edited conccurently the social work * ([#115](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/115)) Prevent social work to be saved when another user edited conccurently the social work
* Modernize the event bundle, with some new fields and multiple improvements * Modernize the event bundle, with some new fields and multiple improvements
### Fixed ### Fixed
* ([#220](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/220)) Fix error in logs about wrong typing of eventArgs in onEditNotificationComment method * ([#220](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/220)) Fix error in logs about wrong typing of eventArgs in onEditNotificationComment method
* ([#256](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/256)) Fix the conditions upon which social actions should be optional or required in relation to social issues within the activity creation form * ([#256](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/256)) Fix the conditions upon which social actions should be optional or required in relation to social issues within the activity creation form
### UX ### UX
* ([#260](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/260)) Order list of centers alphabetically in dropdown 'user' section admin. * ([#260](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/260)) Order list of centers alphabetically in dropdown 'user' section admin.
## v2.15.2 - 2024-01-11 ## v2.15.2 - 2024-01-11
### Fixed ### Fixed
* Fix the id_seq used when creating a new accompanying period participation during fusion of two person files * Fix the id_seq used when creating a new accompanying period participation during fusion of two person files
### DX ### DX
* Set placeholder to False for expanded EntityType form fields where required is set to False. * Set placeholder to False for expanded EntityType form fields where required is set to False.
## v2.15.1 - 2023-12-20 ## v2.15.1 - 2023-12-20
### Fixed ### Fixed
* Fix the household export query to exclude accompanying periods that are in draft state. * Fix the household export query to exclude accompanying periods that are in draft state.
### DX ### DX
* ([#167](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/167)) Fixed readthedocs compilation by updating readthedocs config file and requirements for Sphinx * ([#167](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/167)) Fixed readthedocs compilation by updating readthedocs config file and requirements for Sphinx
## v2.15.0 - 2023-12-11 ## v2.15.0 - 2023-12-11
### Feature ### Feature
* ([#191](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/191)) Add export "number of household associate with an exchange" * ([#191](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/191)) Add export "number of household associate with an exchange"
* ([#235](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/235)) Export: add dates on the filter "filter course by activity type" * ([#235](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/235)) Export: add dates on the filter "filter course by activity type"
### Fixed ### Fixed
* ([#214](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/214)) Fix error when posting an empty comment on an accompanying period. * ([#214](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/214)) Fix error when posting an empty comment on an accompanying period.
* ([#233](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/233)) Fix "filter evaluation by evaluation type" (and add select2 to the list of evaluation types to pick) * ([#233](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/233)) Fix "filter evaluation by evaluation type" (and add select2 to the list of evaluation types to pick)
* ([#234](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/234)) Fix "filter aside activity by date" * ([#234](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/234)) Fix "filter aside activity by date"
* ([#228](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/228)) Fix export of activity for people created before the introduction of the createdAt column on person (during v1) * ([#228](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/228)) Fix export of activity for people created before the introduction of the createdAt column on person (during v1)
* ([#246](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/246)) Do not show activities, evaluations and social work when associated to a confidential accompanying period, except for the users which are allowed to see them * ([#246](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/246)) Do not show activities, evaluations and social work when associated to a confidential accompanying period, except for the users which are allowed to see them
## v2.14.1 - 2023-11-29 ## v2.14.1 - 2023-11-29
### Fixed ### Fixed
* Export: fix list person with custom fields * Export: fix list person with custom fields
* ([#100](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/100)) Add a paginator to budget elements (resource and charge types) in the admin * ([#100](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/100)) Add a paginator to budget elements (resource and charge types) in the admin
* Fix error in ListEvaluation when "handling agents" are alone * Fix error in ListEvaluation when "handling agents" are alone
## v2.14.0 - 2023-11-24 ## v2.14.0 - 2023-11-24
### Feature ### Feature
* ([#161](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/161)) Export: in filter "Filter accompanying period work (social action) by type, goal and result", order the items alphabetically or with the defined order * ([#161](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/161)) Export: in filter "Filter accompanying period work (social action) by type, goal and result", order the items alphabetically or with the defined order
### Fixed ### Fixed
* ([#141](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/141)) Export: on filter "action by type goals, and results", restore the fields when editing a saved export * ([#141](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/141)) Export: on filter "action by type goals, and results", restore the fields when editing a saved export
* ([#219](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/219)) Export: fix the list of accompanying period work, when the "calc date" is null * ([#219](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/219)) Export: fix the list of accompanying period work, when the "calc date" is null
* ([#222](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/222)) Fix rendering of custom fields * ([#222](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/222)) Fix rendering of custom fields
* Fix various errors in custom fields administration * Fix various errors in custom fields administration
## v2.13.0 - 2023-11-21 ## v2.13.0 - 2023-11-21
### Feature ### Feature
@ -225,7 +254,7 @@ and is generated by [Changie](https://github.com/miniscruff/changie).
## v2.12.1 - 2023-11-16 ## v2.12.1 - 2023-11-16
### Fixed ### Fixed
* ([#208](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/208)) Export: fix loading of form for "filter action by type, goal and result" * ([#208](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/208)) Export: fix loading of form for "filter action by type, goal and result"
## v2.12.0 - 2023-11-15 ## v2.12.0 - 2023-11-15
### Feature ### Feature
@ -256,36 +285,36 @@ and is generated by [Changie](https://github.com/miniscruff/changie).
## v2.11.0 - 2023-11-07 ## v2.11.0 - 2023-11-07
### Feature ### Feature
* ([#194](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/194)) Export: add a filter "filter activity by creator job" * ([#194](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/194)) Export: add a filter "filter activity by creator job"
### Fixed ### Fixed
* ([#185](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/185)) Export: fix "group accompanying period by geographical unit": take into account the accompanying periods when the period is not located within an unit * ([#185](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/185)) Export: fix "group accompanying period by geographical unit": take into account the accompanying periods when the period is not located within an unit
* Fix "group activity by creator job" aggregator * Fix "group activity by creator job" aggregator
## v2.10.6 - 2023-11-07 ## v2.10.6 - 2023-11-07
### Fixed ### Fixed
* ([#182](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/182)) Fix merging of double person files. Adjustement relationship sql statement * ([#182](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/182)) Fix merging of double person files. Adjustement relationship sql statement
* ([#185](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/185)) Export: fix aggregator by geographical unit on person: avoid inconsistencies * ([#185](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/185)) Export: fix aggregator by geographical unit on person: avoid inconsistencies
## v2.10.5 - 2023-11-05 ## v2.10.5 - 2023-11-05
### Fixed ### Fixed
* ([#183](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/183)) Fix "problem during download" on some filters, which used a wrong data type * ([#183](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/183)) Fix "problem during download" on some filters, which used a wrong data type
* ([#184](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/184)) Fix filter "activity by date" * ([#184](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/184)) Fix filter "activity by date"
## v2.10.4 - 2023-10-26 ## v2.10.4 - 2023-10-26
### Fixed ### Fixed
* Fix null value constraint errors when merging relationships in doubles * Fix null value constraint errors when merging relationships in doubles
## v2.10.3 - 2023-10-26 ## v2.10.3 - 2023-10-26
### Fixed ### Fixed
* ([#175](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/175)) Replace old method of getting translator with injection of translatorInterface * ([#175](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/175)) Replace old method of getting translator with injection of translatorInterface
## v2.10.2 - 2023-10-26 ## v2.10.2 - 2023-10-26
### Fixed ### Fixed
* ([#175](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/175)) Use injection of translator instead of ->get(). * ([#175](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/175)) Use injection of translator instead of ->get().
## v2.10.1 - 2023-10-24 ## v2.10.1 - 2023-10-24
### Fixed ### Fixed
* Fix export controller when generating an export without any data in session * Fix export controller when generating an export without any data in session
## v2.10.0 - 2023-10-24 ## v2.10.0 - 2023-10-24
### Feature ### Feature
@ -310,11 +339,11 @@ and is generated by [Changie](https://github.com/miniscruff/changie).
## v2.9.2 - 2023-10-17 ## v2.9.2 - 2023-10-17
### Fixed ### Fixed
* Fix possible null values in string's entities * Fix possible null values in string's entities
## v2.9.1 - 2023-10-17 ## v2.9.1 - 2023-10-17
### Fixed ### Fixed
* Fix the handling of activity form when editing or creating an activity in an accompanying period with multiple centers * Fix the handling of activity form when editing or creating an activity in an accompanying period with multiple centers
## v2.9.0 - 2023-10-17 ## v2.9.0 - 2023-10-17
### Feature ### Feature
@ -362,57 +391,57 @@ But if you do not need this any more, you must ensure that the configuration key
## v2.7.0 - 2023-09-27 ## v2.7.0 - 2023-09-27
### Feature ### Feature
* ([#155](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/155)) The regulation list load accompanying periods by exact postal code (address associated with postal code), and not by the content of the postal code (postal code with same code's string) * ([#155](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/155)) The regulation list load accompanying periods by exact postal code (address associated with postal code), and not by the content of the postal code (postal code with same code's string)
### Fixed ### Fixed
* ([#142](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/142)) Fix the label of filter ActivityTypeFilter to a more obvious one * ([#142](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/142)) Fix the label of filter ActivityTypeFilter to a more obvious one
* ([#140](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/140)) [export] Fix association of filter "filter location by type" which did not appears on "list of activities" * ([#140](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/140)) [export] Fix association of filter "filter location by type" which did not appears on "list of activities"
## v2.6.3 - 2023-09-19 ## v2.6.3 - 2023-09-19
### Fixed ### Fixed
* Remove id property from document * Remove id property from document
mappedsuperclass mappedsuperclass
## v2.6.2 - 2023-09-18 ## v2.6.2 - 2023-09-18
### Fixed ### Fixed
* Fix doctrine mapping of AbstractTaskPlaceEvent and SingleTaskPlaceEvent: id property moved. * Fix doctrine mapping of AbstractTaskPlaceEvent and SingleTaskPlaceEvent: id property moved.
## v2.6.1 - 2023-09-14 ## v2.6.1 - 2023-09-14
### Fixed ### Fixed
* Filter out active centers in exports, which uses a different PickCenterType. * Filter out active centers in exports, which uses a different PickCenterType.
## v2.6.0 - 2023-09-14 ## v2.6.0 - 2023-09-14
### Feature ### Feature
* ([#133](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/133)) Add locations in Aside Activity. By default, suggest user location, otherwise a select with all locations. * ([#133](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/133)) Add locations in Aside Activity. By default, suggest user location, otherwise a select with all locations.
* ([#133](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/133)) Adapt Aside Activity exports: display location, filter by location, group by location * ([#133](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/133)) Adapt Aside Activity exports: display location, filter by location, group by location
* Use the CRUD controller for center entity + add the isActive property to be able to mask instances of Center that are no longer in use. * Use the CRUD controller for center entity + add the isActive property to be able to mask instances of Center that are no longer in use.
### Fixed ### Fixed
* ([#107](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/107)) reinstate the fusion of duplicate persons * ([#107](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/107)) reinstate the fusion of duplicate persons
* Missing translation in Work Actions exports * Missing translation in Work Actions exports
* Reimplement the mission type filter on tasks, only for instances that have a config parameter indicating true for this. * Reimplement the mission type filter on tasks, only for instances that have a config parameter indicating true for this.
* ([#135](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/135)) Corrects a typing error in 2 filters, which caused an * ([#135](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/135)) Corrects a typing error in 2 filters, which caused an
error when trying to reedit a saved export error when trying to reedit a saved export
* ([#136](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/136)) [household] when moving a person to a sharing position to a not-sharing position on the same household on the same date, remove the previous household membership on the same household. This fix duplicate member. * ([#136](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/136)) [household] when moving a person to a sharing position to a not-sharing position on the same household on the same date, remove the previous household membership on the same household. This fix duplicate member.
* Add missing translation for comment field placeholder in repositionning household editor. * Add missing translation for comment field placeholder in repositionning household editor.
* Do not send an email to creator twice when adding a comment to a notification * Do not send an email to creator twice when adding a comment to a notification
* ([#107](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/107)) Fix gestion doublon functionality to work with chill bundles v2 * ([#107](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/107)) Fix gestion doublon functionality to work with chill bundles v2
### UX ### UX
* Uniformize badge-person in household banner (background, size) * Uniformize badge-person in household banner (background, size)
## v2.5.3 - 2023-07-20 ## v2.5.3 - 2023-07-20
### Fixed ### Fixed
* ([#132](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/132)) Rendez-vous documents created would appear in all documents lists of all persons with an accompanying period. Or statements are now added to the where clause to filter out documents that come from unrelated accompanying period/ or person rendez-vous. * ([#132](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/132)) Rendez-vous documents created would appear in all documents lists of all persons with an accompanying period. Or statements are now added to the where clause to filter out documents that come from unrelated accompanying period/ or person rendez-vous.
## v2.5.2 - 2023-07-15 ## v2.5.2 - 2023-07-15
### Fixed ### Fixed
* [Collate Address] when updating address point, do not use the point's address reference if the similarity is below the requirement for associating the address reference and the address (it uses the postcode's center instead) * [Collate Address] when updating address point, do not use the point's address reference if the similarity is below the requirement for associating the address reference and the address (it uses the postcode's center instead)
## v2.5.1 - 2023-07-14 ## v2.5.1 - 2023-07-14
### Fixed ### Fixed
* [collate addresses] block collating addresses to another address reference where the address reference is already the best match * [collate addresses] block collating addresses to another address reference where the address reference is already the best match
## v2.5.0 - 2023-07-14 ## v2.5.0 - 2023-07-14
### Feature ### Feature

View File

@ -43,6 +43,7 @@
"symfony/dom-crawler": "^5.4", "symfony/dom-crawler": "^5.4",
"symfony/error-handler": "^5.4", "symfony/error-handler": "^5.4",
"symfony/event-dispatcher": "^5.4", "symfony/event-dispatcher": "^5.4",
"symfony/event-dispatcher-contracts": "^2.4",
"symfony/expression-language": "^5.4", "symfony/expression-language": "^5.4",
"symfony/filesystem": "^5.4", "symfony/filesystem": "^5.4",
"symfony/finder": "^5.4", "symfony/finder": "^5.4",

View File

@ -39,9 +39,12 @@ Implements a :code:`Chill\MainBundle\Cron\CronJobInterface`. Here is an example:
use Chill\MainBundle\Entity\CronJobExecution; use Chill\MainBundle\Entity\CronJobExecution;
use DateInterval; use DateInterval;
use DateTimeImmutable; use DateTimeImmutable;
use Symfony\Component\Clock\ClockInterface;
class MyCronJob implements CronJobInterface class MyCronJob implements CronJobInterface
{ {
function __construct(private ClockInterface $clock) {}
public function canRun(?CronJobExecution $cronJobExecution): bool public function canRun(?CronJobExecution $cronJobExecution): bool
{ {
// the parameter $cronJobExecution contains data about the last execution of the cronjob // the parameter $cronJobExecution contains data about the last execution of the cronjob
@ -56,7 +59,7 @@ Implements a :code:`Chill\MainBundle\Cron\CronJobInterface`. Here is an example:
// this cron job should be executed if the last execution is greater than one day, but only during the night // this cron job should be executed if the last execution is greater than one day, but only during the night
$now = new DateTimeImmutable('now'); $now = $clock->now();
return $cronJobExecution->getLastStart() < $now->sub(new DateInterval('P1D')) return $cronJobExecution->getLastStart() < $now->sub(new DateInterval('P1D'))
&& in_array($now->format('H'), self::ACCEPTED_HOURS, true) && in_array($now->format('H'), self::ACCEPTED_HOURS, true)
@ -69,10 +72,15 @@ Implements a :code:`Chill\MainBundle\Cron\CronJobInterface`. Here is an example:
return 'arbitrary-and-unique-key'; return 'arbitrary-and-unique-key';
} }
public function run(): void public function run(array $lastExecutionData): void
{ {
// here, we execute the command // here, we execute the command
}
// we return execution data, which will be served for next execution
// this data should be easily serializable in a json column: it should contains
// only int, string, etc. Avoid storing object
return ['last-execution-id' => 0];
}
} }
How are cron job scheduled ? How are cron job scheduled ?

View File

@ -0,0 +1,99 @@
<?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\ActivityBundle\Export\Aggregator\PersonAggregators;
use Chill\ActivityBundle\Export\Declarations;
use Chill\MainBundle\Export\AggregatorInterface;
use Chill\PersonBundle\Entity\Household\Household;
use Chill\PersonBundle\Entity\Household\HouseholdMember;
use Chill\PersonBundle\Repository\Household\HouseholdRepository;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Form\FormBuilderInterface;
final readonly class HouseholdAggregator implements AggregatorInterface
{
public function __construct(private HouseholdRepository $householdRepository) {}
public function buildForm(FormBuilderInterface $builder)
{
// nothing to add here
}
public function getFormDefaultData(): array
{
return [];
}
public function getLabels($key, array $values, mixed $data)
{
return function (int|string|null $value): string|int {
if ('_header' === $value) {
return 'export.aggregator.person.by_household.household';
}
if ('' === $value || null === $value || null === $household = $this->householdRepository->find($value)) {
return '';
}
return $household->getId();
};
}
public function getQueryKeys($data)
{
return ['activity_household_agg'];
}
public function getTitle()
{
return 'export.aggregator.person.by_household.title';
}
public function addRole(): ?string
{
return null;
}
public function alterQuery(QueryBuilder $qb, $data)
{
$qb->join(
HouseholdMember::class,
'activity_household_agg_household_member',
Join::WITH,
$qb->expr()->andX(
$qb->expr()->eq('activity_household_agg_household_member.person', 'activity.person'),
$qb->expr()->lte('activity_household_agg_household_member.startDate', 'activity.date'),
$qb->expr()->orX(
$qb->expr()->gte('activity_household_agg_household_member.endDate', 'activity.date'),
$qb->expr()->isNull('activity_household_agg_household_member.endDate')
)
)
);
$qb->join(
Household::class,
'activity_household_agg_household',
Join::WITH,
$qb->expr()->eq('activity_household_agg_household_member.household', 'activity_household_agg_household')
);
$qb
->addSelect('activity_household_agg_household.id AS activity_household_agg')
->addGroupBy('activity_household_agg');
}
public function applyOn()
{
return Declarations::ACTIVITY_PERSON;
}
}

View File

@ -19,6 +19,7 @@ use Chill\MainBundle\Export\FormatterInterface;
use Chill\MainBundle\Export\GroupedExportInterface; use Chill\MainBundle\Export\GroupedExportInterface;
use Chill\MainBundle\Export\ListInterface; use Chill\MainBundle\Export\ListInterface;
use Chill\MainBundle\Templating\TranslatableStringHelperInterface; use Chill\MainBundle\Templating\TranslatableStringHelperInterface;
use Chill\PersonBundle\Entity\Household\HouseholdMember;
use Chill\PersonBundle\Export\Declarations as PersonDeclarations; use Chill\PersonBundle\Export\Declarations as PersonDeclarations;
use Doctrine\DBAL\Exception\InvalidArgumentException; use Doctrine\DBAL\Exception\InvalidArgumentException;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@ -44,6 +45,7 @@ class ListActivity implements ListInterface, GroupedExportInterface
'person_firstname', 'person_firstname',
'person_lastname', 'person_lastname',
'person_id', 'person_id',
'household_id',
]; ];
private readonly bool $filterStatsByCenters; private readonly bool $filterStatsByCenters;
@ -189,19 +191,26 @@ class ListActivity implements ListInterface, GroupedExportInterface
{ {
$centers = array_map(static fn ($el) => $el['center'], $acl); $centers = array_map(static fn ($el) => $el['center'], $acl);
// throw an error if any fields are present // throw an error if no fields are present
if (!\array_key_exists('fields', $data)) { if (!\array_key_exists('fields', $data)) {
throw new InvalidArgumentException('Any fields have been checked.'); throw new InvalidArgumentException('No fields have been checked.');
} }
$qb = $this->entityManager->createQueryBuilder(); $qb = $this->entityManager->createQueryBuilder();
$qb $qb
->from('ChillActivityBundle:Activity', 'activity') ->from('ChillActivityBundle:Activity', 'activity')
->join('activity.person', 'actperson'); ->join('activity.person', 'person')
->join(
HouseholdMember::class,
'householdmember',
Query\Expr\Join::WITH,
'person = householdmember.person AND householdmember.startDate <= activity.date AND (householdmember.endDate IS NULL OR householdmember.endDate > activity.date)'
)
->join('householdmember.household', 'household');
if ($this->filterStatsByCenters) { if ($this->filterStatsByCenters) {
$qb->join('actperson.centerHistory', 'centerHistory'); $qb->join('person.centerHistory', 'centerHistory');
$qb->where( $qb->where(
$qb->expr()->andX( $qb->expr()->andX(
$qb->expr()->lte('centerHistory.startDate', 'activity.date'), $qb->expr()->lte('centerHistory.startDate', 'activity.date'),
@ -224,17 +233,22 @@ class ListActivity implements ListInterface, GroupedExportInterface
break; break;
case 'person_firstname': case 'person_firstname':
$qb->addSelect('actperson.firstName AS person_firstname'); $qb->addSelect('person.firstName AS person_firstname');
break; break;
case 'person_lastname': case 'person_lastname':
$qb->addSelect('actperson.lastName AS person_lastname'); $qb->addSelect('person.lastName AS person_lastname');
break; break;
case 'person_id': case 'person_id':
$qb->addSelect('actperson.id AS person_id'); $qb->addSelect('person.id AS person_id');
break;
case 'household_id':
$qb->addSelect('household.id AS household_id');
break; break;
@ -284,7 +298,7 @@ class ListActivity implements ListInterface, GroupedExportInterface
return ActivityStatsVoter::LISTS; return ActivityStatsVoter::LISTS;
} }
public function supportsModifiers() public function supportsModifiers(): array
{ {
return [ return [
Declarations::ACTIVITY, Declarations::ACTIVITY,

View File

@ -73,7 +73,7 @@ final readonly class PeriodHavingActivityBetweenDatesFilter implements FilterInt
$qb->andWhere( $qb->andWhere(
$qb->expr()->exists( $qb->expr()->exists(
'SELECT 1 FROM '.Activity::class." {$alias} WHERE {$alias}.date >= :{$from} AND {$alias}.date < :{$to} AND {$alias}.accompanyingPeriod = activity.accompanyingPeriod" 'SELECT 1 FROM '.Activity::class." {$alias} WHERE {$alias}.date >= :{$from} AND {$alias}.date < :{$to} AND {$alias}.accompanyingPeriod = acp"
) )
); );

View File

@ -39,7 +39,7 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
return null; return null;
} }
public function alterQuery(QueryBuilder $qb, $data) public function alterQuery(QueryBuilder $qb, $data): void
{ {
// create a subquery for activity // create a subquery for activity
$sqb = $qb->getEntityManager()->createQueryBuilder(); $sqb = $qb->getEntityManager()->createQueryBuilder();
@ -121,7 +121,7 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
]; ];
} }
public function describeAction($data, $format = 'string') public function describeAction($data, $format = 'string'): array
{ {
return [ return [
[] === $data['reasons'] ? [] === $data['reasons'] ?
@ -141,7 +141,7 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
]; ];
} }
public function getTitle() public function getTitle(): string
{ {
return 'export.filter.activity.person_between_dates.title'; return 'export.filter.activity.person_between_dates.title';
} }

View File

@ -243,3 +243,7 @@ services:
Chill\ActivityBundle\Export\Aggregator\PersonAggregators\PersonAggregator: Chill\ActivityBundle\Export\Aggregator\PersonAggregators\PersonAggregator:
tags: tags:
- { name: chill.export_aggregator, alias: activity_person_agg } - { name: chill.export_aggregator, alias: activity_person_agg }
Chill\ActivityBundle\Export\Aggregator\PersonAggregators\HouseholdAggregator:
tags:
- { name: chill.export_aggregator, alias: activity_household_agg }

View File

@ -428,6 +428,9 @@ export:
by_person: by_person:
title: Grouper les échanges par usager (dossier d'usager dans lequel l'échange est enregistré) title: Grouper les échanges par usager (dossier d'usager dans lequel l'échange est enregistré)
person: Usager person: Usager
by_household:
title: Grouper les échanges par ménage
household: Identifiant ménage
acp: acp:
by_activity_type: by_activity_type:
title: Grouper les parcours par type d'échange title: Grouper les parcours par type d'échange

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="row"> <div class="row">
<div class="col-sm"> <div class="col-sm">
<label class="form-label">{{ $t('created_availabilities') }}</label> <label class="form-label">{{ $t("created_availabilities") }}</label>
<vue-multiselect <vue-multiselect
v-model="pickedLocation" v-model="pickedLocation"
:options="locations" :options="locations"
@ -14,10 +14,15 @@
></vue-multiselect> ></vue-multiselect>
</div> </div>
</div> </div>
<div class="display-options row justify-content-between" style="margin-top: 1rem;"> <div
class="display-options row justify-content-between"
style="margin-top: 1rem"
>
<div class="col-sm-9 col-xs-12"> <div class="col-sm-9 col-xs-12">
<div class="input-group mb-3"> <div class="input-group mb-3">
<label class="input-group-text" for="slotDuration">Durée des créneaux</label> <label class="input-group-text" for="slotDuration"
>Durée des créneaux</label
>
<select v-model="slotDuration" id="slotDuration" class="form-select"> <select v-model="slotDuration" id="slotDuration" class="form-select">
<option value="00:05:00">5 minutes</option> <option value="00:05:00">5 minutes</option>
<option value="00:10:00">10 minutes</option> <option value="00:10:00">10 minutes</option>
@ -58,13 +63,20 @@
</select> </select>
</div> </div>
</div> </div>
<div class="col-sm-3 col-xs-12"> <div class="col-xs-12 col-sm-3">
<div class="float-end"> <div class="float-end">
<div class="form-check input-group"> <div class="form-check input-group">
<span class="input-group-text"> <span class="input-group-text">
<input id="showHideWE" class="mt-0" type="checkbox" v-model="showWeekends"> <input
id="showHideWE"
class="mt-0"
type="checkbox"
v-model="showWeekends"
/>
</span> </span>
<label for="showHideWE" class="form-check-label input-group-text">Week-ends</label> <label for="showHideWE" class="form-check-label input-group-text"
>Week-ends</label
>
</div> </div>
</div> </div>
</div> </div>
@ -72,39 +84,86 @@
<FullCalendar :options="calendarOptions" ref="calendarRef"> <FullCalendar :options="calendarOptions" ref="calendarRef">
<template v-slot:eventContent="arg: EventApi"> <template v-slot:eventContent="arg: EventApi">
<span :class="eventClasses(arg.event)"> <span :class="eventClasses(arg.event)">
<b v-if="arg.event.extendedProps.is === 'remote'">{{ arg.event.title}}</b> <b v-if="arg.event.extendedProps.is === 'remote'">{{
<b v-else-if="arg.event.extendedProps.is === 'range'">{{ arg.timeText }} - {{ arg.event.extendedProps.locationName }}</b> arg.event.title
<b v-else-if="arg.event.extendedProps.is === 'local'">{{ arg.event.title}}</b> }}</b>
<b v-else >no 'is'</b> <b v-else-if="arg.event.extendedProps.is === 'range'"
<a v-if="arg.event.extendedProps.is === 'range'" class="fa fa-fw fa-times delete" >{{ arg.timeText }} - {{ arg.event.extendedProps.locationName }}</b
@click.prevent="onClickDelete(arg.event)"> >
</a> <b v-else-if="arg.event.extendedProps.is === 'local'">{{
</span> arg.event.title
}}</b>
<b v-else>no 'is'</b>
<a
v-if="arg.event.extendedProps.is === 'range'"
class="fa fa-fw fa-times delete"
@click.prevent="onClickDelete(arg.event)"
>
</a>
</span>
</template> </template>
</FullCalendar> </FullCalendar>
<div id="copy-widget"> <div id="copy-widget">
<div class="container"> <div class="container mt-2 mb-2">
<div class="row align-items-center">
<div class="col-sm-4 col-xs-12"> <div class="row justify-content-between align-items-center mb-4">
<h6 class="chill-red">{{ $t('copy_range_from_to') }}</h6> <div class="col-xs-12 col-sm-3 col-md-2">
</div> <h6 class="chill-red">{{ $t("copy_range_from_to") }}</h6>
<div class="col-sm-3 col-xs-12"> </div>
<input class="form-control" type="date" v-model="copyFrom" /> <div class="col-xs-12 col-sm-9 col-md-2">
</div> <select v-model="dayOrWeek" id="dayOrWeek" class="form-select">
<div class="col-sm-1 col-xs-12" style="text-align: center; font-size: x-large;"> <option value="day">{{ $t("from_day_to_day") }}</option>
<i class="fa fa-angle-double-right"></i> <option value="week">{{ $t("from_week_to_week") }}</option>
</div> </select>
<div class="col-sm-3 col-xs-12" > </div>
<input class="form-control" type="date" v-model="copyTo" /> <template v-if="dayOrWeek === 'day'">
</div> <div class="col-xs-12 col-sm-3 col-md-3">
<div class="col-sm-1"> <input class="form-control" type="date" v-model="copyFrom" />
<button class="btn btn-action" @click="copyDay"> </div>
{{ $t('copy_range') }} <div class="col-xs-12 col-sm-1 col-md-1 copy-chevron">
</button> <i class="fa fa-angle-double-right"></i>
</div>
<div class="col-xs-12 col-sm-3 col-md-3">
<input class="form-control" type="date" v-model="copyTo" />
</div>
<div class="col-xs-12 col-sm-5 col-md-1">
<button class="btn btn-action float-end" @click="copyDay">
{{ $t("copy_range") }}
</button>
</div>
</template>
<template v-else>
<div class="col-xs-12 col-sm-3 col-md-3">
<select
v-model="copyFromWeek"
id="copyFromWeek"
class="form-select"
>
<option v-for="w in lastWeeks" :value="w.value" :key="w.value">
{{ w.text }}
</option>
</select>
</div>
<div class="col-xs-12 col-sm-1 col-md-1 copy-chevron">
<i class="fa fa-angle-double-right"></i>
</div>
<div class="col-xs-12 col-sm-3 col-md-3">
<select v-model="copyToWeek" id="copyToWeek" class="form-select">
<option v-for="w in nextWeeks" :value="w.value" :key="w.value">
{{ w.text }}
</option>
</select>
</div>
<div class="col-xs-12 col-sm-5 col-md-1">
<button class="btn btn-action float-end" @click="copyWeek">
{{ $t("copy_range") }}
</button>
</div>
</template>
</div> </div>
</div> </div>
</div>
</div> </div>
<!-- not directly seen, but include in a modal --> <!-- not directly seen, but include in a modal -->
@ -112,42 +171,95 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { import type {
CalendarOptions, CalendarOptions,
DatesSetArg, DatesSetArg,
EventInput EventInput,
} from '@fullcalendar/core'; } from "@fullcalendar/core";
import {reactive, computed, ref} from "vue"; import { reactive, computed, ref, onMounted } from "vue";
import {useStore} from "vuex"; import { useStore } from "vuex";
import {key} from './store'; import { key } from "./store";
import FullCalendar from '@fullcalendar/vue3'; import FullCalendar from "@fullcalendar/vue3";
import frLocale from '@fullcalendar/core/locales/fr'; import frLocale from "@fullcalendar/core/locales/fr";
import interactionPlugin, {DropArg, EventResizeDoneArg} from "@fullcalendar/interaction"; import interactionPlugin, {
DropArg,
EventResizeDoneArg,
} from "@fullcalendar/interaction";
import timeGridPlugin from "@fullcalendar/timegrid"; import timeGridPlugin from "@fullcalendar/timegrid";
import {EventApi, DateSelectArg, EventDropArg, EventClickArg} from "@fullcalendar/core"; import {
import {ISOToDate} from "../../../../../ChillMainBundle/Resources/public/chill/js/date"; EventApi,
DateSelectArg,
EventDropArg,
EventClickArg,
} from "@fullcalendar/core";
import {
dateToISO,
ISOToDate,
} from "../../../../../ChillMainBundle/Resources/public/chill/js/date";
import VueMultiselect from "vue-multiselect"; import VueMultiselect from "vue-multiselect";
import {Location} from "../../../../../ChillMainBundle/Resources/public/types"; import { Location } from "../../../../../ChillMainBundle/Resources/public/types";
import EditLocation from "./Components/EditLocation.vue"; import EditLocation from "./Components/EditLocation.vue";
import {useI18n} from "vue-i18n"; import { useI18n } from "vue-i18n";
const store = useStore(key); const store = useStore(key);
const {t} = useI18n(); const { t } = useI18n();
const showWeekends = ref(false); const showWeekends = ref(false);
const slotDuration = ref('00:05:00'); const slotDuration = ref("00:15:00");
const slotMinTime = ref('09:00:00'); const slotMinTime = ref("09:00:00");
const slotMaxTime = ref('18:00:00'); const slotMaxTime = ref("18:00:00");
const copyFrom = ref<string | null>(null); const copyFrom = ref<string | null>(null);
const copyTo = ref<string | null>(null); const copyTo = ref<string | null>(null);
const editLocation = ref<InstanceType<typeof EditLocation> | null>(null) const editLocation = ref<InstanceType<typeof EditLocation> | null>(null);
const dayOrWeek = ref("day");
const copyFromWeek = ref<string | null>(null);
const copyToWeek = ref<string | null>(null);
interface Weeks {
value: string | null;
text: string;
}
const getMonday = (week: number): Date => {
const lastMonday = new Date();
lastMonday.setDate(
lastMonday.getDate() - ((lastMonday.getDay() + 6) % 7) + week * 7
);
return lastMonday;
};
const dateOptions: Intl.DateTimeFormatOptions = {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
};
const lastWeeks = computed((): Weeks[] =>
Array.from(Array(30).keys()).map((w) => {
const lastMonday = getMonday(15-w);
return {
value: dateToISO(lastMonday),
text: `Semaine du ${lastMonday.toLocaleDateString("fr-FR", dateOptions)}`,
};
})
);
const nextWeeks = computed((): Weeks[] =>
Array.from(Array(52).keys()).map((w) => {
const nextMonday = getMonday(w + 1);
return {
value: dateToISO(nextMonday),
text: `Semaine du ${nextMonday.toLocaleDateString("fr-FR", dateOptions)}`,
};
})
);
const baseOptions = ref<CalendarOptions>({ const baseOptions = ref<CalendarOptions>({
locale: frLocale, locale: frLocale,
plugins: [interactionPlugin, timeGridPlugin], plugins: [interactionPlugin, timeGridPlugin],
initialView: 'timeGridWeek', initialView: "timeGridWeek",
initialDate: new Date(), initialDate: new Date(),
scrollTimeReset: false, scrollTimeReset: false,
selectable: true, selectable: true,
@ -164,9 +276,9 @@ const baseOptions = ref<CalendarOptions>({
selectMirror: false, selectMirror: false,
editable: true, editable: true,
headerToolbar: { headerToolbar: {
left: 'prev,next today', left: "prev,next today",
center: 'title', center: "title",
right: 'timeGridWeek,timeGridDay' right: "timeGridWeek,timeGridDay",
}, },
}); });
@ -180,20 +292,23 @@ const locations = computed<Location[]>(() => {
const pickedLocation = computed<Location | null>({ const pickedLocation = computed<Location | null>({
get(): Location | null { get(): Location | null {
return store.state.locations.locationPicked || store.state.locations.currentLocation; return (
store.state.locations.locationPicked ||
store.state.locations.currentLocation
);
}, },
set(newLocation: Location | null): void { set(newLocation: Location | null): void {
store.commit('locations/setLocationPicked', newLocation, {root: true}); store.commit("locations/setLocationPicked", newLocation, { root: true });
} },
}) });
/** /**
* return the show classes for the event * return the show classes for the event
* @param arg * @param arg
*/ */
const eventClasses = function(arg: EventApi): object { const eventClasses = function (arg: EventApi): object {
return {'calendarRangeItems': true}; return { calendarRangeItems: true };
} };
/* /*
// currently, all events are stored into calendarRanges, due to reactivity bug // currently, all events are stored into calendarRanges, due to reactivity bug
@ -230,51 +345,60 @@ const calendarOptions = computed((): CalendarOptions => {
* launched when the calendar range date change * launched when the calendar range date change
*/ */
function onDatesSet(event: DatesSetArg): void { function onDatesSet(event: DatesSetArg): void {
store.dispatch('fullCalendar/setCurrentDatesView', {start: event.start, end: event.end}); store.dispatch("fullCalendar/setCurrentDatesView", {
start: event.start,
end: event.end,
});
} }
function onDateSelect(event: DateSelectArg): void { function onDateSelect(event: DateSelectArg): void {
if (null === pickedLocation.value) { if (null === pickedLocation.value) {
window.alert("Indiquez une localisation avant de créer une période de disponibilité."); window.alert(
"Indiquez une localisation avant de créer une période de disponibilité."
);
return; return;
} }
store.dispatch('calendarRanges/createRange', {start: event.start, end: event.end, location: pickedLocation.value}); store.dispatch("calendarRanges/createRange", {
start: event.start,
end: event.end,
location: pickedLocation.value,
});
} }
/** /**
* When a calendar range is deleted * When a calendar range is deleted
*/ */
function onClickDelete(event: EventApi): void { function onClickDelete(event: EventApi): void {
console.log('onClickDelete', event); if (event.extendedProps.is !== "range") {
if (event.extendedProps.is !== 'range') {
return; return;
} }
store.dispatch('calendarRanges/deleteRange', event.extendedProps.calendarRangeId); store.dispatch(
"calendarRanges/deleteRange",
event.extendedProps.calendarRangeId
);
} }
function onEventDropOrResize(payload: EventDropArg | EventResizeDoneArg) { function onEventDropOrResize(payload: EventDropArg | EventResizeDoneArg) {
if (payload.event.extendedProps.is !== 'range') { if (payload.event.extendedProps.is !== "range") {
return; return;
} }
const changedEvent = payload.event; const changedEvent = payload.event;
store.dispatch('calendarRanges/patchRangeTime', { store.dispatch("calendarRanges/patchRangeTime", {
calendarRangeId: payload.event.extendedProps.calendarRangeId, calendarRangeId: payload.event.extendedProps.calendarRangeId,
start: payload.event.start, start: payload.event.start,
end: payload.event.end, end: payload.event.end,
}); });
}; }
function onEventClick(payload: EventClickArg): void { function onEventClick(payload: EventClickArg): void {
// @ts-ignore TS does not recognize the target. But it does exists. // @ts-ignore TS does not recognize the target. But it does exists.
if (payload.jsEvent.target.classList.contains('delete')) { if (payload.jsEvent.target.classList.contains("delete")) {
return; return;
} }
if (payload.event.extendedProps.is !== 'range') { if (payload.event.extendedProps.is !== "range") {
return; return;
} }
@ -285,10 +409,26 @@ function copyDay() {
if (null === copyFrom.value || null === copyTo.value) { if (null === copyFrom.value || null === copyTo.value) {
return; return;
} }
store.dispatch("calendarRanges/copyFromDayToAnotherDay", {
store.dispatch('calendarRanges/copyFromDayToAnotherDay', {from: ISOToDate(copyFrom.value), to: ISOToDate(copyTo.value)}) from: ISOToDate(copyFrom.value),
to: ISOToDate(copyTo.value),
});
} }
function copyWeek() {
if (null === copyFromWeek.value || null === copyToWeek.value) {
return;
}
store.dispatch("calendarRanges/copyFromWeekToAnotherWeek", {
fromMonday: ISOToDate(copyFromWeek.value),
toMonday: ISOToDate(copyToWeek.value),
});
}
onMounted(() => {
copyFromWeek.value = dateToISO(getMonday(0));
copyToWeek.value = dateToISO(getMonday(1));
});
</script> </script>
<style scoped> <style scoped>
@ -299,4 +439,9 @@ function copyDay() {
z-index: 9999999999; z-index: 9999999999;
padding: 0.25rem 0 0.25rem; padding: 0.25rem 0 0.25rem;
} }
div.copy-chevron {
text-align: center;
font-size: x-large;
width: 2rem;
}
</style> </style>

View File

@ -5,11 +5,9 @@ const appMessages = {
show_my_calendar: "Afficher mon calendrier", show_my_calendar: "Afficher mon calendrier",
show_weekends: "Afficher les week-ends", show_weekends: "Afficher les week-ends",
copy_range: "Copier", copy_range: "Copier",
copy_range_from_to: "Copier les plages d'un jour à l'autre", copy_range_from_to: "Copier les plages",
copy_range_to_next_day: "Copier les plages du jour au jour suivant", from_day_to_day: "d'un jour à l'autre",
copy_range_from_day: "Copier les plages du ", from_week_to_week: "d'une semaine à l'autre",
to_the_next_day: " au jour suivant",
copy_range_to_next_week: "Copier les plages de la semaine à la semaine suivante",
copy_range_how_to: "Créez les plages de disponibilités durant une journée et copiez-les facilement au jour suivant avec ce bouton. Si les week-ends sont cachés, le jour suivant un vendredi sera le lundi.", copy_range_how_to: "Créez les plages de disponibilités durant une journée et copiez-les facilement au jour suivant avec ce bouton. Si les week-ends sont cachés, le jour suivant un vendredi sera le lundi.",
new_range_to_save: "Nouvelles plages à enregistrer", new_range_to_save: "Nouvelles plages à enregistrer",
update_range_to_save: "Plages à modifier", update_range_to_save: "Plages à modifier",

View File

@ -52,6 +52,23 @@ export default <Module<CalendarRangesState, State>>{
} }
} }
return founds;
},
getRangesOnWeek: (state: CalendarRangesState) => (mondayDate: Date): EventInputCalendarRange[] => {
const founds = [];
for (let d of Array.from(Array(7).keys())) {
const dateOfWeek = new Date(mondayDate);
dateOfWeek.setDate(mondayDate.getDate() + d);
const dateStr = <string>dateToISO(dateOfWeek);
for (let range of state.ranges) {
if (isEventInputCalendarRange(range)
&& range.start.startsWith(dateStr)
) {
founds.push(range);
}
}
}
return founds; return founds;
}, },
}, },
@ -238,7 +255,7 @@ export default <Module<CalendarRangesState, State>>{
for (let r of rangesToCopy) { for (let r of rangesToCopy) {
let start = new Date(<Date>ISOToDatetime(r.start)); let start = new Date(<Date>ISOToDatetime(r.start));
start.setFullYear(to.getFullYear(), to.getMonth(), to.getDate()) start.setFullYear(to.getFullYear(), to.getMonth(), to.getDate());
let end = new Date(<Date>ISOToDatetime(r.end)); let end = new Date(<Date>ISOToDatetime(r.end));
end.setFullYear(to.getFullYear(), to.getMonth(), to.getDate()); end.setFullYear(to.getFullYear(), to.getMonth(), to.getDate());
let location = ctx.rootGetters['locations/getLocationById'](r.locationId); let location = ctx.rootGetters['locations/getLocationById'](r.locationId);
@ -246,6 +263,23 @@ export default <Module<CalendarRangesState, State>>{
promises.push(ctx.dispatch('createRange', {start, end, location})); promises.push(ctx.dispatch('createRange', {start, end, location}));
} }
return Promise.all(promises).then(_ => Promise.resolve(null));
},
copyFromWeekToAnotherWeek(ctx, {fromMonday, toMonday}: {fromMonday: Date, toMonday: Date}): Promise<null> {
const rangesToCopy: EventInputCalendarRange[] = ctx.getters['getRangesOnWeek'](fromMonday);
const promises = [];
const diffTime = toMonday.getTime() - fromMonday.getTime();
for (let r of rangesToCopy) {
let start = new Date(<Date>ISOToDatetime(r.start));
let end = new Date(<Date>ISOToDatetime(r.end));
start.setTime(start.getTime() + diffTime);
end.setTime(end.getTime() + diffTime);
let location = ctx.rootGetters['locations/getLocationById'](r.locationId);
promises.push(ctx.dispatch('createRange', {start, end, location}));
}
return Promise.all(promises).then(_ => Promise.resolve(null)); return Promise.all(promises).then(_ => Promise.resolve(null));
} }
} }

View File

@ -16,6 +16,7 @@ const emit = defineEmits<{
const is_dragging: Ref<boolean> = ref(false); const is_dragging: Ref<boolean> = ref(false);
const uploading: Ref<boolean> = ref(false); const uploading: Ref<boolean> = ref(false);
const display_filename: Ref<string|null> = ref(null);
const has_existing_doc = computed<boolean>(() => { const has_existing_doc = computed<boolean>(() => {
return props.existingDoc !== undefined && props.existingDoc !== null; return props.existingDoc !== undefined && props.existingDoc !== null;
@ -77,6 +78,7 @@ const onFileChange = async (event: Event): Promise<void> => {
const handleFile = async (file: File): Promise<void> => { const handleFile = async (file: File): Promise<void> => {
uploading.value = true; uploading.value = true;
display_filename.value = file.name;
const type = file.type; const type = file.type;
// create a stored_object if not exists // create a stored_object if not exists
@ -108,7 +110,7 @@ const handleFile = async (file: File): Promise<void> => {
<template> <template>
<div class="drop-file"> <div class="drop-file">
<div v-if="!uploading" :class="{ area: true, dragging: is_dragging}" @click="onZoneClick" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop"> <div v-if="!uploading" :class="{ area: true, dragging: is_dragging}" @click="onZoneClick" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
<p v-if="has_existing_doc"> <p v-if="has_existing_doc" class="file-icon">
<i class="fa fa-file-pdf-o" v-if="props.existingDoc?.type === 'application/pdf'"></i> <i class="fa fa-file-pdf-o" v-if="props.existingDoc?.type === 'application/pdf'"></i>
<i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/vnd.oasis.opendocument.text'"></i> <i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/vnd.oasis.opendocument.text'"></i>
<i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'"></i> <i class="fa fa-file-word-o" v-else-if="props.existingDoc?.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'"></i>
@ -120,6 +122,8 @@ const handleFile = async (file: File): Promise<void> => {
<i class="fa fa-file-archive-o" v-else-if="props.existingDoc?.type === 'application/x-zip-compressed'"></i> <i class="fa fa-file-archive-o" v-else-if="props.existingDoc?.type === 'application/x-zip-compressed'"></i>
<i class="fa fa-file-code-o" v-else ></i> <i class="fa fa-file-code-o" v-else ></i>
</p> </p>
<p v-if="display_filename !== null" class="display-filename">{{ display_filename }}</p>
<!-- todo i18n --> <!-- todo i18n -->
<p v-if="has_existing_doc">Déposez un document ou cliquez ici pour remplacer le document existant</p> <p v-if="has_existing_doc">Déposez un document ou cliquez ici pour remplacer le document existant</p>
<p v-else>Déposez un document ou cliquez ici pour ouvrir le navigateur de fichier</p> <p v-else>Déposez un document ou cliquez ici pour ouvrir le navigateur de fichier</p>
@ -135,9 +139,18 @@ const handleFile = async (file: File): Promise<void> => {
.drop-file { .drop-file {
width: 100%; width: 100%;
.file-icon {
font-size: xx-large;
}
.display-filename {
font-variant: small-caps;
font-weight: 200;
}
& > .area, & > .waiting { & > .area, & > .waiting {
width: 100%; width: 100%;
height: 8rem; height: 10rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -158,4 +171,5 @@ const handleFile = async (file: File): Promise<void> => {
} }
} }
} }
</style> </style>

View File

@ -1,5 +1,5 @@
<template> <template>
<a :class="props.classes" @click="download_and_open($event)"> <a :class="props.classes" @click="download_and_open($event)" ref="btn">
<i class="fa fa-file-pdf-o"></i> <i class="fa fa-file-pdf-o"></i>
Télécharger en pdf Télécharger en pdf
</a> </a>
@ -9,7 +9,7 @@
import {build_convert_link, download_and_decrypt_doc, download_doc} from "./helpers"; import {build_convert_link, download_and_decrypt_doc, download_doc} from "./helpers";
import mime from "mime"; import mime from "mime";
import {reactive} from "vue"; import {reactive, ref} from "vue";
import {StoredObject} from "../../types"; import {StoredObject} from "../../types";
interface ConvertButtonConfig { interface ConvertButtonConfig {
@ -24,6 +24,7 @@ interface DownloadButtonState {
const props = defineProps<ConvertButtonConfig>(); const props = defineProps<ConvertButtonConfig>();
const state: DownloadButtonState = reactive({content: null}); const state: DownloadButtonState = reactive({content: null});
const btn = ref<HTMLAnchorElement | null>(null);
async function download_and_open(event: Event): Promise<void> { async function download_and_open(event: Event): Promise<void> {
const button = event.target as HTMLAnchorElement; const button = event.target as HTMLAnchorElement;
@ -41,6 +42,14 @@ async function download_and_open(event: Event): Promise<void> {
} }
button.click(); button.click();
const reset_pending = setTimeout(reset_state, 45000);
}
function reset_state(): void {
state.content = null;
btn.value?.removeAttribute('download');
btn.value?.removeAttribute('href');
btn.value?.removeAttribute('type');
} }
</script> </script>

View File

@ -76,6 +76,15 @@ async function download_and_open(event: Event): Promise<void> {
await nextTick(); await nextTick();
open_button.value?.click(); open_button.value?.click();
console.log('open button should have been clicked');
const timer = setTimeout(reset_state, 45000);
}
function reset_state(): void {
state.href_url = '#';
state.is_ready = false;
state.is_running = false;
} }
</script> </script>

View File

@ -94,4 +94,38 @@ class NotificationApiController
return new JsonResponse(null, JsonResponse::HTTP_ACCEPTED, [], false); return new JsonResponse(null, JsonResponse::HTTP_ACCEPTED, [], false);
} }
/**
* @Route("/mark/allread", name="chill_api_main_notification_mark_allread", methods={"POST"})
*/
public function markAllRead(): JsonResponse
{
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new \RuntimeException('Invalid user');
}
$modifiedNotificationIds = $this->notificationRepository->markAllNotificationAsReadForUser($user);
return new JsonResponse($modifiedNotificationIds);
}
/**
* @Route("/mark/undoallread", name="chill_api_main_notification_mark_undoallread", methods={"POST"})
*/
public function undoAllRead(Request $request): JsonResponse
{
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new \RuntimeException('Invalid user');
}
$ids = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR);
$touchedIds = $this->notificationRepository->markAllNotificationAsUnreadForUser($user, $ids);
return new JsonResponse($touchedIds);
}
} }

View File

@ -169,7 +169,7 @@ class NotificationController extends AbstractController
#[Route(path: '/inbox', name: 'chill_main_notification_my')] #[Route(path: '/inbox', name: 'chill_main_notification_my')]
public function inboxAction(): Response public function inboxAction(): Response
{ {
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED'); $this->denyAccessUnlessGranted('ROLE_USER');
$currentUser = $this->security->getUser(); $currentUser = $this->security->getUser();
$notificationsNbr = $this->notificationRepository->countAllForAttendee($currentUser); $notificationsNbr = $this->notificationRepository->countAllForAttendee($currentUser);
@ -177,8 +177,8 @@ class NotificationController extends AbstractController
$notifications = $this->notificationRepository->findAllForAttendee( $notifications = $this->notificationRepository->findAllForAttendee(
$currentUser, $currentUser,
$limit = $paginator->getItemsPerPage(), $paginator->getItemsPerPage(),
$offset = $paginator->getCurrentPage()->getFirstItemNumber() $paginator->getCurrentPage()->getFirstItemNumber()
); );
return $this->render('@ChillMain/Notification/list.html.twig', [ return $this->render('@ChillMain/Notification/list.html.twig', [

View File

@ -278,7 +278,7 @@ final class PasswordController extends AbstractController
} }
/** /**
* @return \Symfony\Component\Form\Form * @return \Symfony\Component\Form\FormInterface
*/ */
private function passwordForm(User $user) private function passwordForm(User $user)
{ {

View File

@ -214,7 +214,7 @@ class UserController extends CRUDController
return $this->redirect( return $this->redirect(
$request->query->has('returnPath') ? $request->query->get('returnPath') : $request->query->has('returnPath') ? $request->query->get('returnPath') :
$this->generateUrl('chill_main_homepage') $this->generateUrl('chill_main_homepage')
); );
} }
@ -249,7 +249,7 @@ class UserController extends CRUDController
return $this->redirect( return $this->redirect(
$request->query->has('returnPath') ? $request->query->get('returnPath') : $request->query->has('returnPath') ? $request->query->get('returnPath') :
$this->generateUrl('chill_crud_admin_user_edit', ['id' => $user->getId()]) $this->generateUrl('chill_crud_admin_user_edit', ['id' => $user->getId()])
); );
} }
@ -264,6 +264,7 @@ class UserController extends CRUDController
return $this->getFilterOrderHelperFactory() return $this->getFilterOrderHelperFactory()
->create(self::class) ->create(self::class)
->addSearchBox(['label']) ->addSearchBox(['label'])
->addCheckbox('activeFilter', [true => 'Active', false => 'Inactive'], ['Active'])
->build(); ->build();
} }
@ -273,11 +274,7 @@ class UserController extends CRUDController
return parent::countEntities($action, $request, $filterOrder); return parent::countEntities($action, $request, $filterOrder);
} }
if (null === $filterOrder->getQueryString()) { return $this->userRepository->countFilteredUsers($filterOrder->getQueryString(), $filterOrder->getCheckboxData('activeFilter'));
return parent::countEntities($action, $request, $filterOrder);
}
return $this->userRepository->countByUsernameOrEmail($filterOrder->getQueryString());
} }
protected function createFormFor(string $action, $entity, ?string $formClass = null, array $formOptions = []): FormInterface protected function createFormFor(string $action, $entity, ?string $formClass = null, array $formOptions = []): FormInterface
@ -334,16 +331,13 @@ class UserController extends CRUDController
return parent::getQueryResult($action, $request, $totalItems, $paginator, $filterOrder); return parent::getQueryResult($action, $request, $totalItems, $paginator, $filterOrder);
} }
if (null === $filterOrder->getQueryString()) { $queryString = $filterOrder->getQueryString();
return parent::getQueryResult($action, $request, $totalItems, $paginator, $filterOrder); $activeFilter = $filterOrder->getCheckboxData('activeFilter');
} $nb = $this->userRepository->countFilteredUsers($queryString, $activeFilter);
return $this->userRepository->findByUsernameOrEmail( $paginator = $this->getPaginatorFactory()->create($nb);
$filterOrder->getQueryString(),
['usernameCanonical' => 'ASC'], return $this->userRepository->findFilteredUsers($queryString, $activeFilter, $paginator->getCurrentPageFirstItemNumber(), $paginator->getItemsPerPage());
$paginator->getItemsPerPage(),
$paginator->getCurrentPageFirstItemNumber()
);
} }
protected function onPrePersist(string $action, $entity, FormInterface $form, Request $request) protected function onPrePersist(string $action, $entity, FormInterface $form, Request $request)
@ -374,10 +368,12 @@ class UserController extends CRUDController
$returnPathParams = $request->query->has('returnPath') ? ['returnPath' => $request->query->get('returnPath')] : []; $returnPathParams = $request->query->has('returnPath') ? ['returnPath' => $request->query->get('returnPath')] : [];
return $this->createFormBuilder() return $this->createFormBuilder()
->setAction($this->generateUrl( ->setAction(
'admin_user_add_groupcenter', $this->generateUrl(
array_merge($returnPathParams, ['uid' => $user->getId()]) 'admin_user_add_groupcenter',
)) array_merge($returnPathParams, ['uid' => $user->getId()])
)
)
->setMethod('POST') ->setMethod('POST')
->add(self::FORM_GROUP_CENTER_COMPOSED, ComposedGroupCenterType::class) ->add(self::FORM_GROUP_CENTER_COMPOSED, ComposedGroupCenterType::class)
->add('submit', SubmitType::class, ['label' => 'Add a new groupCenter']) ->add('submit', SubmitType::class, ['label' => 'Add a new groupCenter'])
@ -392,10 +388,12 @@ class UserController extends CRUDController
$returnPathParams = $request->query->has('returnPath') ? ['returnPath' => $request->query->get('returnPath')] : []; $returnPathParams = $request->query->has('returnPath') ? ['returnPath' => $request->query->get('returnPath')] : [];
return $this->createFormBuilder() return $this->createFormBuilder()
->setAction($this->generateUrl( ->setAction(
'admin_user_delete_groupcenter', $this->generateUrl(
array_merge($returnPathParams, ['uid' => $user->getId(), 'gcid' => $groupCenter->getId()]) 'admin_user_delete_groupcenter',
)) array_merge($returnPathParams, ['uid' => $user->getId(), 'gcid' => $groupCenter->getId()])
)
)
->setMethod('DELETE') ->setMethod('DELETE')
->add('submit', SubmitType::class, ['label' => 'Delete']) ->add('submit', SubmitType::class, ['label' => 'Delete'])
->getForm(); ->getForm();

View File

@ -24,9 +24,9 @@ interface CronJobInterface
* *
* If data is returned, this data is passed as argument on the next execution * If data is returned, this data is passed as argument on the next execution
* *
* @param array $lastExecutionData the data which was returned from the previous execution * @param array<string|int, int|float|string|bool|array<int|string, int|float|string|bool>> $lastExecutionData the data which was returned from the previous execution
* *
* @return array|null optionally return an array with the same data than the previous execution * @return array<string|int, int|float|string|bool|array<int|string, int|float|string|bool>>|null optionally return an array with the same data than the previous execution
*/ */
public function run(array $lastExecutionData): ?array; public function run(array $lastExecutionData): ?array;
} }

View File

@ -13,6 +13,8 @@ namespace Chill\MainBundle\Repository;
use Chill\MainBundle\Entity\Notification; use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\User;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Result;
use Doctrine\DBAL\Statement; use Doctrine\DBAL\Statement;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@ -81,10 +83,7 @@ final class NotificationRepository implements ObjectRepository
$results->free(); $results->free();
} else { } else {
$wheres = []; $wheres = [];
foreach ([ foreach ([['relatedEntityClass' => $relatedEntityClass, 'relatedEntityId' => $relatedEntityId], ...$more] as $k => ['relatedEntityClass' => $relClass, 'relatedEntityId' => $relId]) {
['relatedEntityClass' => $relatedEntityClass, 'relatedEntityId' => $relatedEntityId],
...$more,
] as $k => ['relatedEntityClass' => $relClass, 'relatedEntityId' => $relId]) {
$wheres[] = "(relatedEntityClass = :relatedEntityClass_{$k} AND relatedEntityId = :relatedEntityId_{$k})"; $wheres[] = "(relatedEntityClass = :relatedEntityClass_{$k} AND relatedEntityId = :relatedEntityId_{$k})";
$sqlParams["relatedEntityClass_{$k}"] = $relClass; $sqlParams["relatedEntityClass_{$k}"] = $relClass;
$sqlParams["relatedEntityId_{$k}"] = $relId; $sqlParams["relatedEntityId_{$k}"] = $relId;
@ -228,11 +227,11 @@ final class NotificationRepository implements ObjectRepository
$rsm->addRootEntityFromClassMetadata(Notification::class, 'cmn'); $rsm->addRootEntityFromClassMetadata(Notification::class, 'cmn');
$sql = 'SELECT '.$rsm->generateSelectClause(['cmn' => 'cmn']).' '. $sql = 'SELECT '.$rsm->generateSelectClause(['cmn' => 'cmn']).' '.
'FROM chill_main_notification cmn '. 'FROM chill_main_notification cmn '.
'WHERE '. 'WHERE '.
'EXISTS (select 1 FROM chill_main_notification_addresses_unread cmnau WHERE cmnau.user_id = :userId and cmnau.notification_id = cmn.id) '. 'EXISTS (select 1 FROM chill_main_notification_addresses_unread cmnau WHERE cmnau.user_id = :userId and cmnau.notification_id = cmn.id) '.
'ORDER BY cmn.date DESC '. 'ORDER BY cmn.date DESC '.
'LIMIT :limit OFFSET :offset'; 'LIMIT :limit OFFSET :offset';
$nq = $this->em->createNativeQuery($sql, $rsm) $nq = $this->em->createNativeQuery($sql, $rsm)
->setParameter('userId', $user->getId()) ->setParameter('userId', $user->getId())
@ -255,10 +254,12 @@ final class NotificationRepository implements ObjectRepository
$qb = $this->repository->createQueryBuilder('n'); $qb = $this->repository->createQueryBuilder('n');
// add condition for related entity (in main arguments, and in more) // add condition for related entity (in main arguments, and in more)
$or = $qb->expr()->orX($qb->expr()->andX( $or = $qb->expr()->orX(
$qb->expr()->eq('n.relatedEntityClass', ':relatedEntityClass'), $qb->expr()->andX(
$qb->expr()->eq('n.relatedEntityId', ':relatedEntityId') $qb->expr()->eq('n.relatedEntityClass', ':relatedEntityClass'),
)); $qb->expr()->eq('n.relatedEntityId', ':relatedEntityId')
)
);
$qb $qb
->setParameter('relatedEntityClass', $relatedEntityClass) ->setParameter('relatedEntityClass', $relatedEntityClass)
->setParameter('relatedEntityId', $relatedEntityId); ->setParameter('relatedEntityId', $relatedEntityId);
@ -310,4 +311,86 @@ final class NotificationRepository implements ObjectRepository
return $qb; return $qb;
} }
/**
* @return list<int> the ids of the notifications marked as unread
*/
public function markAllNotificationAsReadForUser(User $user): array
{
// Get the database connection from the entity manager
$connection = $this->em->getConnection();
/** @var Result $results */
$results = $connection->transactional(function (Connection $connection) use ($user) {
// Define the SQL query
$sql = <<<'SQL'
DELETE FROM chill_main_notification_addresses_unread
WHERE user_id = :user_id
RETURNING notification_id
SQL;
return $connection->executeQuery($sql, ['user_id' => $user->getId()]);
});
$notificationIdsTouched = [];
foreach ($results->iterateAssociative() as $row) {
$notificationIdsTouched[] = $row['notification_id'];
}
return array_values($notificationIdsTouched);
}
/**
* @param list<int> $notificationIds
*/
public function markAllNotificationAsUnreadForUser(User $user, array $notificationIds): array
{
// Get the database connection from the entity manager
$connection = $this->em->getConnection();
/** @var Result $results */
$results = $connection->transactional(function (Connection $connection) use ($user, $notificationIds) {
// This query double-check that the user is one of the addresses of the notification or the sender,
// if the notification is already marked as unread, this query does not fails.
// this query return the list of notification id which are affected
$sql = <<<'SQL'
INSERT INTO chill_main_notification_addresses_unread (user_id, notification_id)
SELECT ?, chill_main_notification_addresses_user.notification_id
FROM chill_main_notification_addresses_user JOIN chill_main_notification ON chill_main_notification_addresses_user.notification_id = chill_main_notification.id
WHERE (chill_main_notification_addresses_user.user_id = ? OR chill_main_notification.sender_id = ?)
AND chill_main_notification_addresses_user.notification_id IN ({ notification_ids })
ON CONFLICT (user_id, notification_id) DO NOTHING
RETURNING notification_id
SQL;
$params = [$user->getId(), $user->getId(), $user->getId(), ...array_values($notificationIds)];
$sql = strtr($sql, ['{ notification_ids }' => implode(', ', array_fill(0, count($notificationIds), '?'))]);
return $connection->executeQuery($sql, $params);
});
$notificationIdsTouched = [];
foreach ($results->iterateAssociative() as $row) {
$notificationIdsTouched[] = $row['notification_id'];
}
return array_values($notificationIdsTouched);
}
public function findAllUnreadByUser(User $user): array
{
$rsm = new Query\ResultSetMappingBuilder($this->em);
$rsm->addRootEntityFromClassMetadata(Notification::class, 'cmn');
$sql = 'SELECT '.$rsm->generateSelectClause(['cmn' => 'cmn']).' '.
'FROM chill_main_notification cmn '.
'WHERE '.
'EXISTS (SELECT 1 FROM chill_main_notification_addresses_unread cmnau WHERE cmnau.user_id = :userId AND cmnau.notification_id = cmn.id) '.
'ORDER BY cmn.date DESC';
$nq = $this->em->createNativeQuery($sql, $rsm)
->setParameter('userId', $user->getId());
return $nq->getResult();
}
} }

View File

@ -17,6 +17,7 @@ use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception; use Doctrine\DBAL\Exception;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository; use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException; use Doctrine\ORM\NoResultException;
use Doctrine\ORM\Query\ResultSetMapping; use Doctrine\ORM\Query\ResultSetMapping;
use Doctrine\ORM\Query\ResultSetMappingBuilder; use Doctrine\ORM\Query\ResultSetMappingBuilder;
@ -26,9 +27,25 @@ final readonly class UserRepository implements UserRepositoryInterface
{ {
private EntityRepository $repository; private EntityRepository $repository;
private const FIELDS = ['id', 'email', 'enabled', 'civility_id', 'civility_abbreviation', 'civility_name', 'label', 'mainCenter_id', private const FIELDS = [
'mainCenter_name', 'mainScope_id', 'mainScope_name', 'userJob_id', 'userJob_name', 'currentLocation_id', 'currentLocation_name', 'id',
'mainLocation_id', 'mainLocation_name']; 'email',
'enabled',
'civility_id',
'civility_abbreviation',
'civility_name',
'label',
'mainCenter_id',
'mainCenter_name',
'mainScope_id',
'mainScope_name',
'userJob_id',
'userJob_name',
'currentLocation_id',
'currentLocation_name',
'mainLocation_id',
'mainLocation_name',
];
public function __construct(private EntityManagerInterface $entityManager, private Connection $connection) public function __construct(private EntityManagerInterface $entityManager, private Connection $connection)
{ {
@ -296,6 +313,25 @@ final readonly class UserRepository implements UserRepositoryInterface
return User::class; return User::class;
} }
public function getResult(
QueryBuilder $qb,
?int $start = 0,
?int $limit = 50,
?array $orderBy = [],
): array {
$qb->select('u');
$qb
->setFirstResult($start)
->setMaxResults($limit);
foreach ($orderBy as $field => $direction) {
$qb->addOrderBy('u.'.$field, $direction);
}
return $qb->getQuery()->getResult();
}
private function queryByUsernameOrEmail(string $pattern): QueryBuilder private function queryByUsernameOrEmail(string $pattern): QueryBuilder
{ {
$qb = $this->entityManager->createQueryBuilder()->from(User::class, 'u'); $qb = $this->entityManager->createQueryBuilder()->from(User::class, 'u');
@ -312,4 +348,49 @@ final readonly class UserRepository implements UserRepositoryInterface
return $qb; return $qb;
} }
public function buildFilterBaseQuery(?string $queryString, array $isActive)
{
if (null !== $queryString) {
$qb = $this->queryByUsernameOrEmail($queryString);
} else {
$qb = $this->entityManager->createQueryBuilder()->from(User::class, 'u');
}
// Add condition based on active/inactive status
if (in_array('Active', $isActive, true) && !in_array('Inactive', $isActive, true)) {
$qb->andWhere('u.enabled = true');
} elseif (in_array('Inactive', $isActive, true) && !in_array('Active', $isActive, true)) {
$qb->andWhere('u.enabled = false');
}
return $qb;
}
public function findFilteredUsers(
?string $queryString = null,
array $isActive = ['active'],
?int $start = 0,
?int $limit = 50,
?array $orderBy = ['username' => 'ASC'],
): array {
$qb = $this->buildFilterBaseQuery($queryString, $isActive);
return $this->getResult($qb, $start, $limit, $orderBy);
}
public function countFilteredUsers(
?string $queryString = null,
array $isActive = ['active'],
): int {
$qb = $this->buildFilterBaseQuery($queryString, $isActive);
try {
return $qb
->select('COUNT(u)')
->getQuery()->getSingleScalarResult();
} catch (NoResultException|NonUniqueResultException $e) {
throw new \LogicException('a count query should return one result', previous: $e);
}
}
} }

View File

@ -1,14 +1,16 @@
import {createApp} from "vue"; import { createApp } from "vue";
import NotificationReadToggle from "ChillMainAssets/vuejs/_components/Notification/NotificationReadToggle.vue"; import NotificationReadToggle from "ChillMainAssets/vuejs/_components/Notification/NotificationReadToggle.vue";
import { _createI18n } from "ChillMainAssets/vuejs/_js/i18n"; import { _createI18n } from "ChillMainAssets/vuejs/_js/i18n";
import NotificationReadAllToggle from "ChillMainAssets/vuejs/_components/Notification/NotificationReadAllToggle.vue";
const i18n = _createI18n({}); const i18n = _createI18n({});
window.addEventListener('DOMContentLoaded', function (e) { window.addEventListener("DOMContentLoaded", function (e) {
document.querySelectorAll('.notification_toggle_read_status') document
.forEach(function (el, i) { .querySelectorAll(".notification_toggle_read_status")
createApp({ .forEach(function (el, i) {
template: `<notification-read-toggle createApp({
template: `<notification-read-toggle
:notificationId="notificationId" :notificationId="notificationId"
:buttonClass="buttonClass" :buttonClass="buttonClass"
:buttonNoText="buttonNoText" :buttonNoText="buttonNoText"
@ -17,40 +19,45 @@ window.addEventListener('DOMContentLoaded', function (e) {
@markRead="onMarkRead" @markRead="onMarkRead"
@markUnread="onMarkUnread"> @markUnread="onMarkUnread">
</notification-read-toggle>`, </notification-read-toggle>`,
components: { components: {
NotificationReadToggle, NotificationReadToggle,
}, },
data() { data() {
return { return {
notificationId: el.dataset.notificationId, notificationId: parseInt(el.dataset.notificationId),
buttonClass: el.dataset.buttonClass, buttonClass: el.dataset.buttonClass,
buttonNoText: 'false' === el.dataset.buttonText, buttonNoText: "false" === el.dataset.buttonText,
showUrl: el.dataset.showButtonUrl, showUrl: el.dataset.showButtonUrl,
isRead: 1 === Number.parseInt(el.dataset.notificationCurrentIsRead), isRead: 1 === Number.parseInt(el.dataset.notificationCurrentIsRead),
container: el.dataset.container container: el.dataset.container,
} };
}, },
computed: { computed: {
getContainer() { getContainer() {
return document.querySelectorAll(`div.${this.container}`); return document.querySelectorAll(`div.${this.container}`);
} },
}, },
methods: { methods: {
onMarkRead() { onMarkRead() {
if (typeof this.getContainer[i] !== 'undefined') { if (typeof this.getContainer[i] !== "undefined") {
this.getContainer[i].classList.replace('read', 'unread'); this.getContainer[i].classList.replace("read", "unread");
} else { throw 'data-container attribute is missing' } } else {
this.isRead = false; throw "data-container attribute is missing";
}, }
onMarkUnread() { this.isRead = false;
if (typeof this.getContainer[i] !== 'undefined') { },
this.getContainer[i].classList.replace('unread', 'read'); onMarkUnread() {
} else { throw 'data-container attribute is missing' } if (typeof this.getContainer[i] !== "undefined") {
this.isRead = true; this.getContainer[i].classList.replace("unread", "read");
}, } else {
} throw "data-container attribute is missing";
}) }
.use(i18n) this.isRead = true;
.mount(el); },
}); },
})
.use(i18n)
.mount(el);
});
}); });

View File

@ -0,0 +1,39 @@
import { createApp } from "vue";
import { _createI18n } from "../../vuejs/_js/i18n";
import NotificationReadAllToggle from "../../vuejs/_components/Notification/NotificationReadAllToggle.vue";
const i18n = _createI18n({});
document.addEventListener("DOMContentLoaded", function () {
const elements = document.querySelectorAll(".notification_all_read");
elements.forEach((element) => {
console.log('launch');
createApp({
template: `<notification-read-all-toggle @markAsRead="markAsRead" @markAsUnRead="markAsUnread"></notification-read-all-toggle>`,
components: {
NotificationReadAllToggle,
},
methods: {
markAsRead(id: number) {
const el = document.querySelector<HTMLDivElement>(`div.notification-status[data-notification-id="${id}"]`);
if (el === null) {
return;
}
el.classList.add('read');
el.classList.remove('unread');
},
markAsUnread(id: number) {
const el = document.querySelector<HTMLDivElement>(`div.notification-status[data-notification-id="${id}"]`);
if (el === null) {
return;
}
el.classList.remove('read');
el.classList.add('unread');
},
}
})
.use(i18n)
.mount(element);
});
});

View File

@ -0,0 +1,50 @@
<template>
<div>
<button v-if="idsMarkedAsRead.length === 0"
class="btn btn-primary"
type="button"
@click="markAllRead"
>
<i class="fa fa-sm fa-envelope-open-o"></i> Marquer tout comme lu
</button>
<button v-else
class="btn btn-primary"
type="button"
@click="undo"
>
<i class="fa fa-sm fa-envelope-open-o"></i> Annuler
</button>
</div>
</template>
<script lang="ts" setup>
import { makeFetch } from "../../../lib/api/apiMethods";
import { ref } from "vue";
const emit = defineEmits<{
(e: 'markAsRead', id: number): void,
(e: 'markAsUnRead', id: number): void,
}>();
const idsMarkedAsRead = ref([] as number[]);
async function markAllRead() {
const ids: number[] = await makeFetch("POST", `/api/1.0/main/notification/mark/allread`, null);
for (let i of ids) {
idsMarkedAsRead.value.push(i);
emit('markAsRead', i);
}
}
async function undo() {
const touched: number[] = await makeFetch("POST", `/api/1.0/main/notification/mark/undoallread`, idsMarkedAsRead.value);
while (idsMarkedAsRead.value.length > 0) {
idsMarkedAsRead.value.pop();
}
for (let t of touched) {
emit('markAsUnRead', t);
}
};
</script>
<style lang="scss" scoped></style>

View File

@ -1,47 +1,66 @@
<template> <template>
<div :class="{'btn-group btn-group-sm float-end': isButtonGroup }" <div
role="group" aria-label="Notification actions"> :class="{ 'btn-group btn-group-sm float-end': isButtonGroup }"
role="group"
<button v-if="isRead" aria-label="Notification actions"
class="btn" >
:class="overrideClass" <button
type="button" v-if="isRead"
:title="$t('markAsUnread')" class="btn"
@click="markAsUnread" :class="overrideClass"
> type="button"
:title="$t('markAsUnread')"
@click="markAsUnread"
>
<i class="fa fa-sm fa-envelope-o"></i> <i class="fa fa-sm fa-envelope-o"></i>
<span v-if="!buttonNoText" class="ps-2"> <span v-if="!buttonNoText" class="ps-2">
{{ $t('markAsUnread') }} {{ $t("markAsUnread") }}
</span> </span>
</button> </button>
<button v-if="!isRead" <button
class="btn" v-if="!isRead"
:class="overrideClass" class="btn"
type="button" :class="overrideClass"
:title="$t('markAsRead')" type="button"
@click="markAsRead" :title="$t('markAsRead')"
> @click="markAsRead"
>
<i class="fa fa-sm fa-envelope-open-o"></i> <i class="fa fa-sm fa-envelope-open-o"></i>
<span v-if="!buttonNoText" class="ps-2"> <span v-if="!buttonNoText" class="ps-2">
{{ $t('markAsRead') }} {{ $t("markAsRead") }}
</span> </span>
</button> </button>
<a v-if="isButtonGroup" <a
v-if="isButtonGroup"
type="button" type="button"
class="btn btn-outline-primary" class="btn btn-outline-primary"
:href="showUrl" :href="showUrl"
:title="$t('action.show')" :title="$t('action.show')"
> >
<i class="fa fa-sm fa-comment-o"></i> <i class="fa fa-sm fa-comment-o"></i>
</a> </a>
<!-- "Mark All Read" button -->
<button
v-if="showMarkAllButton"
class="btn"
:class="overrideClass"
type="button"
:title="$t('markAllRead')"
@click="markAllRead"
>
<i class="fa fa-sm fa-envelope-o"></i>
<span v-if="!buttonNoText" class="ps-2">
{{ $t("markAllRead") }}
</span>
</button>
</div> </div>
</template> </template>
<script> <script>
import { makeFetch } from 'ChillMainAssets/lib/api/apiMethods.ts'; import { makeFetch } from "ChillMainAssets/lib/api/apiMethods.ts";
export default { export default {
name: "NotificationReadToggle", name: "NotificationReadToggle",
@ -57,7 +76,7 @@ export default {
// Optional // Optional
buttonClass: { buttonClass: {
required: false, required: false,
type: String type: String,
}, },
buttonNoText: { buttonNoText: {
required: false, required: false,
@ -65,14 +84,14 @@ export default {
}, },
showUrl: { showUrl: {
required: false, required: false,
type: String type: String,
} },
}, },
emits: ['markRead', 'markUnread'], emits: ["markRead", "markUnread"],
computed: { computed: {
/// [Option] override default button appearance (btn-misc) /// [Option] override default button appearance (btn-misc)
overrideClass() { overrideClass() {
return this.buttonClass ? this.buttonClass : 'btn-misc' return this.buttonClass ? this.buttonClass : "btn-misc";
}, },
/// [Option] don't display text on button /// [Option] don't display text on button
buttonHideText() { buttonHideText() {
@ -82,31 +101,48 @@ export default {
// When passed, the component return a button-group with 2 buttons. // When passed, the component return a button-group with 2 buttons.
isButtonGroup() { isButtonGroup() {
return this.showUrl; return this.showUrl;
} },
}, },
methods: { methods: {
markAsUnread() { markAsUnread() {
makeFetch('POST', `/api/1.0/main/notification/${this.notificationId}/mark/unread`, []).then(response => { makeFetch(
this.$emit('markRead', { notificationId: this.notificationId }); "POST",
}) `/api/1.0/main/notification/${this.notificationId}/mark/unread`,
[]
).then((response) => {
this.$emit("markRead", {notificationId: this.notificationId});
});
}, },
markAsRead() { markAsRead() {
makeFetch('POST', `/api/1.0/main/notification/${this.notificationId}/mark/read`, []).then(response => { makeFetch(
this.$emit('markUnread', { notificationId: this.notificationId }); "POST",
}) `/api/1.0/main/notification/${this.notificationId}/mark/read`,
[]
).then((response) => {
this.$emit("markUnread", {
notificationId: this.notificationId,
});
});
},
markAllRead() {
makeFetch(
"POST",
`/api/1.0/main/notification/markallread`,
[]
).then((response) => {
this.$emit("markAllRead");
});
}, },
}, },
i18n: { i18n: {
messages: { messages: {
fr: { fr: {
markAsUnread: 'Marquer comme non-lu', markAsUnread: "Marquer comme non-lu",
markAsRead: 'Marquer comme lu' markAsRead: "Marquer comme lu",
} },
} },
} },
} };
</script> </script>
<style lang="scss"> <style lang="scss"></style>
</style>

View File

@ -1,30 +1,33 @@
{% macro title(c) %} {% macro title(c) %}
<div class="item-row title"> <div class="item-row title">
<h2 class="notification-title"> <h2 class="notification-title">
<a href="{{ chill_path_add_return_path('chill_main_notification_show', {'id': c.notification.id}) }}"> <a
href="{{ chill_path_add_return_path('chill_main_notification_show', {
id: c.notification.id
}) }}"
>
{{ c.notification.title }} {{ c.notification.title }}
</a> </a>
</h2> </h2>
</div> </div>
{% endmacro %} {% endmacro %}
{% macro header(c) %} {% macro header(c) %}
<div class="item-row notification-header mt-2"> <div class="item-row notification-header mt-2">
<div class="item-col"> <div class="item-col">
<ul class="small_in_title"> <ul class="small_in_title">
{% if c.step is not defined or c.step == 'inbox' %} {% if c.step is not defined or c.step == 'inbox' %}
<li class="notification-from"> <li class="notification-from">
<span class="item-key"> <span class="item-key">
<abbr title="{{ 'notification.received_from'|trans }}"> <abbr title="{{ 'notification.received_from' | trans }}">
{{ 'notification.from'|trans }} : {{ "notification.from" | trans }} :
</abbr> </abbr>
</span> </span>
{% if not c.notification.isSystem %} {% if not c.notification.isSystem %}
<span class="badge-user"> <span class="badge-user">
{{ c.notification.sender|chill_entity_render_string({'at_date': c.notification.date}) }} {{ c.notification.sender | chill_entity_render_string({'at_date': c.notification.date}) }}
</span> </span>
{% else %} {% else %}
<span class="badge-user system">{{ 'notification.is_system'|trans }}</span> <span class="badge-user system">{{ "notification.is_system" | trans }}</span>
{% endif %} {% endif %}
</li> </li>
{% endif %} {% endif %}
@ -32,34 +35,37 @@
<li class="notification-to"> <li class="notification-to">
{% if c.notification_cc is defined %} {% if c.notification_cc is defined %}
{% if c.notification_cc %} {% if c.notification_cc %}
<span class="item-key"> <span class="item-key">
<abbr title="{{ 'notification.sent_cc'|trans }}"> <abbr title="{{ 'notification.sent_cc' | trans }}">
{{ 'notification.cc'|trans }} : {{ "notification.cc" | trans }} :
</abbr> </abbr>
</span> </span>
{% else %} {% else %}
<span class="item-key"> <span class="item-key">
<abbr title="{{ 'notification.sent_to'|trans }}"> <abbr title="{{ 'notification.sent_to' | trans }}">
{{ 'notification.to'|trans }} : {{ "notification.to" | trans }} :
</abbr> </abbr>
</span> </span>
{% endif %} {% endif %}
{% else %} {% else %}
<span class="item-key"> <span class="item-key">
<abbr title="{{ 'notification.sent_to'|trans }}"> <abbr title="{{ 'notification.sent_to' | trans }}">
{{ 'notification.to'|trans }} : {{ "notification.to" | trans }} :
</abbr> </abbr>
</span> </span>
{% endif %} {% endif %}
{% for a in c.notification.addressees %} {% for a in c.notification.addressees %}
<span class="badge-user"> <span class="badge-user">
{{ a|chill_entity_render_string({'at_date': c.notification.date}) }} {{ a | chill_entity_render_string({'at_date': c.notification.date}) }}
</span> </span>
{% endfor %} {% endfor %}
{% for a in c.notification.addressesEmails %} {% for a in c.notification.addressesEmails %}
<span class="badge-user" title="{{ 'notification.Email with access link'|trans|e('html_attr') }}"> <span
{{ a }} class="badge-user"
</span> title="{{ 'notification.Email with access link'|trans|e('html_attr') }}"
>
{{ a }}
</span>
{% endfor %} {% endfor %}
</li> </li>
{% endif %} {% endif %}
@ -70,7 +76,6 @@
</div> </div>
</div> </div>
{% endmacro %} {% endmacro %}
{% macro content(c) %} {% macro content(c) %}
<div class="item-row separator"> <div class="item-row separator">
{% if c.data is defined %} {% if c.data is defined %}
@ -83,60 +88,77 @@
<div class="notification-content"> <div class="notification-content">
{% if c.full_content is defined and c.full_content == true %} {% if c.full_content is defined and c.full_content == true %}
{% if c.notification.message is not empty %} {% if c.notification.message is not empty %}
{{ c.notification.message|chill_markdown_to_html }} {{ c.notification.message | chill_markdown_to_html }}
{% else %} {% else %}
<p class="chill-no-data-statement">{{ 'Any comment'|trans }}</p> <p class="chill-no-data-statement">{{ "Any comment" | trans }}</p>
{% endif %} {% endif %}
{% else %} {% else %}
{% if c.notification.message is not empty %} {% if c.notification.message is not empty %}
{{ c.notification.message|u.truncate(250, '…', false)|chill_markdown_to_html }} {{ c.notification.message|u.truncate(250, '…', false)|chill_markdown_to_html }}
<p class="read-more"><a href="{{ chill_path_add_return_path('chill_main_notification_show', {'id': c.notification.id}) }}">{{ 'Read more'|trans }}</a></p> <p class="read-more">
<a
href="{{ chill_path_add_return_path('chill_main_notification_show', {
id: c.notification.id
}) }}"
>{{ "Read more" | trans }}</a>
</p>
{% else %} {% else %}
<p class="chill-no-data-statement">{{ 'Any comment'|trans }}</p> <p class="chill-no-data-statement">{{ "Any comment" | trans }}</p>
{% endif %} {% endif %}
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% endmacro %} {% endmacro %}
{% macro actions(c) %} {% macro actions(c) %}
{% if c.action_button is not defined or c.action_button != false %} {% if c.action_button is not defined or c.action_button != false %}
<div class="item-row separator"> <div class="item-row separator">
<div class="item-col item-meta"> <div class="item-col item-meta">
{% if c.notification.comments|length > 0 %} {% if c.notification.comments|length > 0 %}
<div class="comment-counter"> <div class="comment-counter">
<span class="counter"> <span class="counter">
{{ 'notification.counter comments'|trans({'nb': c.notification.comments|length }) }} {{ 'notification.counter comments'|trans({'nb': c.notification.comments|length }) }}
</span> </span>
</div> </div>
{% endif %} {% endif %}
</div> </div>
<div class="item-col"> <div class="item-col">
<ul class="record_actions"> <ul class="record_actions">
<li> <li>
{# Vue component #} {# Vue component #}
<span class="notification_toggle_read_status" <span
data-notification-id="{{ c.notification.id }}" class="notification_toggle_read_status"
data-notification-current-is-read="{{ c.notification.isReadBy(app.user) }}" data-notification-id="{{ c.notification.id }}"
data-container="notification-status" data-notification-current-is-read="{{ c.notification.isReadBy(app.user) }}"
data-container="notification-status"
></span> ></span>
</li> </li>
{% if is_granted('CHILL_MAIN_NOTIFICATION_UPDATE', c.notification) %} {% if is_granted('CHILL_MAIN_NOTIFICATION_UPDATE', c.notification) %}
<li> <li>
<a href="{{ chill_path_add_return_path('chill_main_notification_edit', {'id': c.notification.id}) }}" <a
class="btn btn-edit" title="{{ 'Edit'|trans }}"></a> href="{{ chill_path_add_return_path(
'chill_main_notification_edit',
{ id: c.notification.id }
) }}"
class="btn btn-edit"
title="{{ 'Edit' | trans }}"
></a>
</li> </li>
{% endif %} {% endif %}
{% if is_granted('CHILL_MAIN_NOTIFICATION_SEE', c.notification) %} {% if is_granted('CHILL_MAIN_NOTIFICATION_SEE',
c.notification) %}
<li> <li>
<a href="{{ chill_path_add_return_path('chill_main_notification_show', {'id': c.notification.id}) }}" <a
class="btn {% if not c.notification.isSystem %}btn-show change-icon{% else %}btn-misc{% endif %}" title="{{ 'notification.see_comments_thread'|trans }}"> href="{{ chill_path_add_return_path(
'chill_main_notification_show',
{ id: c.notification.id }
) }}"
class="btn {% if not c.notification.isSystem %}btn-show change-icon{% else %}btn-misc{% endif %}"
title="{{ 'notification.see_comments_thread' | trans }}"
>
{% if not c.notification.isSystem() %} {% if not c.notification.isSystem() %}
<i class="fa fa-comment"></i> <i class="fa fa-comment"></i>
{% else %} {% else %}
{{ 'Read more'|trans }} {{ "Read more" | trans }}
{% endif %} {% endif %}
</a> </a>
</li> </li>
@ -147,24 +169,30 @@
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}
<div class="item-bloc notification-status {% if notification.isReadBy(app.user) %}read{% else %}unread{% endif %}"> <div
class="item-bloc notification-status {% if notification.isReadBy(app.user) %}read{% else %}unread{% endif %}"
data-notification-id="{{ notification.id|escape('html_attr') }}"
>
{% if fold_item is defined and fold_item != false %} {% if fold_item is defined and fold_item != false %}
<div class="accordion-header" id="flush-heading-{{ notification.id }}"> <div class="accordion-header" id="flush-heading-{{ notification.id }}">
<button type="button" class="accordion-button collapsed" <button
data-bs-toggle="collapse" data-bs-target="#flush-collapse-{{ notification.id }}" type="button"
aria-expanded="false" aria-controls="flush-collapse-{{ notification.id }}"> class="accordion-button collapsed"
data-bs-toggle="collapse"
data-bs-target="#flush-collapse-{{ notification.id }}"
aria-expanded="false"
aria-controls="flush-collapse-{{ notification.id }}"
>
{{ _self.title(_context) }} {{ _self.title(_context) }}
</button> </button>
{{ _self.header(_context) }} {{ _self.header(_context) }}
</div> </div>
<div id="flush-collapse-{{ notification.id }}" <div
id="flush-collapse-{{ notification.id }}"
class="accordion-collapse collapse" class="accordion-collapse collapse"
aria-labelledby="flush-heading-{{ notification.id }}" aria-labelledby="flush-heading-{{ notification.id }}"
data-bs-parent="#notification-fold"> data-bs-parent="#notification-fold"
>
{{ _self.content(_context) }} {{ _self.content(_context) }}
</div> </div>
{{ _self.actions(_context) }} {{ _self.actions(_context) }}
@ -174,5 +202,4 @@
{{ _self.content(_context) }} {{ _self.content(_context) }}
{{ _self.actions(_context) }} {{ _self.actions(_context) }}
{% endif %} {% endif %}
</div> </div>

View File

@ -1,62 +1,78 @@
{% extends "@ChillMain/layout.html.twig" %} {% extends "@ChillMain/layout.html.twig" %}
{% block title 'notification.My own notifications'|trans %} {% block title 'notification.My own notifications'|trans %}
{% block js %} {% block js %}
{{ parent() }} {{ parent() }}
{{ encore_entry_script_tags('mod_notification_toggle_read_status') }} {{ encore_entry_script_tags("mod_notification_toggle_read_status") }}
{{ encore_entry_script_tags("mod_notification_toggle_read_all_status") }}
{% endblock %} {% endblock %}
{% block css %} {% block css %}
{{ parent() }} {{ parent() }}
{{ encore_entry_link_tags('mod_notification_toggle_read_status') }} {{ encore_entry_link_tags("mod_notification_toggle_read_status") }}
{{ encore_entry_link_tags("mod_notification_toggle_read_all_status") }}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="col-10 notification notification-list"> <div class="col-10 notification notification-list">
<h1>{{ block('title') }}</h1> <h1>{{ block("title") }}</h1>
<ul class="nav nav-pills justify-content-center">
<li class="nav-item">
<a
class="nav-link {% if step == 'inbox' %}active{% endif %}"
href="{{ path('chill_main_notification_my') }}"
>
{{ "notification.Notifications received" | trans }}
{% if unreads['inbox'] > 0 %}
<span class="badge rounded-pill bg-danger">
{{ unreads["inbox"] }}
</span>
{% endif %}
</a>
</li>
<li class="nav-item">
<a
class="nav-link {% if step == 'sent' %}active{% endif %}"
href="{{ path('chill_main_notification_sent') }}"
>
{{ "notification.Notifications sent" | trans }}
{% if unreads['sent'] > 0 %}
<span class="badge rounded-pill bg-danger">
{{ unreads["sent"] }}
</span>
{% endif %}
</a>
</li>
</ul>
<ul class="nav nav-pills justify-content-center"> {% if datas|length == 0 %} {% if step == 'inbox' %}
<li class="nav-item"> <p class="chill-no-data-statement">
<a class="nav-link {% if step == 'inbox' %}active{% endif %}" href="{{ path('chill_main_notification_my') }}"> {{ "notification.Any notification received" | trans }}
{{ 'notification.Notifications received'|trans }} </p>
{% if unreads['inbox'] > 0 %}
<span class="badge rounded-pill bg-danger">
{{ unreads['inbox'] }}
</span>
{% endif %}
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if step == 'sent' %}active{% endif %}" href="{{ path('chill_main_notification_sent') }}">
{{ 'notification.Notifications sent'|trans }}
{% if unreads['sent'] > 0 %}
<span class="badge rounded-pill bg-danger">
{{ unreads['sent'] }}
</span>
{% endif %}
</a>
</li>
</ul>
{% if datas|length == 0 %}
{% if step == 'inbox' %}
<p class="chill-no-data-statement">{{ 'notification.Any notification received'|trans }}</p>
{% else %} {% else %}
<p class="chill-no-data-statement">{{ 'notification.Any notification sent'|trans }}</p> <p class="chill-no-data-statement">
{{ "notification.Any notification sent" | trans }}
</p>
{% endif %} {% endif %}
{% else %} {% else %}
<div class="flex-table accordion accordion-flush" id="notification-fold"> <div class="flex-table accordion accordion-flush" id="notification-fold">
{% for data in datas %} {% for data in datas %}
{% set notification = data.notification %} {% set notification = data.notification %}
{% include '@ChillMain/Notification/_list_item.html.twig' with { {% include '@ChillMain/Notification/_list_item.html.twig' with {
'fold_item': true, 'fold_item': true, 'notification_cc': data.template_data.notificationCc
'notification_cc': data.template_data.notificationCc is defined ? data.template_data.notificationCc : false is defined ? data.template_data.notificationCc : false } %}
} %} {% endfor %}
{% endfor %} </div>
</div>
{{ chill_pagination(paginator) }}
{% endif %}
<ul class="record_actions sticky-form-buttons justify-content-end">
<li class="ml-auto d-flex align-items-center gap-2">
<span class="notification_all_read"></span>
</li>
</ul>
</div>
{{ chill_pagination(paginator) }}
{% endif %}
</div>
{% endblock content %} {% endblock content %}

View File

@ -65,7 +65,7 @@ class PasswordRecoverLocker
if (0 === $this->chillRedis->exists($key)) { if (0 === $this->chillRedis->exists($key)) {
$this->chillRedis->set($key, 1); $this->chillRedis->set($key, 1);
$this->chillRedis->setTimeout($key, $ttl); $this->chillRedis->expire($key, $ttl);
break; break;
} }

View File

@ -15,6 +15,12 @@ use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Statement; use Doctrine\DBAL\Statement;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
/**
* Import addresses into the database.
*
* This importer do some optimization about the import, ensuring that adresses are inserted and reconciled with
* the existing one on a optimized way.
*/
final class AddressReferenceBaseImporter final class AddressReferenceBaseImporter
{ {
private const INSERT = <<<'SQL' private const INSERT = <<<'SQL'
@ -47,11 +53,18 @@ final class AddressReferenceBaseImporter
public function __construct(private readonly Connection $defaultConnection, private readonly LoggerInterface $logger) {} public function __construct(private readonly Connection $defaultConnection, private readonly LoggerInterface $logger) {}
public function finalize(): void /**
* Finalize the import process and make reconciliation with addresses.
*
* @param bool $allowRemoveDoubleRefId if true, allow the importer to remove automatically addresses with same refid
*
* @throws \Exception
*/
public function finalize(bool $allowRemoveDoubleRefId = false): void
{ {
$this->doInsertPending(); $this->doInsertPending();
$this->updateAddressReferenceTable(); $this->updateAddressReferenceTable($allowRemoveDoubleRefId);
$this->deleteTemporaryTable(); $this->deleteTemporaryTable();
@ -59,6 +72,11 @@ final class AddressReferenceBaseImporter
$this->isInitialized = false; $this->isInitialized = false;
} }
/**
* Do import a single address.
*
* @throws \Exception
*/
public function importAddress( public function importAddress(
string $refAddress, string $refAddress,
?string $refPostalCode, ?string $refPostalCode,
@ -167,15 +185,48 @@ final class AddressReferenceBaseImporter
$this->isInitialized = true; $this->isInitialized = true;
} }
private function updateAddressReferenceTable(): void private function updateAddressReferenceTable(bool $allowRemoveDoubleRefId): void
{ {
$this->defaultConnection->executeStatement( $this->defaultConnection->executeStatement(
'CREATE INDEX idx_ref_add_temp ON reference_address_temp (refid)' 'CREATE INDEX idx_ref_add_temp ON reference_address_temp (refid)'
); );
// 1) Add new addresses // 0) detect for doublon in current temporary table
$this->logger->info(self::LOG_PREFIX.'upsert new addresses'); $results = $this->defaultConnection->executeQuery(
$affected = $this->defaultConnection->executeStatement("INSERT INTO chill_main_address_reference 'SELECT COUNT(*) AS nb_appearance, refid FROM reference_address_temp GROUP BY refid HAVING count(*) > 1'
);
$hasDouble = false;
foreach ($results->iterateAssociative() as $result) {
$this->logger->error(self::LOG_PREFIX.'Some reference id are present more than one time', ['nb_apparearance' => $result['nb_appearance'], 'refid' => $result['refid']]);
$hasDouble = true;
}
if ($hasDouble) {
if ($allowRemoveDoubleRefId) {
$this->logger->alert(self::LOG_PREFIX.'We are going to remove the addresses which are present more than once in the table');
$this->defaultConnection->executeStatement('ALTER TABLE reference_address_temp ADD COLUMN gid SERIAL');
$removed = $this->defaultConnection->executeStatement(<<<'SQL'
WITH ordering AS (
SELECT gid, rank() over (PARTITION BY refid ORDER BY gid DESC) AS ranking
FROM reference_address_temp
WHERE refid IN (SELECT refid FROM reference_address_temp group by refid having count(*) > 1)
),
keep_last AS (
SELECT gid, ranking FROM ordering where ranking > 1
)
DELETE FROM reference_address_temp WHERE gid IN (SELECT gid FROM keep_last);
SQL);
$this->logger->alert(self::LOG_PREFIX.'addresses with same refid present twice, we removed some double', ['nb_removed', $removed]);
} else {
throw new \RuntimeException('Some addresses are present twice in the database, we cannot process them');
}
}
$this->defaultConnection->transactional(function ($connection): void {
// 1) Add new addresses
$this->logger->info(self::LOG_PREFIX.'upsert new addresses');
$affected = $connection->executeStatement("INSERT INTO chill_main_address_reference
(id, postcode_id, refid, street, streetnumber, municipalitycode, source, point, createdat, deletedat, updatedat) (id, postcode_id, refid, street, streetnumber, municipalitycode, source, point, createdat, deletedat, updatedat)
SELECT SELECT
nextval('chill_main_address_reference_id_seq'), nextval('chill_main_address_reference_id_seq'),
@ -193,16 +244,17 @@ final class AddressReferenceBaseImporter
ON CONFLICT (refid, source) DO UPDATE ON CONFLICT (refid, source) DO UPDATE
SET postcode_id = excluded.postcode_id, refid = excluded.refid, street = excluded.street, streetnumber = excluded.streetnumber, municipalitycode = excluded.municipalitycode, source = excluded.source, point = excluded.point, updatedat = NOW(), deletedAt = NULL SET postcode_id = excluded.postcode_id, refid = excluded.refid, street = excluded.street, streetnumber = excluded.streetnumber, municipalitycode = excluded.municipalitycode, source = excluded.source, point = excluded.point, updatedat = NOW(), deletedAt = NULL
"); ");
$this->logger->info(self::LOG_PREFIX.'addresses upserted', ['upserted' => $affected]); $this->logger->info(self::LOG_PREFIX.'addresses upserted', ['upserted' => $affected]);
// 3) Delete addresses // 3) Delete addresses
$this->logger->info(self::LOG_PREFIX.'soft delete adresses'); $this->logger->info(self::LOG_PREFIX.'soft delete adresses');
$affected = $this->defaultConnection->executeStatement('UPDATE chill_main_address_reference $affected = $connection->executeStatement('UPDATE chill_main_address_reference
SET deletedat = NOW() SET deletedat = NOW()
WHERE WHERE
chill_main_address_reference.refid NOT IN (SELECT refid FROM reference_address_temp WHERE source LIKE ?) chill_main_address_reference.refid NOT IN (SELECT refid FROM reference_address_temp WHERE source LIKE ?)
AND chill_main_address_reference.source LIKE ? AND chill_main_address_reference.source LIKE ?
', [$this->currentSource, $this->currentSource]); ', [$this->currentSource, $this->currentSource]);
$this->logger->info(self::LOG_PREFIX.'addresses deleted', ['deleted' => $affected]); $this->logger->info(self::LOG_PREFIX.'addresses deleted', ['deleted' => $affected]);
});
} }
} }

View File

@ -42,7 +42,8 @@ class PostalCodeBaseImporter
NOW(), NOW(),
NOW() NOW()
FROM g FROM g
ON CONFLICT (code, refpostalcodeid, postalcodeSource) WHERE refpostalcodeid IS NOT NULL DO UPDATE SET label = excluded.label, center = excluded.center, updatedAt = NOW() ON CONFLICT (code, refpostalcodeid, postalcodeSource) WHERE refpostalcodeid IS NOT NULL DO UPDATE
SET label = excluded.label, center = excluded.center, updatedAt = CASE WHEN NOT st_equals(excluded.center, chill_main_postal_code.center) OR excluded.label != chill_main_postal_code.label THEN NOW() ELSE chill_main_postal_code.updatedAt END
SQL; SQL;
private const VALUE = '(?, ?, ?, ?, ?, ?, ?, ?)'; private const VALUE = '(?, ?, ?, ?, ?, ?, ?, ?)';

View File

@ -9,7 +9,7 @@ declare(strict_types=1);
* the LICENSE file that was distributed with this source code. * the LICENSE file that was distributed with this source code.
*/ */
namespace Repository; namespace Chill\MainBundle\Tests\Repository;
use Chill\MainBundle\Entity\NewsItem; use Chill\MainBundle\Entity\NewsItem;
use Chill\MainBundle\Repository\NewsItemRepository; use Chill\MainBundle\Repository\NewsItemRepository;

View File

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\MainBundle\Tests\Repository;
use Chill\MainBundle\Entity\Notification;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Repository\NotificationRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
*
* @coversNothing
*/
class NotificationRepositoryTest extends KernelTestCase
{
private EntityManagerInterface $entityManager;
private NotificationRepository $repository;
protected function setUp(): void
{
self::bootKernel();
$this->entityManager = static::$kernel->getContainer()->get('doctrine.orm.entity_manager');
$this->repository = new NotificationRepository($this->entityManager);
}
public function testMarkAllNotificationAsReadForUser(): void
{
$user = $this->entityManager->createQuery('SELECT u FROM '.User::class.' u')
->setMaxResults(1)->getSingleResult();
$notification = (new Notification())
->setRelatedEntityClass('\Dummy')
->setRelatedEntityId(0)
;
$notification->addAddressee($user)->markAsUnreadBy($user);
$this->entityManager->persist($notification);
$this->entityManager->flush();
$notification->markAsUnreadBy($user);
$this->entityManager->flush();
$this->entityManager->refresh($notification);
if ($notification->isReadBy($user)) {
throw new \LogicException('Notification should not be marked as read');
}
$notificationsIds = $this->repository->markAllNotificationAsReadForUser($user);
self::assertContains($notification->getId(), $notificationsIds);
$this->entityManager->clear();
$notification = $this->entityManager->find(Notification::class, $notification->getId());
self::assertTrue($notification->isReadBy($user));
}
public function testMarkAllNotificationAsUnreadForUser(): void
{
$user = $this->entityManager->createQuery('SELECT u FROM '.User::class.' u')
->setMaxResults(1)->getSingleResult();
$notification = (new Notification())
->setRelatedEntityClass('\Dummy')
->setRelatedEntityId(0)
;
$notification->addAddressee($user); // we do not mark the notification as unread by the user
$this->entityManager->persist($notification);
$this->entityManager->flush();
$notification->markAsReadBy($user);
$this->entityManager->flush();
$this->entityManager->refresh($notification);
if (!$notification->isReadBy($user)) {
throw new \LogicException('Notification should be marked as read');
}
$notificationsIds = $this->repository->markAllNotificationAsUnreadForUser($user, [$notification->getId()]);
self::assertContains($notification->getId(), $notificationsIds);
}
}

View File

@ -0,0 +1,55 @@
<?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 ChillMainBundle\Tests\Repository;
use Chill\MainBundle\Repository\UserRepository;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* @internal
*
* @coversNothing
*/
class UserRepositoryTest extends KernelTestCase
{
private UserRepository $userRepository;
protected function setUp(): void
{
self::bootKernel();
$entityManager = static::$kernel->getContainer()->get('doctrine.orm.entity_manager');
$connection = $entityManager->getConnection();
$this->userRepository = new UserRepository($entityManager, $connection);
}
public function testCountFilteredUsers(): void
{
self::assertIsInt($this->userRepository->countFilteredUsers(null, ['Active']));
self::assertIsInt($this->userRepository->countFilteredUsers(null, ['Active', 'Inactive']));
self::assertIsInt($this->userRepository->countFilteredUsers(null, ['Inactive']));
self::assertIsInt($this->userRepository->countFilteredUsers('center', ['Active']));
self::assertIsInt($this->userRepository->countFilteredUsers('center', ['Active', 'Inactive']));
self::assertIsInt($this->userRepository->countFilteredUsers('center', ['Inactive']));
self::assertIsInt($this->userRepository->countFilteredUsers('center'));
}
public function testFindByFilteredUsers(): void
{
self::assertIsArray($this->userRepository->findFilteredUsers(null, ['Active']));
self::assertIsArray($this->userRepository->findFilteredUsers(null, ['Active', 'Inactive']));
self::assertIsArray($this->userRepository->findFilteredUsers(null, ['Inactive']));
self::assertIsArray($this->userRepository->findFilteredUsers('center', ['Active']));
self::assertIsArray($this->userRepository->findFilteredUsers('center', ['Active', 'Inactive']));
self::assertIsArray($this->userRepository->findFilteredUsers('center', ['Inactive']));
self::assertIsArray($this->userRepository->findFilteredUsers('center'));
}
}

View File

@ -5,8 +5,8 @@ info:
title: "Chill api" title: "Chill api"
description: "Api documentation for chill. Currently, work in progress" description: "Api documentation for chill. Currently, work in progress"
servers: servers:
- url: "/api" - url: "/api"
description: "Your current dev server" description: "Your current dev server"
components: components:
schemas: schemas:
@ -165,7 +165,6 @@ components:
endDate: endDate:
$ref: "#/components/schemas/Date" $ref: "#/components/schemas/Date"
paths: paths:
/1.0/search.json: /1.0/search.json:
get: get:
@ -182,25 +181,25 @@ paths:
The results are ordered by relevance, from the most to the lowest relevant. The results are ordered by relevance, from the most to the lowest relevant.
parameters: parameters:
- name: q - name: q
in: query in: query
required: true required: true
description: the pattern to search description: the pattern to search
schema: schema:
type: string type: string
- name: type[] - name: type[]
in: query in: query
required: true required: true
description: the type entities amongst the search is performed description: the type entities amongst the search is performed
schema: schema:
type: array type: array
items: items:
type: string type: string
enum: enum:
- person - person
- thirdparty - thirdparty
- user - user
- household - household
responses: responses:
200: 200:
description: "OK" description: "OK"
@ -237,7 +236,7 @@ paths:
minItems: 2 minItems: 2
maxItems: 2 maxItems: 2
postcode: postcode:
$ref: '#/components/schemas/PostalCode' $ref: "#/components/schemas/PostalCode"
steps: steps:
type: string type: string
street: street:
@ -261,21 +260,21 @@ paths:
- address - address
summary: Return an address by id summary: Return an address by id
parameters: parameters:
- name: id - name: id
in: path in: path
required: true required: true
description: The address id description: The address id
schema: schema:
type: integer type: integer
format: integer format: integer
minimum: 1 minimum: 1
responses: responses:
200: 200:
description: "ok" description: "ok"
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/Address' $ref: "#/components/schemas/Address"
404: 404:
description: "not found" description: "not found"
401: 401:
@ -285,14 +284,14 @@ paths:
- address - address
summary: patch an address summary: patch an address
parameters: parameters:
- name: id - name: id
in: path in: path
required: true required: true
description: The address id description: The address id
schema: schema:
type: integer type: integer
format: integer format: integer
minimum: 1 minimum: 1
requestBody: requestBody:
required: true required: true
content: content:
@ -321,7 +320,7 @@ paths:
minItems: 2 minItems: 2
maxItems: 2 maxItems: 2
postcode: postcode:
$ref: '#/components/schemas/PostalCode' $ref: "#/components/schemas/PostalCode"
steps: steps:
type: string type: string
street: street:
@ -344,28 +343,27 @@ paths:
400: 400:
description: "transition cannot be applyed" description: "transition cannot be applyed"
/1.0/main/address/{id}/duplicate.json: /1.0/main/address/{id}/duplicate.json:
post: post:
tags: tags:
- address - address
summary: Duplicate an existing address summary: Duplicate an existing address
parameters: parameters:
- name: id - name: id
in: path in: path
required: true required: true
description: The address id that will be duplicated description: The address id that will be duplicated
schema: schema:
type: integer type: integer
format: integer format: integer
minimum: 1 minimum: 1
responses: responses:
200: 200:
description: "ok" description: "ok"
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/Address' $ref: "#/components/schemas/Address"
404: 404:
description: "not found" description: "not found"
401: 401:
@ -377,12 +375,12 @@ paths:
- address - address
summary: Return a list of all reference addresses summary: Return a list of all reference addresses
parameters: parameters:
- in: query - in: query
name: postal_code name: postal_code
required: false required: false
schema: schema:
type: integer type: integer
description: The id of a postal code to filter the reference addresses description: The id of a postal code to filter the reference addresses
responses: responses:
200: 200:
description: "ok" description: "ok"
@ -392,21 +390,21 @@ paths:
- address - address
summary: Return a reference address by id summary: Return a reference address by id
parameters: parameters:
- name: id - name: id
in: path in: path
required: true required: true
description: The reference address id description: The reference address id
schema: schema:
type: integer type: integer
format: integer format: integer
minimum: 1 minimum: 1
responses: responses:
200: 200:
description: "ok" description: "ok"
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/AddressReference' $ref: "#/components/schemas/AddressReference"
404: 404:
description: "not found" description: "not found"
401: 401:
@ -419,27 +417,27 @@ paths:
- search - search
summary: Return a reference address by id summary: Return a reference address by id
parameters: parameters:
- name: id - name: id
in: path in: path
required: true required: true
description: The reference address id description: The reference address id
schema: schema:
type: integer type: integer
format: integer format: integer
minimum: 1 minimum: 1
- name: q - name: q
in: query in: query
required: true required: true
description: The search pattern description: The search pattern
schema: schema:
type: string type: string
responses: responses:
200: 200:
description: "ok" description: "ok"
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/AddressReference' $ref: "#/components/schemas/AddressReference"
404: 404:
description: "not found" description: "not found"
401: 401:
@ -452,12 +450,12 @@ paths:
- address - address
summary: Return a list of all postal-code summary: Return a list of all postal-code
parameters: parameters:
- in: query - in: query
name: country name: country
required: false required: false
schema: schema:
type: integer type: integer
description: The id of a country to filter the postal code description: The id of a country to filter the postal code
responses: responses:
200: 200:
description: "ok" description: "ok"
@ -477,7 +475,7 @@ paths:
code: code:
type: string type: string
country: country:
$ref: '#/components/schemas/Country' $ref: "#/components/schemas/Country"
responses: responses:
401: 401:
description: "Unauthorized" description: "Unauthorized"
@ -496,21 +494,21 @@ paths:
- address - address
summary: Return a postal code by id summary: Return a postal code by id
parameters: parameters:
- name: id - name: id
in: path in: path
required: true required: true
description: The postal code id description: The postal code id
schema: schema:
type: integer type: integer
format: integer format: integer
minimum: 1 minimum: 1
responses: responses:
200: 200:
description: "ok" description: "ok"
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/PostalCode' $ref: "#/components/schemas/PostalCode"
404: 404:
description: "not found" description: "not found"
401: 401:
@ -523,25 +521,25 @@ paths:
- search - search
summary: Search a postal code summary: Search a postal code
parameters: parameters:
- name: q - name: q
in: query in: query
required: true required: true
description: The search pattern description: The search pattern
schema: schema:
type: string type: string
- name: country - name: country
in: query in: query
required: false required: false
description: The country id description: The country id
schema: schema:
type: integer type: integer
responses: responses:
200: 200:
description: "ok" description: "ok"
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/PostalCode' $ref: "#/components/schemas/PostalCode"
404: 404:
description: "not found" description: "not found"
400: 400:
@ -561,27 +559,26 @@ paths:
- address - address
summary: Return a country by id summary: Return a country by id
parameters: parameters:
- name: id - name: id
in: path in: path
required: true required: true
description: The country id description: The country id
schema: schema:
type: integer type: integer
format: integer format: integer
minimum: 1 minimum: 1
responses: responses:
200: 200:
description: "ok" description: "ok"
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/Country' $ref: "#/components/schemas/Country"
404: 404:
description: "not found" description: "not found"
401: 401:
description: "Unauthorized" description: "Unauthorized"
/1.0/main/user.json: /1.0/main/user.json:
get: get:
tags: tags:
@ -612,21 +609,21 @@ paths:
- user - user
summary: Return a user by id summary: Return a user by id
parameters: parameters:
- name: id - name: id
in: path in: path
required: true required: true
description: The user id description: The user id
schema: schema:
type: integer type: integer
format: integer format: integer
minimum: 1 minimum: 1
responses: responses:
200: 200:
description: "ok" description: "ok"
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/User' $ref: "#/components/schemas/User"
404: 404:
description: "not found" description: "not found"
401: 401:
@ -649,14 +646,14 @@ paths:
- scope - scope
summary: return a list of scopes summary: return a list of scopes
parameters: parameters:
- name: id - name: id
in: path in: path
required: true required: true
description: The scope id description: The scope id
schema: schema:
type: integer type: integer
format: integer format: integer
minimum: 1 minimum: 1
responses: responses:
200: 200:
description: "ok" description: "ok"
@ -724,14 +721,14 @@ paths:
- location - location
summary: Return the given location summary: Return the given location
parameters: parameters:
- name: id - name: id
in: path in: path
required: true required: true
description: The location id description: The location id
schema: schema:
type: integer type: integer
format: integer format: integer
minimum: 1 minimum: 1
responses: responses:
200: 200:
description: "ok" description: "ok"
@ -784,21 +781,21 @@ paths:
id: 1 id: 1
class: 'Chill\PersonBundle\Entity\AccompanyingPeriod' class: 'Chill\PersonBundle\Entity\AccompanyingPeriod'
roles: roles:
- 'CHILL_PERSON_ACCOMPANYING_PERIOD_SEE' - "CHILL_PERSON_ACCOMPANYING_PERIOD_SEE"
/1.0/main/notification/{id}/mark/read: /1.0/main/notification/{id}/mark/read:
post: post:
tags: tags:
- notification - notification
summary: mark a notification as read summary: mark a notification as read
parameters: parameters:
- name: id - name: id
in: path in: path
required: true required: true
description: The notification id description: The notification id
schema: schema:
type: integer type: integer
format: integer format: integer
minimum: 1 minimum: 1
responses: responses:
202: 202:
description: "accepted" description: "accepted"
@ -810,23 +807,55 @@ paths:
- notification - notification
summary: mark a notification as unread summary: mark a notification as unread
parameters: parameters:
- name: id - name: id
in: path in: path
required: true required: true
description: The notification id description: The notification id
schema: schema:
type: integer type: integer
format: integer format: integer
minimum: 1 minimum: 1
responses: responses:
202: 202:
description: "accepted" description: "accepted"
403: 403:
description: "unauthorized" description: "unauthorized"
/1.0/main/notification/mark/allread:
post:
tags:
- notification
summary: Mark all notifications as read
responses:
202:
description: "accepted"
403:
description: "unauthorized"
/1.0/main/notification/mark/undoallread:
post: # Use POST method for creating resources
tags:
- notification
summary: Mark notifications as unread
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
ids:
type: array
items:
type: integer
example: [1, 2, 3] # Example array of IDs
responses:
"202":
description: Notifications marked as unread successfully
"403":
description: Unauthorized
/1.0/main/civility.json: /1.0/main/civility.json:
get: get:
tags: tags:
- civility - civility
summary: Return all civility types summary: Return all civility types
responses: responses:
200: 200:
@ -844,7 +873,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/UserJob' $ref: "#/components/schemas/UserJob"
/1.0/main/workflow/my: /1.0/main/workflow/my:
get: get:
tags: tags:
@ -858,7 +887,7 @@ paths:
schema: schema:
type: array type: array
items: items:
$ref: '#/components/schemas/Workflow' $ref: "#/components/schemas/Workflow"
403: 403:
description: "Unauthorized" description: "Unauthorized"
/1.0/main/workflow/my-cc: /1.0/main/workflow/my-cc:
@ -874,7 +903,7 @@ paths:
schema: schema:
type: array type: array
items: items:
$ref: '#/components/schemas/Workflow' $ref: "#/components/schemas/Workflow"
403: 403:
description: "Unauthorized" description: "Unauthorized"
/1.0/main/dashboard-config-item.json: /1.0/main/dashboard-config-item.json:
@ -888,7 +917,7 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/DashboardConfigItem' $ref: "#/components/schemas/DashboardConfigItem"
403: 403:
description: "Unauthorized" description: "Unauthorized"
@ -905,6 +934,6 @@ paths:
schema: schema:
type: array type: array
items: items:
$ref: '#/components/schemas/NewsItem' $ref: "#/components/schemas/NewsItem"
403: 403:
description: "Unauthorized" description: "Unauthorized"

View File

@ -72,6 +72,7 @@ module.exports = function(encore, entries)
encore.addEntry('mod_blur', __dirname + '/Resources/public/module/blur/index.js'); encore.addEntry('mod_blur', __dirname + '/Resources/public/module/blur/index.js');
encore.addEntry('mod_input_address', __dirname + '/Resources/public/vuejs/Address/mod_input_address_index.js'); encore.addEntry('mod_input_address', __dirname + '/Resources/public/vuejs/Address/mod_input_address_index.js');
encore.addEntry('mod_notification_toggle_read_status', __dirname + '/Resources/public/module/notification/toggle_read.js'); encore.addEntry('mod_notification_toggle_read_status', __dirname + '/Resources/public/module/notification/toggle_read.js');
encore.addEntry('mod_notification_toggle_read_all_status', __dirname + '/Resources/public/module/notification/toggle_read_all.ts');
encore.addEntry('mod_pickentity_type', __dirname + '/Resources/public/module/pick-entity/index.js'); encore.addEntry('mod_pickentity_type', __dirname + '/Resources/public/module/pick-entity/index.js');
encore.addEntry('mod_entity_workflow_subscribe', __dirname + '/Resources/public/module/entity-workflow-subscribe/index.js'); encore.addEntry('mod_entity_workflow_subscribe', __dirname + '/Resources/public/module/entity-workflow-subscribe/index.js');
encore.addEntry('mod_entity_workflow_pick', __dirname + '/Resources/public/module/entity-workflow-pick/index.js'); encore.addEntry('mod_entity_workflow_pick', __dirname + '/Resources/public/module/entity-workflow-pick/index.js');

View File

@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\PersonBundle\Export\Filter\PersonFilters;
use Chill\MainBundle\Export\FilterInterface;
use Chill\MainBundle\Form\Type\PickRollingDateType;
use Chill\MainBundle\Service\RollingDate\RollingDate;
use Chill\MainBundle\Service\RollingDate\RollingDateConverterInterface;
use Chill\PersonBundle\Entity\AccompanyingPeriodParticipation;
use Chill\PersonBundle\Export\Declarations;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Form\FormBuilderInterface;
final readonly class WithoutParticipationBetweenDatesFilter implements FilterInterface
{
public function __construct(
private RollingDateConverterInterface $rollingDateConverter,
) {}
public function addRole(): ?string
{
return null;
}
public function alterQuery(QueryBuilder $qb, $data)
{
$p = 'without_participation_between_dates_filter';
$qb
->andWhere(
$qb->expr()->not(
$qb->expr()->exists(
'SELECT 1 FROM '.AccompanyingPeriodParticipation::class." {$p}_acp JOIN {$p}_acp.accompanyingPeriod {$p}_acpp ".
"WHERE {$p}_acp.person = person ".
"AND OVERLAPSI({$p}_acp.startDate, {$p}_acp.endDate), (:{$p}_date_after, :{$p}_date_before) = TRUE ".
"AND OVERLAPSI({$p}_acpp.openingDate, {$p}_acpp.closingDate), (:{$p}_date_after, :{$p}_date_before) = TRUE"
)
)
)
->setParameter("{$p}_date_after", $this->rollingDateConverter->convert($data['date_after']), Types::DATE_IMMUTABLE)
->setParameter("{$p}_date_before", $this->rollingDateConverter->convert($data['date_before']), Types::DATE_IMMUTABLE);
}
public function applyOn(): string
{
return Declarations::PERSON_TYPE;
}
public function buildForm(FormBuilderInterface $builder)
{
$builder->add('date_after', PickRollingDateType::class, [
'label' => 'export.filter.person.without_participation_between_dates.date_after',
]);
$builder->add('date_before', PickRollingDateType::class, [
'label' => 'export.filter.person.without_participation_between_dates.date_before',
]);
}
public function getFormDefaultData(): array
{
return [
'date_after' => new RollingDate(RollingDate::T_YEAR_CURRENT_START),
'date_before' => new RollingDate(RollingDate::T_TODAY),
];
}
public function describeAction($data, $format = 'string')
{
return ['exports.filter.person.without_participation_between_dates.Filtered by having no participations during period: between', [
'dateafter' => $this->rollingDateConverter->convert($data['date_after']),
'datebefore' => $this->rollingDateConverter->convert($data['date_before']),
]];
}
public function getTitle()
{
return 'export.filter.person.without_participation_between_dates.title';
}
}

View File

@ -85,11 +85,11 @@ final readonly class AccompanyingPeriodWorkEndDateBetweenDateFilter implements F
}; };
$end = match ($data['keep_null']) { $end = match ($data['keep_null']) {
true => $qb->expr()->orX( true => $qb->expr()->orX(
$qb->expr()->gt('acpw.endDate', ':'.$as), $qb->expr()->gt('COALESCE(acp.closingDate, acpw.endDate)', ':'.$as),
$qb->expr()->isNull('acpw.endDate') $qb->expr()->isNull('acpw.endDate')
), ),
false => $qb->expr()->andX( false => $qb->expr()->andX(
$qb->expr()->gt('acpw.endDate', ':'.$as), $qb->expr()->gt('COALESCE(acp.closingDate, acpw.endDate)', ':'.$as),
$qb->expr()->isNotNull('acpw.endDate') $qb->expr()->isNotNull('acpw.endDate')
), ),
default => throw new \LogicException('This value is not supported'), default => throw new \LogicException('This value is not supported'),

View File

@ -0,0 +1,62 @@
<?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 Export\Filter\PersonFilters;
use Chill\MainBundle\Service\RollingDate\RollingDate;
use Chill\MainBundle\Test\Export\AbstractFilterTest;
use Chill\PersonBundle\Entity\Person;
use Chill\PersonBundle\Export\Filter\PersonFilters\WithoutParticipationBetweenDatesFilter;
use Doctrine\ORM\EntityManagerInterface;
/**
* @internal
*
* @coversNothing
*/
final class WithoutParticipationBetweenDatesFilterTest extends AbstractFilterTest
{
private WithoutParticipationBetweenDatesFilter $filter;
protected function setUp(): void
{
self::bootKernel();
$this->filter = self::getContainer()->get(WithoutParticipationBetweenDatesFilter::class);
}
public function getFilter()
{
return $this->filter;
}
public static function getFormData(): array
{
return [
[
'date_after' => new RollingDate(RollingDate::T_YEAR_CURRENT_START),
'date_before' => new RollingDate(RollingDate::T_TODAY),
],
];
}
public static function getQueryBuilders(): iterable
{
self::bootKernel();
$em = self::getContainer()->get(EntityManagerInterface::class);
return [
$em->createQueryBuilder()
->select('person.id')
->from(Person::class, 'person'),
];
}
}

View File

@ -124,6 +124,10 @@ services:
tags: tags:
- { name: chill.export_filter, alias: person_with_participation_between_dates_filter } - { name: chill.export_filter, alias: person_with_participation_between_dates_filter }
Chill\PersonBundle\Export\Filter\PersonFilters\WithoutParticipationBetweenDatesFilter:
tags:
- { name: chill.export_filter, alias: person_without_participation_between_dates_filter }
## Aggregators ## Aggregators
chill.person.export.aggregator_nationality: chill.person.export.aggregator_nationality:
class: Chill\PersonBundle\Export\Aggregator\PersonAggregators\NationalityAggregator class: Chill\PersonBundle\Export\Aggregator\PersonAggregators\NationalityAggregator

View File

@ -136,6 +136,9 @@ exports:
Filtered by person\'s geographical unit (based on address) computed at date, only units: Filtered by person\'s geographical unit (based on address) computed at date, only units:
"Filtré par zone géographique sur base de l'adresse, calculé à {datecalc, date, short}, seulement les zones suivantes: {units}" "Filtré par zone géographique sur base de l'adresse, calculé à {datecalc, date, short}, seulement les zones suivantes: {units}"
filter: filter:
person:
without_participation_between_dates:
"Filtered by having no participations during period: between": "Uniquement les usagers qui n'ont été concerné par aucun parcours entre le {dateafter, date, short} et le {datebefore, date, short}"
course: course:
not_having_address_reference: not_having_address_reference:
describe: >- describe: >-

View File

@ -1188,6 +1188,10 @@ export:
date_before: Concerné par un parcours avant le date_before: Concerné par un parcours avant le
title: Filtrer les usagers ayant été associés à un parcours ouverts un jour dans la période de temps indiquée title: Filtrer les usagers ayant été associés à un parcours ouverts un jour dans la période de temps indiquée
'Filtered by participations during period: between %dateafter% and %datebefore%': 'Filtré par personne concerné par un parcours dans la periode entre: %dateafter% et %datebefore%' 'Filtered by participations during period: between %dateafter% and %datebefore%': 'Filtré par personne concerné par un parcours dans la periode entre: %dateafter% et %datebefore%'
without_participation_between_dates:
date_after: Après le
date_before: Avant le
title: Filtrer les usagers n'ayant été associés à aucun parcours
course: course:
not_having_address_reference: not_having_address_reference: