mirror of
https://gitlab.com/Chill-Projet/chill-bundles.git
synced 2025-11-10 06:08:25 +00:00
Merge branch 'ticket-app-master' into ticket/64-identifiants-person
# Conflicts: # package.json # src/Bundle/ChillMainBundle/Entity/User.php # src/Bundle/ChillMainBundle/Resources/public/types.ts # src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Modal.vue # src/Bundle/ChillPersonBundle/chill.api.specs.yaml # src/Bundle/ChillTicketBundle/src/Messenger/Handler/PostTicketUpdateMessageHandler.php
This commit is contained in:
7
.changes/unreleased/DX-20251027-150053.yaml
Normal file
7
.changes/unreleased/DX-20251027-150053.yaml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
kind: DX
|
||||||
|
body: |
|
||||||
|
Send notifications log to dedicated channel, if it exists
|
||||||
|
time: 2025-10-27T15:00:53.309372316+01:00
|
||||||
|
custom:
|
||||||
|
Issue: ""
|
||||||
|
SchemaChange: No schema change
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
kind: Feature
|
|
||||||
body: Add a command to generate a list of permissions
|
|
||||||
time: 2025-09-04T18:10:32.334524026+02:00
|
|
||||||
custom:
|
|
||||||
Issue: ""
|
|
||||||
SchemaChange: No schema change
|
|
||||||
6
.changes/unreleased/Feature-20251029-152510.yaml
Normal file
6
.changes/unreleased/Feature-20251029-152510.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
kind: Feature
|
||||||
|
body: Add columns for comments linked to an activity in the activity list export
|
||||||
|
time: 2025-10-29T15:25:10.493968528+01:00
|
||||||
|
custom:
|
||||||
|
Issue: "404"
|
||||||
|
SchemaChange: No schema change
|
||||||
6
.changes/unreleased/Fixed-20251029-124355.yaml
Normal file
6
.changes/unreleased/Fixed-20251029-124355.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
kind: Fixed
|
||||||
|
body: 'Fix: display also social actions linked to parents of the selected social issue'
|
||||||
|
time: 2025-10-29T12:43:55.008647232+01:00
|
||||||
|
custom:
|
||||||
|
Issue: "451"
|
||||||
|
SchemaChange: No schema change
|
||||||
6
.changes/unreleased/Fixed-20251029-143836.yaml
Normal file
6
.changes/unreleased/Fixed-20251029-143836.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
kind: Fixed
|
||||||
|
body: 'Fix: export actions and their results in csv even when action does not have any goals attached to it.'
|
||||||
|
time: 2025-10-29T14:38:36.195220844+01:00
|
||||||
|
custom:
|
||||||
|
Issue: "453"
|
||||||
|
SchemaChange: No schema change
|
||||||
6
.changes/unreleased/Fixed-20251104-135108.yaml
Normal file
6
.changes/unreleased/Fixed-20251104-135108.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
kind: Fixed
|
||||||
|
body: Fix the possibility to delete a workflow
|
||||||
|
time: 2025-11-04T13:51:08.113234488+01:00
|
||||||
|
custom:
|
||||||
|
Issue: ""
|
||||||
|
SchemaChange: Drop or rename table or columns, or enforce new constraint that must be manually fixed
|
||||||
6
.changes/unreleased/UX-20251006-123932.yaml
Normal file
6
.changes/unreleased/UX-20251006-123932.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
kind: UX
|
||||||
|
body: Change the terms 'cercle' and 'centre' to 'service', and 'territoire' respectively.
|
||||||
|
time: 2025-10-06T12:39:32.514056818+02:00
|
||||||
|
custom:
|
||||||
|
Issue: "425"
|
||||||
|
SchemaChange: No schema change
|
||||||
6
.changes/unreleased/UX-20251029-110804.yaml
Normal file
6
.changes/unreleased/UX-20251029-110804.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
kind: UX
|
||||||
|
body: Improve the ux for selecting whether user wants to be notified of the final step of a workflow or all steps
|
||||||
|
time: 2025-10-29T11:08:04.077020411+01:00
|
||||||
|
custom:
|
||||||
|
Issue: "542"
|
||||||
|
SchemaChange: No schema change
|
||||||
6
.changes/unreleased/UX-20251030-180919.yaml
Normal file
6
.changes/unreleased/UX-20251030-180919.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
kind: UX
|
||||||
|
body: Expand timeSpent choices for evaluation document and translate them to user locale or fallback 'fr'
|
||||||
|
time: 2025-10-30T18:09:19.373907522+01:00
|
||||||
|
custom:
|
||||||
|
Issue: ""
|
||||||
|
SchemaChange: No schema change
|
||||||
10
.changes/v4.3.0.md
Normal file
10
.changes/v4.3.0.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
## v4.3.0 - 2025-09-08
|
||||||
|
### Feature
|
||||||
|
* ([#409](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/409)) Add 45 and 60 min calendar ranges
|
||||||
|
* Add a command to generate a list of permissions
|
||||||
|
* ([#412](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/412)) Add an absence end date
|
||||||
|
|
||||||
|
**Schema Change**: Add columns or tables
|
||||||
|
### Fixed
|
||||||
|
* fix date formatting in calendar range display
|
||||||
|
* Change route URL to avoid clash with person duplicate controller method
|
||||||
8
.changes/v4.4.0.md
Normal file
8
.changes/v4.4.0.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
## v4.4.0 - 2025-09-11
|
||||||
|
### Feature
|
||||||
|
* ([#359](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/359)) Allow the merge of two accompanying period works
|
||||||
|
* ([#369](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/369)) Duplication of a document to another accompanying period work evaluation
|
||||||
|
* ([#359](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/359)) Fusion of two accompanying period works
|
||||||
|
### Fixed
|
||||||
|
* Fix display of 'duplicate' and 'merge' buttons in CRUD templates
|
||||||
|
* Fix saving notification preferences in user's profile
|
||||||
3
.changes/v4.4.1.md
Normal file
3
.changes/v4.4.1.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
## v4.4.1 - 2025-09-11
|
||||||
|
### Fixed
|
||||||
|
* fix translations in duplicate evaluation document modal and realign close modal button
|
||||||
3
.changes/v4.4.2.md
Normal file
3
.changes/v4.4.2.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
## v4.4.2 - 2025-09-12
|
||||||
|
### Fixed
|
||||||
|
* Fix document generation and workflow generation do not work on accompanying period work documents
|
||||||
13
.changes/v4.5.0.md
Normal file
13
.changes/v4.5.0.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
## v4.5.0 - 2025-10-03
|
||||||
|
### Feature
|
||||||
|
* Only allow delete of attachment on workflows that are not final
|
||||||
|
* Move up signature buttons on index workflow page for easier access
|
||||||
|
* Filter out document from attachment list if it is the same as the workflow document
|
||||||
|
* Block edition on attached document on workflow, if the workflow is finalized or sent external
|
||||||
|
* Convert workflow's attached document to pdf while sending them external
|
||||||
|
* After a signature is canceled or rejected, going to a waiting page until the post-process routines apply a workflow transition
|
||||||
|
### Fixed
|
||||||
|
* ([#426](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/426)) Increased the number of required characters when setting a new password in Chill from 9 to 14 - GDPR compliance
|
||||||
|
* Fix permissions on storedObject which are subject by a workflow
|
||||||
|
### DX
|
||||||
|
* Introduce a WaitingScreen component to display a waiting screen
|
||||||
4
.changes/v4.5.1.md
Normal file
4
.changes/v4.5.1.md
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
## v4.5.1 - 2025-10-03
|
||||||
|
### Fixed
|
||||||
|
* Add missing javascript dependency
|
||||||
|
* Add exception handling for conversion of attachment on sending external, when documens are already in pdf
|
||||||
14
.changes/v4.6.0.md
Normal file
14
.changes/v4.6.0.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
## v4.6.0 - 2025-10-15
|
||||||
|
### Feature
|
||||||
|
* ([#423](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/423)) Create environment banner that can be activated and configured depending on the image deployed
|
||||||
|
* ([#394](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/394)) Only show active workflow on the page "my tracked workflow"
|
||||||
|
### Fixed
|
||||||
|
* Fix loading of classLists in SocialIssuesAcc.vue, ensure elements are present
|
||||||
|
* Fix the rendering of list of StoredObjectVersions, where there are kept version (before converting to pdf) and intermediate versions deleted
|
||||||
|
* ([#434](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/434)) Notification: fix editing of sent notification by removing form.addressesEmails, a field that no longer exists
|
||||||
|
* Fix loading of social issues and social actions within vue component
|
||||||
|
* ([#446](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/446)) Add unique condition on stored object filename, with cleaning step on existing duplicate filenames
|
||||||
|
|
||||||
|
**Schema Change**: Drop or rename table or columns, or enforce new constraint that must be manually fixed
|
||||||
|
* [workflow] take permissions into account to delete the workflow attachment
|
||||||
|
* ([#448](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/448)) Fix the execution of daily cronjob notification, when the previous last execution storage was invalid
|
||||||
3
.changes/v4.6.1.md
Normal file
3
.changes/v4.6.1.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
## v4.6.1 - 2025-10-27
|
||||||
|
### Fixed
|
||||||
|
* Fix export case where no 'reason' is picked within the PersonHavingActivityBetweenDateFilter.php
|
||||||
@@ -239,6 +239,7 @@ The tests are run from the project's root (not from the bundle's root: so, do no
|
|||||||
Tests must be run using the `symfony` command:
|
Tests must be run using the `symfony` command:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
||||||
# Run a specific test file
|
# Run a specific test file
|
||||||
symfony composer exec phpunit -- path/to/TestFile.php
|
symfony composer exec phpunit -- path/to/TestFile.php
|
||||||
|
|
||||||
@@ -246,6 +247,9 @@ symfony composer exec phpunit -- path/to/TestFile.php
|
|||||||
symfony composer exec phpunit -- --filter methodName path/to/TestFile.php
|
symfony composer exec phpunit -- --filter methodName path/to/TestFile.php
|
||||||
```
|
```
|
||||||
|
|
||||||
|
When writing tests, only test specific files. Do not run all tests or the full
|
||||||
|
test suite.
|
||||||
|
|
||||||
#### Test Structure
|
#### Test Structure
|
||||||
|
|
||||||
Tests are organized by bundle and follow the same structure as the bundle itself:
|
Tests are organized by bundle and follow the same structure as the bundle itself:
|
||||||
|
|||||||
70
CHANGELOG.md
70
CHANGELOG.md
@@ -6,6 +6,72 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html),
|
|||||||
and is generated by [Changie](https://github.com/miniscruff/changie).
|
and is generated by [Changie](https://github.com/miniscruff/changie).
|
||||||
|
|
||||||
|
|
||||||
|
## v4.6.1 - 2025-10-27
|
||||||
|
### Fixed
|
||||||
|
* Fix export case where no 'reason' is picked within the PersonHavingActivityBetweenDateFilter.php
|
||||||
|
|
||||||
|
## v4.6.0 - 2025-10-15
|
||||||
|
### Feature
|
||||||
|
* ([#423](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/423)) Create environment banner that can be activated and configured depending on the image deployed
|
||||||
|
* ([#394](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/394)) Only show active workflow on the page "my tracked workflow"
|
||||||
|
### Fixed
|
||||||
|
* Fix loading of classLists in SocialIssuesAcc.vue, ensure elements are present
|
||||||
|
* Fix the rendering of list of StoredObjectVersions, where there are kept version (before converting to pdf) and intermediate versions deleted
|
||||||
|
* ([#434](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/434)) Notification: fix editing of sent notification by removing form.addressesEmails, a field that no longer exists
|
||||||
|
* Fix loading of social issues and social actions within vue component
|
||||||
|
* ([#446](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/446)) Add unique condition on stored object filename, with cleaning step on existing duplicate filenames
|
||||||
|
|
||||||
|
**Schema Change**: Drop or rename table or columns, or enforce new constraint that must be manually fixed
|
||||||
|
* [workflow] take permissions into account to delete the workflow attachment
|
||||||
|
* ([#448](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/448)) Fix the execution of daily cronjob notification, when the previous last execution storage was invalid
|
||||||
|
|
||||||
|
## v4.5.1 - 2025-10-03
|
||||||
|
### Fixed
|
||||||
|
* Add missing javascript dependency
|
||||||
|
* Add exception handling for conversion of attachment on sending external, when documens are already in pdf
|
||||||
|
|
||||||
|
## v4.5.0 - 2025-10-03
|
||||||
|
### Feature
|
||||||
|
* Only allow delete of attachment on workflows that are not final
|
||||||
|
* Move up signature buttons on index workflow page for easier access
|
||||||
|
* Filter out document from attachment list if it is the same as the workflow document
|
||||||
|
* Block edition on attached document on workflow, if the workflow is finalized or sent external
|
||||||
|
* Convert workflow's attached document to pdf while sending them external
|
||||||
|
* After a signature is canceled or rejected, going to a waiting page until the post-process routines apply a workflow transition
|
||||||
|
### Fixed
|
||||||
|
* ([#426](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/426)) Increased the number of required characters when setting a new password in Chill from 9 to 14 - GDPR compliance
|
||||||
|
* Fix permissions on storedObject which are subject by a workflow
|
||||||
|
### DX
|
||||||
|
* Introduce a WaitingScreen component to display a waiting screen
|
||||||
|
|
||||||
|
## v4.4.2 - 2025-09-12
|
||||||
|
### Fixed
|
||||||
|
* Fix document generation and workflow generation do not work on accompanying period work documents
|
||||||
|
|
||||||
|
## v4.4.1 - 2025-09-11
|
||||||
|
### Fixed
|
||||||
|
* fix translations in duplicate evaluation document modal and realign close modal button
|
||||||
|
|
||||||
|
## v4.4.0 - 2025-09-11
|
||||||
|
### Feature
|
||||||
|
* ([#359](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/359)) Allow the merge of two accompanying period works
|
||||||
|
* ([#369](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/369)) Duplication of a document to another accompanying period work evaluation
|
||||||
|
* ([#359](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/359)) Fusion of two accompanying period works
|
||||||
|
### Fixed
|
||||||
|
* Fix display of 'duplicate' and 'merge' buttons in CRUD templates
|
||||||
|
* Fix saving notification preferences in user's profile
|
||||||
|
|
||||||
|
## v4.3.0 - 2025-09-08
|
||||||
|
### Feature
|
||||||
|
* ([#409](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/409)) Add 45 and 60 min calendar ranges
|
||||||
|
* Add a command to generate a list of permissions
|
||||||
|
* ([#412](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/412)) Add an absence end date
|
||||||
|
|
||||||
|
**Schema Change**: Add columns or tables
|
||||||
|
### Fixed
|
||||||
|
* fix date formatting in calendar range display
|
||||||
|
* Change route URL to avoid clash with person duplicate controller method
|
||||||
|
|
||||||
## v4.2.1 - 2025-09-03
|
## v4.2.1 - 2025-09-03
|
||||||
### Fixed
|
### Fixed
|
||||||
* Fix exports to work with DirectExportInterface
|
* Fix exports to work with DirectExportInterface
|
||||||
@@ -714,7 +780,7 @@ Fix color of Chill footer
|
|||||||
- ajout d'un filtre et regroupement par usager participant sur les échanges
|
- ajout d'un filtre et regroupement par usager participant sur les échanges
|
||||||
- ajout d'un regroupement: par type d'activité associé au parcours;
|
- ajout d'un regroupement: par type d'activité associé au parcours;
|
||||||
- trie les filtre et regroupements par ordre alphabétique dans els exports
|
- trie les filtre et regroupements par ordre alphabétique dans els exports
|
||||||
- ajout d'un paramètre qui permet de désactiver le filtre par centre dans les exports
|
- ajout d'un paramètre qui permet de désactiver le filtre par territoire dans les exports
|
||||||
- correction de l'interface de date dans les filtres et regroupements "par statut du parcours à la date"
|
- correction de l'interface de date dans les filtres et regroupements "par statut du parcours à la date"
|
||||||
|
|
||||||
## v2.9.2 - 2023-10-17
|
## v2.9.2 - 2023-10-17
|
||||||
@@ -894,7 +960,7 @@ error when trying to reedit a saved export
|
|||||||
- ajout d'un regroupement par métier des intervenants sur un parcours;
|
- ajout d'un regroupement par métier des intervenants sur un parcours;
|
||||||
- ajout d'un regroupement par service des intervenants sur un parcours;
|
- ajout d'un regroupement par service des intervenants sur un parcours;
|
||||||
- ajout d'un regroupement par utilisateur intervenant sur un parcours
|
- ajout d'un regroupement par utilisateur intervenant sur un parcours
|
||||||
- ajout d'un regroupement "par centre de l'usager";
|
- ajout d'un regroupement "par territoire de l'usager";
|
||||||
- ajout d'un filtre "par métier intervenant sur un parcours";
|
- ajout d'un filtre "par métier intervenant sur un parcours";
|
||||||
- ajout d'un filtre "par service intervenant sur un parcours";
|
- ajout d'un filtre "par service intervenant sur un parcours";
|
||||||
- création d'un rôle spécifique pour voir les parcours confidentiels (et séparer de celui de la liste qui permet de ré-assigner les parcours en lot);
|
- création d'un rôle spécifique pour voir les parcours confidentiels (et séparer de celui de la liste qui permet de ré-assigner les parcours en lot);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
"ext-openssl": "*",
|
"ext-openssl": "*",
|
||||||
"ext-redis": "*",
|
"ext-redis": "*",
|
||||||
"ext-zlib": "*",
|
"ext-zlib": "*",
|
||||||
"champs-libres/wopi-bundle": "dev-master@dev",
|
"champs-libres/wopi-bundle": "dev-symfony-v5@dev",
|
||||||
"champs-libres/wopi-lib": "dev-master@dev",
|
"champs-libres/wopi-lib": "dev-master@dev",
|
||||||
"doctrine/data-fixtures": "^1.8",
|
"doctrine/data-fixtures": "^1.8",
|
||||||
"doctrine/doctrine-bundle": "^2.1",
|
"doctrine/doctrine-bundle": "^2.1",
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
|
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
|
||||||
loophp\PsrHttpMessageBridgeBundle\PsrHttpMessageBridgeBundle::class => ['all' => true],
|
|
||||||
ChampsLibres\WopiBundle\WopiBundle::class => ['all' => true],
|
ChampsLibres\WopiBundle\WopiBundle::class => ['all' => true],
|
||||||
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
|
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
|
||||||
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
|
Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
|
||||||
@@ -38,4 +37,5 @@ return [
|
|||||||
Chill\TicketBundle\ChillTicketBundle::class => ['all' => true],
|
Chill\TicketBundle\ChillTicketBundle::class => ['all' => true],
|
||||||
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
|
Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true],
|
||||||
Symfony\UX\Translator\UxTranslatorBundle::class => ['all' => true],
|
Symfony\UX\Translator\UxTranslatorBundle::class => ['all' => true],
|
||||||
|
loophp\PsrHttpMessageBridgeBundle\PsrHttpMessageBridgeBundle::class => ['all' => true],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
chill_main:
|
chill_main:
|
||||||
available_languages: [ '%env(resolve:LOCALE)%', 'en' ]
|
available_languages: [ '%env(resolve:LOCALE)%', 'en' ]
|
||||||
available_countries: ['BE', 'FR']
|
available_countries: ['BE', 'FR']
|
||||||
|
top_banner:
|
||||||
|
visible: false
|
||||||
|
text:
|
||||||
|
fr: 'Vous travaillez actuellement avec la version de PRÉ-PRODUCTION.'
|
||||||
|
nl: 'Je werkt momenteel in de PRE-PRODUCTIE versie'
|
||||||
|
color: '#353535'
|
||||||
|
background_color: '#d8bb48'
|
||||||
notifications:
|
notifications:
|
||||||
from_email: '%env(resolve:NOTIFICATION_FROM_EMAIL)%'
|
from_email: '%env(resolve:NOTIFICATION_FROM_EMAIL)%'
|
||||||
from_name: '%env(resolve:NOTIFICATION_FROM_NAME)%'
|
from_name: '%env(resolve:NOTIFICATION_FROM_NAME)%'
|
||||||
|
|||||||
2
config/packages/chill_aside_activity.yaml
Normal file
2
config/packages/chill_aside_activity.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
chill_aside_activity:
|
||||||
|
show_concerned_persons_count: hidden
|
||||||
@@ -23,8 +23,8 @@ class "Document" {
|
|||||||
- text description
|
- text description
|
||||||
- ArrayCollection_DocumentCategory categories
|
- ArrayCollection_DocumentCategory categories
|
||||||
- varchar_150 content #link to openstack
|
- varchar_150 content #link to openstack
|
||||||
- Center center
|
- Territoire territoire
|
||||||
- Cercle cercle
|
- Service service
|
||||||
- User user
|
- User user
|
||||||
- DateTime date # Creation date
|
- DateTime date # Creation date
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ Certaines données sont historisées:
|
|||||||
|
|
||||||
- les référents d'un parcours;
|
- les référents d'un parcours;
|
||||||
- les statuts d'un parcours;
|
- les statuts d'un parcours;
|
||||||
- la liaison entre les centres et les usagers;
|
- la liaison entre les territoires et les usagers;
|
||||||
- etc.
|
- etc.
|
||||||
|
|
||||||
Dans ces cas-là, Chill crée généralement deux colonnes, qui sont habituellement nommées :code:`startDate` et :code:`endDate`. Lorsque la colonne :code:`endDate` est à :code:`NULL`, cela signifie que la période n'est pas "fermée". La colonne :code:`startDate` n'est pas nullable.
|
Dans ces cas-là, Chill crée généralement deux colonnes, qui sont habituellement nommées :code:`startDate` et :code:`endDate`. Lorsque la colonne :code:`endDate` est à :code:`NULL`, cela signifie que la période n'est pas "fermée". La colonne :code:`startDate` n'est pas nullable.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
order,table_schema,table_name,commentaire
|
order,table_schema,table_name,commentaire
|
||||||
1,chill_3party,party_category,Catégorie de tiers
|
1,chill_3party,party_category,Catégorie de tiers
|
||||||
2,chill_3party,party_center,Association entre les tiers et les centres (déprécié)
|
2,chill_3party,party_center,Association entre les tiers et les territoires (déprécié)
|
||||||
3,chill_3party,party_profession,Profession du tiers (déprécié)
|
3,chill_3party,party_profession,Profession du tiers (déprécié)
|
||||||
4,chill_3party,third_party,Tiers
|
4,chill_3party,third_party,Tiers
|
||||||
5,chill_3party,thirdparty_category,association tiers - catégories
|
5,chill_3party,thirdparty_category,association tiers - catégories
|
||||||
@@ -54,7 +54,7 @@ order,table_schema,table_name,commentaire
|
|||||||
53,public,activitytpresence,Présence aux échanges
|
53,public,activitytpresence,Présence aux échanges
|
||||||
54,public,activitytype,Types d'échanges
|
54,public,activitytype,Types d'échanges
|
||||||
55,public,activitytypecategory,Catégories de types d'échanges
|
55,public,activitytypecategory,Catégories de types d'échanges
|
||||||
56,public,centers,"Centres (territoires, agences, etc.)"
|
56,public,centers,"Territoires (territoires, agences, etc.)"
|
||||||
57,public,chill_activity_activity_chill_person_socialaction,
|
57,public,chill_activity_activity_chill_person_socialaction,
|
||||||
58,public,chill_activity_activity_chill_person_socialissue
|
58,public,chill_activity_activity_chill_person_socialissue
|
||||||
59,public,chill_docgen_template,Gabarits de documents
|
59,public,chill_docgen_template,Gabarits de documents
|
||||||
@@ -111,7 +111,7 @@ order,table_schema,table_name,commentaire
|
|||||||
110,public,chill_person_marital_status,Etats civils
|
110,public,chill_person_marital_status,Etats civils
|
||||||
111,public,chill_person_not_duplicate,
|
111,public,chill_person_not_duplicate,
|
||||||
112,public,chill_person_person,Usagers
|
112,public,chill_person_person,Usagers
|
||||||
113,public,chill_person_person_center_history,Historique des centres d'un usagers
|
113,public,chill_person_person_center_history,Historique des territoires d'un usagers
|
||||||
114,public,chill_person_persons_to_addresses,Déprécié
|
114,public,chill_person_persons_to_addresses,Déprécié
|
||||||
115,public,chill_person_phone,Numéros d etéléphone supplémentaires d'un usager
|
115,public,chill_person_phone,Numéros d etéléphone supplémentaires d'un usager
|
||||||
116,public,chill_person_relations,Types de relations de filiation
|
116,public,chill_person_relations,Types de relations de filiation
|
||||||
@@ -142,7 +142,7 @@ order,table_schema,table_name,commentaire
|
|||||||
141,public,permission_groups
|
141,public,permission_groups
|
||||||
142,public,permissionsgroup_rolescope
|
142,public,permissionsgroup_rolescope
|
||||||
143,public,persons_spoken_languages
|
143,public,persons_spoken_languages
|
||||||
144,public,regroupment,Regroupement de centres
|
144,public,regroupment,Regroupement de territoires
|
||||||
145,public,regroupment_center,
|
145,public,regroupment_center,
|
||||||
146,public,role_scopes,
|
146,public,role_scopes,
|
||||||
147,public,scopes,Services
|
147,public,scopes,Services
|
||||||
|
|||||||
|
Can't render this file because it has a wrong number of fields in line 28.
|
@@ -41,7 +41,7 @@
|
|||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
"typescript-eslint": "^8.13.0",
|
"typescript-eslint": "^8.13.0",
|
||||||
"vue-loader": "^17.0.0",
|
"vue-loader": "^17.0.0",
|
||||||
"vue-tsc": "^3.1.1",
|
"vue-tsc": "^3.1.3",
|
||||||
"webpack": "^5.75.0",
|
"webpack": "^5.75.0",
|
||||||
"webpack-cli": "^5.0.1"
|
"webpack-cli": "^5.0.1"
|
||||||
},
|
},
|
||||||
@@ -56,6 +56,7 @@
|
|||||||
"@tsconfig/node20": "^20.1.4",
|
"@tsconfig/node20": "^20.1.4",
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
"@types/leaflet": "^1.9.3",
|
"@types/leaflet": "^1.9.3",
|
||||||
|
"@vueuse/core": "^13.9.0",
|
||||||
"bootstrap-icons": "^1.11.3",
|
"bootstrap-icons": "^1.11.3",
|
||||||
"dropzone": "^5.7.6",
|
"dropzone": "^5.7.6",
|
||||||
"es6-promise": "^4.2.8",
|
"es6-promise": "^4.2.8",
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ class ListActivityHelper
|
|||||||
->leftJoin('activity.location', 'location')
|
->leftJoin('activity.location', 'location')
|
||||||
->addSelect('location.name AS locationName')
|
->addSelect('location.name AS locationName')
|
||||||
->addSelect('activity.sentReceived')
|
->addSelect('activity.sentReceived')
|
||||||
|
->addSelect('activity.comment.comment AS commentText')
|
||||||
|
->addSelect('activity.comment.date AS commentDate')
|
||||||
|
->addSelect('JSON_BUILD_OBJECT(\'uid\', activity.comment.userId, \'d\', activity.comment.date) AS commentUser')
|
||||||
->addSelect('JSON_BUILD_OBJECT(\'uid\', IDENTITY(activity.createdBy), \'d\', activity.createdAt) AS createdBy')
|
->addSelect('JSON_BUILD_OBJECT(\'uid\', IDENTITY(activity.createdBy), \'d\', activity.createdAt) AS createdBy')
|
||||||
->addSelect('activity.createdAt')
|
->addSelect('activity.createdAt')
|
||||||
->addSelect('JSON_BUILD_OBJECT(\'uid\', IDENTITY(activity.updatedBy), \'d\', activity.updatedAt) AS updatedBy')
|
->addSelect('JSON_BUILD_OBJECT(\'uid\', IDENTITY(activity.updatedBy), \'d\', activity.updatedAt) AS updatedBy')
|
||||||
@@ -87,6 +90,8 @@ class ListActivityHelper
|
|||||||
'createdAt', 'updatedAt' => $this->dateTimeHelper->getLabel($key),
|
'createdAt', 'updatedAt' => $this->dateTimeHelper->getLabel($key),
|
||||||
'createdBy', 'updatedBy' => $this->userHelper->getLabel($key, $values, $key),
|
'createdBy', 'updatedBy' => $this->userHelper->getLabel($key, $values, $key),
|
||||||
'date' => $this->dateTimeHelper->getLabel(self::MSG_KEY.$key),
|
'date' => $this->dateTimeHelper->getLabel(self::MSG_KEY.$key),
|
||||||
|
'commentDate' => $this->dateTimeHelper->getLabel(self::MSG_KEY.'comment_date'),
|
||||||
|
'commentUser' => $this->userHelper->getLabel($key, $values, self::MSG_KEY.'comment_user'),
|
||||||
'attendeeName' => function ($value) {
|
'attendeeName' => function ($value) {
|
||||||
if ('_header' === $value) {
|
if ('_header' === $value) {
|
||||||
return 'Attendee';
|
return 'Attendee';
|
||||||
@@ -176,6 +181,9 @@ class ListActivityHelper
|
|||||||
'usersNames',
|
'usersNames',
|
||||||
'thirdPartiesIds',
|
'thirdPartiesIds',
|
||||||
'thirdPartiesNames',
|
'thirdPartiesNames',
|
||||||
|
'commentText',
|
||||||
|
'commentDate',
|
||||||
|
'commentUser',
|
||||||
'createdBy',
|
'createdBy',
|
||||||
'createdAt',
|
'createdAt',
|
||||||
'updatedBy',
|
'updatedBy',
|
||||||
|
|||||||
@@ -90,7 +90,9 @@ class ActivityReasonFilter implements ExportElementValidatedInterface, FilterInt
|
|||||||
|
|
||||||
public function getFormDefaultData(): array
|
public function getFormDefaultData(): array
|
||||||
{
|
{
|
||||||
return [];
|
return [
|
||||||
|
'reasons' => [],
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function describeAction($data, ExportGenerationContext $context): string|\Symfony\Contracts\Translation\TranslatableInterface|array
|
public function describeAction($data, ExportGenerationContext $context): string|\Symfony\Contracts\Translation\TranslatableInterface|array
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
|
|||||||
|
|
||||||
public function alterQuery(QueryBuilder $qb, $data, ExportGenerationContext $exportGenerationContext): void
|
public function alterQuery(QueryBuilder $qb, $data, ExportGenerationContext $exportGenerationContext): void
|
||||||
{
|
{
|
||||||
|
error_log('alterQuery called with data: '.json_encode(array_keys($data)));
|
||||||
|
|
||||||
// create a subquery for activity
|
// create a subquery for activity
|
||||||
$sqb = $qb->getEntityManager()->createQueryBuilder();
|
$sqb = $qb->getEntityManager()->createQueryBuilder();
|
||||||
$sqb->select('1')
|
$sqb->select('1')
|
||||||
@@ -59,7 +61,6 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
|
|||||||
if (\in_array('activity', $qb->getAllAliases(), true)) {
|
if (\in_array('activity', $qb->getAllAliases(), true)) {
|
||||||
$sqb->andWhere('activity_person_having_activity.id = activity.id');
|
$sqb->andWhere('activity_person_having_activity.id = activity.id');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isset($data['reasons']) && [] !== $data['reasons']) {
|
if (isset($data['reasons']) && [] !== $data['reasons']) {
|
||||||
// add clause activity reason
|
// add clause activity reason
|
||||||
$sqb->join('activity_person_having_activity.reasons', 'reasons_person_having_activity');
|
$sqb->join('activity_person_having_activity.reasons', 'reasons_person_having_activity');
|
||||||
@@ -124,12 +125,38 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
|
|||||||
|
|
||||||
public function normalizeFormData(array $formData): array
|
public function normalizeFormData(array $formData): array
|
||||||
{
|
{
|
||||||
return ['date_from_rolling' => $formData['date_from_rolling']->normalize(), 'date_to_rolling' => $formData['date_to_rolling']->normalize()];
|
$normalized = [
|
||||||
|
'date_from_rolling' => $formData['date_from_rolling']->normalize(),
|
||||||
|
'date_to_rolling' => $formData['date_to_rolling']->normalize(),
|
||||||
|
'reasons' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isset($formData['reasons']) && [] !== $formData['reasons']) {
|
||||||
|
$normalized['reasons'] = array_map(
|
||||||
|
fn (ActivityReason $reason) => $reason->getId(),
|
||||||
|
$formData['reasons']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function denormalizeFormData(array $formData, int $fromVersion): array
|
public function denormalizeFormData(array $formData, int $fromVersion): array
|
||||||
{
|
{
|
||||||
return ['date_from_rolling' => RollingDate::fromNormalized($formData['date_from_rolling']), 'date_to_rolling' => RollingDate::fromNormalized($formData['date_to_rolling'])];
|
$denormalized = [
|
||||||
|
'date_from_rolling' => RollingDate::fromNormalized($formData['date_from_rolling']),
|
||||||
|
'date_to_rolling' => RollingDate::fromNormalized($formData['date_to_rolling']),
|
||||||
|
'reasons' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (isset($formData['reasons']) && [] !== $formData['reasons']) {
|
||||||
|
$denormalized['reasons'] = array_map(
|
||||||
|
fn ($id) => $this->activityReasonRepository->find($id),
|
||||||
|
$formData['reasons']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $denormalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getFormDefaultData(): array
|
public function getFormDefaultData(): array
|
||||||
@@ -143,10 +170,12 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
|
|||||||
|
|
||||||
public function describeAction($data, ExportGenerationContext $context): array
|
public function describeAction($data, ExportGenerationContext $context): array
|
||||||
{
|
{
|
||||||
|
$reasons = $data['reasons'] ?? [];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
[] === $data['reasons'] ?
|
[] === $reasons ?
|
||||||
'export.filter.person_between_dates.describe_action_with_no_subject'
|
'export.filter.activity.describe_action_with_no_subject'
|
||||||
: 'export.filter.person_between_dates.describe_action_with_subject',
|
: 'export.filter.activity.describe_action_with_subject',
|
||||||
[
|
[
|
||||||
'date_from' => $this->rollingDateConverter->convert($data['date_from_rolling']),
|
'date_from' => $this->rollingDateConverter->convert($data['date_from_rolling']),
|
||||||
'date_to' => $this->rollingDateConverter->convert($data['date_to_rolling']),
|
'date_to' => $this->rollingDateConverter->convert($data['date_to_rolling']),
|
||||||
@@ -154,7 +183,7 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
|
|||||||
', ',
|
', ',
|
||||||
array_map(
|
array_map(
|
||||||
fn (ActivityReason $r): string => '"'.$this->translatableStringHelper->localize($r->getName()).'"',
|
fn (ActivityReason $r): string => '"'.$this->translatableStringHelper->localize($r->getName()).'"',
|
||||||
$data['reasons']
|
$reasons
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -168,6 +197,7 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
|
|||||||
|
|
||||||
public function validateForm($data, ExecutionContextInterface $context): void
|
public function validateForm($data, ExecutionContextInterface $context): void
|
||||||
{
|
{
|
||||||
|
error_log('validateForm called with data: '.json_encode(array_keys($data)));
|
||||||
if ($this->rollingDateConverter->convert($data['date_from_rolling'])
|
if ($this->rollingDateConverter->convert($data['date_from_rolling'])
|
||||||
>= $this->rollingDateConverter->convert($data['date_to_rolling'])) {
|
>= $this->rollingDateConverter->convert($data['date_to_rolling'])) {
|
||||||
$context->buildViolation('export.filter.activity.person_between_dates.date mismatch')
|
$context->buildViolation('export.filter.activity.person_between_dates.date mismatch')
|
||||||
|
|||||||
@@ -1,98 +1,103 @@
|
|||||||
<template>
|
<template>
|
||||||
<teleport to="#social-issues-acc">
|
<teleport to="#social-issues-acc">
|
||||||
<div class="mb-3 row">
|
<div class="mb-3 row">
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<label :class="socialIssuesClassList">{{
|
<label :class="socialIssuesClassList">{{
|
||||||
trans(ACTIVITY_SOCIAL_ISSUES)
|
trans(ACTIVITY_SOCIAL_ISSUES)
|
||||||
}}</label>
|
}}</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-8">
|
<div class="col-8">
|
||||||
<check-social-issue
|
<check-social-issue
|
||||||
v-for="issue in socialIssuesList"
|
v-for="issue in socialIssuesList"
|
||||||
:key="issue.id"
|
:key="issue.id"
|
||||||
:issue="issue"
|
:issue="issue"
|
||||||
:selection="socialIssuesSelected"
|
:selection="socialIssuesSelected"
|
||||||
@updateSelected="updateIssuesSelected"
|
@updateSelected="updateIssuesSelected"
|
||||||
>
|
>
|
||||||
</check-social-issue>
|
</check-social-issue>
|
||||||
|
|
||||||
<div class="my-3">
|
<div class="my-3">
|
||||||
<VueMultiselect
|
<VueMultiselect
|
||||||
name="otherIssues"
|
name="otherIssues"
|
||||||
label="text"
|
label="text"
|
||||||
track-by="id"
|
track-by="id"
|
||||||
open-direction="bottom"
|
open-direction="bottom"
|
||||||
:close-on-select="true"
|
:close-on-select="true"
|
||||||
:preserve-search="false"
|
:preserve-search="false"
|
||||||
:reset-after="true"
|
:reset-after="true"
|
||||||
:hide-selected="true"
|
:hide-selected="true"
|
||||||
:taggable="false"
|
:taggable="false"
|
||||||
:multiple="false"
|
:multiple="false"
|
||||||
:searchable="true"
|
:searchable="true"
|
||||||
:allow-empty="true"
|
:allow-empty="true"
|
||||||
:show-labels="false"
|
:show-labels="false"
|
||||||
:loading="issueIsLoading"
|
:loading="issueIsLoading"
|
||||||
:placeholder="trans(ACTIVITY_CHOOSE_OTHER_SOCIAL_ISSUE)"
|
:placeholder="trans(ACTIVITY_CHOOSE_OTHER_SOCIAL_ISSUE)"
|
||||||
:options="socialIssuesOther"
|
:options="socialIssuesOther"
|
||||||
@select="addIssueInList"
|
@select="addIssueInList"
|
||||||
>
|
>
|
||||||
</VueMultiselect>
|
</VueMultiselect>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3 row">
|
|
||||||
<div class="col-4">
|
|
||||||
<label :class="socialActionsClassList">{{
|
|
||||||
trans(ACTIVITY_SOCIAL_ACTIONS)
|
|
||||||
}}</label>
|
|
||||||
</div>
|
|
||||||
<div class="col-8">
|
|
||||||
<div v-if="actionIsLoading === true">
|
|
||||||
<i class="chill-green fa fa-circle-o-notch fa-spin fa-lg"></i>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span
|
<div class="mb-3 row">
|
||||||
v-else-if="socialIssuesSelected.length === 0"
|
<div class="col-4">
|
||||||
class="inline-choice chill-no-data-statement mt-3"
|
<label :class="socialActionsClassList">{{
|
||||||
>
|
trans(ACTIVITY_SOCIAL_ACTIONS)
|
||||||
{{ trans(ACTIVITY_SELECT_FIRST_A_SOCIAL_ISSUE) }}
|
}}</label>
|
||||||
</span>
|
</div>
|
||||||
|
<div class="col-8">
|
||||||
|
<div v-if="actionIsLoading === true">
|
||||||
|
<i
|
||||||
|
class="chill-green fa fa-circle-o-notch fa-spin fa-lg"
|
||||||
|
></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
<template
|
<span
|
||||||
v-else-if="
|
v-else-if="socialIssuesSelected.length === 0"
|
||||||
socialActionsList.length > 0 &&
|
class="inline-choice chill-no-data-statement mt-3"
|
||||||
(socialIssuesSelected.length || socialActionsSelected.length)
|
>
|
||||||
"
|
{{ trans(ACTIVITY_SELECT_FIRST_A_SOCIAL_ISSUE) }}
|
||||||
>
|
</span>
|
||||||
<div
|
|
||||||
id="actionsList"
|
|
||||||
v-for="group in socialActionsList"
|
|
||||||
:key="group.issue"
|
|
||||||
>
|
|
||||||
<span class="badge bg-chill-l-gray text-dark">{{
|
|
||||||
group.issue
|
|
||||||
}}</span>
|
|
||||||
<check-social-action
|
|
||||||
v-for="action in group.actions"
|
|
||||||
:key="action.id"
|
|
||||||
:action="action"
|
|
||||||
:selection="socialActionsSelected"
|
|
||||||
@updateSelected="updateActionsSelected"
|
|
||||||
>
|
|
||||||
</check-social-action>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<span
|
<template
|
||||||
v-else-if="actionAreLoaded && socialActionsList.length === 0"
|
v-else-if="
|
||||||
class="inline-choice chill-no-data-statement mt-3"
|
socialActionsList.length > 0 &&
|
||||||
>
|
(socialIssuesSelected.length ||
|
||||||
{{ trans(ACTIVITY_SOCIAL_ACTION_LIST_EMPTY) }}
|
socialActionsSelected.length)
|
||||||
</span>
|
"
|
||||||
</div>
|
>
|
||||||
</div>
|
<div
|
||||||
</teleport>
|
id="actionsList"
|
||||||
|
v-for="group in socialActionsList"
|
||||||
|
:key="group.issue"
|
||||||
|
>
|
||||||
|
<span class="badge bg-chill-l-gray text-dark">{{
|
||||||
|
group.issue
|
||||||
|
}}</span>
|
||||||
|
<check-social-action
|
||||||
|
v-for="action in group.actions"
|
||||||
|
:key="action.id"
|
||||||
|
:action="action"
|
||||||
|
:selection="socialActionsSelected"
|
||||||
|
@updateSelected="updateActionsSelected"
|
||||||
|
>
|
||||||
|
</check-social-action>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-else-if="
|
||||||
|
actionAreLoaded && socialActionsList.length === 0
|
||||||
|
"
|
||||||
|
class="inline-choice chill-no-data-statement mt-3"
|
||||||
|
>
|
||||||
|
{{ trans(ACTIVITY_SOCIAL_ACTION_LIST_EMPTY) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -101,153 +106,175 @@ import CheckSocialIssue from "./SocialIssuesAcc/CheckSocialIssue.vue";
|
|||||||
import CheckSocialAction from "./SocialIssuesAcc/CheckSocialAction.vue";
|
import CheckSocialAction from "./SocialIssuesAcc/CheckSocialAction.vue";
|
||||||
import { getSocialIssues, getSocialActionByIssue } from "../api.js";
|
import { getSocialIssues, getSocialActionByIssue } from "../api.js";
|
||||||
import {
|
import {
|
||||||
ACTIVITY_SOCIAL_ACTION_LIST_EMPTY,
|
ACTIVITY_SOCIAL_ACTION_LIST_EMPTY,
|
||||||
ACTIVITY_SELECT_FIRST_A_SOCIAL_ISSUE,
|
ACTIVITY_SELECT_FIRST_A_SOCIAL_ISSUE,
|
||||||
ACTIVITY_SOCIAL_ACTIONS,
|
ACTIVITY_SOCIAL_ACTIONS,
|
||||||
ACTIVITY_SOCIAL_ISSUES,
|
ACTIVITY_SOCIAL_ISSUES,
|
||||||
ACTIVITY_CHOOSE_OTHER_SOCIAL_ISSUE,
|
ACTIVITY_CHOOSE_OTHER_SOCIAL_ISSUE,
|
||||||
trans,
|
trans,
|
||||||
} from "translator";
|
} from "translator";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "SocialIssuesAcc",
|
name: "SocialIssuesAcc",
|
||||||
components: {
|
components: {
|
||||||
CheckSocialIssue,
|
CheckSocialIssue,
|
||||||
CheckSocialAction,
|
CheckSocialAction,
|
||||||
VueMultiselect,
|
VueMultiselect,
|
||||||
},
|
|
||||||
setup() {
|
|
||||||
return {
|
|
||||||
trans,
|
|
||||||
ACTIVITY_SOCIAL_ACTION_LIST_EMPTY,
|
|
||||||
ACTIVITY_SELECT_FIRST_A_SOCIAL_ISSUE,
|
|
||||||
ACTIVITY_SOCIAL_ACTIONS,
|
|
||||||
ACTIVITY_SOCIAL_ISSUES,
|
|
||||||
ACTIVITY_CHOOSE_OTHER_SOCIAL_ISSUE,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
issueIsLoading: false,
|
|
||||||
actionIsLoading: false,
|
|
||||||
actionAreLoaded: false,
|
|
||||||
socialIssuesClassList: `col-form-label ${document.querySelector("input#chill_activitybundle_activity_socialIssues").getAttribute("required") ? "required" : ""}`,
|
|
||||||
socialActionsClassList: `col-form-label ${document.querySelector("input#chill_activitybundle_activity_socialActions").getAttribute("required") ? "required" : ""}`,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
socialIssuesList() {
|
|
||||||
return this.$store.state.activity.accompanyingPeriod.socialIssues;
|
|
||||||
},
|
},
|
||||||
socialIssuesSelected() {
|
setup() {
|
||||||
return this.$store.state.activity.socialIssues;
|
return {
|
||||||
|
trans,
|
||||||
|
ACTIVITY_SOCIAL_ACTION_LIST_EMPTY,
|
||||||
|
ACTIVITY_SELECT_FIRST_A_SOCIAL_ISSUE,
|
||||||
|
ACTIVITY_SOCIAL_ACTIONS,
|
||||||
|
ACTIVITY_SOCIAL_ISSUES,
|
||||||
|
ACTIVITY_CHOOSE_OTHER_SOCIAL_ISSUE,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
socialIssuesOther() {
|
data() {
|
||||||
return this.$store.state.socialIssuesOther;
|
return {
|
||||||
|
issueIsLoading: false,
|
||||||
|
actionIsLoading: false,
|
||||||
|
actionAreLoaded: false,
|
||||||
|
socialIssuesClassList: {
|
||||||
|
"col-form-label": true,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
socialActionsClassList: {
|
||||||
|
"col-form-label": true,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
},
|
},
|
||||||
socialActionsList() {
|
computed: {
|
||||||
return this.$store.getters.socialActionsListSorted;
|
socialIssuesList() {
|
||||||
|
return this.$store.state.activity.accompanyingPeriod.socialIssues;
|
||||||
|
},
|
||||||
|
socialIssuesSelected() {
|
||||||
|
return this.$store.state.activity.socialIssues;
|
||||||
|
},
|
||||||
|
socialIssuesOther() {
|
||||||
|
return this.$store.state.socialIssuesOther;
|
||||||
|
},
|
||||||
|
socialActionsList() {
|
||||||
|
return this.$store.getters.socialActionsListSorted;
|
||||||
|
},
|
||||||
|
socialActionsSelected() {
|
||||||
|
return this.$store.state.activity.socialActions;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
socialActionsSelected() {
|
mounted() {
|
||||||
return this.$store.state.activity.socialActions;
|
/* Load classNames after element is present */
|
||||||
},
|
const socialActionsEl = document.querySelector(
|
||||||
},
|
"input#chill_activitybundle_activity_socialActions",
|
||||||
mounted() {
|
);
|
||||||
/* Load other issues in multiselect */
|
if (socialActionsEl && socialActionsEl.hasAttribute("required")) {
|
||||||
this.issueIsLoading = true;
|
this.socialActionsClassList.required = true;
|
||||||
this.actionAreLoaded = false;
|
|
||||||
|
|
||||||
getSocialIssues().then((response) => {
|
|
||||||
/* Add issues to the store */
|
|
||||||
this.$store.commit("updateIssuesOther", response);
|
|
||||||
|
|
||||||
/* Add in list the issues already associated (if not yet listed) */
|
|
||||||
this.socialIssuesSelected.forEach((issue) => {
|
|
||||||
if (
|
|
||||||
this.socialIssuesList.filter((i) => i.id === issue.id).length !== 1
|
|
||||||
) {
|
|
||||||
this.$store.commit("addIssueInList", issue);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
/* Remove from multiselect the issues that are not yet in the checkbox list */
|
const socialIssuesEl = document.querySelector(
|
||||||
this.socialIssuesList.forEach((issue) => {
|
"input#chill_activitybundle_activity_socialIssues",
|
||||||
this.$store.commit("removeIssueInOther", issue);
|
);
|
||||||
});
|
if (socialIssuesEl && socialIssuesEl.hasAttribute("required")) {
|
||||||
|
this.socialIssuesClassList.required = true;
|
||||||
|
}
|
||||||
|
|
||||||
/* Filter issues */
|
/* Load other issues in multiselect */
|
||||||
this.$store.commit("filterList", "issues");
|
this.issueIsLoading = true;
|
||||||
|
this.actionAreLoaded = false;
|
||||||
|
|
||||||
/* Add in list the actions already associated (if not yet listed) */
|
getSocialIssues().then((response) => {
|
||||||
this.socialActionsSelected.forEach((action) => {
|
/* Add issues to the store */
|
||||||
this.$store.commit("addActionInList", action);
|
this.$store.commit("updateIssuesOther", response);
|
||||||
});
|
|
||||||
|
|
||||||
/* Filter actions */
|
/* Add in list the issues already associated (if not yet listed) */
|
||||||
this.$store.commit("filterList", "actions");
|
this.socialIssuesSelected.forEach((issue) => {
|
||||||
|
if (
|
||||||
|
this.socialIssuesList.filter((i) => i.id === issue.id)
|
||||||
|
.length !== 1
|
||||||
|
) {
|
||||||
|
this.$store.commit("addIssueInList", issue);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.issueIsLoading = false;
|
/* Remove from multiselect the issues that are not yet in the checkbox list */
|
||||||
this.actionAreLoaded = true;
|
this.socialIssuesList.forEach((issue) => {
|
||||||
this.updateActionsList();
|
this.$store.commit("removeIssueInOther", issue);
|
||||||
});
|
});
|
||||||
},
|
|
||||||
methods: {
|
/* Filter issues */
|
||||||
/* When choosing an issue in multiselect, add it in checkboxes (as selected),
|
this.$store.commit("filterList", "issues");
|
||||||
|
|
||||||
|
/* Add in list the actions already associated (if not yet listed) */
|
||||||
|
this.socialActionsSelected.forEach((action) => {
|
||||||
|
this.$store.commit("addActionInList", action);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Filter actions */
|
||||||
|
this.$store.commit("filterList", "actions");
|
||||||
|
|
||||||
|
this.issueIsLoading = false;
|
||||||
|
this.actionAreLoaded = true;
|
||||||
|
this.updateActionsList();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
/* When choosing an issue in multiselect, add it in checkboxes (as selected),
|
||||||
remove it from multiselect, and add socialActions concerned
|
remove it from multiselect, and add socialActions concerned
|
||||||
*/
|
*/
|
||||||
addIssueInList(value) {
|
addIssueInList(value) {
|
||||||
//console.log('addIssueInList', value);
|
//console.log('addIssueInList', value);
|
||||||
this.$store.commit("addIssueInList", value);
|
this.$store.commit("addIssueInList", value);
|
||||||
this.$store.commit("removeIssueInOther", value);
|
this.$store.commit("removeIssueInOther", value);
|
||||||
this.$store.dispatch("addIssueSelected", value);
|
this.$store.dispatch("addIssueSelected", value);
|
||||||
this.updateActionsList();
|
this.updateActionsList();
|
||||||
},
|
},
|
||||||
/* Update value for selected issues checkboxes
|
/* Update value for selected issues checkboxes
|
||||||
*/
|
*/
|
||||||
updateIssuesSelected(issues) {
|
updateIssuesSelected(issues) {
|
||||||
//console.log('updateIssuesSelected', issues);
|
//console.log('updateIssuesSelected', issues);
|
||||||
this.$store.dispatch("updateIssuesSelected", issues);
|
this.$store.dispatch("updateIssuesSelected", issues);
|
||||||
this.updateActionsList();
|
this.updateActionsList();
|
||||||
},
|
},
|
||||||
/* Update value for selected actions checkboxes
|
/* Update value for selected actions checkboxes
|
||||||
*/
|
*/
|
||||||
updateActionsSelected(actions) {
|
updateActionsSelected(actions) {
|
||||||
//console.log('updateActionsSelected', actions);
|
//console.log('updateActionsSelected', actions);
|
||||||
this.$store.dispatch("updateActionsSelected", actions);
|
this.$store.dispatch("updateActionsSelected", actions);
|
||||||
},
|
},
|
||||||
/* Add socialActions concerned: after reset, loop on each issue selected
|
/* Add socialActions concerned: after reset, loop on each issue selected
|
||||||
to get social actions concerned
|
to get social actions concerned
|
||||||
*/
|
*/
|
||||||
updateActionsList() {
|
updateActionsList() {
|
||||||
this.resetActionsList();
|
this.resetActionsList();
|
||||||
this.socialIssuesSelected.forEach((item) => {
|
this.socialIssuesSelected.forEach((item) => {
|
||||||
this.actionIsLoading = true;
|
this.actionIsLoading = true;
|
||||||
getSocialActionByIssue(item.id).then(
|
getSocialActionByIssue(item.id).then(
|
||||||
(actions) =>
|
(actions) =>
|
||||||
new Promise((resolve) => {
|
new Promise((resolve) => {
|
||||||
actions.results.forEach((action) => {
|
actions.results.forEach((action) => {
|
||||||
this.$store.commit("addActionInList", action);
|
this.$store.commit("addActionInList", action);
|
||||||
}, this);
|
}, this);
|
||||||
|
|
||||||
this.$store.commit("filterList", "actions");
|
this.$store.commit("filterList", "actions");
|
||||||
|
|
||||||
this.actionIsLoading = false;
|
this.actionIsLoading = false;
|
||||||
this.actionAreLoaded = true;
|
this.actionAreLoaded = true;
|
||||||
resolve();
|
resolve();
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}, this);
|
}, this);
|
||||||
|
},
|
||||||
|
/* Reset socialActions List: flush list and restore selected actions
|
||||||
|
*/
|
||||||
|
resetActionsList() {
|
||||||
|
this.$store.commit("resetActionsList");
|
||||||
|
this.actionAreLoaded = false;
|
||||||
|
this.socialActionsSelected.forEach((item) => {
|
||||||
|
this.$store.commit("addActionInList", item);
|
||||||
|
}, this);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
/* Reset socialActions List: flush list and restore selected actions
|
|
||||||
*/
|
|
||||||
resetActionsList() {
|
|
||||||
this.$store.commit("resetActionsList");
|
|
||||||
this.actionAreLoaded = false;
|
|
||||||
this.socialActionsSelected.forEach((item) => {
|
|
||||||
this.$store.commit("addActionInList", item);
|
|
||||||
}, this);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -257,18 +284,18 @@ export default {
|
|||||||
@import "ChillMainAssets/chill/scss/chill_variables";
|
@import "ChillMainAssets/chill/scss/chill_variables";
|
||||||
|
|
||||||
span.multiselect__single {
|
span.multiselect__single {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
#actionsList {
|
#actionsList {
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
margin: 0.5rem;
|
margin: 0.5rem;
|
||||||
background-color: whitesmoke;
|
background-color: whitesmoke;
|
||||||
}
|
}
|
||||||
|
|
||||||
span.badge {
|
span.badge {
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
@include badge_social($social-issue-color);
|
@include badge_social($social-issue-color);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ Attendee: Présence de l'usager
|
|||||||
attendee: présence de l'usager
|
attendee: présence de l'usager
|
||||||
list_reasons: liste des sujets
|
list_reasons: liste des sujets
|
||||||
user_username: nom de l'utilisateur
|
user_username: nom de l'utilisateur
|
||||||
circle_name: nom du cercle
|
circle_name: nom du service
|
||||||
Remark: Commentaire
|
Remark: Commentaire
|
||||||
No comments: Aucun commentaire
|
No comments: Aucun commentaire
|
||||||
Add a new activity: Ajouter une nouvel échange
|
Add a new activity: Ajouter une nouvel échange
|
||||||
@@ -20,7 +20,7 @@ not present: absent
|
|||||||
Delete: Supprimer
|
Delete: Supprimer
|
||||||
Update: Mettre à jour
|
Update: Mettre à jour
|
||||||
Update activity: Modifier l'échange
|
Update activity: Modifier l'échange
|
||||||
Scope: Cercle
|
Scope: Service
|
||||||
Activity data: Données de l'échange
|
Activity data: Données de l'échange
|
||||||
Activity location: Localisation de l'échange
|
Activity location: Localisation de l'échange
|
||||||
No reason associated: Aucun sujet
|
No reason associated: Aucun sujet
|
||||||
@@ -398,13 +398,15 @@ export:
|
|||||||
sent received: Envoyé ou reçu
|
sent received: Envoyé ou reçu
|
||||||
emergency: Urgence
|
emergency: Urgence
|
||||||
accompanying course id: Identifiant du parcours
|
accompanying course id: Identifiant du parcours
|
||||||
course circles: Cercles du parcours
|
course circles: Services du parcours
|
||||||
travelTime: Durée de déplacement
|
travelTime: Durée de déplacement
|
||||||
durationTime: Durée
|
durationTime: Durée
|
||||||
id: Identifiant
|
id: Identifiant
|
||||||
List activities linked to an accompanying course: Liste les échanges liés à un parcours en fonction de différents filtres.
|
List activities linked to an accompanying course: Liste les échanges liés à un parcours en fonction de différents filtres.
|
||||||
List activity linked to a course: Liste des échanges liés à un parcours
|
List activity linked to a course: Liste des échanges liés à un parcours
|
||||||
|
commentText: Commentaire
|
||||||
|
comment_date: Date de la dernière édition du commentaire
|
||||||
|
comment_user: Dernière édition par
|
||||||
|
|
||||||
filter:
|
filter:
|
||||||
activity:
|
activity:
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ final class ChillAsideActivityExtension extends Extension implements PrependExte
|
|||||||
$config = $this->processConfiguration($configuration, $configs);
|
$config = $this->processConfiguration($configuration, $configs);
|
||||||
|
|
||||||
$container->setParameter('chill_aside_activity.form.time_duration', $config['form']['time_duration']);
|
$container->setParameter('chill_aside_activity.form.time_duration', $config['form']['time_duration']);
|
||||||
|
$container->setParameter('chill_aside_activity.show_concerned_persons_count', 'visible' === $config['show_concerned_persons_count']);
|
||||||
|
|
||||||
$loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../config'));
|
$loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../config'));
|
||||||
$loader->load('services.yaml');
|
$loader->load('services.yaml');
|
||||||
@@ -38,6 +39,24 @@ final class ChillAsideActivityExtension extends Extension implements PrependExte
|
|||||||
{
|
{
|
||||||
$this->prependRoute($container);
|
$this->prependRoute($container);
|
||||||
$this->prependCruds($container);
|
$this->prependCruds($container);
|
||||||
|
$this->prependTwigConfig($container);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prependTwigConfig(ContainerBuilder $container)
|
||||||
|
{
|
||||||
|
// Get the configuration for this bundle
|
||||||
|
$chillAsideActivityConfig = $container->getExtensionConfig($this->getAlias());
|
||||||
|
$config = $this->processConfiguration($this->getConfiguration($chillAsideActivityConfig, $container), $chillAsideActivityConfig);
|
||||||
|
|
||||||
|
// Add configuration to twig globals
|
||||||
|
$twigConfig = [
|
||||||
|
'globals' => [
|
||||||
|
'chill_aside_activity_config' => [
|
||||||
|
'show_concerned_persons_count' => 'visible' === $config['show_concerned_persons_count'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
$container->prependExtensionConfig('twig', $twigConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function prependCruds(ContainerBuilder $container)
|
protected function prependCruds(ContainerBuilder $container)
|
||||||
|
|||||||
@@ -141,6 +141,12 @@ class Configuration implements ConfigurationInterface
|
|||||||
->end()
|
->end()
|
||||||
->end()
|
->end()
|
||||||
->end()
|
->end()
|
||||||
|
->end()
|
||||||
|
->enumNode('show_concerned_persons_count')
|
||||||
|
->values(['hidden', 'visible'])
|
||||||
|
->defaultValue('hidden')
|
||||||
|
->info('Show the concerned persons count field in aside activity forms and views')
|
||||||
|
->end()
|
||||||
->end();
|
->end();
|
||||||
|
|
||||||
return $treeBuilder;
|
return $treeBuilder;
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ class AsideActivity implements TrackCreationInterface, TrackUpdateInterface
|
|||||||
#[ORM\ManyToOne(targetEntity: User::class)]
|
#[ORM\ManyToOne(targetEntity: User::class)]
|
||||||
private User $updatedBy;
|
private User $updatedBy;
|
||||||
|
|
||||||
|
#[Assert\GreaterThanOrEqual(0)]
|
||||||
|
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: true)]
|
||||||
|
private ?int $concernedPersonsCount = 0;
|
||||||
|
|
||||||
public function getAgent(): ?User
|
public function getAgent(): ?User
|
||||||
{
|
{
|
||||||
return $this->agent;
|
return $this->agent;
|
||||||
@@ -186,4 +190,16 @@ class AsideActivity implements TrackCreationInterface, TrackUpdateInterface
|
|||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getConcernedPersonsCount(): ?int
|
||||||
|
{
|
||||||
|
return $this->concernedPersonsCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setConcernedPersonsCount(?int $concernedPersonsCount): self
|
||||||
|
{
|
||||||
|
$this->concernedPersonsCount = $concernedPersonsCount;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\AsideActivityBundle\Export\Aggregator;
|
||||||
|
|
||||||
|
use Chill\AsideActivityBundle\Export\Declarations;
|
||||||
|
use Chill\MainBundle\Export\AggregatorInterface;
|
||||||
|
use Doctrine\ORM\QueryBuilder;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
|
||||||
|
class ByConcernedPersonsCountAggregator implements AggregatorInterface
|
||||||
|
{
|
||||||
|
public function addRole(): ?string
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function alterQuery(QueryBuilder $qb, $data, \Chill\MainBundle\Export\ExportGenerationContext $exportGenerationContext): void
|
||||||
|
{
|
||||||
|
$qb->addSelect('aside.concernedPersonsCount AS by_concerned_persons_count_aggregator')
|
||||||
|
->addGroupBy('by_concerned_persons_count_aggregator');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function applyOn(): string
|
||||||
|
{
|
||||||
|
return Declarations::ASIDE_ACTIVITY_TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function buildForm(FormBuilderInterface $builder): void
|
||||||
|
{
|
||||||
|
// No form needed
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getNormalizationVersion(): int
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function normalizeFormData(array $formData): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function denormalizeFormData(array $formData, int $fromVersion): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFormDefaultData(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLabels($key, array $values, $data): callable
|
||||||
|
{
|
||||||
|
return function ($value): string {
|
||||||
|
if ('_header' === $value) {
|
||||||
|
return 'export.aggregator.Concerned persons count';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $value) {
|
||||||
|
return 'export.aggregator.No concerned persons count specified';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) $value;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getQueryKeys($data): array
|
||||||
|
{
|
||||||
|
return ['by_concerned_persons_count_aggregator'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTitle(): string
|
||||||
|
{
|
||||||
|
return 'export.aggregator.Group by concerned persons count';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\AsideActivityBundle\Export\Export;
|
||||||
|
|
||||||
|
use Chill\AsideActivityBundle\Export\Declarations;
|
||||||
|
use Chill\AsideActivityBundle\Repository\AsideActivityRepository;
|
||||||
|
use Chill\AsideActivityBundle\Security\AsideActivityVoter;
|
||||||
|
use Chill\MainBundle\Export\ExportInterface;
|
||||||
|
use Chill\MainBundle\Export\FormatterInterface;
|
||||||
|
use Chill\MainBundle\Export\GroupedExportInterface;
|
||||||
|
use Doctrine\ORM\Query;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
|
||||||
|
class SumConcernedPersonsCountAsideActivity implements ExportInterface, GroupedExportInterface
|
||||||
|
{
|
||||||
|
public function __construct(private readonly AsideActivityRepository $repository) {}
|
||||||
|
|
||||||
|
public function buildForm(FormBuilderInterface $builder) {}
|
||||||
|
|
||||||
|
public function getNormalizationVersion(): int
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function normalizeFormData(array $formData): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function denormalizeFormData(array $formData, int $fromVersion): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFormDefaultData(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAllowedFormattersTypes(): array
|
||||||
|
{
|
||||||
|
return [FormatterInterface::TYPE_TABULAR];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'export.Sum concerned persons count for aside activities';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getGroup(): string
|
||||||
|
{
|
||||||
|
return 'export.Exports of aside activities';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLabels($key, array $values, $data)
|
||||||
|
{
|
||||||
|
if ('export_sum_concerned_persons_count' !== $key) {
|
||||||
|
throw new \LogicException("the key {$key} is not used by this export");
|
||||||
|
}
|
||||||
|
|
||||||
|
$labels = array_combine($values, $values);
|
||||||
|
$labels['_header'] = $this->getTitle();
|
||||||
|
|
||||||
|
return static fn ($value) => $labels[$value];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getQueryKeys($data): array
|
||||||
|
{
|
||||||
|
return ['export_sum_concerned_persons_count'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getResult($query, $data, \Chill\MainBundle\Export\ExportGenerationContext $context): array
|
||||||
|
{
|
||||||
|
return $query->getQuery()->getResult(Query::HYDRATE_SCALAR);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTitle(): string
|
||||||
|
{
|
||||||
|
return 'export.Sum concerned persons count for aside activities';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getType(): string
|
||||||
|
{
|
||||||
|
return Declarations::ASIDE_ACTIVITY_TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function initiateQuery(array $requiredModifiers, array $acl, array $data, \Chill\MainBundle\Export\ExportGenerationContext $context): \Doctrine\ORM\QueryBuilder
|
||||||
|
{
|
||||||
|
$qb = $this->repository->createQueryBuilder('aside');
|
||||||
|
|
||||||
|
$qb->select('SUM(COALESCE(aside.concernedPersonsCount, 0)) as export_sum_concerned_persons_count');
|
||||||
|
|
||||||
|
return $qb;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requiredRole(): string
|
||||||
|
{
|
||||||
|
return AsideActivityVoter::STATS;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function supportsModifiers(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Declarations::ASIDE_ACTIVITY_TYPE,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
|
|||||||
use Symfony\Component\Form\AbstractType;
|
use Symfony\Component\Form\AbstractType;
|
||||||
use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeToTimestampTransformer;
|
use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeToTimestampTransformer;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
|
||||||
use Symfony\Component\Form\FormBuilderInterface;
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
use Symfony\Component\Form\FormEvent;
|
use Symfony\Component\Form\FormEvent;
|
||||||
use Symfony\Component\Form\FormEvents;
|
use Symfony\Component\Form\FormEvents;
|
||||||
@@ -29,11 +30,13 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
|
|||||||
final class AsideActivityFormType extends AbstractType
|
final class AsideActivityFormType extends AbstractType
|
||||||
{
|
{
|
||||||
private readonly array $timeChoices;
|
private readonly array $timeChoices;
|
||||||
|
private readonly bool $showConcernedPersonsCount;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
ParameterBagInterface $parameterBag,
|
ParameterBagInterface $parameterBag,
|
||||||
) {
|
) {
|
||||||
$this->timeChoices = $parameterBag->get('chill_aside_activity.form.time_duration');
|
$this->timeChoices = $parameterBag->get('chill_aside_activity.form.time_duration');
|
||||||
|
$this->showConcernedPersonsCount = $parameterBag->get('chill_aside_activity.show_concerned_persons_count');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function buildForm(FormBuilderInterface $builder, array $options)
|
public function buildForm(FormBuilderInterface $builder, array $options)
|
||||||
@@ -76,6 +79,16 @@ final class AsideActivityFormType extends AbstractType
|
|||||||
->add('location', PickUserLocationType::class)
|
->add('location', PickUserLocationType::class)
|
||||||
;
|
;
|
||||||
|
|
||||||
|
if ($this->showConcernedPersonsCount) {
|
||||||
|
$builder->add('concernedPersonsCount', IntegerType::class, [
|
||||||
|
'label' => 'Concerned persons count',
|
||||||
|
'required' => false,
|
||||||
|
'attr' => [
|
||||||
|
'min' => 0,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
foreach (['duration'] as $fieldName) {
|
foreach (['duration'] as $fieldName) {
|
||||||
$builder->get($fieldName)
|
$builder->get($fieldName)
|
||||||
->addModelTransformer($durationTimeTransformer);
|
->addModelTransformer($durationTimeTransformer);
|
||||||
|
|||||||
@@ -42,6 +42,11 @@
|
|||||||
{%- if entity.location.name is defined -%}
|
{%- if entity.location.name is defined -%}
|
||||||
<div><i class="fa fa-fw fa-map-marker"></i>{{ entity.location.name }}</div>
|
<div><i class="fa fa-fw fa-map-marker"></i>{{ entity.location.name }}</div>
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
|
|
||||||
|
{%- if entity.concernedPersonsCount > 0 -%}
|
||||||
|
<div><i class="fa fa-fw fa-user"></i>{{ entity.concernedPersonsCount }}</div>
|
||||||
|
{%- endif -%}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="item-col" style="justify-content: flex-end;">
|
<div class="item-col" style="justify-content: flex-end;">
|
||||||
<div class="box">
|
<div class="box">
|
||||||
|
|||||||
@@ -38,6 +38,11 @@
|
|||||||
<dt class="inline">{{ 'Duration'|trans }}</dt>
|
<dt class="inline">{{ 'Duration'|trans }}</dt>
|
||||||
<dd>{{ entity.duration|date('H:i') }}</dd>
|
<dd>{{ entity.duration|date('H:i') }}</dd>
|
||||||
|
|
||||||
|
{% if chill_aside_activity_config.show_concerned_persons_count == 'visible' %}
|
||||||
|
<dt class="inline">{{ 'Concerned persons count'|trans }}</dt>
|
||||||
|
<dd>{{ entity.concernedPersonsCount }}</dd>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<dt class="inline">{{ 'Remark'|trans }}</dt>
|
<dt class="inline">{{ 'Remark'|trans }}</dt>
|
||||||
{%- if entity.note is empty -%}
|
{%- if entity.note is empty -%}
|
||||||
<dd>
|
<dd>
|
||||||
@@ -55,5 +60,6 @@
|
|||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
{% block content_view_actions_duplicate_link %}{% endblock %}
|
||||||
{% endembed %}
|
{% endembed %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\AsideActivityBundle\Tests\Export\Aggregator;
|
||||||
|
|
||||||
|
use Chill\AsideActivityBundle\Entity\AsideActivity;
|
||||||
|
use Chill\AsideActivityBundle\Export\Aggregator\ByConcernedPersonsCountAggregator;
|
||||||
|
use Chill\MainBundle\Test\Export\AbstractAggregatorTest;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*
|
||||||
|
* @coversNothing
|
||||||
|
*/
|
||||||
|
class ByConcernedPersonsCountAggregatorTest extends AbstractAggregatorTest
|
||||||
|
{
|
||||||
|
public function getAggregator()
|
||||||
|
{
|
||||||
|
return new ByConcernedPersonsCountAggregator();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getFormData(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
[],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getQueryBuilders(): iterable
|
||||||
|
{
|
||||||
|
self::bootKernel();
|
||||||
|
$em = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
|
||||||
|
return [
|
||||||
|
$em->createQueryBuilder()
|
||||||
|
->select('count(aside.id)')
|
||||||
|
->from(AsideActivity::class, 'aside'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\AsideActivityBundle\Tests\Export\Export;
|
||||||
|
|
||||||
|
use Chill\AsideActivityBundle\Export\Export\SumConcernedPersonsCountAsideActivity;
|
||||||
|
use Chill\AsideActivityBundle\Repository\AsideActivityRepository;
|
||||||
|
use Chill\MainBundle\Test\Export\AbstractExportTest;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*
|
||||||
|
* @coversNothing
|
||||||
|
*/
|
||||||
|
final class SumConcernedPersonsCountAsideActivityTest extends AbstractExportTest
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
self::bootKernel();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getExport()
|
||||||
|
{
|
||||||
|
$repository = self::getContainer()->get(AsideActivityRepository::class);
|
||||||
|
|
||||||
|
yield new SumConcernedPersonsCountAsideActivity($repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getFormData(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
[],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getModifiersCombination(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
['aside_activity'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,10 @@ services:
|
|||||||
tags:
|
tags:
|
||||||
- { name: chill.export, alias: 'avg_aside_activity_duration' }
|
- { name: chill.export, alias: 'avg_aside_activity_duration' }
|
||||||
|
|
||||||
|
Chill\AsideActivityBundle\Export\Export\SumConcernedPersonsCountAsideActivity:
|
||||||
|
tags:
|
||||||
|
- { name: chill.export, alias: 'sum_aside_activity_concerned_persons_count' }
|
||||||
|
|
||||||
## Filters
|
## Filters
|
||||||
chill.aside_activity.export.date_filter:
|
chill.aside_activity.export.date_filter:
|
||||||
class: Chill\AsideActivityBundle\Export\Filter\ByDateFilter
|
class: Chill\AsideActivityBundle\Export\Filter\ByDateFilter
|
||||||
@@ -70,3 +74,7 @@ services:
|
|||||||
Chill\AsideActivityBundle\Export\Aggregator\ByLocationAggregator:
|
Chill\AsideActivityBundle\Export\Aggregator\ByLocationAggregator:
|
||||||
tags:
|
tags:
|
||||||
- { name: chill.export_aggregator, alias: 'aside_activity_location_aggregator' }
|
- { name: chill.export_aggregator, alias: 'aside_activity_location_aggregator' }
|
||||||
|
|
||||||
|
Chill\AsideActivityBundle\Export\Aggregator\ByConcernedPersonsCountAggregator:
|
||||||
|
tags:
|
||||||
|
- { name: chill.export_aggregator, alias: 'aside_activity_concerned_persons_count_aggregator' }
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\Migrations\AsideActivity;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20251006113048 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add concernedPersonsCount property to AsideActivity entity';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE chill_asideactivity.asideactivity ADD concernedPersonsCount INT DEFAULT 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE chill_asideactivity.AsideActivity DROP concernedPersonsCount');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ Emergency: Urgent
|
|||||||
by: "Par "
|
by: "Par "
|
||||||
location: Lieu
|
location: Lieu
|
||||||
Asideactivity location: Localisation de l'activité
|
Asideactivity location: Localisation de l'activité
|
||||||
|
Concerned persons count: Nombre d'usager concernés
|
||||||
|
|
||||||
# Crud
|
# Crud
|
||||||
crud:
|
crud:
|
||||||
@@ -177,7 +178,7 @@ export:
|
|||||||
agent_id: Utilisateur
|
agent_id: Utilisateur
|
||||||
creator_id: Créateur
|
creator_id: Créateur
|
||||||
main_scope: Service principal de l'utilisateur
|
main_scope: Service principal de l'utilisateur
|
||||||
main_center: Centre principal de l'utilisateur
|
main_center: Territoire principal de l'utilisateur
|
||||||
aside_activity_type: Catégorie d'activité annexe
|
aside_activity_type: Catégorie d'activité annexe
|
||||||
date: Date
|
date: Date
|
||||||
duration: Durée
|
duration: Durée
|
||||||
@@ -190,6 +191,7 @@ export:
|
|||||||
Count aside activities by various parameters.: Compte le nombre d'activités annexes selon divers critères
|
Count aside activities by various parameters.: Compte le nombre d'activités annexes selon divers critères
|
||||||
Average aside activities duration: Durée moyenne des activités annexes
|
Average aside activities duration: Durée moyenne des activités annexes
|
||||||
Sum aside activities duration: Durée des activités annexes
|
Sum aside activities duration: Durée des activités annexes
|
||||||
|
Sum concerned persons count for aside activities: Nombre d'usager concernés par les activités annexes
|
||||||
filter:
|
filter:
|
||||||
Filter by aside activity date: Filtrer les activités annexes par date
|
Filter by aside activity date: Filtrer les activités annexes par date
|
||||||
Filter by aside activity type: Filtrer les activités annexes par type d'activité
|
Filter by aside activity type: Filtrer les activités annexes par type d'activité
|
||||||
@@ -210,6 +212,8 @@ export:
|
|||||||
'Filtered by aside activity location: only %location%': "Filtré par localisation: uniquement %location%"
|
'Filtered by aside activity location: only %location%': "Filtré par localisation: uniquement %location%"
|
||||||
aggregator:
|
aggregator:
|
||||||
Group by aside activity type: Grouper les activités annexes par type d'activité
|
Group by aside activity type: Grouper les activités annexes par type d'activité
|
||||||
|
Group by concerned persons count: Grouper les activités annexes par nombre d'usagers conernés
|
||||||
|
Concerned persons count: Nombre d'usagers concernés
|
||||||
Aside activity type: Type d'activité annexe
|
Aside activity type: Type d'activité annexe
|
||||||
by_user_job:
|
by_user_job:
|
||||||
Aggregate by user job: Grouper les activités annexes par métier des utilisateurs
|
Aggregate by user job: Grouper les activités annexes par métier des utilisateurs
|
||||||
|
|||||||
@@ -1,146 +1,166 @@
|
|||||||
<template>
|
<template>
|
||||||
<teleport to="#mainUser">
|
<teleport to="#mainUser">
|
||||||
<h2 class="chill-red">Utilisateur principal</h2>
|
<h2 class="chill-red">Utilisateur principal</h2>
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<div>
|
||||||
<div v-if="null !== this.$store.getters.getMainUser">
|
<div v-if="null !== this.$store.getters.getMainUser">
|
||||||
<calendar-active :user="this.$store.getters.getMainUser" />
|
<calendar-active :user="this.$store.getters.getMainUser" />
|
||||||
|
</div>
|
||||||
|
<pick-entity
|
||||||
|
:multiple="false"
|
||||||
|
:types="['user']"
|
||||||
|
:uniqid="'main_user_calendar'"
|
||||||
|
:picked="
|
||||||
|
null !== this.$store.getters.getMainUser
|
||||||
|
? [this.$store.getters.getMainUser]
|
||||||
|
: []
|
||||||
|
"
|
||||||
|
:removable-if-set="false"
|
||||||
|
:display-picked="false"
|
||||||
|
:suggested="this.suggestedUsers"
|
||||||
|
:label="'main_user'"
|
||||||
|
@add-new-entity="setMainUser"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<pick-entity
|
</teleport>
|
||||||
:multiple="false"
|
|
||||||
:types="['user']"
|
|
||||||
:uniqid="'main_user_calendar'"
|
|
||||||
:picked="
|
|
||||||
null !== this.$store.getters.getMainUser
|
|
||||||
? [this.$store.getters.getMainUser]
|
|
||||||
: []
|
|
||||||
"
|
|
||||||
:removable-if-set="false"
|
|
||||||
:display-picked="false"
|
|
||||||
:suggested="this.suggestedUsers"
|
|
||||||
:label="'main_user'"
|
|
||||||
@add-new-entity="setMainUser"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</teleport>
|
|
||||||
|
|
||||||
<concerned-groups />
|
<concerned-groups />
|
||||||
|
|
||||||
<teleport to="#schedule">
|
<teleport to="#schedule">
|
||||||
<div class="row mb-3" v-if="activity.startDate !== null">
|
<div class="row mb-3" v-if="activity.startDate !== null">
|
||||||
<label class="col-form-label col-sm-4">Date</label>
|
<label class="col-form-label col-sm-4">Date</label>
|
||||||
<div class="col-sm-8">
|
<div class="col-sm-8">
|
||||||
{{ $d(activity.startDate, "long") }} -
|
{{ $d(activity.startDate, "long") }} -
|
||||||
{{ $d(activity.endDate, "hoursOnly") }}
|
{{ $d(activity.endDate, "hoursOnly") }}
|
||||||
<span v-if="activity.calendarRange === null"
|
<span v-if="activity.calendarRange === null"
|
||||||
>(Pas de plage de disponibilité sélectionnée)</span
|
>(Pas de plage de disponibilité sélectionnée)</span
|
||||||
|
>
|
||||||
|
<span v-else>(Une plage de disponibilité sélectionnée)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</teleport>
|
||||||
|
|
||||||
|
<location />
|
||||||
|
|
||||||
|
<teleport to="#fullCalendar">
|
||||||
|
<div class="calendar-actives">
|
||||||
|
<template v-for="u in getActiveUsers" :key="u.id">
|
||||||
|
<calendar-active
|
||||||
|
:user="u"
|
||||||
|
:invite="this.$store.getters.getInviteForUser(u)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="display-options row justify-content-between"
|
||||||
|
style="margin-top: 1rem"
|
||||||
>
|
>
|
||||||
<span v-else>(Une plage de disponibilité sélectionnée)</span>
|
<div class="col-sm-9 col-xs-12">
|
||||||
</div>
|
<div class="input-group mb-3">
|
||||||
</div>
|
<label class="input-group-text" for="slotDuration"
|
||||||
</teleport>
|
>Durée des créneaux</label
|
||||||
|
>
|
||||||
<location />
|
<select
|
||||||
|
v-model="slotDuration"
|
||||||
<teleport to="#fullCalendar">
|
id="slotDuration"
|
||||||
<div class="calendar-actives">
|
class="form-select"
|
||||||
<template v-for="u in getActiveUsers" :key="u.id">
|
>
|
||||||
<calendar-active
|
<option value="00:05:00">5 minutes</option>
|
||||||
:user="u"
|
<option value="00:10:00">10 minutes</option>
|
||||||
:invite="this.$store.getters.getInviteForUser(u)"
|
<option value="00:15:00">15 minutes</option>
|
||||||
/>
|
<option value="00:30:00">30 minutes</option>
|
||||||
</template>
|
<option value="00:45:00">45 minutes</option>
|
||||||
</div>
|
<option value="00:60:00">60 minutes</option>
|
||||||
<div
|
</select>
|
||||||
class="display-options row justify-content-between"
|
<label class="input-group-text" for="slotMinTime">De</label>
|
||||||
style="margin-top: 1rem"
|
<select
|
||||||
>
|
v-model="slotMinTime"
|
||||||
<div class="col-sm-9 col-xs-12">
|
id="slotMinTime"
|
||||||
<div class="input-group mb-3">
|
class="form-select"
|
||||||
<label class="input-group-text" for="slotDuration"
|
>
|
||||||
>Durée des créneaux</label
|
<option value="00:00:00">0h</option>
|
||||||
>
|
<option value="01:00:00">1h</option>
|
||||||
<select v-model="slotDuration" id="slotDuration" class="form-select">
|
<option value="02:00:00">2h</option>
|
||||||
<option value="00:05:00">5 minutes</option>
|
<option value="03:00:00">3h</option>
|
||||||
<option value="00:10:00">10 minutes</option>
|
<option value="04:00:00">4h</option>
|
||||||
<option value="00:15:00">15 minutes</option>
|
<option value="05:00:00">5h</option>
|
||||||
<option value="00:30:00">30 minutes</option>
|
<option value="06:00:00">6h</option>
|
||||||
</select>
|
<option value="07:00:00">7h</option>
|
||||||
<label class="input-group-text" for="slotMinTime">De</label>
|
<option value="08:00:00">8h</option>
|
||||||
<select v-model="slotMinTime" id="slotMinTime" class="form-select">
|
<option value="09:00:00">9h</option>
|
||||||
<option value="00:00:00">0h</option>
|
<option value="10:00:00">10h</option>
|
||||||
<option value="01:00:00">1h</option>
|
<option value="11:00:00">11h</option>
|
||||||
<option value="02:00:00">2h</option>
|
<option value="12:00:00">12h</option>
|
||||||
<option value="03:00:00">3h</option>
|
</select>
|
||||||
<option value="04:00:00">4h</option>
|
<label class="input-group-text" for="slotMaxTime">À</label>
|
||||||
<option value="05:00:00">5h</option>
|
<select
|
||||||
<option value="06:00:00">6h</option>
|
v-model="slotMaxTime"
|
||||||
<option value="07:00:00">7h</option>
|
id="slotMaxTime"
|
||||||
<option value="08:00:00">8h</option>
|
class="form-select"
|
||||||
<option value="09:00:00">9h</option>
|
>
|
||||||
<option value="10:00:00">10h</option>
|
<option value="12:00:00">12h</option>
|
||||||
<option value="11:00:00">11h</option>
|
<option value="13:00:00">13h</option>
|
||||||
<option value="12:00:00">12h</option>
|
<option value="14:00:00">14h</option>
|
||||||
</select>
|
<option value="15:00:00">15h</option>
|
||||||
<label class="input-group-text" for="slotMaxTime">À</label>
|
<option value="16:00:00">16h</option>
|
||||||
<select v-model="slotMaxTime" id="slotMaxTime" class="form-select">
|
<option value="17:00:00">17h</option>
|
||||||
<option value="12:00:00">12h</option>
|
<option value="18:00:00">18h</option>
|
||||||
<option value="13:00:00">13h</option>
|
<option value="19:00:00">19h</option>
|
||||||
<option value="14:00:00">14h</option>
|
<option value="20:00:00">20h</option>
|
||||||
<option value="15:00:00">15h</option>
|
<option value="21:00:00">21h</option>
|
||||||
<option value="16:00:00">16h</option>
|
<option value="22:00:00">22h</option>
|
||||||
<option value="17:00:00">17h</option>
|
<option value="23:00:00">23h</option>
|
||||||
<option value="18:00:00">18h</option>
|
<option value="23:59:59">24h</option>
|
||||||
<option value="19:00:00">19h</option>
|
</select>
|
||||||
<option value="20:00:00">20h</option>
|
</div>
|
||||||
<option value="21:00:00">21h</option>
|
</div>
|
||||||
<option value="22:00:00">22h</option>
|
<div class="col-sm-3 col-xs-12">
|
||||||
<option value="23:00:00">23h</option>
|
<div class="float-end">
|
||||||
<option value="23:59:59">24h</option>
|
<div class="form-check input-group">
|
||||||
</select>
|
<span class="input-group-text">
|
||||||
|
<input
|
||||||
|
id="showHideWE"
|
||||||
|
class="mt-0"
|
||||||
|
type="checkbox"
|
||||||
|
v-model="hideWeekends"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<label
|
||||||
|
for="showHideWE"
|
||||||
|
class="form-check-label input-group-text"
|
||||||
|
>Week-ends</label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<FullCalendar ref="fullCalendar" :options="calendarOptions">
|
||||||
<div class="col-sm-3 col-xs-12">
|
<template #eventContent="arg">
|
||||||
<div class="float-end">
|
<span>
|
||||||
<div class="form-check input-group">
|
<b v-if="arg.event.extendedProps.is === 'remote'">{{
|
||||||
<span class="input-group-text">
|
arg.event.title
|
||||||
<input
|
}}</b>
|
||||||
id="showHideWE"
|
<b v-else-if="arg.event.extendedProps.is === 'range'"
|
||||||
class="mt-0"
|
>{{ arg.timeText }}
|
||||||
type="checkbox"
|
{{ arg.event.extendedProps.locationName }}
|
||||||
v-model="hideWeekends"
|
<small>{{
|
||||||
/>
|
arg.event.extendedProps.userLabel
|
||||||
</span>
|
}}</small></b
|
||||||
<label for="showHideWE" class="form-check-label input-group-text"
|
>
|
||||||
>Week-ends</label
|
<b v-else-if="arg.event.extendedProps.is === 'current'"
|
||||||
>
|
>{{ arg.timeText }} {{ $t("current_selected") }}
|
||||||
</div>
|
</b>
|
||||||
</div>
|
<b v-else-if="arg.event.extendedProps.is === 'local'">{{
|
||||||
</div>
|
arg.event.title
|
||||||
</div>
|
}}</b>
|
||||||
<FullCalendar ref="fullCalendar" :options="calendarOptions">
|
<b v-else
|
||||||
<template #eventContent="arg">
|
>{{ arg.timeText }} {{ $t("current_selected") }}
|
||||||
<span>
|
</b>
|
||||||
<b v-if="arg.event.extendedProps.is === 'remote'">{{
|
</span>
|
||||||
arg.event.title
|
</template>
|
||||||
}}</b>
|
</FullCalendar>
|
||||||
<b v-else-if="arg.event.extendedProps.is === 'range'"
|
</teleport>
|
||||||
>{{ arg.timeText }}
|
|
||||||
{{ arg.event.extendedProps.locationName }}
|
|
||||||
<small>{{ arg.event.extendedProps.userLabel }}</small></b
|
|
||||||
>
|
|
||||||
<b v-else-if="arg.event.extendedProps.is === 'current'"
|
|
||||||
>{{ arg.timeText }} {{ $t("current_selected") }}
|
|
||||||
</b>
|
|
||||||
<b v-else-if="arg.event.extendedProps.is === 'local'">{{
|
|
||||||
arg.event.title
|
|
||||||
}}</b>
|
|
||||||
<b v-else>{{ arg.timeText }} {{ $t("current_selected") }} </b>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</FullCalendar>
|
|
||||||
</teleport>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -157,210 +177,219 @@ import PickEntity from "ChillMainAssets/vuejs/PickEntity/PickEntity.vue";
|
|||||||
import { mapGetters, mapState } from "vuex";
|
import { mapGetters, mapState } from "vuex";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "App",
|
name: "App",
|
||||||
components: {
|
components: {
|
||||||
ConcernedGroups,
|
ConcernedGroups,
|
||||||
Location,
|
Location,
|
||||||
FullCalendar,
|
FullCalendar,
|
||||||
CalendarActive,
|
CalendarActive,
|
||||||
PickEntity,
|
PickEntity,
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
errorMsg: [],
|
|
||||||
showMyCalendar: false,
|
|
||||||
slotDuration: "00:05:00",
|
|
||||||
slotMinTime: "09:00:00",
|
|
||||||
slotMaxTime: "18:00:00",
|
|
||||||
hideWeekEnds: true,
|
|
||||||
previousUser: [],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
...mapGetters(["getMainUser"]),
|
|
||||||
...mapState(["activity"]),
|
|
||||||
events() {
|
|
||||||
return this.$store.getters.getEventSources;
|
|
||||||
},
|
},
|
||||||
calendarOptions() {
|
data() {
|
||||||
return {
|
return {
|
||||||
locale: frLocale,
|
errorMsg: [],
|
||||||
plugins: [
|
showMyCalendar: false,
|
||||||
dayGridPlugin,
|
slotDuration: "00:05:00",
|
||||||
interactionPlugin,
|
slotMinTime: "09:00:00",
|
||||||
timeGridPlugin,
|
slotMaxTime: "18:00:00",
|
||||||
dayGridPlugin,
|
hideWeekEnds: true,
|
||||||
listPlugin,
|
previousUser: [],
|
||||||
],
|
};
|
||||||
initialView: "timeGridWeek",
|
},
|
||||||
initialDate: this.$store.getters.getInitialDate,
|
computed: {
|
||||||
eventSources: this.events,
|
...mapGetters(["getMainUser"]),
|
||||||
selectable: true,
|
...mapState(["activity"]),
|
||||||
slotMinTime: this.slotMinTime,
|
events() {
|
||||||
slotMaxTime: this.slotMaxTime,
|
return this.$store.getters.getEventSources;
|
||||||
scrollTimeReset: false,
|
|
||||||
datesSet: this.onDatesSet,
|
|
||||||
select: this.onDateSelect,
|
|
||||||
eventChange: this.onEventChange,
|
|
||||||
eventClick: this.onEventClick,
|
|
||||||
selectMirror: true,
|
|
||||||
editable: true,
|
|
||||||
weekends: !this.hideWeekEnds,
|
|
||||||
headerToolbar: {
|
|
||||||
left: "prev,next today",
|
|
||||||
center: "title",
|
|
||||||
right: "timeGridWeek,timeGridDay,listWeek",
|
|
||||||
},
|
},
|
||||||
views: {
|
calendarOptions() {
|
||||||
timeGrid: {
|
return {
|
||||||
slotEventOverlap: false,
|
locale: frLocale,
|
||||||
slotDuration: this.slotDuration,
|
plugins: [
|
||||||
},
|
dayGridPlugin,
|
||||||
|
interactionPlugin,
|
||||||
|
timeGridPlugin,
|
||||||
|
dayGridPlugin,
|
||||||
|
listPlugin,
|
||||||
|
],
|
||||||
|
initialView: "timeGridWeek",
|
||||||
|
initialDate: this.$store.getters.getInitialDate,
|
||||||
|
eventSources: this.events,
|
||||||
|
selectable: true,
|
||||||
|
slotMinTime: this.slotMinTime,
|
||||||
|
slotMaxTime: this.slotMaxTime,
|
||||||
|
scrollTimeReset: false,
|
||||||
|
datesSet: this.onDatesSet,
|
||||||
|
select: this.onDateSelect,
|
||||||
|
eventChange: this.onEventChange,
|
||||||
|
eventClick: this.onEventClick,
|
||||||
|
selectMirror: true,
|
||||||
|
editable: true,
|
||||||
|
weekends: !this.hideWeekEnds,
|
||||||
|
headerToolbar: {
|
||||||
|
left: "prev,next today",
|
||||||
|
center: "title",
|
||||||
|
right: "timeGridWeek,timeGridDay,listWeek",
|
||||||
|
},
|
||||||
|
views: {
|
||||||
|
timeGrid: {
|
||||||
|
slotEventOverlap: false,
|
||||||
|
slotDuration: this.slotDuration,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getActiveUsers() {
|
||||||
|
const users = [];
|
||||||
|
for (const id of this.$store.state.currentView.users.keys()) {
|
||||||
|
users.push(this.$store.getters.getUserDataById(id).user);
|
||||||
|
}
|
||||||
|
return users;
|
||||||
|
},
|
||||||
|
suggestedUsers() {
|
||||||
|
const suggested = [];
|
||||||
|
|
||||||
|
this.$data.previousUser.forEach((u) => {
|
||||||
|
if (u.id !== this.$store.getters.getMainUser.id) {
|
||||||
|
suggested.push(u);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return suggested;
|
||||||
},
|
},
|
||||||
};
|
|
||||||
},
|
},
|
||||||
getActiveUsers() {
|
methods: {
|
||||||
const users = [];
|
setMainUser({ entity }) {
|
||||||
for (const id of this.$store.state.currentView.users.keys()) {
|
const user = entity;
|
||||||
users.push(this.$store.getters.getUserDataById(id).user);
|
console.log("setMainUser APP", entity);
|
||||||
}
|
|
||||||
return users;
|
if (
|
||||||
|
user.id !== this.$store.getters.getMainUser &&
|
||||||
|
(this.$store.state.activity.calendarRange !== null ||
|
||||||
|
this.$store.state.activity.startDate !== null ||
|
||||||
|
this.$store.state.activity.endDate !== null)
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
this.$t("change_main_user_will_reset_event_data"),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// add the previous user, if any, in the previous user list (in use for suggestion)
|
||||||
|
if (null !== this.$store.getters.getMainUser) {
|
||||||
|
const suggestedUids = new Set(
|
||||||
|
this.$data.previousUser.map((u) => u.id),
|
||||||
|
);
|
||||||
|
if (!suggestedUids.has(this.$store.getters.getMainUser.id)) {
|
||||||
|
this.$data.previousUser.push(
|
||||||
|
this.$store.getters.getMainUser,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$store.dispatch("setMainUser", user);
|
||||||
|
this.$store.commit("showUserOnCalendar", {
|
||||||
|
user,
|
||||||
|
ranges: true,
|
||||||
|
remotes: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
removeMainUser(user) {
|
||||||
|
console.log("removeMainUser APP", user);
|
||||||
|
|
||||||
|
window.alert(this.$t("main_user_is_mandatory"));
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
onDatesSet(event) {
|
||||||
|
console.log("onDatesSet", event);
|
||||||
|
this.$store.dispatch("setCurrentDatesView", {
|
||||||
|
start: event.start,
|
||||||
|
end: event.end,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onDateSelect(payload) {
|
||||||
|
console.log("onDateSelect", payload);
|
||||||
|
|
||||||
|
// show an alert if changing mainUser
|
||||||
|
if (
|
||||||
|
(this.$store.getters.getMainUser !== null &&
|
||||||
|
this.$store.state.me.id !==
|
||||||
|
this.$store.getters.getMainUser.id) ||
|
||||||
|
this.$store.getters.getMainUser === null
|
||||||
|
) {
|
||||||
|
if (!window.confirm(this.$t("will_change_main_user_for_me"))) {
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
this.$store.commit("showUserOnCalendar", {
|
||||||
|
user: this.$store.state.me,
|
||||||
|
remotes: true,
|
||||||
|
ranges: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$store.dispatch("setEventTimes", {
|
||||||
|
start: payload.start,
|
||||||
|
end: payload.end,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onEventChange(payload) {
|
||||||
|
console.log("onEventChange", payload);
|
||||||
|
if (this.$store.state.activity.calendarRange !== null) {
|
||||||
|
throw new Error(
|
||||||
|
"not allowed to edit a calendar associated with a calendar range",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.$store.dispatch("setEventTimes", {
|
||||||
|
start: payload.event.start,
|
||||||
|
end: payload.event.end,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onEventClick(payload) {
|
||||||
|
if (payload.event.extendedProps.is !== "range") {
|
||||||
|
// do nothing when clicking on remote
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// show an alert if changing mainUser
|
||||||
|
if (
|
||||||
|
this.$store.getters.getMainUser !== null &&
|
||||||
|
payload.event.extendedProps.userId !==
|
||||||
|
this.$store.getters.getMainUser.id
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
this.$t("this_calendar_range_will_change_main_user"),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$store.dispatch("associateCalendarToRange", {
|
||||||
|
range: payload.event,
|
||||||
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
suggestedUsers() {
|
|
||||||
const suggested = [];
|
|
||||||
|
|
||||||
this.$data.previousUser.forEach((u) => {
|
|
||||||
if (u.id !== this.$store.getters.getMainUser.id) {
|
|
||||||
suggested.push(u);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return suggested;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
setMainUser({ entity }) {
|
|
||||||
const user = entity;
|
|
||||||
console.log("setMainUser APP", entity);
|
|
||||||
|
|
||||||
if (
|
|
||||||
user.id !== this.$store.getters.getMainUser &&
|
|
||||||
(this.$store.state.activity.calendarRange !== null ||
|
|
||||||
this.$store.state.activity.startDate !== null ||
|
|
||||||
this.$store.state.activity.endDate !== null)
|
|
||||||
) {
|
|
||||||
if (
|
|
||||||
!window.confirm(this.$t("change_main_user_will_reset_event_data"))
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// add the previous user, if any, in the previous user list (in use for suggestion)
|
|
||||||
if (null !== this.$store.getters.getMainUser) {
|
|
||||||
const suggestedUids = new Set(this.$data.previousUser.map((u) => u.id));
|
|
||||||
if (!suggestedUids.has(this.$store.getters.getMainUser.id)) {
|
|
||||||
this.$data.previousUser.push(this.$store.getters.getMainUser);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$store.dispatch("setMainUser", user);
|
|
||||||
this.$store.commit("showUserOnCalendar", {
|
|
||||||
user,
|
|
||||||
ranges: true,
|
|
||||||
remotes: true,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
removeMainUser(user) {
|
|
||||||
console.log("removeMainUser APP", user);
|
|
||||||
|
|
||||||
window.alert(this.$t("main_user_is_mandatory"));
|
|
||||||
return;
|
|
||||||
},
|
|
||||||
onDatesSet(event) {
|
|
||||||
console.log("onDatesSet", event);
|
|
||||||
this.$store.dispatch("setCurrentDatesView", {
|
|
||||||
start: event.start,
|
|
||||||
end: event.end,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onDateSelect(payload) {
|
|
||||||
console.log("onDateSelect", payload);
|
|
||||||
|
|
||||||
// show an alert if changing mainUser
|
|
||||||
if (
|
|
||||||
(this.$store.getters.getMainUser !== null &&
|
|
||||||
this.$store.state.me.id !== this.$store.getters.getMainUser.id) ||
|
|
||||||
this.$store.getters.getMainUser === null
|
|
||||||
) {
|
|
||||||
if (!window.confirm(this.$t("will_change_main_user_for_me"))) {
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
this.$store.commit("showUserOnCalendar", {
|
|
||||||
user: this.$store.state.me,
|
|
||||||
remotes: true,
|
|
||||||
ranges: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$store.dispatch("setEventTimes", {
|
|
||||||
start: payload.start,
|
|
||||||
end: payload.end,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onEventChange(payload) {
|
|
||||||
console.log("onEventChange", payload);
|
|
||||||
if (this.$store.state.activity.calendarRange !== null) {
|
|
||||||
throw new Error(
|
|
||||||
"not allowed to edit a calendar associated with a calendar range",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
this.$store.dispatch("setEventTimes", {
|
|
||||||
start: payload.event.start,
|
|
||||||
end: payload.event.end,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onEventClick(payload) {
|
|
||||||
if (payload.event.extendedProps.is !== "range") {
|
|
||||||
// do nothing when clicking on remote
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// show an alert if changing mainUser
|
|
||||||
if (
|
|
||||||
this.$store.getters.getMainUser !== null &&
|
|
||||||
payload.event.extendedProps.userId !==
|
|
||||||
this.$store.getters.getMainUser.id
|
|
||||||
) {
|
|
||||||
if (
|
|
||||||
!window.confirm(this.$t("this_calendar_range_will_change_main_user"))
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$store.dispatch("associateCalendarToRange", {
|
|
||||||
range: payload.event,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.calendar-actives {
|
.calendar-actives {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.display-options {
|
.display-options {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* for events which are range */
|
/* for events which are range */
|
||||||
.fc-event.isrange {
|
.fc-event.isrange {
|
||||||
border-width: 3px;
|
border-width: 3px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,177 +1,228 @@
|
|||||||
<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"
|
||||||
:label="'name'"
|
:label="'name'"
|
||||||
:track-by="'id'"
|
:track-by="'id'"
|
||||||
:selectLabel="'Presser \'Entrée\' pour choisir'"
|
:selectLabel="'Presser \'Entrée\' pour choisir'"
|
||||||
:selectedLabel="'Choisir'"
|
:selectedLabel="'Choisir'"
|
||||||
:deselectLabel="'Presser \'Entrée\' pour enlever'"
|
:deselectLabel="'Presser \'Entrée\' pour enlever'"
|
||||||
:placeholder="'Choisir'"
|
:placeholder="'Choisir'"
|
||||||
></vue-multiselect>
|
></vue-multiselect>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="display-options row justify-content-between"
|
|
||||||
style="margin-top: 1rem"
|
|
||||||
>
|
|
||||||
<div class="col-sm-9 col-xs-12">
|
|
||||||
<div class="input-group mb-3">
|
|
||||||
<label class="input-group-text" for="slotDuration"
|
|
||||||
>Durée des créneaux</label
|
|
||||||
>
|
|
||||||
<select v-model="slotDuration" id="slotDuration" class="form-select">
|
|
||||||
<option value="00:05:00">5 minutes</option>
|
|
||||||
<option value="00:10:00">10 minutes</option>
|
|
||||||
<option value="00:15:00">15 minutes</option>
|
|
||||||
<option value="00:30:00">30 minutes</option>
|
|
||||||
</select>
|
|
||||||
<label class="input-group-text" for="slotMinTime">De</label>
|
|
||||||
<select v-model="slotMinTime" id="slotMinTime" class="form-select">
|
|
||||||
<option value="00:00:00">0h</option>
|
|
||||||
<option value="01:00:00">1h</option>
|
|
||||||
<option value="02:00:00">2h</option>
|
|
||||||
<option value="03:00:00">3h</option>
|
|
||||||
<option value="04:00:00">4h</option>
|
|
||||||
<option value="05:00:00">5h</option>
|
|
||||||
<option value="06:00:00">6h</option>
|
|
||||||
<option value="07:00:00">7h</option>
|
|
||||||
<option value="08:00:00">8h</option>
|
|
||||||
<option value="09:00:00">9h</option>
|
|
||||||
<option value="10:00:00">10h</option>
|
|
||||||
<option value="11:00:00">11h</option>
|
|
||||||
<option value="12:00:00">12h</option>
|
|
||||||
</select>
|
|
||||||
<label class="input-group-text" for="slotMaxTime">À</label>
|
|
||||||
<select v-model="slotMaxTime" id="slotMaxTime" class="form-select">
|
|
||||||
<option value="12:00:00">12h</option>
|
|
||||||
<option value="13:00:00">13h</option>
|
|
||||||
<option value="14:00:00">14h</option>
|
|
||||||
<option value="15:00:00">15h</option>
|
|
||||||
<option value="16:00:00">16h</option>
|
|
||||||
<option value="17:00:00">17h</option>
|
|
||||||
<option value="18:00:00">18h</option>
|
|
||||||
<option value="19:00:00">19h</option>
|
|
||||||
<option value="20:00:00">20h</option>
|
|
||||||
<option value="21:00:00">21h</option>
|
|
||||||
<option value="22:00:00">22h</option>
|
|
||||||
<option value="23:00:00">23h</option>
|
|
||||||
<option value="23:59:59">24h</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-xs-12 col-sm-3">
|
|
||||||
<div class="float-end">
|
|
||||||
<div class="form-check input-group">
|
|
||||||
<span class="input-group-text">
|
|
||||||
<input
|
|
||||||
id="showHideWE"
|
|
||||||
class="mt-0"
|
|
||||||
type="checkbox"
|
|
||||||
v-model="showWeekends"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<label for="showHideWE" class="form-check-label input-group-text"
|
|
||||||
>Week-ends</label
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div
|
||||||
<FullCalendar :options="calendarOptions" ref="calendarRef">
|
class="display-options row justify-content-between"
|
||||||
<template v-slot:eventContent="{ event }: { event: EventApi }">
|
style="margin-top: 1rem"
|
||||||
<span :class="eventClasses">
|
>
|
||||||
<b v-if="event.extendedProps.is === 'remote'">{{ event.title }}</b>
|
<div class="col-sm-9 col-xs-12">
|
||||||
<b v-else-if="event.extendedProps.is === 'range'"
|
<div class="input-group mb-3">
|
||||||
>{{ formatDate(event.startStr) }} -
|
<label class="input-group-text" for="slotDuration"
|
||||||
{{ event.extendedProps.locationName }}</b
|
>Durée des créneaux</label
|
||||||
>
|
>
|
||||||
<b v-else-if="event.extendedProps.is === 'local'">{{ event.title }}</b>
|
<select
|
||||||
<b v-else>no 'is'</b>
|
v-model="slotDuration"
|
||||||
<a
|
id="slotDuration"
|
||||||
v-if="event.extendedProps.is === 'range'"
|
class="form-select"
|
||||||
class="fa fa-fw fa-times delete"
|
>
|
||||||
@click.prevent="onClickDelete(event)"
|
<option value="00:05:00">5 minutes</option>
|
||||||
>
|
<option value="00:10:00">10 minutes</option>
|
||||||
</a>
|
<option value="00:15:00">15 minutes</option>
|
||||||
</span>
|
<option value="00:30:00">30 minutes</option>
|
||||||
</template>
|
<option value="00:45:00">45 minutes</option>
|
||||||
</FullCalendar>
|
<option value="00:60:00">60 minutes</option>
|
||||||
|
</select>
|
||||||
<div id="copy-widget">
|
<label class="input-group-text" for="slotMinTime">De</label>
|
||||||
<div class="container mt-2 mb-2">
|
<select
|
||||||
<div class="row justify-content-between align-items-center mb-4">
|
v-model="slotMinTime"
|
||||||
<div class="col-xs-12 col-sm-3 col-md-2">
|
id="slotMinTime"
|
||||||
<h6 class="chill-red">{{ $t("copy_range_from_to") }}</h6>
|
class="form-select"
|
||||||
|
>
|
||||||
|
<option value="00:00:00">0h</option>
|
||||||
|
<option value="01:00:00">1h</option>
|
||||||
|
<option value="02:00:00">2h</option>
|
||||||
|
<option value="03:00:00">3h</option>
|
||||||
|
<option value="04:00:00">4h</option>
|
||||||
|
<option value="05:00:00">5h</option>
|
||||||
|
<option value="06:00:00">6h</option>
|
||||||
|
<option value="07:00:00">7h</option>
|
||||||
|
<option value="08:00:00">8h</option>
|
||||||
|
<option value="09:00:00">9h</option>
|
||||||
|
<option value="10:00:00">10h</option>
|
||||||
|
<option value="11:00:00">11h</option>
|
||||||
|
<option value="12:00:00">12h</option>
|
||||||
|
</select>
|
||||||
|
<label class="input-group-text" for="slotMaxTime">À</label>
|
||||||
|
<select
|
||||||
|
v-model="slotMaxTime"
|
||||||
|
id="slotMaxTime"
|
||||||
|
class="form-select"
|
||||||
|
>
|
||||||
|
<option value="12:00:00">12h</option>
|
||||||
|
<option value="13:00:00">13h</option>
|
||||||
|
<option value="14:00:00">14h</option>
|
||||||
|
<option value="15:00:00">15h</option>
|
||||||
|
<option value="16:00:00">16h</option>
|
||||||
|
<option value="17:00:00">17h</option>
|
||||||
|
<option value="18:00:00">18h</option>
|
||||||
|
<option value="19:00:00">19h</option>
|
||||||
|
<option value="20:00:00">20h</option>
|
||||||
|
<option value="21:00:00">21h</option>
|
||||||
|
<option value="22:00:00">22h</option>
|
||||||
|
<option value="23:00:00">23h</option>
|
||||||
|
<option value="23:59:59">24h</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-xs-12 col-sm-9 col-md-2">
|
<div class="col-xs-12 col-sm-3">
|
||||||
<select v-model="dayOrWeek" id="dayOrWeek" class="form-select">
|
<div class="float-end">
|
||||||
<option value="day">{{ $t("from_day_to_day") }}</option>
|
<div class="form-check input-group">
|
||||||
<option value="week">
|
<span class="input-group-text">
|
||||||
{{ $t("from_week_to_week") }}
|
<input
|
||||||
</option>
|
id="showHideWE"
|
||||||
</select>
|
class="mt-0"
|
||||||
|
type="checkbox"
|
||||||
|
v-model="showWeekends"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<label
|
||||||
|
for="showHideWE"
|
||||||
|
class="form-check-label input-group-text"
|
||||||
|
>Week-ends</label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="dayOrWeek === 'day'">
|
</div>
|
||||||
<div class="col-xs-12 col-sm-3 col-md-3">
|
<FullCalendar :options="calendarOptions" ref="calendarRef">
|
||||||
<input class="form-control" type="date" v-model="copyFrom" />
|
<template v-slot:eventContent="{ event }: { event: EventApi }">
|
||||||
</div>
|
<span :class="eventClasses">
|
||||||
<div class="col-xs-12 col-sm-1 col-md-1 copy-chevron">
|
<b v-if="event.extendedProps.is === 'remote'">{{
|
||||||
<i class="fa fa-angle-double-right"></i>
|
event.title
|
||||||
</div>
|
}}</b>
|
||||||
<div class="col-xs-12 col-sm-3 col-md-3">
|
<b v-else-if="event.extendedProps.is === 'range'"
|
||||||
<input class="form-control" type="date" v-model="copyTo" />
|
>{{ formatDate(event.startStr, "time") }} -
|
||||||
</div>
|
{{ formatDate(event.endStr, "time") }}:
|
||||||
<div class="col-xs-12 col-sm-5 col-md-1">
|
{{ event.extendedProps.locationName }}</b
|
||||||
<button class="btn btn-action float-end" @click="copyDay">
|
>
|
||||||
{{ $t("copy_range") }}
|
<b v-else-if="event.extendedProps.is === 'local'">{{
|
||||||
</button>
|
event.title
|
||||||
</div>
|
}}</b>
|
||||||
|
<b v-else>no 'is'</b>
|
||||||
|
<a
|
||||||
|
v-if="event.extendedProps.is === 'range'"
|
||||||
|
class="fa fa-fw fa-times delete"
|
||||||
|
@click.prevent="onClickDelete(event)"
|
||||||
|
>
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
</FullCalendar>
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- not directly seen, but include in a modal -->
|
<div id="copy-widget">
|
||||||
<edit-location ref="editLocation"></edit-location>
|
<div class="container mt-2 mb-2">
|
||||||
|
<div class="row justify-content-between align-items-center mb-4">
|
||||||
|
<div class="col-xs-12 col-sm-3 col-md-2">
|
||||||
|
<h6 class="chill-red">{{ $t("copy_range_from_to") }}</h6>
|
||||||
|
</div>
|
||||||
|
<div class="col-xs-12 col-sm-9 col-md-2">
|
||||||
|
<select
|
||||||
|
v-model="dayOrWeek"
|
||||||
|
id="dayOrWeek"
|
||||||
|
class="form-select"
|
||||||
|
>
|
||||||
|
<option value="day">{{ $t("from_day_to_day") }}</option>
|
||||||
|
<option value="week">
|
||||||
|
{{ $t("from_week_to_week") }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<template v-if="dayOrWeek === 'day'">
|
||||||
|
<div class="col-xs-12 col-sm-3 col-md-3">
|
||||||
|
<input
|
||||||
|
class="form-control"
|
||||||
|
type="date"
|
||||||
|
v-model="copyFrom"
|
||||||
|
/>
|
||||||
|
</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">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- not directly seen, but include in a modal -->
|
||||||
|
<edit-location ref="editLocation"></edit-location>
|
||||||
</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 { computed, ref, onMounted } from "vue";
|
import { computed, ref, onMounted } from "vue";
|
||||||
import { useStore } from "vuex";
|
import { useStore } from "vuex";
|
||||||
@@ -179,14 +230,14 @@ 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, {
|
import interactionPlugin, {
|
||||||
EventResizeDoneArg,
|
EventResizeDoneArg,
|
||||||
} from "@fullcalendar/interaction";
|
} from "@fullcalendar/interaction";
|
||||||
import timeGridPlugin from "@fullcalendar/timegrid";
|
import timeGridPlugin from "@fullcalendar/timegrid";
|
||||||
import {
|
import {
|
||||||
EventApi,
|
EventApi,
|
||||||
DateSelectArg,
|
DateSelectArg,
|
||||||
EventDropArg,
|
EventDropArg,
|
||||||
EventClickArg,
|
EventClickArg,
|
||||||
} from "@fullcalendar/core";
|
} from "@fullcalendar/core";
|
||||||
import { dateToISO, ISOToDate } from "ChillMainAssets/chill/js/date";
|
import { dateToISO, ISOToDate } from "ChillMainAssets/chill/js/date";
|
||||||
import VueMultiselect from "vue-multiselect";
|
import VueMultiselect from "vue-multiselect";
|
||||||
@@ -207,96 +258,113 @@ const copyFromWeek = ref<string | null>(null);
|
|||||||
const copyToWeek = ref<string | null>(null);
|
const copyToWeek = ref<string | null>(null);
|
||||||
|
|
||||||
interface Weeks {
|
interface Weeks {
|
||||||
value: string | null;
|
value: string | null;
|
||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getMonday = (week: number): Date => {
|
const getMonday = (week: number): Date => {
|
||||||
const lastMonday = new Date();
|
const lastMonday = new Date();
|
||||||
lastMonday.setDate(
|
lastMonday.setDate(
|
||||||
lastMonday.getDate() - ((lastMonday.getDay() + 6) % 7) + week * 7,
|
lastMonday.getDate() - ((lastMonday.getDay() + 6) % 7) + week * 7,
|
||||||
);
|
);
|
||||||
return lastMonday;
|
return lastMonday;
|
||||||
};
|
};
|
||||||
|
|
||||||
const dateOptions: Intl.DateTimeFormatOptions = {
|
const dateOptions: Intl.DateTimeFormatOptions = {
|
||||||
weekday: "long",
|
weekday: "long",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
};
|
};
|
||||||
|
|
||||||
const lastWeeks = computed((): Weeks[] =>
|
const lastWeeks = computed((): Weeks[] =>
|
||||||
Array.from(Array(30).keys()).map((w) => {
|
Array.from(Array(30).keys()).map((w) => {
|
||||||
const lastMonday = getMonday(15 - w);
|
const lastMonday = getMonday(15 - w);
|
||||||
return {
|
return {
|
||||||
value: dateToISO(lastMonday),
|
value: dateToISO(lastMonday),
|
||||||
text: `Semaine du ${lastMonday.toLocaleDateString("fr-FR", dateOptions)}`,
|
text: `Semaine du ${lastMonday.toLocaleDateString("fr-FR", dateOptions)}`,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const nextWeeks = computed((): Weeks[] =>
|
const nextWeeks = computed((): Weeks[] =>
|
||||||
Array.from(Array(52).keys()).map((w) => {
|
Array.from(Array(52).keys()).map((w) => {
|
||||||
const nextMonday = getMonday(w + 1);
|
const nextMonday = getMonday(w + 1);
|
||||||
return {
|
return {
|
||||||
value: dateToISO(nextMonday),
|
value: dateToISO(nextMonday),
|
||||||
text: `Semaine du ${nextMonday.toLocaleDateString("fr-FR", dateOptions)}`,
|
text: `Semaine du ${nextMonday.toLocaleDateString("fr-FR", dateOptions)}`,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const formatDate = (datetime: string) => {
|
const formatDate = (datetime: string, format: null | "time" = null) => {
|
||||||
console.log(typeof datetime);
|
const date = ISOToDate(datetime);
|
||||||
return ISOToDate(datetime);
|
if (!date) return "";
|
||||||
|
|
||||||
|
if (format === "time") {
|
||||||
|
return date.toLocaleTimeString("fr-FR", {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// French date formatting
|
||||||
|
return date.toLocaleDateString("fr-FR", {
|
||||||
|
weekday: "short",
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
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,
|
||||||
// when the dates are changes in the fullcalendar view OR when new events are added
|
// when the dates are changes in the fullcalendar view OR when new events are added
|
||||||
datesSet: onDatesSet,
|
datesSet: onDatesSet,
|
||||||
// when a date is selected
|
// when a date is selected
|
||||||
select: onDateSelect,
|
select: onDateSelect,
|
||||||
// when a event is resized
|
// when a event is resized
|
||||||
eventResize: onEventDropOrResize,
|
eventResize: onEventDropOrResize,
|
||||||
// when an event is moved
|
// when an event is moved
|
||||||
eventDrop: onEventDropOrResize,
|
eventDrop: onEventDropOrResize,
|
||||||
// when an event si clicked
|
// when an event si clicked
|
||||||
eventClick: onEventClick,
|
eventClick: onEventClick,
|
||||||
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",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const ranges = computed<EventInput[]>(() => {
|
const ranges = computed<EventInput[]>(() => {
|
||||||
return store.state.calendarRanges.ranges;
|
return store.state.calendarRanges.ranges;
|
||||||
});
|
});
|
||||||
|
|
||||||
const locations = computed<Location[]>(() => {
|
const locations = computed<Location[]>(() => {
|
||||||
return store.state.locations.locations;
|
return store.state.locations.locations;
|
||||||
});
|
});
|
||||||
|
|
||||||
const pickedLocation = computed<Location | null>({
|
const pickedLocation = computed<Location | null>({
|
||||||
get(): Location | null {
|
get(): Location | null {
|
||||||
return (
|
return (
|
||||||
store.state.locations.locationPicked ||
|
store.state.locations.locationPicked ||
|
||||||
store.state.locations.currentLocation
|
store.state.locations.currentLocation
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
set(newLocation: Location | null): void {
|
set(newLocation: Location | null): void {
|
||||||
store.commit("locations/setLocationPicked", newLocation, {
|
store.commit("locations/setLocationPicked", newLocation, {
|
||||||
root: true,
|
root: true,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -325,116 +393,116 @@ const sources = computed<EventSourceInput[]>(() => {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const calendarOptions = computed((): CalendarOptions => {
|
const calendarOptions = computed((): CalendarOptions => {
|
||||||
return {
|
return {
|
||||||
...baseOptions.value,
|
...baseOptions.value,
|
||||||
weekends: showWeekends.value,
|
weekends: showWeekends.value,
|
||||||
slotDuration: slotDuration.value,
|
slotDuration: slotDuration.value,
|
||||||
events: ranges.value,
|
events: ranges.value,
|
||||||
slotMinTime: slotMinTime.value,
|
slotMinTime: slotMinTime.value,
|
||||||
slotMaxTime: slotMaxTime.value,
|
slotMaxTime: slotMaxTime.value,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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", {
|
store.dispatch("fullCalendar/setCurrentDatesView", {
|
||||||
start: event.start,
|
start: event.start,
|
||||||
end: event.end,
|
end: event.end,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDateSelect(event: DateSelectArg): void {
|
function onDateSelect(event: DateSelectArg): void {
|
||||||
if (null === pickedLocation.value) {
|
if (null === pickedLocation.value) {
|
||||||
window.alert(
|
window.alert(
|
||||||
"Indiquez une localisation avant de créer une période de disponibilité.",
|
"Indiquez une localisation avant de créer une période de disponibilité.",
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
store.dispatch("calendarRanges/createRange", {
|
store.dispatch("calendarRanges/createRange", {
|
||||||
start: event.start,
|
start: event.start,
|
||||||
end: event.end,
|
end: event.end,
|
||||||
location: pickedLocation.value,
|
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 {
|
||||||
if (event.extendedProps.is !== "range") {
|
if (event.extendedProps.is !== "range") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
store.dispatch(
|
store.dispatch(
|
||||||
"calendarRanges/deleteRange",
|
"calendarRanges/deleteRange",
|
||||||
event.extendedProps.calendarRangeId,
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
editLocation.value?.startEdit(payload.event);
|
editLocation.value?.startEdit(payload.event);
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyDay() {
|
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),
|
from: ISOToDate(copyFrom.value),
|
||||||
to: ISOToDate(copyTo.value),
|
to: ISOToDate(copyTo.value),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyWeek() {
|
function copyWeek() {
|
||||||
if (null === copyFromWeek.value || null === copyToWeek.value) {
|
if (null === copyFromWeek.value || null === copyToWeek.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
store.dispatch("calendarRanges/copyFromWeekToAnotherWeek", {
|
store.dispatch("calendarRanges/copyFromWeekToAnotherWeek", {
|
||||||
fromMonday: ISOToDate(copyFromWeek.value),
|
fromMonday: ISOToDate(copyFromWeek.value),
|
||||||
toMonday: ISOToDate(copyToWeek.value),
|
toMonday: ISOToDate(copyToWeek.value),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
copyFromWeek.value = dateToISO(getMonday(0));
|
copyFromWeek.value = dateToISO(getMonday(0));
|
||||||
copyToWeek.value = dateToISO(getMonday(1));
|
copyToWeek.value = dateToISO(getMonday(1));
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
#copy-widget {
|
#copy-widget {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
bottom: 0px;
|
bottom: 0px;
|
||||||
background-color: white;
|
background-color: white;
|
||||||
z-index: 9999999999;
|
z-index: 9999999999;
|
||||||
padding: 0.25rem 0 0.25rem;
|
padding: 0.25rem 0 0.25rem;
|
||||||
}
|
}
|
||||||
div.copy-chevron {
|
div.copy-chevron {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: x-large;
|
font-size: x-large;
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ final readonly class StoredObjectVersionApiController
|
|||||||
|
|
||||||
return new JsonResponse(
|
return new JsonResponse(
|
||||||
$this->serializer->serialize(
|
$this->serializer->serialize(
|
||||||
new Collection($items, $paginator),
|
new Collection(array_values($items->toArray()), $paginator),
|
||||||
'json',
|
'json',
|
||||||
[AbstractNormalizer::GROUPS => ['read', StoredObjectVersionNormalizer::WITH_POINT_IN_TIMES_CONTEXT, StoredObjectVersionNormalizer::WITH_RESTORED_CONTEXT]]
|
[AbstractNormalizer::GROUPS => ['read', StoredObjectVersionNormalizer::WITH_POINT_IN_TIMES_CONTEXT, StoredObjectVersionNormalizer::WITH_RESTORED_CONTEXT]]
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -23,10 +23,14 @@ use Random\RandomException;
|
|||||||
* Store each version of StoredObject's.
|
* Store each version of StoredObject's.
|
||||||
*
|
*
|
||||||
* A version should not be created manually: use the method @see{StoredObject::registerVersion} instead.
|
* A version should not be created manually: use the method @see{StoredObject::registerVersion} instead.
|
||||||
|
*
|
||||||
|
* Each filename must be unique within the same StoredObject. We add a condition on id to apply this condition only for
|
||||||
|
* newly created versions when this new index is applied.
|
||||||
*/
|
*/
|
||||||
#[ORM\Entity]
|
#[ORM\Entity]
|
||||||
#[ORM\Table('chill_doc.stored_object_version')]
|
#[ORM\Table('chill_doc.stored_object_version')]
|
||||||
#[ORM\UniqueConstraint(name: 'chill_doc_stored_object_version_unique_by_object', columns: ['stored_object_id', 'version'])]
|
#[ORM\UniqueConstraint(name: 'chill_doc_stored_object_version_unique_by_object', columns: ['stored_object_id', 'version'])]
|
||||||
|
#[ORM\UniqueConstraint(name: 'chill_doc_stored_object_version_unique_by_filename', columns: ['filename'], options: ['where' => '(id > 0)'])]
|
||||||
class StoredObjectVersion implements TrackCreationInterface
|
class StoredObjectVersion implements TrackCreationInterface
|
||||||
{
|
{
|
||||||
use TrackCreationTrait;
|
use TrackCreationTrait;
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?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\DocStoreBundle\Exception;
|
||||||
|
|
||||||
|
class ConversionWithSameMimeTypeException extends \RuntimeException
|
||||||
|
{
|
||||||
|
public function __construct(string $mimeType, ?\Throwable $previous = null)
|
||||||
|
{
|
||||||
|
parent::__construct("Conversion to same MIME type '{$mimeType}' is not allowed: already at the same MIME type", 0, $previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { DateTime } from "ChillMainAssets/types";
|
|||||||
import { StoredObject } from "ChillDocStoreAssets/types/index";
|
import { StoredObject } from "ChillDocStoreAssets/types/index";
|
||||||
|
|
||||||
export interface GenericDocMetadata {
|
export interface GenericDocMetadata {
|
||||||
isPresent: boolean;
|
isPresent: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,57 +15,74 @@ export interface EmptyMetadata extends GenericDocMetadata {}
|
|||||||
* Minimal Metadata for a GenericDoc with a normalizer
|
* Minimal Metadata for a GenericDoc with a normalizer
|
||||||
*/
|
*/
|
||||||
export interface BaseMetadata extends GenericDocMetadata {
|
export interface BaseMetadata extends GenericDocMetadata {
|
||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A generic doc is a document attached to a Person or an AccompanyingPeriod.
|
* A generic doc is a document attached to a Person or an AccompanyingPeriod.
|
||||||
*/
|
*/
|
||||||
export interface GenericDoc {
|
export interface GenericDoc {
|
||||||
type: "doc_store_generic_doc";
|
type: "doc_store_generic_doc";
|
||||||
uniqueKey: string;
|
uniqueKey: string;
|
||||||
key: string;
|
key: string;
|
||||||
identifiers: object;
|
identifiers: { id: number };
|
||||||
context: "person" | "accompanying-period";
|
context: "person" | "accompanying-period";
|
||||||
doc_date: DateTime;
|
doc_date: DateTime;
|
||||||
metadata: GenericDocMetadata;
|
metadata: GenericDocMetadata;
|
||||||
storedObject: StoredObject | null;
|
storedObject: StoredObject | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenericDocForAccompanyingPeriod extends GenericDoc {
|
export interface GenericDocForAccompanyingPeriod extends GenericDoc {
|
||||||
context: "accompanying-period";
|
context: "accompanying-period";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isGenericDocForAccompanyingPeriod(
|
||||||
|
doc: GenericDoc,
|
||||||
|
): doc is GenericDocForAccompanyingPeriod {
|
||||||
|
return doc.context === "accompanying-period";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isGenericDocWithStoredObject(
|
||||||
|
doc: GenericDoc,
|
||||||
|
): doc is GenericDoc & { storedObject: StoredObject } {
|
||||||
|
return doc.storedObject !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BaseMetadataWithHtml extends BaseMetadata {
|
interface BaseMetadataWithHtml extends BaseMetadata {
|
||||||
html: string;
|
html: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenericDocForAccompanyingCourseDocument
|
export interface GenericDocForAccompanyingCourseDocument
|
||||||
extends GenericDocForAccompanyingPeriod {
|
extends GenericDocForAccompanyingPeriod {
|
||||||
key: "accompanying_course_document";
|
key: "accompanying_course_document";
|
||||||
metadata: BaseMetadataWithHtml;
|
metadata: BaseMetadataWithHtml;
|
||||||
|
storedObject: StoredObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenericDocForAccompanyingCourseActivityDocument
|
export interface GenericDocForAccompanyingCourseActivityDocument
|
||||||
extends GenericDocForAccompanyingPeriod {
|
extends GenericDocForAccompanyingPeriod {
|
||||||
key: "accompanying_course_activity_document";
|
key: "accompanying_course_activity_document";
|
||||||
metadata: BaseMetadataWithHtml;
|
metadata: BaseMetadataWithHtml;
|
||||||
|
storedObject: StoredObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenericDocForAccompanyingCourseCalendarDocument
|
export interface GenericDocForAccompanyingCourseCalendarDocument
|
||||||
extends GenericDocForAccompanyingPeriod {
|
extends GenericDocForAccompanyingPeriod {
|
||||||
key: "accompanying_course_calendar_document";
|
key: "accompanying_course_calendar_document";
|
||||||
metadata: BaseMetadataWithHtml;
|
metadata: BaseMetadataWithHtml;
|
||||||
|
storedObject: StoredObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenericDocForAccompanyingCoursePersonDocument
|
export interface GenericDocForAccompanyingCoursePersonDocument
|
||||||
extends GenericDocForAccompanyingPeriod {
|
extends GenericDocForAccompanyingPeriod {
|
||||||
key: "person_document";
|
key: "person_document";
|
||||||
metadata: BaseMetadataWithHtml;
|
metadata: BaseMetadataWithHtml;
|
||||||
|
storedObject: StoredObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GenericDocForAccompanyingCourseWorkEvaluationDocument
|
export interface GenericDocForAccompanyingCourseWorkEvaluationDocument
|
||||||
extends GenericDocForAccompanyingPeriod {
|
extends GenericDocForAccompanyingPeriod {
|
||||||
key: "accompanying_period_work_evaluation_document";
|
key: "accompanying_period_work_evaluation_document";
|
||||||
metadata: BaseMetadataWithHtml;
|
metadata: BaseMetadataWithHtml;
|
||||||
|
storedObject: StoredObject;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,26 +4,27 @@ import { StoredObject, StoredObjectVersion } from "../../types";
|
|||||||
import DropFileWidget from "ChillDocStoreAssets/vuejs/DropFileWidget/DropFileWidget.vue";
|
import DropFileWidget from "ChillDocStoreAssets/vuejs/DropFileWidget/DropFileWidget.vue";
|
||||||
import { computed, reactive } from "vue";
|
import { computed, reactive } from "vue";
|
||||||
import { useToast } from "vue-toast-notification";
|
import { useToast } from "vue-toast-notification";
|
||||||
|
import { DOCUMENT_ADD, trans } from "translator";
|
||||||
|
|
||||||
interface DropFileConfig {
|
interface DropFileConfig {
|
||||||
allowRemove: boolean;
|
allowRemove: boolean;
|
||||||
existingDoc?: StoredObject;
|
existingDoc?: StoredObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<DropFileConfig>(), {
|
const props = withDefaults(defineProps<DropFileConfig>(), {
|
||||||
allowRemove: false,
|
allowRemove: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(
|
(
|
||||||
e: "addDocument",
|
e: "addDocument",
|
||||||
{
|
{
|
||||||
stored_object: StoredObject,
|
stored_object: StoredObject,
|
||||||
stored_object_version: StoredObjectVersion,
|
stored_object_version: StoredObjectVersion,
|
||||||
file_name: string,
|
file_name: string,
|
||||||
},
|
},
|
||||||
): void;
|
): void;
|
||||||
(e: "removeDocument"): void;
|
(e: "removeDocument"): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const $toast = useToast();
|
const $toast = useToast();
|
||||||
@@ -33,67 +34,65 @@ const state = reactive({ showModal: false });
|
|||||||
const modalClasses = { "modal-dialog-centered": true, "modal-md": true };
|
const modalClasses = { "modal-dialog-centered": true, "modal-md": true };
|
||||||
|
|
||||||
const buttonState = computed<"add" | "replace">(() => {
|
const buttonState = computed<"add" | "replace">(() => {
|
||||||
if (props.existingDoc === undefined || props.existingDoc === null) {
|
if (props.existingDoc === undefined || props.existingDoc === null) {
|
||||||
return "add";
|
return "add";
|
||||||
}
|
}
|
||||||
|
|
||||||
return "replace";
|
return "replace";
|
||||||
});
|
});
|
||||||
|
|
||||||
function onAddDocument({
|
function onAddDocument({
|
||||||
stored_object,
|
stored_object,
|
||||||
stored_object_version,
|
stored_object_version,
|
||||||
file_name,
|
file_name,
|
||||||
}: {
|
}: {
|
||||||
stored_object: StoredObject;
|
stored_object: StoredObject;
|
||||||
stored_object_version: StoredObjectVersion;
|
stored_object_version: StoredObjectVersion;
|
||||||
file_name: string;
|
file_name: string;
|
||||||
}): void {
|
}): void {
|
||||||
const message =
|
const message =
|
||||||
buttonState.value === "add" ? "Document ajouté" : "Document remplacé";
|
buttonState.value === "add" ? "Document ajouté" : "Document remplacé";
|
||||||
$toast.success(message);
|
$toast.success(message);
|
||||||
emit("addDocument", { stored_object_version, stored_object, file_name });
|
emit("addDocument", { stored_object_version, stored_object, file_name });
|
||||||
state.showModal = false;
|
state.showModal = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onRemoveDocument(): void {
|
function onRemoveDocument(): void {
|
||||||
emit("removeDocument");
|
emit("removeDocument");
|
||||||
}
|
}
|
||||||
|
|
||||||
function openModal(): void {
|
function openModal(): void {
|
||||||
state.showModal = true;
|
state.showModal = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeModal(): void {
|
function closeModal(): void {
|
||||||
state.showModal = false;
|
state.showModal = false;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<button
|
<button
|
||||||
v-if="buttonState === 'add'"
|
v-if="buttonState === 'add'"
|
||||||
@click="openModal"
|
@click="openModal"
|
||||||
class="btn btn-create"
|
class="btn btn-create"
|
||||||
>
|
>
|
||||||
Ajouter un document
|
{{ trans(DOCUMENT_ADD) }}
|
||||||
</button>
|
</button>
|
||||||
<button v-else @click="openModal" class="btn btn-edit">
|
<button v-else @click="openModal" class="btn btn-edit"></button>
|
||||||
Remplacer le document
|
<modal
|
||||||
</button>
|
v-if="state.showModal"
|
||||||
<modal
|
:modal-dialog-class="modalClasses"
|
||||||
v-if="state.showModal"
|
@close="closeModal"
|
||||||
:modal-dialog-class="modalClasses"
|
>
|
||||||
@close="closeModal"
|
<template v-slot:body>
|
||||||
>
|
<drop-file-widget
|
||||||
<template v-slot:body>
|
:existing-doc="existingDoc"
|
||||||
<drop-file-widget
|
:allow-remove="allowRemove"
|
||||||
:existing-doc="existingDoc"
|
@add-document="onAddDocument"
|
||||||
:allow-remove="allowRemove"
|
@remove-document="onRemoveDocument"
|
||||||
@add-document="onAddDocument"
|
></drop-file-widget>
|
||||||
@remove-document="onRemoveDocument"
|
</template>
|
||||||
></drop-file-widget>
|
</modal>
|
||||||
</template>
|
|
||||||
</modal>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss"></style>
|
<style scoped lang="scss"></style>
|
||||||
|
|||||||
@@ -1,184 +1,196 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
StoredObject,
|
StoredObject,
|
||||||
StoredObjectPointInTime,
|
StoredObjectPointInTime,
|
||||||
StoredObjectVersionWithPointInTime,
|
StoredObjectVersionWithPointInTime,
|
||||||
} from "./../../../types";
|
} from "ChillDocStoreAssets/types";
|
||||||
import UserRenderBoxBadge from "ChillMainAssets/vuejs/_components/Entity/UserRenderBoxBadge.vue";
|
import UserRenderBoxBadge from "ChillMainAssets/vuejs/_components/Entity/UserRenderBoxBadge.vue";
|
||||||
import { ISOToDatetime } from "./../../../../../../ChillMainBundle/Resources/public/chill/js/date";
|
import { ISOToDatetime } from "ChillMainAssets/chill/js/date";
|
||||||
import FileIcon from "ChillDocStoreAssets/vuejs/FileIcon.vue";
|
import FileIcon from "ChillDocStoreAssets/vuejs/FileIcon.vue";
|
||||||
import RestoreVersionButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/HistoryButton/RestoreVersionButton.vue";
|
import RestoreVersionButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/HistoryButton/RestoreVersionButton.vue";
|
||||||
import DownloadButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DownloadButton.vue";
|
import DownloadButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DownloadButton.vue";
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
|
|
||||||
interface HistoryButtonListItemConfig {
|
interface HistoryButtonListItemConfig {
|
||||||
version: StoredObjectVersionWithPointInTime;
|
version: StoredObjectVersionWithPointInTime;
|
||||||
storedObject: StoredObject;
|
storedObject: StoredObject;
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
isCurrent: boolean;
|
isCurrent: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
restoreVersion: [newVersion: StoredObjectVersionWithPointInTime];
|
restoreVersion: [newVersion: StoredObjectVersionWithPointInTime];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const props = defineProps<HistoryButtonListItemConfig>();
|
const props = defineProps<HistoryButtonListItemConfig>();
|
||||||
|
|
||||||
const onRestore = ({
|
const onRestore = ({
|
||||||
newVersion,
|
newVersion,
|
||||||
}: {
|
}: {
|
||||||
newVersion: StoredObjectVersionWithPointInTime;
|
newVersion: StoredObjectVersionWithPointInTime;
|
||||||
}) => {
|
}) => {
|
||||||
emit("restoreVersion", { newVersion });
|
emit("restoreVersion", { newVersion });
|
||||||
};
|
};
|
||||||
|
|
||||||
const isKeptBeforeConversion = computed<boolean>(() => {
|
const isKeptBeforeConversion = computed<boolean>(() => {
|
||||||
if ("point-in-times" in props.version) {
|
if ("point-in-times" in props.version) {
|
||||||
return props.version["point-in-times"].reduce(
|
return props.version["point-in-times"].reduce(
|
||||||
(accumulator: boolean, pit: StoredObjectPointInTime) =>
|
(accumulator: boolean, pit: StoredObjectPointInTime) =>
|
||||||
accumulator || "keep-before-conversion" === pit.reason,
|
accumulator || "keep-before-conversion" === pit.reason,
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const isRestored = computed<boolean>(
|
const isRestored = computed<boolean>(
|
||||||
() => props.version.version > 0 && null !== props.version["from-restored"],
|
() => props.version.version > 0 && null !== props.version["from-restored"],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isDuplicated = computed<boolean>(
|
const isDuplicated = computed<boolean>(
|
||||||
() => props.version.version === 0 && null !== props.version["from-restored"],
|
() =>
|
||||||
|
props.version.version === 0 && null !== props.version["from-restored"],
|
||||||
);
|
);
|
||||||
|
|
||||||
const classes = computed<{
|
const classes = computed<{
|
||||||
row: true;
|
row: true;
|
||||||
"row-hover": true;
|
"row-hover": true;
|
||||||
"blinking-1": boolean;
|
"blinking-1": boolean;
|
||||||
"blinking-2": boolean;
|
"blinking-2": boolean;
|
||||||
}>(() => ({
|
}>(() => ({
|
||||||
row: true,
|
row: true,
|
||||||
"row-hover": true,
|
"row-hover": true,
|
||||||
"blinking-1": props.isRestored && 0 === props.version.version % 2,
|
"blinking-1": props.isRestored && 0 === props.version.version % 2,
|
||||||
"blinking-2": props.isRestored && 1 === props.version.version % 2,
|
"blinking-2": props.isRestored && 1 === props.version.version % 2,
|
||||||
}));
|
}));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="classes">
|
<div :class="classes">
|
||||||
<div
|
<div
|
||||||
class="col-12 tags"
|
class="col-12 tags"
|
||||||
v-if="isCurrent || isKeptBeforeConversion || isRestored || isDuplicated"
|
v-if="
|
||||||
>
|
isCurrent ||
|
||||||
<span class="badge bg-success" v-if="isCurrent">Version actuelle</span>
|
isKeptBeforeConversion ||
|
||||||
<span class="badge bg-info" v-if="isKeptBeforeConversion"
|
isRestored ||
|
||||||
>Conservée avant conversion dans un autre format</span
|
isDuplicated
|
||||||
>
|
"
|
||||||
<span class="badge bg-info" v-if="isRestored"
|
>
|
||||||
>Restaurée depuis la version
|
<span class="badge bg-success" v-if="isCurrent"
|
||||||
{{ version["from-restored"]?.version + 1 }}</span
|
>Version actuelle</span
|
||||||
>
|
>
|
||||||
<span class="badge bg-info" v-if="isDuplicated"
|
<span class="badge bg-info" v-if="isKeptBeforeConversion"
|
||||||
>Dupliqué depuis un autre document</span
|
>Conservée avant conversion dans un autre format</span
|
||||||
>
|
>
|
||||||
|
<span class="badge bg-info" v-if="isRestored"
|
||||||
|
>Restaurée depuis la version
|
||||||
|
{{ version["from-restored"]?.version + 1 }}</span
|
||||||
|
>
|
||||||
|
<span class="badge bg-info" v-if="isDuplicated"
|
||||||
|
>Dupliqué depuis un autre document</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<file-icon :type="version.type"></file-icon>
|
||||||
|
<span
|
||||||
|
><strong> #{{ version.version + 1 }} </strong></span
|
||||||
|
>
|
||||||
|
<template
|
||||||
|
v-if="version.createdBy !== null && version.createdAt !== null"
|
||||||
|
><strong v-if="version.version == 0">créé par</strong
|
||||||
|
><strong v-else>modifié par</strong>
|
||||||
|
<span class="badge-user"
|
||||||
|
><UserRenderBoxBadge
|
||||||
|
:user="version.createdBy"
|
||||||
|
></UserRenderBoxBadge
|
||||||
|
></span>
|
||||||
|
<strong>à</strong>
|
||||||
|
{{
|
||||||
|
$d(ISOToDatetime(version.createdAt.datetime8601), "long")
|
||||||
|
}}</template
|
||||||
|
><template
|
||||||
|
v-if="version.createdBy === null && version.createdAt !== null"
|
||||||
|
><strong v-if="version.version == 0">Créé le</strong
|
||||||
|
><strong v-else>modifié le</strong>
|
||||||
|
{{
|
||||||
|
$d(ISOToDatetime(version.createdAt.datetime8601), "long")
|
||||||
|
}}</template
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<ul class="record_actions small slim on-version-actions">
|
||||||
|
<li v-if="canEdit && !isCurrent">
|
||||||
|
<restore-version-button
|
||||||
|
:stored-object-version="props.version"
|
||||||
|
@restore-version="onRestore"
|
||||||
|
></restore-version-button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<download-button
|
||||||
|
:stored-object="storedObject"
|
||||||
|
:at-version="version"
|
||||||
|
:classes="{
|
||||||
|
btn: true,
|
||||||
|
'btn-outline-primary': true,
|
||||||
|
'btn-sm': true,
|
||||||
|
}"
|
||||||
|
:display-action-string-in-button="false"
|
||||||
|
></download-button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
|
||||||
<file-icon :type="version.type"></file-icon>
|
|
||||||
<span
|
|
||||||
><strong> #{{ version.version + 1 }} </strong></span
|
|
||||||
>
|
|
||||||
<template v-if="version.createdBy !== null && version.createdAt !== null"
|
|
||||||
><strong v-if="version.version == 0">créé par</strong
|
|
||||||
><strong v-else>modifié par</strong>
|
|
||||||
<span class="badge-user"
|
|
||||||
><UserRenderBoxBadge :user="version.createdBy"></UserRenderBoxBadge
|
|
||||||
></span>
|
|
||||||
<strong>à</strong>
|
|
||||||
{{
|
|
||||||
$d(ISOToDatetime(version.createdAt.datetime8601), "long")
|
|
||||||
}}</template
|
|
||||||
><template v-if="version.createdBy === null && version.createdAt !== null"
|
|
||||||
><strong v-if="version.version == 0">Créé le</strong
|
|
||||||
><strong v-else>modifié le</strong>
|
|
||||||
{{
|
|
||||||
$d(ISOToDatetime(version.createdAt.datetime8601), "long")
|
|
||||||
}}</template
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<ul class="record_actions small slim on-version-actions">
|
|
||||||
<li v-if="canEdit && !isCurrent">
|
|
||||||
<restore-version-button
|
|
||||||
:stored-object-version="props.version"
|
|
||||||
@restore-version="onRestore"
|
|
||||||
></restore-version-button>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<download-button
|
|
||||||
:stored-object="storedObject"
|
|
||||||
:at-version="version"
|
|
||||||
:classes="{
|
|
||||||
btn: true,
|
|
||||||
'btn-outline-primary': true,
|
|
||||||
'btn-sm': true,
|
|
||||||
}"
|
|
||||||
:display-action-string-in-button="false"
|
|
||||||
></download-button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
div.tags {
|
div.tags {
|
||||||
span.badge:not(:last-child) {
|
span.badge:not(:last-child) {
|
||||||
margin-right: 0.5rem;
|
margin-right: 0.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// to make the animation restart, we have the same animation twice,
|
// to make the animation restart, we have the same animation twice,
|
||||||
// and alternate between both
|
// and alternate between both
|
||||||
.blinking-1 {
|
.blinking-1 {
|
||||||
animation-name: backgroundColorPalette-1;
|
animation-name: backgroundColorPalette-1;
|
||||||
animation-duration: 8s;
|
animation-duration: 8s;
|
||||||
animation-iteration-count: 1;
|
animation-iteration-count: 1;
|
||||||
animation-direction: normal;
|
animation-direction: normal;
|
||||||
animation-timing-function: linear;
|
animation-timing-function: linear;
|
||||||
}
|
}
|
||||||
@keyframes backgroundColorPalette-1 {
|
@keyframes backgroundColorPalette-1 {
|
||||||
0% {
|
0% {
|
||||||
background: var(--bs-chill-green-dark);
|
background: var(--bs-chill-green-dark);
|
||||||
}
|
}
|
||||||
25% {
|
25% {
|
||||||
background: var(--bs-chill-green);
|
background: var(--bs-chill-green);
|
||||||
}
|
}
|
||||||
65% {
|
65% {
|
||||||
background: var(--bs-chill-beige);
|
background: var(--bs-chill-beige);
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
background: unset;
|
background: unset;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.blinking-2 {
|
.blinking-2 {
|
||||||
animation-name: backgroundColorPalette-2;
|
animation-name: backgroundColorPalette-2;
|
||||||
animation-duration: 8s;
|
animation-duration: 8s;
|
||||||
animation-iteration-count: 1;
|
animation-iteration-count: 1;
|
||||||
animation-direction: normal;
|
animation-direction: normal;
|
||||||
animation-timing-function: linear;
|
animation-timing-function: linear;
|
||||||
}
|
}
|
||||||
@keyframes backgroundColorPalette-2 {
|
@keyframes backgroundColorPalette-2 {
|
||||||
0% {
|
0% {
|
||||||
background: var(--bs-chill-green-dark);
|
background: var(--bs-chill-green-dark);
|
||||||
}
|
}
|
||||||
25% {
|
25% {
|
||||||
background: var(--bs-chill-green);
|
background: var(--bs-chill-green);
|
||||||
}
|
}
|
||||||
65% {
|
65% {
|
||||||
background: var(--bs-chill-beige);
|
background: var(--bs-chill-beige);
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
background: unset;
|
background: unset;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -175,8 +175,6 @@ async function download_and_decrypt_doc(
|
|||||||
throw new Error("no version associated to stored object");
|
throw new Error("no version associated to stored object");
|
||||||
}
|
}
|
||||||
|
|
||||||
// sometimes, the downloadInfo may be embedded into the storedObject
|
|
||||||
console.log("storedObject", storedObject);
|
|
||||||
let downloadInfo;
|
let downloadInfo;
|
||||||
if (
|
if (
|
||||||
typeof storedObject._links !== "undefined" &&
|
typeof storedObject._links !== "undefined" &&
|
||||||
|
|||||||
@@ -46,6 +46,16 @@ abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface
|
|||||||
|
|
||||||
public function voteOnAttribute(StoredObjectRoleEnum $attribute, StoredObject $subject, TokenInterface $token): bool
|
public function voteOnAttribute(StoredObjectRoleEnum $attribute, StoredObject $subject, TokenInterface $token): bool
|
||||||
{
|
{
|
||||||
|
// we first try to get the permission from the workflow, as attachement (this is the less intensive query)
|
||||||
|
$workflowPermissionAsAttachment = match ($attribute) {
|
||||||
|
StoredObjectRoleEnum::SEE => $this->workflowDocumentService->isAllowedByWorkflowForReadOperation($subject),
|
||||||
|
StoredObjectRoleEnum::EDIT => $this->workflowDocumentService->isAllowedByWorkflowForWriteOperation($subject),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (WorkflowRelatedEntityPermissionHelper::FORCE_DENIED === $workflowPermissionAsAttachment) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Retrieve the related entity
|
// Retrieve the related entity
|
||||||
$entity = $this->getRepository()->findAssociatedEntityToStoredObject($subject);
|
$entity = $this->getRepository()->findAssociatedEntityToStoredObject($subject);
|
||||||
|
|
||||||
@@ -66,7 +76,7 @@ abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface
|
|||||||
return match ($workflowPermission) {
|
return match ($workflowPermission) {
|
||||||
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT => true,
|
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT => true,
|
||||||
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED => false,
|
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED => false,
|
||||||
WorkflowRelatedEntityPermissionHelper::ABSTAIN => $regularPermission,
|
WorkflowRelatedEntityPermissionHelper::ABSTAIN => WorkflowRelatedEntityPermissionHelper::FORCE_GRANT === $workflowPermissionAsAttachment || $regularPermission,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ namespace Chill\DocStoreBundle\Security\Authorization;
|
|||||||
use Chill\DocStoreBundle\Entity\StoredObject;
|
use Chill\DocStoreBundle\Entity\StoredObject;
|
||||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for voting on stored object permissions.
|
||||||
|
*
|
||||||
|
* Each time a stored object is attached to a document, the voter is responsible for determining
|
||||||
|
* whether the user has the necessary permissions to access or modify the stored object.
|
||||||
|
*/
|
||||||
interface StoredObjectVoterInterface
|
interface StoredObjectVoterInterface
|
||||||
{
|
{
|
||||||
public function supports(StoredObjectRoleEnum $attribute, StoredObject $subject): bool;
|
public function supports(StoredObjectRoleEnum $attribute, StoredObject $subject): bool;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use Chill\DocStoreBundle\Entity\StoredObject;
|
|||||||
use Chill\DocStoreBundle\Entity\StoredObjectPointInTime;
|
use Chill\DocStoreBundle\Entity\StoredObjectPointInTime;
|
||||||
use Chill\DocStoreBundle\Entity\StoredObjectPointInTimeReasonEnum;
|
use Chill\DocStoreBundle\Entity\StoredObjectPointInTimeReasonEnum;
|
||||||
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
|
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
|
||||||
|
use Chill\DocStoreBundle\Exception\ConversionWithSameMimeTypeException;
|
||||||
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
|
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
|
||||||
use Chill\WopiBundle\Service\WopiConverter;
|
use Chill\WopiBundle\Service\WopiConverter;
|
||||||
use Symfony\Component\Mime\MimeTypesInterface;
|
use Symfony\Component\Mime\MimeTypesInterface;
|
||||||
@@ -41,9 +42,10 @@ class StoredObjectToPdfConverter
|
|||||||
*
|
*
|
||||||
* @return array{0: StoredObjectPointInTime, 1: StoredObjectVersion, 2?: string} contains the point in time before conversion and the new version of the stored object. The converted content is included in the response if $includeConvertedContent is true
|
* @return array{0: StoredObjectPointInTime, 1: StoredObjectVersion, 2?: string} contains the point in time before conversion and the new version of the stored object. The converted content is included in the response if $includeConvertedContent is true
|
||||||
*
|
*
|
||||||
* @throws \UnexpectedValueException if the preferred mime type for the conversion is not found
|
* @throws \UnexpectedValueException if the preferred mime type for the conversion is not found
|
||||||
* @throws \RuntimeException if the conversion or storage of the new version fails
|
* @throws \RuntimeException if the conversion or storage of the new version fails
|
||||||
* @throws StoredObjectManagerException
|
* @throws StoredObjectManagerException
|
||||||
|
* @throws ConversionWithSameMimeTypeException if the document has already the same mime type79*
|
||||||
*/
|
*/
|
||||||
public function addConvertedVersion(StoredObject $storedObject, string $lang, $convertTo = 'pdf', bool $includeConvertedContent = false): array
|
public function addConvertedVersion(StoredObject $storedObject, string $lang, $convertTo = 'pdf', bool $includeConvertedContent = false): array
|
||||||
{
|
{
|
||||||
@@ -56,7 +58,7 @@ class StoredObjectToPdfConverter
|
|||||||
$currentVersion = $storedObject->getCurrentVersion();
|
$currentVersion = $storedObject->getCurrentVersion();
|
||||||
|
|
||||||
if ($currentVersion->getType() === $newMimeType) {
|
if ($currentVersion->getType() === $newMimeType) {
|
||||||
throw new \UnexpectedValueException('Already at the same mime type');
|
throw new ConversionWithSameMimeTypeException($newMimeType);
|
||||||
}
|
}
|
||||||
|
|
||||||
$content = $this->storedObjectManager->read($currentVersion);
|
$content = $this->storedObjectManager->read($currentVersion);
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ class StoredObjectVersionApiControllerTest extends \PHPUnit\Framework\TestCase
|
|||||||
$storedObject->registerVersion();
|
$storedObject->registerVersion();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// remove one version in the history
|
||||||
|
$v5 = $storedObject->getVersions()->get(5);
|
||||||
|
$storedObject->removeVersion($v5);
|
||||||
|
|
||||||
$security = $this->prophesize(Security::class);
|
$security = $this->prophesize(Security::class);
|
||||||
$security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)
|
$security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)
|
||||||
->willReturn(true)
|
->willReturn(true)
|
||||||
@@ -53,6 +57,7 @@ class StoredObjectVersionApiControllerTest extends \PHPUnit\Framework\TestCase
|
|||||||
self::assertEquals($response->getStatusCode(), 200);
|
self::assertEquals($response->getStatusCode(), 200);
|
||||||
self::assertIsArray($body);
|
self::assertIsArray($body);
|
||||||
self::assertArrayHasKey('results', $body);
|
self::assertArrayHasKey('results', $body);
|
||||||
|
self::assertIsList($body['results']);
|
||||||
self::assertCount(10, $body['results']);
|
self::assertCount(10, $body['results']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,9 +86,165 @@ class AbstractStoredObjectVoterTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @dataProvider dataProviderVoteOnAttribute
|
* @dataProvider dataProviderVoteOnAttributeWithStoredObjectPermission
|
||||||
*/
|
*/
|
||||||
public function testVoteOnAttribute(
|
public function testVoteOnAttributeWithStoredObjectPermission(
|
||||||
|
StoredObjectRoleEnum $attribute,
|
||||||
|
bool $expected,
|
||||||
|
bool $isGrantedRegularPermission,
|
||||||
|
string $isGrantedWorkflowPermission,
|
||||||
|
string $isGrantedStoredObjectAttachment,
|
||||||
|
): void {
|
||||||
|
$storedObject = new StoredObject();
|
||||||
|
$repository = new DummyRepository($related = new \stdClass());
|
||||||
|
$token = new UsernamePasswordToken(new User(), 'dummy');
|
||||||
|
|
||||||
|
$security = $this->prophesize(Security::class);
|
||||||
|
$security->isGranted('SOME_ROLE', $related)->willReturn($isGrantedRegularPermission);
|
||||||
|
|
||||||
|
$workflowRelatedEntityPermissionHelper = $this->prophesize(WorkflowRelatedEntityPermissionHelper::class);
|
||||||
|
|
||||||
|
$security = $this->prophesize(Security::class);
|
||||||
|
$security->isGranted('SOME_ROLE', $related)->willReturn($isGrantedRegularPermission);
|
||||||
|
|
||||||
|
if (StoredObjectRoleEnum::SEE === $attribute) {
|
||||||
|
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($storedObject)
|
||||||
|
->shouldBeCalled()
|
||||||
|
->willReturn($isGrantedStoredObjectAttachment);
|
||||||
|
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($related)
|
||||||
|
->willReturn($isGrantedWorkflowPermission);
|
||||||
|
} elseif (StoredObjectRoleEnum::EDIT === $attribute) {
|
||||||
|
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForWriteOperation($storedObject)
|
||||||
|
->shouldBeCalled()
|
||||||
|
->willReturn($isGrantedStoredObjectAttachment);
|
||||||
|
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForWriteOperation($related)
|
||||||
|
->willReturn($isGrantedWorkflowPermission);
|
||||||
|
} else {
|
||||||
|
throw new \LogicException('Invalid attribute for StoredObjectVoter');
|
||||||
|
}
|
||||||
|
|
||||||
|
$storedObjectVoter = new class ($repository, $workflowRelatedEntityPermissionHelper->reveal(), $security->reveal()) extends AbstractStoredObjectVoter {
|
||||||
|
public function __construct(private $repository, $helper, $security)
|
||||||
|
{
|
||||||
|
parent::__construct($security, $helper);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getRepository(): AssociatedEntityToStoredObjectInterface
|
||||||
|
{
|
||||||
|
return $this->repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getClass(): string
|
||||||
|
{
|
||||||
|
return \stdClass::class;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function attributeToRole(StoredObjectRoleEnum $attribute): string
|
||||||
|
{
|
||||||
|
return 'SOME_ROLE';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function canBeAssociatedWithWorkflow(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$actual = $storedObjectVoter->voteOnAttribute($attribute, $storedObject, $token);
|
||||||
|
|
||||||
|
self::assertEquals($expected, $actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function dataProviderVoteOnAttributeWithStoredObjectPermission(): iterable
|
||||||
|
{
|
||||||
|
foreach (['read' => StoredObjectRoleEnum::SEE, 'write' => StoredObjectRoleEnum::EDIT] as $action => $attribute) {
|
||||||
|
yield 'Not related to any workflow nor attachment ('.$action.')' => [
|
||||||
|
$attribute,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
|
||||||
|
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
|
||||||
|
];
|
||||||
|
|
||||||
|
yield 'Not related to any workflow nor attachment (refuse) ('.$action.')' => [
|
||||||
|
$attribute,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
|
||||||
|
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
|
||||||
|
];
|
||||||
|
|
||||||
|
yield 'Is granted by a workflow takes precedence (workflow) ('.$action.')' => [
|
||||||
|
$attribute,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
|
||||||
|
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
|
||||||
|
];
|
||||||
|
|
||||||
|
yield 'Is granted by a workflow takes precedence (stored object) ('.$action.')' => [
|
||||||
|
$attribute,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
|
||||||
|
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
|
||||||
|
];
|
||||||
|
|
||||||
|
yield 'Is granted by a workflow takes precedence (workflow) although grant ('.$action.')' => [
|
||||||
|
$attribute,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
|
||||||
|
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
|
||||||
|
];
|
||||||
|
|
||||||
|
yield 'Is granted by a workflow takes precedence (stored object) although grant ('.$action.')' => [
|
||||||
|
$attribute,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
|
||||||
|
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
|
||||||
|
];
|
||||||
|
|
||||||
|
yield 'Is granted by a workflow takes precedence (initially refused) (workflow) although grant ('.$action.')' => [
|
||||||
|
$attribute,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
|
||||||
|
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
|
||||||
|
];
|
||||||
|
|
||||||
|
yield 'Is granted by a workflow takes precedence (initially refused) (stored object) although grant ('.$action.')' => [
|
||||||
|
$attribute,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
|
||||||
|
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED,
|
||||||
|
];
|
||||||
|
|
||||||
|
yield 'Force grant inverse the regular permission (workflow) ('.$action.')' => [
|
||||||
|
$attribute,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
|
||||||
|
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
|
||||||
|
];
|
||||||
|
|
||||||
|
yield 'Force grant inverse the regular permission (so) ('.$action.')' => [
|
||||||
|
$attribute,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
WorkflowRelatedEntityPermissionHelper::ABSTAIN,
|
||||||
|
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT,
|
||||||
|
];
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider dataProviderVoteOnAttributeWithoutStoredObjectPermission
|
||||||
|
*/
|
||||||
|
public function testVoteOnAttributeWithoutStoredObjectPermission(
|
||||||
StoredObjectRoleEnum $attribute,
|
StoredObjectRoleEnum $attribute,
|
||||||
bool $expected,
|
bool $expected,
|
||||||
bool $canBeAssociatedWithWorkflow,
|
bool $canBeAssociatedWithWorkflow,
|
||||||
@@ -105,6 +261,10 @@ class AbstractStoredObjectVoterTest extends TestCase
|
|||||||
$security->isGranted('SOME_ROLE', $related)->willReturn($isGrantedRegularPermission);
|
$security->isGranted('SOME_ROLE', $related)->willReturn($isGrantedRegularPermission);
|
||||||
|
|
||||||
$workflowRelatedEntityPermissionHelper = $this->prophesize(WorkflowRelatedEntityPermissionHelper::class);
|
$workflowRelatedEntityPermissionHelper = $this->prophesize(WorkflowRelatedEntityPermissionHelper::class);
|
||||||
|
|
||||||
|
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($storedObject)->willReturn(WorkflowRelatedEntityPermissionHelper::ABSTAIN);
|
||||||
|
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForWriteOperation($storedObject)->willReturn(WorkflowRelatedEntityPermissionHelper::ABSTAIN);
|
||||||
|
|
||||||
if (null !== $isGrantedWorkflowPermissionRead) {
|
if (null !== $isGrantedWorkflowPermissionRead) {
|
||||||
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($related)
|
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($related)
|
||||||
->willReturn($isGrantedWorkflowPermissionRead)->shouldBeCalled();
|
->willReturn($isGrantedWorkflowPermissionRead)->shouldBeCalled();
|
||||||
@@ -123,7 +283,7 @@ class AbstractStoredObjectVoterTest extends TestCase
|
|||||||
self::assertEquals($expected, $voter->voteOnAttribute($attribute, $storedObject, $token), $message);
|
self::assertEquals($expected, $voter->voteOnAttribute($attribute, $storedObject, $token), $message);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function dataProviderVoteOnAttribute(): iterable
|
public static function dataProviderVoteOnAttributeWithoutStoredObjectPermission(): iterable
|
||||||
{
|
{
|
||||||
// not associated on a workflow
|
// not associated on a workflow
|
||||||
yield [StoredObjectRoleEnum::SEE, true, false, true, null, null, 'not associated on a workflow, granted by regular access, must not rely on helper'];
|
yield [StoredObjectRoleEnum::SEE, true, false, true, null, null, 'not associated on a workflow, granted by regular access, must not rely on helper'];
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chill is a software for social workers
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view
|
||||||
|
* the LICENSE file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Chill\Migrations\DocStore;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20251013094414 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'DocStore: Enforce filename uniqueness on chill_doc.stored_object_version; clean duplicates and add partial unique index on filename (for new rows only).';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// 1) Clean duplicates: for each (stored_object_id, filename, key, iv), keep only the last inserted row
|
||||||
|
// and delete all others. Use ROW_NUMBER over id DESC to define the last one.
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
WITH ranked AS (
|
||||||
|
SELECT id,
|
||||||
|
rank() OVER (
|
||||||
|
PARTITION BY stored_object_id, filename, "key"::jsonb, iv::jsonb
|
||||||
|
ORDER BY id DESC
|
||||||
|
) AS rn
|
||||||
|
FROM chill_doc.stored_object_version
|
||||||
|
)
|
||||||
|
DELETE FROM chill_doc.stored_object_version sov
|
||||||
|
USING ranked r
|
||||||
|
WHERE sov.id = r.id
|
||||||
|
AND r.rn > 1
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
// 2) Create a partial unique index on filename that applies only to subsequently inserted rows.
|
||||||
|
// Per user's instruction, compute the cutoff using the stored_object_id sequence value.
|
||||||
|
$nextVal = (int) $this->connection->fetchOne("SELECT nextval('chill_doc.stored_object_version_id_seq')");
|
||||||
|
|
||||||
|
// Safety: if somehow sequence is not available, fallback to current max id from the table
|
||||||
|
if ($nextVal <= 0) {
|
||||||
|
$nextVal = (int) $this->connection->fetchOne('SELECT COALESCE(MAX(id), 0) FROM chill_doc.stored_object_version');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->addSql(sprintf(
|
||||||
|
'CREATE UNIQUE INDEX chill_doc_stored_object_version_unique_by_filename ON chill_doc.stored_object_version (filename) WHERE id > %d',
|
||||||
|
$nextVal
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// Drop the partial unique index; data cleanup is irreversible.
|
||||||
|
$this->addSql('DROP INDEX IF EXISTS chill_doc_stored_object_version_unique_by_filename');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@ See the document: Voir le document
|
|||||||
|
|
||||||
document:
|
document:
|
||||||
Any title: Aucun titre
|
Any title: Aucun titre
|
||||||
|
replace: Remplacer
|
||||||
|
Add: Ajouter un document
|
||||||
|
|
||||||
generic_doc:
|
generic_doc:
|
||||||
filter:
|
filter:
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ final class EventController extends AbstractController
|
|||||||
'class' => Center::class,
|
'class' => Center::class,
|
||||||
'choices' => $centers,
|
'choices' => $centers,
|
||||||
'placeholder' => $this->translator->trans('Pick a center'),
|
'placeholder' => $this->translator->trans('Pick a center'),
|
||||||
'label' => 'To which centre should the event be associated ?',
|
'label' => 'To which territory should the event be associated ?',
|
||||||
])
|
])
|
||||||
->add('submit', SubmitType::class, [
|
->add('submit', SubmitType::class, [
|
||||||
'label' => 'Next step',
|
'label' => 'Next step',
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ CHILL_EVENT_PARTICIPATION_SEE_DETAILS: Voir le détail d'une participation
|
|||||||
|
|
||||||
# TODO check place to put this
|
# TODO check place to put this
|
||||||
Next step: Étape suivante
|
Next step: Étape suivante
|
||||||
To which centre should the event be associated ?: À quel centre doit être associé l'événement ?
|
To which territory should the event be associated ?: À quel territoire doit être associé l'événement ?
|
||||||
|
|
||||||
# timeline
|
# timeline
|
||||||
past: passé
|
past: passé
|
||||||
@@ -151,7 +151,7 @@ event:
|
|||||||
filter:
|
filter:
|
||||||
event_types: Par types d'événement
|
event_types: Par types d'événement
|
||||||
event_dates: Par date d'événement
|
event_dates: Par date d'événement
|
||||||
center: Par centre
|
center: Par territoire
|
||||||
by_responsable: Par responsable
|
by_responsable: Par responsable
|
||||||
pick_responsable: Filtrer par responsables
|
pick_responsable: Filtrer par responsables
|
||||||
budget:
|
budget:
|
||||||
@@ -188,7 +188,7 @@ event_id: Identifiant
|
|||||||
event_name: Nom
|
event_name: Nom
|
||||||
event_date: Date
|
event_date: Date
|
||||||
event_type: Type d'évenement
|
event_type: Type d'évenement
|
||||||
event_center: Centre
|
event_center: Territoire
|
||||||
event_moderator: Responsable
|
event_moderator: Responsable
|
||||||
event_participants_count: Nombre de participants
|
event_participants_count: Nombre de participants
|
||||||
event_location: Localisation
|
event_location: Localisation
|
||||||
|
|||||||
@@ -118,7 +118,7 @@
|
|||||||
|
|
||||||
{{ entity.notes|chill_print_or_message("Aucune note", 'blockquote') }}
|
{{ entity.notes|chill_print_or_message("Aucune note", 'blockquote') }}
|
||||||
{% endblock crud_content_view_details %}
|
{% endblock crud_content_view_details %}
|
||||||
|
{% block content_view_actions_duplicate_link %}{% endblock %}
|
||||||
{% block content_view_actions_back %}
|
{% block content_view_actions_back %}
|
||||||
<li class="cancel">
|
<li class="cancel">
|
||||||
<a class="btn btn-cancel" href="{{ chill_return_path_or('chill_job_report_index', { 'person': entity.person.id }) }}">
|
<a class="btn btn-cancel" href="{{ chill_return_path_or('chill_job_report_index', { 'person': entity.person.id }) }}">
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
{% endblock crud_content_view_details %}
|
{% endblock crud_content_view_details %}
|
||||||
|
{% block content_view_actions_duplicate_link %}{% endblock %}
|
||||||
|
|
||||||
{% block content_view_actions_back %}
|
{% block content_view_actions_back %}
|
||||||
<li class="cancel">
|
<li class="cancel">
|
||||||
|
|||||||
@@ -206,6 +206,8 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
{% block content_view_actions_duplicate_link %}{% endblock %}
|
||||||
|
|
||||||
{% block content_view_actions_after %}
|
{% block content_view_actions_after %}
|
||||||
<li>
|
<li>
|
||||||
<a class="btn btn-misc" href="{{ chill_return_path_or('chill_crud_immersion_bilan', { 'id': entity.id, 'person_id': entity.person.id }) }}">
|
<a class="btn btn-misc" href="{{ chill_return_path_or('chill_crud_immersion_bilan', { 'id': entity.id, 'person_id': entity.person.id }) }}">
|
||||||
|
|||||||
@@ -94,6 +94,7 @@
|
|||||||
|
|
||||||
|
|
||||||
{% endblock crud_content_view_details %}
|
{% endblock crud_content_view_details %}
|
||||||
|
{% block content_view_actions_duplicate_link %}{% endblock %}
|
||||||
|
|
||||||
{% block content_view_actions_back %}
|
{% block content_view_actions_back %}
|
||||||
<li class="cancel">
|
<li class="cancel">
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?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\Action\User\UpdateProfile;
|
||||||
|
|
||||||
|
use Chill\MainBundle\Entity\User;
|
||||||
|
use Chill\MainBundle\Notification\NotificationFlagManager;
|
||||||
|
use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint;
|
||||||
|
use libphonenumber\PhoneNumber;
|
||||||
|
|
||||||
|
final class UpdateProfileCommand
|
||||||
|
{
|
||||||
|
public array $notificationFlags = [];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
#[PhonenumberConstraint]
|
||||||
|
public ?PhoneNumber $phonenumber,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function create(User $user, NotificationFlagManager $flagManager): self
|
||||||
|
{
|
||||||
|
$updateProfileCommand = new self($user->getPhonenumber());
|
||||||
|
|
||||||
|
foreach ($flagManager->getAllNotificationFlagProviders() as $provider) {
|
||||||
|
$updateProfileCommand->setNotificationFlag(
|
||||||
|
$provider->getFlag(),
|
||||||
|
User::NOTIF_FLAG_IMMEDIATE_EMAIL,
|
||||||
|
$user->isNotificationSendImmediately($provider->getFlag())
|
||||||
|
);
|
||||||
|
$updateProfileCommand->setNotificationFlag(
|
||||||
|
$provider->getFlag(),
|
||||||
|
User::NOTIF_FLAG_DAILY_DIGEST,
|
||||||
|
$user->isNotificationDailyDigest($provider->getFlag())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $updateProfileCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param User::NOTIF_FLAG_IMMEDIATE_EMAIL|User::NOTIF_FLAG_DAILY_DIGEST $kind
|
||||||
|
*/
|
||||||
|
private function setNotificationFlag(string $type, string $kind, bool $value): void
|
||||||
|
{
|
||||||
|
if (!array_key_exists($type, $this->notificationFlags)) {
|
||||||
|
$this->notificationFlags[$type] = ['immediate_email' => true, 'daily_digest' => false];
|
||||||
|
}
|
||||||
|
|
||||||
|
$k = match ($kind) {
|
||||||
|
User::NOTIF_FLAG_IMMEDIATE_EMAIL => 'immediate_email',
|
||||||
|
User::NOTIF_FLAG_DAILY_DIGEST => 'daily_digest',
|
||||||
|
};
|
||||||
|
|
||||||
|
$this->notificationFlags[$type][$k] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?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\Action\User\UpdateProfile;
|
||||||
|
|
||||||
|
use Chill\MainBundle\Entity\User;
|
||||||
|
|
||||||
|
final readonly class UpdateProfileCommandHandler
|
||||||
|
{
|
||||||
|
public function updateProfile(User $user, UpdateProfileCommand $command): void
|
||||||
|
{
|
||||||
|
$user->setPhonenumber($command->phonenumber);
|
||||||
|
|
||||||
|
foreach ($command->notificationFlags as $flag => $values) {
|
||||||
|
$user->setNotificationImmediately($flag, $values['immediate_email']);
|
||||||
|
$user->setNotificationDailyDigest($flag, $values['daily_digest']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -334,7 +334,7 @@ class ChillImportUsersCommand extends Command
|
|||||||
|
|
||||||
protected function loadUsers()
|
protected function loadUsers()
|
||||||
{
|
{
|
||||||
$reader = Reader::createFromPath($this->tempInput->getArgument('csvfile'));
|
$reader = Reader::from($this->tempInput->getArgument('csvfile'));
|
||||||
$reader->setHeaderOffset(0);
|
$reader->setHeaderOffset(0);
|
||||||
|
|
||||||
foreach ($reader->getRecords() as $line => $r) {
|
foreach ($reader->getRecords() as $line => $r) {
|
||||||
@@ -362,7 +362,7 @@ class ChillImportUsersCommand extends Command
|
|||||||
|
|
||||||
protected function prepareGroupingCenters()
|
protected function prepareGroupingCenters()
|
||||||
{
|
{
|
||||||
$reader = Reader::createFromPath($this->tempInput->getOption('grouping-centers'));
|
$reader = Reader::from($this->tempInput->getOption('grouping-centers'));
|
||||||
$reader->setHeaderOffset(0);
|
$reader->setHeaderOffset(0);
|
||||||
|
|
||||||
foreach ($reader->getRecords() as $r) {
|
foreach ($reader->getRecords() as $r) {
|
||||||
@@ -378,7 +378,7 @@ class ChillImportUsersCommand extends Command
|
|||||||
|
|
||||||
protected function prepareWriter()
|
protected function prepareWriter()
|
||||||
{
|
{
|
||||||
$this->output = $output = Writer::createFromPath($this->tempInput
|
$this->output = $output = Writer::from($this->tempInput
|
||||||
->getOption('csv-dump'), 'a+');
|
->getOption('csv-dump'), 'a+');
|
||||||
|
|
||||||
$output->insertOne([
|
$output->insertOne([
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ class ChillUserSendRenewPasswordCodeCommand extends Command
|
|||||||
protected function getReader()
|
protected function getReader()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$reader = Reader::createFromPath($this->input->getArgument('csvfile'));
|
$reader = Reader::from($this->input->getArgument('csvfile'));
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->logger->error('The csv file could not be read', [
|
$this->logger->error('The csv file could not be read', [
|
||||||
'path' => $this->input->getArgument('csvfile'),
|
'path' => $this->input->getArgument('csvfile'),
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class AbsenceController extends AbstractController
|
|||||||
$user = $this->security->getUser();
|
$user = $this->security->getUser();
|
||||||
|
|
||||||
$user->setAbsenceStart(null);
|
$user->setAbsenceStart(null);
|
||||||
|
$user->setAbsenceEnd(null);
|
||||||
$em = $this->managerRegistry->getManager();
|
$em = $this->managerRegistry->getManager();
|
||||||
$em->flush();
|
$em->flush();
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ final readonly class UserExportController
|
|||||||
|
|
||||||
$users = $this->userRepository->findAllAsArray($request->getLocale());
|
$users = $this->userRepository->findAllAsArray($request->getLocale());
|
||||||
|
|
||||||
$csv = Writer::createFromPath('php://temp', 'r+');
|
$csv = Writer::from('php://temp', 'r+');
|
||||||
$csv->insertOne(
|
$csv->insertOne(
|
||||||
array_map(
|
array_map(
|
||||||
fn (string $e) => $this->translator->trans('admin.users.export.'.$e),
|
fn (string $e) => $this->translator->trans('admin.users.export.'.$e),
|
||||||
@@ -104,7 +104,7 @@ final readonly class UserExportController
|
|||||||
|
|
||||||
$userPermissions = $this->userRepository->findAllUserACLAsArray();
|
$userPermissions = $this->userRepository->findAllUserACLAsArray();
|
||||||
|
|
||||||
$csv = Writer::createFromPath('php://temp', 'r+');
|
$csv = Writer::from('php://temp', 'r+');
|
||||||
$csv->insertOne(
|
$csv->insertOne(
|
||||||
array_map(
|
array_map(
|
||||||
fn (string $e) => $this->translator->trans('admin.users.export.'.$e),
|
fn (string $e) => $this->translator->trans('admin.users.export.'.$e),
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Chill is a software for social workers
|
|
||||||
*
|
|
||||||
* For the full copyright and license information, please view
|
|
||||||
* the LICENSE file that was distributed with this source code.
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Chill\MainBundle\Controller;
|
|
||||||
|
|
||||||
use Chill\MainBundle\Form\UserProfileType;
|
|
||||||
use Chill\MainBundle\Security\ChillSecurity;
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
|
||||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
|
||||||
use Symfony\Component\Routing\Annotation\Route;
|
|
||||||
|
|
||||||
final class UserProfileController extends AbstractController
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly TranslatorInterface $translator,
|
|
||||||
private readonly ChillSecurity $security,
|
|
||||||
private readonly \Doctrine\Persistence\ManagerRegistry $managerRegistry,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User profile that allows editing of phonenumber and visualization of certain data.
|
|
||||||
*/
|
|
||||||
#[Route(path: '/{_locale}/main/user/my-profile', name: 'chill_main_user_profile')]
|
|
||||||
public function __invoke(Request $request)
|
|
||||||
{
|
|
||||||
if (!$this->security->isGranted('ROLE_USER')) {
|
|
||||||
throw new AccessDeniedHttpException();
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = $this->security->getUser();
|
|
||||||
$editForm = $this->createForm(UserProfileType::class, $user);
|
|
||||||
|
|
||||||
$editForm->get('notificationFlags')->setData($user->getNotificationFlags());
|
|
||||||
|
|
||||||
$editForm->handleRequest($request);
|
|
||||||
|
|
||||||
if ($editForm->isSubmitted() && $editForm->isValid()) {
|
|
||||||
$notificationFlagsData = $editForm->get('notificationFlags')->getData();
|
|
||||||
$user->setNotificationFlags($notificationFlagsData);
|
|
||||||
|
|
||||||
$em = $this->managerRegistry->getManager();
|
|
||||||
$em->flush();
|
|
||||||
$this->addFlash('success', $this->translator->trans('user.profile.Profile successfully updated!'));
|
|
||||||
|
|
||||||
return $this->redirectToRoute('chill_main_user_profile');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->render('@ChillMain/User/profile.html.twig', [
|
|
||||||
'user' => $user,
|
|
||||||
'form' => $editForm->createView(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<?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\Controller;
|
||||||
|
|
||||||
|
use Chill\MainBundle\Action\User\UpdateProfile\UpdateProfileCommand;
|
||||||
|
use Chill\MainBundle\Action\User\UpdateProfile\UpdateProfileCommandHandler;
|
||||||
|
use Chill\MainBundle\Entity\User;
|
||||||
|
use Chill\MainBundle\Form\UpdateProfileType;
|
||||||
|
use Chill\MainBundle\Notification\NotificationFlagManager;
|
||||||
|
use Chill\MainBundle\Security\ChillSecurity;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\Form\FormFactoryInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpFoundation\Session\Session;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
|
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||||
|
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||||
|
use Symfony\Component\Routing\Annotation\Route;
|
||||||
|
use Twig\Environment;
|
||||||
|
|
||||||
|
final readonly class UserUpdateProfileController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private TranslatorInterface $translator,
|
||||||
|
private ChillSecurity $security,
|
||||||
|
private EntityManagerInterface $entityManager,
|
||||||
|
private NotificationFlagManager $notificationFlagManager,
|
||||||
|
private FormFactoryInterface $formFactory,
|
||||||
|
private UrlGeneratorInterface $urlGenerator,
|
||||||
|
private Environment $twig,
|
||||||
|
private UpdateProfileCommandHandler $updateProfileCommandHandler,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User profile that allows editing of phonenumber and visualization of certain data.
|
||||||
|
*/
|
||||||
|
#[Route(path: '/{_locale}/main/user/my-profile', name: 'chill_main_user_profile')]
|
||||||
|
public function __invoke(Request $request, Session $session)
|
||||||
|
{
|
||||||
|
if (!$this->security->isGranted('ROLE_USER')) {
|
||||||
|
throw new AccessDeniedHttpException();
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->security->getUser();
|
||||||
|
|
||||||
|
$command = UpdateProfileCommand::create($user, $this->notificationFlagManager);
|
||||||
|
$editForm = $this->formFactory->create(UpdateProfileType::class, $command);
|
||||||
|
|
||||||
|
$editForm->handleRequest($request);
|
||||||
|
|
||||||
|
if ($editForm->isSubmitted() && $editForm->isValid()) {
|
||||||
|
$this->updateProfileCommandHandler->updateProfile($user, $command);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
$session->getFlashBag()->add('success', $this->translator->trans('user.profile.Profile successfully updated!'));
|
||||||
|
|
||||||
|
return new RedirectResponse($this->urlGenerator->generate('chill_main_user_profile'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response($this->twig->render('@ChillMain/User/profile.html.twig', [
|
||||||
|
'user' => $user,
|
||||||
|
'form' => $editForm->createView(),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Chill\MainBundle\Controller;
|
namespace Chill\MainBundle\Controller;
|
||||||
|
|
||||||
|
use Chill\MainBundle\CRUD\Controller\ApiController;
|
||||||
use Chill\MainBundle\Entity\User;
|
use Chill\MainBundle\Entity\User;
|
||||||
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
|
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
|
||||||
use Chill\MainBundle\Pagination\PaginatorFactory;
|
use Chill\MainBundle\Pagination\PaginatorFactory;
|
||||||
@@ -27,7 +28,7 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
|||||||
use Symfony\Component\Security\Core\Security;
|
use Symfony\Component\Security\Core\Security;
|
||||||
use Symfony\Component\Serializer\SerializerInterface;
|
use Symfony\Component\Serializer\SerializerInterface;
|
||||||
|
|
||||||
class WorkflowApiController
|
class WorkflowApiController extends ApiController
|
||||||
{
|
{
|
||||||
public function __construct(private readonly EntityManagerInterface $entityManager, private readonly EntityWorkflowRepository $entityWorkflowRepository, private readonly PaginatorFactory $paginatorFactory, private readonly Security $security, private readonly SerializerInterface $serializer) {}
|
public function __construct(private readonly EntityManagerInterface $entityManager, private readonly EntityWorkflowRepository $entityWorkflowRepository, private readonly PaginatorFactory $paginatorFactory, private readonly Security $security, private readonly SerializerInterface $serializer) {}
|
||||||
|
|
||||||
|
|||||||
@@ -264,11 +264,12 @@ class WorkflowController extends AbstractController
|
|||||||
{
|
{
|
||||||
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
|
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
|
||||||
|
|
||||||
$total = $this->entityWorkflowRepository->countBySubscriber($this->security->getUser());
|
$total = $this->entityWorkflowRepository->countBySubscriber($this->security->getUser(), false);
|
||||||
$paginator = $this->paginatorFactory->create($total);
|
$paginator = $this->paginatorFactory->create($total);
|
||||||
|
|
||||||
$workflows = $this->entityWorkflowRepository->findBySubscriber(
|
$workflows = $this->entityWorkflowRepository->findBySubscriber(
|
||||||
$this->security->getUser(),
|
$this->security->getUser(),
|
||||||
|
false,
|
||||||
['createdAt' => 'DESC'],
|
['createdAt' => 'DESC'],
|
||||||
$paginator->getItemsPerPage(),
|
$paginator->getItemsPerPage(),
|
||||||
$paginator->getCurrentPageFirstItemNumber()
|
$paginator->getCurrentPageFirstItemNumber()
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ final readonly class WorkflowSignatureStateChangeController
|
|||||||
$signature,
|
$signature,
|
||||||
$request,
|
$request,
|
||||||
EntityWorkflowStepSignatureVoter::CANCEL,
|
EntityWorkflowStepSignatureVoter::CANCEL,
|
||||||
function (EntityWorkflowStepSignature $signature) {$this->signatureStepStateChanger->markSignatureAsCanceled($signature); },
|
fn (EntityWorkflowStepSignature $signature): string => $this->signatureStepStateChanger->markSignatureAsCanceled($signature),
|
||||||
'@ChillMain/WorkflowSignature/cancel.html.twig',
|
'@ChillMain/WorkflowSignature/cancel.html.twig',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -56,11 +56,18 @@ final readonly class WorkflowSignatureStateChangeController
|
|||||||
$signature,
|
$signature,
|
||||||
$request,
|
$request,
|
||||||
EntityWorkflowStepSignatureVoter::REJECT,
|
EntityWorkflowStepSignatureVoter::REJECT,
|
||||||
function (EntityWorkflowStepSignature $signature) {$this->signatureStepStateChanger->markSignatureAsRejected($signature); },
|
fn (EntityWorkflowStepSignature $signature): string => $this->signatureStepStateChanger->markSignatureAsRejected($signature),
|
||||||
'@ChillMain/WorkflowSignature/reject.html.twig',
|
'@ChillMain/WorkflowSignature/reject.html.twig',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param callable(EntityWorkflowStepSignature): string $markSignature
|
||||||
|
*
|
||||||
|
* @throws \Twig\Error\LoaderError
|
||||||
|
* @throws \Twig\Error\RuntimeError
|
||||||
|
* @throws \Twig\Error\SyntaxError
|
||||||
|
*/
|
||||||
private function markSignatureAction(
|
private function markSignatureAction(
|
||||||
EntityWorkflowStepSignature $signature,
|
EntityWorkflowStepSignature $signature,
|
||||||
Request $request,
|
Request $request,
|
||||||
@@ -79,12 +86,13 @@ final readonly class WorkflowSignatureStateChangeController
|
|||||||
$form->handleRequest($request);
|
$form->handleRequest($request);
|
||||||
|
|
||||||
if ($form->isSubmitted() && $form->isValid()) {
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
$this->entityManager->wrapInTransaction(function () use ($signature, $markSignature) {
|
$expectedStep = $this->entityManager->wrapInTransaction(fn () => $markSignature($signature));
|
||||||
$markSignature($signature);
|
|
||||||
});
|
|
||||||
|
|
||||||
return new RedirectResponse(
|
return new RedirectResponse(
|
||||||
$this->chillUrlGenerator->returnPathOr('chill_main_workflow_show', ['id' => $signature->getStep()->getEntityWorkflow()->getId()])
|
$this->chillUrlGenerator->forwardReturnPath(
|
||||||
|
'chill_main_workflow_wait',
|
||||||
|
['id' => $signature->getStep()->getEntityWorkflow()->getId(), 'expectedStep' => $expectedStep]
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?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\Controller;
|
||||||
|
|
||||||
|
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
|
||||||
|
use Chill\MainBundle\Routing\ChillUrlGeneratorInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Annotation\Route;
|
||||||
|
use Twig\Environment;
|
||||||
|
|
||||||
|
final readonly class WorkflowWaitStepChangeController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ChillUrlGeneratorInterface $chillUrlGenerator,
|
||||||
|
private Environment $twig,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
#[Route('/{_locale}/main/workflow/{id}/wait/{expectedStep}', name: 'chill_main_workflow_wait', methods: ['GET'])]
|
||||||
|
public function waitForSignatureChange(EntityWorkflow $entityWorkflow, string $expectedStep): Response
|
||||||
|
{
|
||||||
|
if ($entityWorkflow->getStep() === $expectedStep) {
|
||||||
|
return new RedirectResponse(
|
||||||
|
$this->chillUrlGenerator->returnPathOr('chill_main_workflow_show', ['id' => $entityWorkflow->getId()])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
$this->twig->render('@ChillMain/Workflow/waiting.html.twig', ['workflow' => $entityWorkflow, 'expectedStep' => $expectedStep])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ use Chill\MainBundle\Controller\UserGroupAdminController;
|
|||||||
use Chill\MainBundle\Controller\UserGroupApiController;
|
use Chill\MainBundle\Controller\UserGroupApiController;
|
||||||
use Chill\MainBundle\Controller\UserJobApiController;
|
use Chill\MainBundle\Controller\UserJobApiController;
|
||||||
use Chill\MainBundle\Controller\UserJobController;
|
use Chill\MainBundle\Controller\UserJobController;
|
||||||
|
use Chill\MainBundle\Controller\WorkflowApiController;
|
||||||
use Chill\MainBundle\DependencyInjection\Widget\Factory\WidgetFactoryInterface;
|
use Chill\MainBundle\DependencyInjection\Widget\Factory\WidgetFactoryInterface;
|
||||||
use Chill\MainBundle\Doctrine\DQL\Age;
|
use Chill\MainBundle\Doctrine\DQL\Age;
|
||||||
use Chill\MainBundle\Doctrine\DQL\Extract;
|
use Chill\MainBundle\Doctrine\DQL\Extract;
|
||||||
@@ -66,6 +67,7 @@ use Chill\MainBundle\Entity\Regroupment;
|
|||||||
use Chill\MainBundle\Entity\User;
|
use Chill\MainBundle\Entity\User;
|
||||||
use Chill\MainBundle\Entity\UserGroup;
|
use Chill\MainBundle\Entity\UserGroup;
|
||||||
use Chill\MainBundle\Entity\UserJob;
|
use Chill\MainBundle\Entity\UserJob;
|
||||||
|
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
|
||||||
use Chill\MainBundle\Form\CenterType;
|
use Chill\MainBundle\Form\CenterType;
|
||||||
use Chill\MainBundle\Form\CivilityType;
|
use Chill\MainBundle\Form\CivilityType;
|
||||||
use Chill\MainBundle\Form\CountryType;
|
use Chill\MainBundle\Form\CountryType;
|
||||||
@@ -79,6 +81,7 @@ use Chill\MainBundle\Form\UserGroupType;
|
|||||||
use Chill\MainBundle\Form\UserJobType;
|
use Chill\MainBundle\Form\UserJobType;
|
||||||
use Chill\MainBundle\Form\UserType;
|
use Chill\MainBundle\Form\UserType;
|
||||||
use Chill\MainBundle\Security\Authorization\ChillExportVoter;
|
use Chill\MainBundle\Security\Authorization\ChillExportVoter;
|
||||||
|
use Chill\MainBundle\Security\Authorization\EntityWorkflowVoter;
|
||||||
use Misd\PhoneNumberBundle\Doctrine\DBAL\Types\PhoneNumberType;
|
use Misd\PhoneNumberBundle\Doctrine\DBAL\Types\PhoneNumberType;
|
||||||
use Ramsey\Uuid\Doctrine\UuidType;
|
use Ramsey\Uuid\Doctrine\UuidType;
|
||||||
use Symfony\Component\Config\FileLocator;
|
use Symfony\Component\Config\FileLocator;
|
||||||
@@ -202,6 +205,11 @@ class ChillMainExtension extends Extension implements
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$container->setParameter(
|
||||||
|
'chill_main.top_banner',
|
||||||
|
$config['top_banner'] ?? []
|
||||||
|
);
|
||||||
|
|
||||||
$loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../config'));
|
$loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../config'));
|
||||||
$loader->load('services.yaml');
|
$loader->load('services.yaml');
|
||||||
$loader->load('services/doctrine.yaml');
|
$loader->load('services/doctrine.yaml');
|
||||||
@@ -247,6 +255,7 @@ class ChillMainExtension extends Extension implements
|
|||||||
'name' => $config['installation_name'], ],
|
'name' => $config['installation_name'], ],
|
||||||
'available_languages' => $config['available_languages'],
|
'available_languages' => $config['available_languages'],
|
||||||
'add_address' => $config['add_address'],
|
'add_address' => $config['add_address'],
|
||||||
|
'chill_main_config' => $config,
|
||||||
],
|
],
|
||||||
'form_themes' => ['@ChillMain/Form/fields.html.twig'],
|
'form_themes' => ['@ChillMain/Form/fields.html.twig'],
|
||||||
];
|
];
|
||||||
@@ -940,6 +949,21 @@ class ChillMainExtension extends Extension implements
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'class' => EntityWorkflow::class,
|
||||||
|
'name' => 'workflow',
|
||||||
|
'base_path' => '/api/1.0/main/workflow',
|
||||||
|
'base_role' => EntityWorkflowVoter::SEE,
|
||||||
|
'controller' => WorkflowApiController::class,
|
||||||
|
'actions' => [
|
||||||
|
'_entity' => [
|
||||||
|
'methods' => [
|
||||||
|
Request::METHOD_GET => true,
|
||||||
|
Request::METHOD_HEAD => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,6 +168,20 @@ class Configuration implements ConfigurationInterface
|
|||||||
->end()
|
->end()
|
||||||
->end()
|
->end()
|
||||||
->end()
|
->end()
|
||||||
|
->arrayNode('top_banner')
|
||||||
|
->canBeUnset()
|
||||||
|
->children()
|
||||||
|
->booleanNode('visible')
|
||||||
|
->defaultFalse()
|
||||||
|
->end()
|
||||||
|
->arrayNode('text')
|
||||||
|
->useAttributeAsKey('lang')
|
||||||
|
->scalarPrototype()->end()
|
||||||
|
->end() // end of text
|
||||||
|
->scalarNode('color')->defaultNull()->end()
|
||||||
|
->scalarNode('background_color')->defaultNull()->end()
|
||||||
|
->end() // end of top_banner children
|
||||||
|
->end() // end of top_banner
|
||||||
->arrayNode('widgets')
|
->arrayNode('widgets')
|
||||||
->canBeEnabled()
|
->canBeEnabled()
|
||||||
->canBeUnset()
|
->canBeUnset()
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
|||||||
use Symfony\Component\Security\Core\User\UserInterface;
|
use Symfony\Component\Security\Core\User\UserInterface;
|
||||||
use Symfony\Component\Serializer\Annotation as Serializer;
|
use Symfony\Component\Serializer\Annotation as Serializer;
|
||||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||||
|
use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User.
|
* User.
|
||||||
@@ -44,6 +46,8 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
|
|||||||
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true)]
|
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||||
private ?\DateTimeImmutable $absenceStart = null;
|
private ?\DateTimeImmutable $absenceStart = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true)]
|
||||||
|
private ?\DateTimeImmutable $absenceEnd = null;
|
||||||
/**
|
/**
|
||||||
* Array where SAML attributes's data are stored.
|
* Array where SAML attributes's data are stored.
|
||||||
*/
|
*/
|
||||||
@@ -156,6 +160,11 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
|
|||||||
return $this->absenceStart;
|
return $this->absenceStart;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getAbsenceEnd(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->absenceEnd;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get attributes.
|
* Get attributes.
|
||||||
*
|
*
|
||||||
@@ -335,7 +344,13 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
|
|||||||
|
|
||||||
public function isAbsent(): bool
|
public function isAbsent(): bool
|
||||||
{
|
{
|
||||||
return null !== $this->getAbsenceStart() && $this->getAbsenceStart() <= new \DateTimeImmutable('now');
|
$now = new \DateTimeImmutable('now');
|
||||||
|
$absenceStart = $this->getAbsenceStart();
|
||||||
|
$absenceEnd = $this->getAbsenceEnd();
|
||||||
|
|
||||||
|
return null !== $absenceStart
|
||||||
|
&& $absenceStart <= $now
|
||||||
|
&& (null === $absenceEnd || $now <= $absenceEnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -409,6 +424,11 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
|
|||||||
$this->absenceStart = $absenceStart;
|
$this->absenceStart = $absenceStart;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function setAbsenceEnd(?\DateTimeImmutable $absenceEnd): void
|
||||||
|
{
|
||||||
|
$this->absenceEnd = $absenceEnd;
|
||||||
|
}
|
||||||
|
|
||||||
public function setAttributeByDomain(string $domain, string $key, $value): self
|
public function setAttributeByDomain(string $domain, string $key, $value): self
|
||||||
{
|
{
|
||||||
$this->attributes[$domain][$key] = $value;
|
$this->attributes[$domain][$key] = $value;
|
||||||
@@ -632,46 +652,82 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getNotificationFlags(): array
|
private function getNotificationFlagData(string $flag): array
|
||||||
{
|
{
|
||||||
return $this->notificationFlags;
|
return $this->notificationFlags[$flag] ?? [self::NOTIF_FLAG_IMMEDIATE_EMAIL];
|
||||||
}
|
|
||||||
|
|
||||||
public function setNotificationFlags(array $notificationFlags)
|
|
||||||
{
|
|
||||||
$this->notificationFlags = $notificationFlags;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getNotificationFlagData(string $flag): array
|
|
||||||
{
|
|
||||||
return $this->notificationFlags[$flag] ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setNotificationFlagData(string $flag, array $data): void
|
|
||||||
{
|
|
||||||
$this->notificationFlags[$flag] = $data;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isNotificationSendImmediately(string $type): bool
|
public function isNotificationSendImmediately(string $type): bool
|
||||||
{
|
{
|
||||||
if ([] === $this->getNotificationFlagData($type) || in_array(User::NOTIF_FLAG_IMMEDIATE_EMAIL, $this->getNotificationFlagData($type), true)) {
|
return $this->isNotificationForElement($type, self::NOTIF_FLAG_IMMEDIATE_EMAIL);
|
||||||
return true;
|
}
|
||||||
|
|
||||||
|
public function setNotificationImmediately(string $type, bool $active): void
|
||||||
|
{
|
||||||
|
$this->setNotificationFlagElement($type, $active, self::NOTIF_FLAG_IMMEDIATE_EMAIL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setNotificationDailyDigest(string $type, bool $active): void
|
||||||
|
{
|
||||||
|
$this->setNotificationFlagElement($type, $active, self::NOTIF_FLAG_DAILY_DIGEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param self::NOTIF_FLAG_IMMEDIATE_EMAIL|self::NOTIF_FLAG_DAILY_DIGEST $kind
|
||||||
|
*/
|
||||||
|
private function setNotificationFlagElement(string $type, bool $active, string $kind): void
|
||||||
|
{
|
||||||
|
$notificationFlags = [...$this->notificationFlags];
|
||||||
|
$changed = false;
|
||||||
|
|
||||||
|
if (!isset($notificationFlags[$type])) {
|
||||||
|
$notificationFlags[$type] = [self::NOTIF_FLAG_IMMEDIATE_EMAIL];
|
||||||
|
$changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
if ($active) {
|
||||||
|
if (!in_array($kind, $notificationFlags[$type], true)) {
|
||||||
|
$notificationFlags[$type] = [...$notificationFlags[$type], $kind];
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (in_array($kind, $notificationFlags[$type], true)) {
|
||||||
|
$notificationFlags[$type] = array_values(
|
||||||
|
array_filter($notificationFlags[$type], static fn ($k) => $k !== $kind)
|
||||||
|
);
|
||||||
|
$changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($changed) {
|
||||||
|
$this->notificationFlags = [...$notificationFlags];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isNotificationForElement(string $type, string $kind): bool
|
||||||
|
{
|
||||||
|
return in_array($kind, $this->getNotificationFlagData($type), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isNotificationDailyDigest(string $type): bool
|
public function isNotificationDailyDigest(string $type): bool
|
||||||
{
|
{
|
||||||
if (in_array(User::NOTIF_FLAG_DAILY_DIGEST, $this->getNotificationFlagData($type), true)) {
|
return $this->isNotificationForElement($type, self::NOTIF_FLAG_DAILY_DIGEST);
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getLocale(): string
|
public function getLocale(): string
|
||||||
{
|
{
|
||||||
return 'fr';
|
return 'fr';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Assert\Callback]
|
||||||
|
public function validateAbsenceDates(ExecutionContextInterface $context): void
|
||||||
|
{
|
||||||
|
if (null !== $this->getAbsenceEnd() && null === $this->getAbsenceStart()) {
|
||||||
|
$context->buildViolation(
|
||||||
|
'user.absence_end_requires_start'
|
||||||
|
)
|
||||||
|
->atPath('absenceEnd')
|
||||||
|
->addViolation();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ class EntityWorkflowStep
|
|||||||
/**
|
/**
|
||||||
* @var Collection<int, EntityWorkflowStepHold>
|
* @var Collection<int, EntityWorkflowStepHold>
|
||||||
*/
|
*/
|
||||||
#[ORM\OneToMany(mappedBy: 'step', targetEntity: EntityWorkflowStepHold::class)]
|
#[ORM\OneToMany(mappedBy: 'step', targetEntity: EntityWorkflowStepHold::class, cascade: ['remove'])]
|
||||||
private Collection $holdsOnStep;
|
private Collection $holdsOnStep;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -23,9 +23,14 @@ class AbsenceType extends AbstractType
|
|||||||
{
|
{
|
||||||
$builder
|
$builder
|
||||||
->add('absenceStart', ChillDateType::class, [
|
->add('absenceStart', ChillDateType::class, [
|
||||||
'required' => true,
|
'required' => false,
|
||||||
'input' => 'datetime_immutable',
|
'input' => 'datetime_immutable',
|
||||||
'label' => 'absence.Absence start',
|
'label' => 'absence.Absence start',
|
||||||
|
])
|
||||||
|
->add('absenceEnd', ChillDateType::class, [
|
||||||
|
'required' => false,
|
||||||
|
'input' => 'datetime_immutable',
|
||||||
|
'label' => 'absence.Absence end',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Chill is a software for social workers
|
|
||||||
*
|
|
||||||
* For the full copyright and license information, please view
|
|
||||||
* the LICENSE file that was distributed with this source code.
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace Chill\MainBundle\Form\DataMapper;
|
|
||||||
|
|
||||||
use Chill\MainBundle\Entity\User;
|
|
||||||
use Symfony\Component\Form\DataMapperInterface;
|
|
||||||
|
|
||||||
final readonly class NotificationFlagDataMapper implements DataMapperInterface
|
|
||||||
{
|
|
||||||
public function __construct(private array $notificationFlagProviders) {}
|
|
||||||
|
|
||||||
public function mapDataToForms($viewData, $forms): void
|
|
||||||
{
|
|
||||||
if (null === $viewData) {
|
|
||||||
$viewData = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$formsArray = iterator_to_array($forms);
|
|
||||||
|
|
||||||
foreach ($this->notificationFlagProviders as $flagProvider) {
|
|
||||||
$flag = $flagProvider->getFlag();
|
|
||||||
|
|
||||||
if (isset($formsArray[$flag])) {
|
|
||||||
$flagForm = $formsArray[$flag];
|
|
||||||
|
|
||||||
$immediateEmailChecked = in_array(User::NOTIF_FLAG_IMMEDIATE_EMAIL, $viewData[$flag] ?? [], true)
|
|
||||||
|| !array_key_exists($flag, $viewData);
|
|
||||||
$dailyEmailChecked = in_array(User::NOTIF_FLAG_DAILY_DIGEST, $viewData[$flag] ?? [], true);
|
|
||||||
|
|
||||||
if ($flagForm->has('immediate_email')) {
|
|
||||||
$flagForm->get('immediate_email')->setData($immediateEmailChecked);
|
|
||||||
}
|
|
||||||
if ($flagForm->has('daily_email')) {
|
|
||||||
$flagForm->get('daily_email')->setData($dailyEmailChecked);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function mapFormsToData($forms, &$viewData): void
|
|
||||||
{
|
|
||||||
$formsArray = iterator_to_array($forms);
|
|
||||||
$viewData = [];
|
|
||||||
|
|
||||||
foreach ($this->notificationFlagProviders as $flagProvider) {
|
|
||||||
$flag = $flagProvider->getFlag();
|
|
||||||
|
|
||||||
if (isset($formsArray[$flag])) {
|
|
||||||
$flagForm = $formsArray[$flag];
|
|
||||||
$viewData[$flag] = [];
|
|
||||||
|
|
||||||
if (true === $flagForm['immediate_email']->getData()) {
|
|
||||||
$viewData[$flag][] = User::NOTIF_FLAG_IMMEDIATE_EMAIL;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (true === $flagForm['daily_email']->getData()) {
|
|
||||||
$viewData[$flag][] = User::NOTIF_FLAG_DAILY_DIGEST;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ([] === $viewData[$flag]) {
|
|
||||||
$viewData[$flag][] = User::NOTIF_FLAG_IMMEDIATE_EMAIL;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,11 +11,9 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Chill\MainBundle\Form\Type;
|
namespace Chill\MainBundle\Form\Type;
|
||||||
|
|
||||||
use Chill\MainBundle\Form\DataMapper\NotificationFlagDataMapper;
|
|
||||||
use Chill\MainBundle\Notification\NotificationFlagManager;
|
use Chill\MainBundle\Notification\NotificationFlagManager;
|
||||||
use Symfony\Component\Form\AbstractType;
|
use Symfony\Component\Form\AbstractType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\FormType;
|
|
||||||
use Symfony\Component\Form\FormBuilderInterface;
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
@@ -30,27 +28,24 @@ class NotificationFlagsType extends AbstractType
|
|||||||
|
|
||||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
{
|
{
|
||||||
$builder->setDataMapper(new NotificationFlagDataMapper($this->notificationFlagProviders));
|
|
||||||
|
|
||||||
foreach ($this->notificationFlagProviders as $flagProvider) {
|
foreach ($this->notificationFlagProviders as $flagProvider) {
|
||||||
$flag = $flagProvider->getFlag();
|
$flag = $flagProvider->getFlag();
|
||||||
$builder->add($flag, FormType::class, [
|
$flagBuilder = $builder->create($flag, options: [
|
||||||
'label' => $flagProvider->getLabel(),
|
'label' => $flagProvider->getLabel(),
|
||||||
'required' => false,
|
'compound' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$builder->get($flag)
|
$flagBuilder
|
||||||
->add('immediate_email', CheckboxType::class, [
|
->add('immediate_email', CheckboxType::class, [
|
||||||
'label' => false,
|
'label' => false,
|
||||||
'required' => false,
|
'required' => false,
|
||||||
'mapped' => false,
|
|
||||||
])
|
])
|
||||||
->add('daily_email', CheckboxType::class, [
|
->add('daily_digest', CheckboxType::class, [
|
||||||
'label' => false,
|
'label' => false,
|
||||||
'required' => false,
|
'required' => false,
|
||||||
'mapped' => false,
|
|
||||||
])
|
])
|
||||||
;
|
;
|
||||||
|
$builder->add($flagBuilder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +53,7 @@ class NotificationFlagsType extends AbstractType
|
|||||||
{
|
{
|
||||||
$resolver->setDefaults([
|
$resolver->setDefaults([
|
||||||
'data_class' => null,
|
'data_class' => null,
|
||||||
|
'compound' => true,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,31 +11,29 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Chill\MainBundle\Form;
|
namespace Chill\MainBundle\Form;
|
||||||
|
|
||||||
|
use Chill\MainBundle\Action\User\UpdateProfile\UpdateProfileCommand;
|
||||||
use Chill\MainBundle\Form\Type\ChillPhoneNumberType;
|
use Chill\MainBundle\Form\Type\ChillPhoneNumberType;
|
||||||
use Chill\MainBundle\Form\Type\NotificationFlagsType;
|
use Chill\MainBundle\Form\Type\NotificationFlagsType;
|
||||||
use Symfony\Component\Form\AbstractType;
|
use Symfony\Component\Form\AbstractType;
|
||||||
use Symfony\Component\Form\FormBuilderInterface;
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
|
||||||
class UserProfileType extends AbstractType
|
class UpdateProfileType extends AbstractType
|
||||||
{
|
{
|
||||||
public function buildForm(FormBuilderInterface $builder, array $options)
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
{
|
{
|
||||||
$builder
|
$builder
|
||||||
->add('phonenumber', ChillPhoneNumberType::class, [
|
->add('phonenumber', ChillPhoneNumberType::class, [
|
||||||
'required' => false,
|
'required' => false,
|
||||||
])
|
])
|
||||||
->add('notificationFlags', NotificationFlagsType::class, [
|
->add('notificationFlags', NotificationFlagsType::class)
|
||||||
'label' => false,
|
|
||||||
'mapped' => false,
|
|
||||||
])
|
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function configureOptions(OptionsResolver $resolver)
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
{
|
{
|
||||||
$resolver->setDefaults([
|
$resolver->setDefaults([
|
||||||
'data_class' => \Chill\MainBundle\Entity\User::class,
|
'data_class' => UpdateProfileCommand::class,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,7 +59,7 @@ class UserPasswordType extends AbstractType
|
|||||||
'invalid_message' => 'The password fields must match',
|
'invalid_message' => 'The password fields must match',
|
||||||
'constraints' => [
|
'constraints' => [
|
||||||
new Length([
|
new Length([
|
||||||
'min' => 9,
|
'min' => 14,
|
||||||
'minMessage' => 'The password must be greater than {{ limit }} characters',
|
'minMessage' => 'The password must be greater than {{ limit }} characters',
|
||||||
]),
|
]),
|
||||||
new NotBlank(),
|
new NotBlank(),
|
||||||
|
|||||||
@@ -105,6 +105,11 @@ class UserType extends AbstractType
|
|||||||
'required' => false,
|
'required' => false,
|
||||||
'input' => 'datetime_immutable',
|
'input' => 'datetime_immutable',
|
||||||
'label' => 'absence.Absence start',
|
'label' => 'absence.Absence start',
|
||||||
|
])
|
||||||
|
->add('absenceEnd', ChillDateType::class, [
|
||||||
|
'required' => false,
|
||||||
|
'input' => 'datetime_immutable',
|
||||||
|
'label' => 'absence.Absence end',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// @phpstan-ignore-next-line
|
// @phpstan-ignore-next-line
|
||||||
|
|||||||
@@ -53,11 +53,16 @@ readonly class DailyNotificationDigestCronjob implements CronJobInterface
|
|||||||
public function run(array $lastExecutionData): ?array
|
public function run(array $lastExecutionData): ?array
|
||||||
{
|
{
|
||||||
$now = $this->clock->now();
|
$now = $this->clock->now();
|
||||||
|
|
||||||
if (isset($lastExecutionData['last_execution'])) {
|
if (isset($lastExecutionData['last_execution'])) {
|
||||||
$lastExecution = \DateTimeImmutable::createFromFormat(
|
$lastExecution = \DateTimeImmutable::createFromFormat(
|
||||||
\DateTimeImmutable::ATOM,
|
\DateTimeImmutable::ATOM,
|
||||||
$lastExecutionData['last_execution']
|
$lastExecutionData['last_execution']
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (false === $lastExecution) {
|
||||||
|
$lastExecution = $now->sub(new \DateInterval('P1D'));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
$lastExecution = $now->sub(new \DateInterval('P1D'));
|
$lastExecution = $now->sub(new \DateInterval('P1D'));
|
||||||
}
|
}
|
||||||
@@ -96,7 +101,7 @@ readonly class DailyNotificationDigestCronjob implements CronJobInterface
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'last_execution' => $now->format('Y-m-d-H:i:s.u e'),
|
'last_execution' => $now->format(\DateTimeInterface::ATOM),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,9 +57,15 @@ class EntityWorkflowRepository implements ObjectRepository
|
|||||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function countBySubscriber(User $user): int
|
/**
|
||||||
|
* @param bool|null $isFinal true to get only the entityWorkflow which is finalized, false to get the workflows that are not finalized, and null to ignore
|
||||||
|
*
|
||||||
|
* @throws \Doctrine\ORM\NoResultException
|
||||||
|
* @throws \Doctrine\ORM\NonUniqueResultException
|
||||||
|
*/
|
||||||
|
public function countBySubscriber(User $user, ?bool $isFinal = null): int
|
||||||
{
|
{
|
||||||
$qb = $this->buildQueryBySubscriber($user)->select('count(ew)');
|
$qb = $this->buildQueryBySubscriber($user, $isFinal)->select('count(ew)');
|
||||||
|
|
||||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||||
}
|
}
|
||||||
@@ -182,9 +188,14 @@ class EntityWorkflowRepository implements ObjectRepository
|
|||||||
return $qb->getQuery()->getResult();
|
return $qb->getQuery()->getResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function findBySubscriber(User $user, ?array $orderBy = null, $limit = null, $offset = null): array
|
/**
|
||||||
|
* @param bool|null $isFinal true to get only the entityWorkflow which is finalized, false to get the workflows that are not finalized, and null to ignore
|
||||||
|
* @param mixed|null $limit
|
||||||
|
* @param mixed|null $offset
|
||||||
|
*/
|
||||||
|
public function findBySubscriber(User $user, ?bool $isFinal = null, ?array $orderBy = null, $limit = null, $offset = null): array
|
||||||
{
|
{
|
||||||
$qb = $this->buildQueryBySubscriber($user)->select('ew');
|
$qb = $this->buildQueryBySubscriber($user, $isFinal)->select('ew');
|
||||||
|
|
||||||
foreach ($orderBy as $key => $sort) {
|
foreach ($orderBy as $key => $sort) {
|
||||||
$qb->addOrderBy('ew.'.$key, $sort);
|
$qb->addOrderBy('ew.'.$key, $sort);
|
||||||
@@ -312,7 +323,7 @@ class EntityWorkflowRepository implements ObjectRepository
|
|||||||
return $qb;
|
return $qb;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function buildQueryBySubscriber(User $user): QueryBuilder
|
private function buildQueryBySubscriber(User $user, ?bool $isFinal): QueryBuilder
|
||||||
{
|
{
|
||||||
$qb = $this->repository->createQueryBuilder('ew');
|
$qb = $this->repository->createQueryBuilder('ew');
|
||||||
|
|
||||||
@@ -325,6 +336,14 @@ class EntityWorkflowRepository implements ObjectRepository
|
|||||||
|
|
||||||
$qb->setParameter('user', $user);
|
$qb->setParameter('user', $user);
|
||||||
|
|
||||||
|
if (null !== $isFinal) {
|
||||||
|
if ($isFinal) {
|
||||||
|
$qb->andWhere(sprintf('EXISTS (SELECT 1 FROM %s step WHERE step.isFinal = true AND ew = step.entityWorkflow)', EntityWorkflowStep::class));
|
||||||
|
} else {
|
||||||
|
$qb->andWhere(sprintf('NOT EXISTS (SELECT 1 FROM %s step WHERE step.isFinal = true AND ew = step.entityWorkflow)', EntityWorkflowStep::class));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return $qb;
|
return $qb;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,15 +13,15 @@
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export const dateToISO = (date: Date | null): string | null => {
|
export const dateToISO = (date: Date | null): string | null => {
|
||||||
if (null === date) {
|
if (null === date) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
date.getFullYear(),
|
date.getFullYear(),
|
||||||
(date.getMonth() + 1).toString().padStart(2, "0"),
|
(date.getMonth() + 1).toString().padStart(2, "0"),
|
||||||
date.getDate().toString().padStart(2, "0"),
|
date.getDate().toString().padStart(2, "0"),
|
||||||
].join("-");
|
].join("-");
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,16 +30,21 @@ export const dateToISO = (date: Date | null): string | null => {
|
|||||||
* **Experimental**
|
* **Experimental**
|
||||||
*/
|
*/
|
||||||
export const ISOToDate = (str: string | null): Date | null => {
|
export const ISOToDate = (str: string | null): Date | null => {
|
||||||
if (null === str) {
|
if (null === str) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if ("" === str.trim()) {
|
if ("" === str.trim()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [year, month, day] = str.split("-").map((p) => parseInt(p));
|
// If the string already contains time info, use it directly
|
||||||
|
if (str.includes("T") || str.includes(" ")) {
|
||||||
|
return new Date(str);
|
||||||
|
}
|
||||||
|
|
||||||
return new Date(year, month - 1, day, 0, 0, 0, 0);
|
// Otherwise, parse date only
|
||||||
|
const [year, month, day] = str.split("-").map((p) => parseInt(p));
|
||||||
|
return new Date(year, month - 1, day, 0, 0, 0, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,19 +52,21 @@ export const ISOToDate = (str: string | null): Date | null => {
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export const ISOToDatetime = (str: string | null): Date | null => {
|
export const ISOToDatetime = (str: string | null): Date | null => {
|
||||||
if (null === str) {
|
if (null === str) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [cal, times] = str.split("T"),
|
const [cal, times] = str.split("T"),
|
||||||
[year, month, date] = cal.split("-").map((s) => parseInt(s)),
|
[year, month, date] = cal.split("-").map((s) => parseInt(s)),
|
||||||
[time, timezone] = times.split(times.charAt(8)),
|
[time, timezone] = times.split(times.charAt(8)),
|
||||||
[hours, minutes, seconds] = time.split(":").map((s) => parseInt(s));
|
[hours, minutes, seconds] = time.split(":").map((s) => parseInt(s));
|
||||||
if ("0000" === timezone) {
|
if ("0000" === timezone) {
|
||||||
return new Date(Date.UTC(year, month - 1, date, hours, minutes, seconds));
|
return new Date(
|
||||||
}
|
Date.UTC(year, month - 1, date, hours, minutes, seconds),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return new Date(year, month - 1, date, hours, minutes, seconds);
|
return new Date(year, month - 1, date, hours, minutes, seconds);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,96 +74,97 @@ export const ISOToDatetime = (str: string | null): Date | null => {
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export const datetimeToISO = (date: Date): string => {
|
export const datetimeToISO = (date: Date): string => {
|
||||||
let cal, time, offset;
|
const cal = [
|
||||||
cal = [
|
date.getFullYear(),
|
||||||
date.getFullYear(),
|
(date.getMonth() + 1).toString().padStart(2, "0"),
|
||||||
(date.getMonth() + 1).toString().padStart(2, "0"),
|
date.getDate().toString().padStart(2, "0"),
|
||||||
date.getDate().toString().padStart(2, "0"),
|
].join("-");
|
||||||
].join("-");
|
|
||||||
|
|
||||||
time = [
|
const time = [
|
||||||
date.getHours().toString().padStart(2, "0"),
|
date.getHours().toString().padStart(2, "0"),
|
||||||
date.getMinutes().toString().padStart(2, "0"),
|
date.getMinutes().toString().padStart(2, "0"),
|
||||||
date.getSeconds().toString().padStart(2, "0"),
|
date.getSeconds().toString().padStart(2, "0"),
|
||||||
].join(":");
|
].join(":");
|
||||||
|
|
||||||
offset = [
|
const offset = [
|
||||||
date.getTimezoneOffset() <= 0 ? "+" : "-",
|
date.getTimezoneOffset() <= 0 ? "+" : "-",
|
||||||
Math.abs(Math.floor(date.getTimezoneOffset() / 60))
|
Math.abs(Math.floor(date.getTimezoneOffset() / 60))
|
||||||
.toString()
|
.toString()
|
||||||
.padStart(2, "0"),
|
.padStart(2, "0"),
|
||||||
":",
|
":",
|
||||||
Math.abs(date.getTimezoneOffset() % 60)
|
Math.abs(date.getTimezoneOffset() % 60)
|
||||||
.toString()
|
.toString()
|
||||||
.padStart(2, "0"),
|
.padStart(2, "0"),
|
||||||
].join("");
|
].join("");
|
||||||
|
|
||||||
const x = cal + "T" + time + offset;
|
const x = cal + "T" + time + offset;
|
||||||
|
|
||||||
return x;
|
return x;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const intervalDaysToISO = (days: number | string | null): string => {
|
export const intervalDaysToISO = (days: number | string | null): string => {
|
||||||
if (null === days) {
|
if (null === days) {
|
||||||
return "P0D";
|
return "P0D";
|
||||||
}
|
}
|
||||||
|
|
||||||
return `P${days}D`;
|
return `P${days}D`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const intervalISOToDays = (str: string | null): number | null => {
|
export const intervalISOToDays = (str: string | null): number | null => {
|
||||||
if (null === str) {
|
if (null === str) {
|
||||||
return null;
|
return null;
|
||||||
}
|
|
||||||
|
|
||||||
if ("" === str.trim()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let days = 0;
|
|
||||||
let isDate = true;
|
|
||||||
let vstring = "";
|
|
||||||
for (let i = 0; i < str.length; i = i + 1) {
|
|
||||||
if (!isDate) {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
switch (str.charAt(i)) {
|
|
||||||
case "P":
|
|
||||||
isDate = true;
|
|
||||||
break;
|
|
||||||
case "T":
|
|
||||||
isDate = false;
|
|
||||||
break;
|
|
||||||
case "0":
|
|
||||||
case "1":
|
|
||||||
case "2":
|
|
||||||
case "3":
|
|
||||||
case "4":
|
|
||||||
case "5":
|
|
||||||
case "6":
|
|
||||||
case "7":
|
|
||||||
case "8":
|
|
||||||
case "9":
|
|
||||||
vstring = vstring + str.charAt(i);
|
|
||||||
break;
|
|
||||||
case "Y":
|
|
||||||
days = days + Number.parseInt(vstring) * 365;
|
|
||||||
vstring = "";
|
|
||||||
break;
|
|
||||||
case "M":
|
|
||||||
days = days + Number.parseInt(vstring) * 30;
|
|
||||||
vstring = "";
|
|
||||||
break;
|
|
||||||
case "D":
|
|
||||||
days = days + Number.parseInt(vstring);
|
|
||||||
vstring = "";
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
throw Error("this character should not appears: " + str.charAt(i));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return days;
|
if ("" === str.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let days = 0;
|
||||||
|
let isDate = true;
|
||||||
|
let vstring = "";
|
||||||
|
for (let i = 0; i < str.length; i = i + 1) {
|
||||||
|
if (!isDate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
switch (str.charAt(i)) {
|
||||||
|
case "P":
|
||||||
|
isDate = true;
|
||||||
|
break;
|
||||||
|
case "T":
|
||||||
|
isDate = false;
|
||||||
|
break;
|
||||||
|
case "0":
|
||||||
|
case "1":
|
||||||
|
case "2":
|
||||||
|
case "3":
|
||||||
|
case "4":
|
||||||
|
case "5":
|
||||||
|
case "6":
|
||||||
|
case "7":
|
||||||
|
case "8":
|
||||||
|
case "9":
|
||||||
|
vstring = vstring + str.charAt(i);
|
||||||
|
break;
|
||||||
|
case "Y":
|
||||||
|
days = days + Number.parseInt(vstring) * 365;
|
||||||
|
vstring = "";
|
||||||
|
break;
|
||||||
|
case "M":
|
||||||
|
days = days + Number.parseInt(vstring) * 30;
|
||||||
|
vstring = "";
|
||||||
|
break;
|
||||||
|
case "D":
|
||||||
|
days = days + Number.parseInt(vstring);
|
||||||
|
vstring = "";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw Error(
|
||||||
|
"this character should not appears: " + str.charAt(i),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return days;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getTimezoneOffsetString(date: Date, timeZone: string): string {
|
export function getTimezoneOffsetString(date: Date, timeZone: string): string {
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Extracts the "returnPath" parameter from the current URL's query string and returns it.
|
||||||
|
* If the parameter is not present, returns the provided fallback path.
|
||||||
|
*
|
||||||
|
* @param {string} fallbackPath - The fallback path to use if "returnPath" is not found in the query string.
|
||||||
|
* @return {string} The "returnPath" from the query string, or the fallback path if "returnPath" is not present.
|
||||||
|
*/
|
||||||
|
export function returnPathOr(fallbackPath: string): string {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const returnPath = urlParams.get("returnPath");
|
||||||
|
|
||||||
|
return returnPath ?? fallbackPath;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { EntityWorkflow } from "ChillMainAssets/types";
|
||||||
|
import { makeFetch } from "ChillMainAssets/lib/api/apiMethods";
|
||||||
|
|
||||||
|
export const fetchWorkflow = async (
|
||||||
|
workflowId: number,
|
||||||
|
): Promise<EntityWorkflow> => {
|
||||||
|
try {
|
||||||
|
return await makeFetch<null, EntityWorkflow>(
|
||||||
|
"GET",
|
||||||
|
`/api/1.0/main/workflow/${workflowId}.json`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to fetch workflow ${workflowId}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import { GenericDoc } from "ChillDocStoreAssets/types/generic_doc";
|
import {
|
||||||
|
GenericDoc,
|
||||||
|
isGenericDocWithStoredObject,
|
||||||
|
} from "ChillDocStoreAssets/types/generic_doc";
|
||||||
import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types";
|
import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types";
|
||||||
import { CreatableEntityType } from "ChillPersonAssets/types";
|
import { CreatableEntityType } from "ChillPersonAssets/types";
|
||||||
import {ThirdpartyCompany} from "../../../ChillThirdPartyBundle/Resources/public/types";
|
import {ThirdpartyCompany} from "../../../ChillThirdPartyBundle/Resources/public/types";
|
||||||
|
import { Person } from "../../../ChillPersonBundle/Resources/public/types";
|
||||||
|
|
||||||
export interface DateTime {
|
export interface DateTime {
|
||||||
datetime: string;
|
datetime: string;
|
||||||
@@ -277,6 +281,77 @@ export interface WorkflowAttachment {
|
|||||||
genericDoc: null | GenericDoc;
|
genericDoc: null | GenericDoc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AttachmentWithDocAndStored = WorkflowAttachment & {
|
||||||
|
genericDoc: GenericDoc & { storedObject: StoredObject };
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isAttachmentWithDocAndStored(
|
||||||
|
a: WorkflowAttachment,
|
||||||
|
): a is AttachmentWithDocAndStored {
|
||||||
|
return (
|
||||||
|
isWorkflowAttachmentWithGenericDoc(a) &&
|
||||||
|
isGenericDocWithStoredObject(a.genericDoc)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWorkflowAttachmentWithGenericDoc(
|
||||||
|
attachment: WorkflowAttachment,
|
||||||
|
): attachment is WorkflowAttachment & { genericDoc: GenericDoc } {
|
||||||
|
return attachment.genericDoc !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Workflow {
|
||||||
|
name: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntityWorkflowStep {
|
||||||
|
type: "entity_workflow_step";
|
||||||
|
id: number;
|
||||||
|
comment: string;
|
||||||
|
currentStep: StepDefinition;
|
||||||
|
isFinal: boolean;
|
||||||
|
isFreezed: boolean;
|
||||||
|
isFinalized: boolean;
|
||||||
|
transitionPrevious: Transition | null;
|
||||||
|
transitionAfter: Transition | null;
|
||||||
|
previousId: number | null;
|
||||||
|
nextId: number | null;
|
||||||
|
transitionPreviousBy: User | null;
|
||||||
|
transitionPreviousAt: DateTime | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Transition {
|
||||||
|
name: string;
|
||||||
|
text: string;
|
||||||
|
isForward: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StepDefinition {
|
||||||
|
name: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntityWorkflow {
|
||||||
|
type: "entity_workflow";
|
||||||
|
id: number;
|
||||||
|
relatedEntityClass: string;
|
||||||
|
relatedEntityId: number;
|
||||||
|
workflow: Workflow;
|
||||||
|
currentStep: EntityWorkflowStep;
|
||||||
|
steps: EntityWorkflowStep[];
|
||||||
|
datas: WorkflowData;
|
||||||
|
title: string;
|
||||||
|
isOnHoldAtCurrentStep: boolean;
|
||||||
|
_permissions: {
|
||||||
|
CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkflowData {
|
||||||
|
persons: Person[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ExportGeneration {
|
export interface ExportGeneration {
|
||||||
id: string;
|
id: string;
|
||||||
type: "export_generation";
|
type: "export_generation";
|
||||||
@@ -440,3 +515,8 @@ export type CreateComponentThirdPartyAddContact = {
|
|||||||
*/
|
*/
|
||||||
export type CreateComponentConfig = CreateComponentConfigGeneral | CreateComponentThirdPartyAddContact;
|
export type CreateComponentConfig = CreateComponentConfigGeneral | CreateComponentThirdPartyAddContact;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Possible states for the WaitingScreen Component.
|
||||||
|
*/
|
||||||
|
export type WaitingScreenState = "pending" | "failure" | "stopped" | "ready";
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
trans,
|
trans,
|
||||||
EXPORT_GENERATION_EXPORT_GENERATION_IS_PENDING,
|
EXPORT_GENERATION_EXPORT_GENERATION_IS_PENDING,
|
||||||
EXPORT_GENERATION_TOO_MANY_RETRIES,
|
EXPORT_GENERATION_TOO_MANY_RETRIES,
|
||||||
EXPORT_GENERATION_ERROR_WHILE_GENERATING_EXPORT,
|
EXPORT_GENERATION_ERROR_WHILE_GENERATING_EXPORT,
|
||||||
EXPORT_GENERATION_EXPORT_READY,
|
EXPORT_GENERATION_EXPORT_READY,
|
||||||
} from "translator";
|
} from "translator";
|
||||||
import { computed, onMounted, ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types";
|
import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types";
|
||||||
import { fetchExportGenerationStatus } from "ChillMainAssets/lib/api/export";
|
import { fetchExportGenerationStatus } from "ChillMainAssets/lib/api/export";
|
||||||
import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue";
|
import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue";
|
||||||
import { ExportGeneration } from "ChillMainAssets/types";
|
import WaitingScreen from "../_components/WaitingScreen.vue";
|
||||||
|
import { ExportGeneration, WaitingScreenState } from "ChillMainAssets/types";
|
||||||
|
|
||||||
interface AppProps {
|
interface AppProps {
|
||||||
exportGenerationId: string;
|
exportGenerationId: string;
|
||||||
title: string;
|
title: string;
|
||||||
createdDate: string;
|
createdDate: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<AppProps>();
|
const props = defineProps<AppProps>();
|
||||||
@@ -23,24 +24,27 @@ const props = defineProps<AppProps>();
|
|||||||
const exportGeneration = ref<ExportGeneration | null>(null);
|
const exportGeneration = ref<ExportGeneration | null>(null);
|
||||||
|
|
||||||
const status = computed<StoredObjectStatus>(
|
const status = computed<StoredObjectStatus>(
|
||||||
() => exportGeneration.value?.status ?? "pending",
|
() => exportGeneration.value?.status ?? "pending",
|
||||||
);
|
);
|
||||||
const storedObject = computed<null | StoredObject>(() => {
|
const storedObject = computed<null | StoredObject>(() => {
|
||||||
if (exportGeneration.value === null) {
|
if (exportGeneration.value === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return exportGeneration.value?.storedObject;
|
return exportGeneration.value?.storedObject;
|
||||||
});
|
});
|
||||||
|
|
||||||
const isPending = computed<boolean>(() => status.value === "pending");
|
const isPending = computed<boolean>(() => status.value === "pending");
|
||||||
const isFetching = computed<boolean>(
|
|
||||||
() => tryiesForReady.value < maxTryiesForReady,
|
|
||||||
);
|
|
||||||
const isReady = computed<boolean>(() => status.value === "ready");
|
|
||||||
const isFailure = computed<boolean>(() => status.value === "failure");
|
|
||||||
const filename = computed<string>(() => `${props.title}-${props.createdDate}`);
|
const filename = computed<string>(() => `${props.title}-${props.createdDate}`);
|
||||||
|
|
||||||
|
const state = computed<WaitingScreenState>((): WaitingScreenState => {
|
||||||
|
if (status.value === "empty") {
|
||||||
|
return "pending";
|
||||||
|
}
|
||||||
|
|
||||||
|
return status.value;
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* counter for the number of times that we check for a new status
|
* counter for the number of times that we check for a new status
|
||||||
*/
|
*/
|
||||||
@@ -52,87 +56,69 @@ let tryiesForReady = ref<number>(0);
|
|||||||
const maxTryiesForReady = 120;
|
const maxTryiesForReady = 120;
|
||||||
|
|
||||||
const checkForReady = function (): void {
|
const checkForReady = function (): void {
|
||||||
if (
|
if (
|
||||||
"ready" === status.value ||
|
"ready" === status.value ||
|
||||||
"empty" === status.value ||
|
"empty" === status.value ||
|
||||||
"failure" === status.value ||
|
"failure" === status.value ||
|
||||||
// stop reloading if the page stays opened for a long time
|
// stop reloading if the page stays opened for a long time
|
||||||
tryiesForReady.value > maxTryiesForReady
|
tryiesForReady.value > maxTryiesForReady
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
tryiesForReady.value = tryiesForReady.value + 1;
|
tryiesForReady.value = tryiesForReady.value + 1;
|
||||||
setTimeout(onObjectNewStatusCallback, 5000);
|
setTimeout(onObjectNewStatusCallback, 5000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onObjectNewStatusCallback = async function (): Promise<void> {
|
const onObjectNewStatusCallback = async function (): Promise<void> {
|
||||||
exportGeneration.value = await fetchExportGenerationStatus(
|
exportGeneration.value = await fetchExportGenerationStatus(
|
||||||
props.exportGenerationId,
|
props.exportGenerationId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (isPending.value) {
|
||||||
|
checkForReady();
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
if (isPending.value) {
|
|
||||||
checkForReady();
|
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.resolve();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
onObjectNewStatusCallback();
|
onObjectNewStatusCallback();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div id="waiting-screen">
|
<WaitingScreen :state="state">
|
||||||
<div v-if="isPending && isFetching" class="alert alert-danger text-center">
|
<template v-slot:pending>
|
||||||
<div>
|
<p>
|
||||||
<p>
|
{{ trans(EXPORT_GENERATION_EXPORT_GENERATION_IS_PENDING) }}
|
||||||
{{ trans(EXPORT_GENERATION_EXPORT_GENERATION_IS_PENDING) }}
|
</p>
|
||||||
</p>
|
</template>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<template v-slot:stopped>
|
||||||
<i class="fa fa-cog fa-spin fa-3x fa-fw"></i>
|
<p>
|
||||||
<span class="sr-only">Loading...</span>
|
{{ trans(EXPORT_GENERATION_TOO_MANY_RETRIES) }}
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</template>
|
||||||
<div v-if="isPending && !isFetching" class="alert alert-info">
|
|
||||||
<div>
|
|
||||||
<p>
|
|
||||||
{{ trans(EXPORT_GENERATION_TOO_MANY_RETRIES) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="isFailure" class="alert alert-danger text-center">
|
|
||||||
<div>
|
|
||||||
<p>
|
|
||||||
{{ trans(EXPORT_GENERATION_ERROR_WHILE_GENERATING_EXPORT) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="isReady" class="alert alert-success text-center">
|
|
||||||
<div>
|
|
||||||
<p>
|
|
||||||
{{ trans(EXPORT_GENERATION_EXPORT_READY) }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p v-if="storedObject !== null">
|
<template v-slot:failure>
|
||||||
<document-action-buttons-group
|
<p>
|
||||||
:stored-object="storedObject"
|
{{ trans(EXPORT_GENERATION_ERROR_WHILE_GENERATING_EXPORT) }}
|
||||||
:filename="filename"
|
</p>
|
||||||
></document-action-buttons-group>
|
</template>
|
||||||
</p>
|
|
||||||
</div>
|
<template v-slot:ready>
|
||||||
</div>
|
<p>
|
||||||
</div>
|
{{ trans(EXPORT_GENERATION_EXPORT_READY) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p v-if="storedObject !== null">
|
||||||
|
<document-action-buttons-group
|
||||||
|
:stored-object="storedObject"
|
||||||
|
:filename="filename"
|
||||||
|
></document-action-buttons-group>
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</WaitingScreen>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
#waiting-screen {
|
|
||||||
> .alert {
|
|
||||||
min-height: 350px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useIntervalFn } from "@vueuse/core";
|
||||||
|
import { fetchWorkflow } from "ChillMainAssets/lib/workflow/api";
|
||||||
|
import { returnPathOr } from "ChillMainAssets/lib/return_path/returnPathHelper";
|
||||||
|
import { ref } from "vue";
|
||||||
|
import WaitingScreen from "ChillMainAssets/vuejs/_components/WaitingScreen.vue";
|
||||||
|
import { WaitingScreenState } from "ChillMainAssets/types";
|
||||||
|
import {
|
||||||
|
trans,
|
||||||
|
WORKFLOW_WAIT_TITLE,
|
||||||
|
WORKFLOW_WAIT_ERROR_WHILE_WAITING,
|
||||||
|
WORKFLOW_WAIT_SUCCESS,
|
||||||
|
} from "translator";
|
||||||
|
|
||||||
|
interface WaitPostProcessWorkflowComponentProps {
|
||||||
|
workflowId: number;
|
||||||
|
expectedStep: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<WaitPostProcessWorkflowComponentProps>();
|
||||||
|
const counter = ref<number>(0);
|
||||||
|
const MAX_TRYIES = 50;
|
||||||
|
|
||||||
|
const state = ref<WaitingScreenState>("pending");
|
||||||
|
|
||||||
|
const { pause, resume } = useIntervalFn(
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
const workflow = await fetchWorkflow(props.workflowId);
|
||||||
|
counter.value++;
|
||||||
|
if (workflow.currentStep.currentStep.name === props.expectedStep) {
|
||||||
|
window.location.assign(
|
||||||
|
returnPathOr("/fr/main/workflow" + workflow.id + "/show"),
|
||||||
|
);
|
||||||
|
resume();
|
||||||
|
state.value = "ready";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (counter.value > MAX_TRYIES) {
|
||||||
|
pause();
|
||||||
|
state.value = "failure";
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
pause();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
2000,
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="container">
|
||||||
|
<WaitingScreen :state="state">
|
||||||
|
<template v-slot:pending>
|
||||||
|
<p>
|
||||||
|
{{ trans(WORKFLOW_WAIT_TITLE) }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<template v-slot:failure>
|
||||||
|
<p>
|
||||||
|
{{ trans(WORKFLOW_WAIT_ERROR_WHILE_WAITING) }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<template v-slot:ready>
|
||||||
|
<p>
|
||||||
|
{{ trans(WORKFLOW_WAIT_SUCCESS) }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</WaitingScreen>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { createApp } from "vue";
|
||||||
|
import App from "./App.vue";
|
||||||
|
|
||||||
|
function mountApp(): void {
|
||||||
|
const el = document.querySelector<HTMLDivElement>(".screen-wait");
|
||||||
|
if (!el) {
|
||||||
|
console.error(
|
||||||
|
"WaitPostProcessWorkflow: mount element .screen-wait not found",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflowIdAttr = el.getAttribute("data-workflow-id");
|
||||||
|
const expectedStep = el.getAttribute("data-expected-step") || "";
|
||||||
|
|
||||||
|
if (!workflowIdAttr) {
|
||||||
|
console.error(
|
||||||
|
"WaitPostProcessWorkflow: data-workflow-id attribute missing on mount element",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!expectedStep) {
|
||||||
|
console.error(
|
||||||
|
"WaitPostProcessWorkflow: data-expected-step attribute missing on mount element",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflowId = Number(workflowIdAttr);
|
||||||
|
if (Number.isNaN(workflowId)) {
|
||||||
|
console.error(
|
||||||
|
"WaitPostProcessWorkflow: data-workflow-id is not a valid number:",
|
||||||
|
workflowIdAttr,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = createApp(App, {
|
||||||
|
workflowId,
|
||||||
|
expectedStep,
|
||||||
|
});
|
||||||
|
|
||||||
|
app.mount(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", mountApp);
|
||||||
|
} else {
|
||||||
|
mountApp();
|
||||||
|
}
|
||||||
@@ -1,23 +1,25 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, useTemplateRef } from "vue";
|
import { computed, onMounted, ref, useTemplateRef } from "vue";
|
||||||
import type { WorkflowAttachment } from "ChillMainAssets/types";
|
import type { EntityWorkflow, WorkflowAttachment } from "ChillMainAssets/types";
|
||||||
import PickGenericDocModal from "ChillMainAssets/vuejs/WorkflowAttachment/Component/PickGenericDocModal.vue";
|
import PickGenericDocModal from "ChillMainAssets/vuejs/WorkflowAttachment/Component/PickGenericDocModal.vue";
|
||||||
import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc";
|
import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc";
|
||||||
import AttachmentList from "ChillMainAssets/vuejs/WorkflowAttachment/Component/AttachmentList.vue";
|
import AttachmentList from "ChillMainAssets/vuejs/WorkflowAttachment/Component/AttachmentList.vue";
|
||||||
import { GenericDoc } from "ChillDocStoreAssets/types";
|
import { GenericDoc } from "ChillDocStoreAssets/types";
|
||||||
|
import { fetchWorkflow } from "ChillMainAssets/lib/workflow/api";
|
||||||
|
import { trans, WORKFLOW_ATTACHMENTS_ADD_AN_ATTACHMENT } from "translator";
|
||||||
|
|
||||||
interface AppConfig {
|
interface AppConfig {
|
||||||
workflowId: number;
|
workflowId: number;
|
||||||
accompanyingPeriodId: number;
|
accompanyingPeriodId: number;
|
||||||
attachments: WorkflowAttachment[];
|
attachments: WorkflowAttachment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(
|
(
|
||||||
e: "pickGenericDoc",
|
e: "pickGenericDoc",
|
||||||
payload: { genericDoc: GenericDocForAccompanyingPeriod },
|
payload: { genericDoc: GenericDocForAccompanyingPeriod },
|
||||||
): void;
|
): void;
|
||||||
(e: "removeAttachment", payload: { attachment: WorkflowAttachment }): void;
|
(e: "removeAttachment", payload: { attachment: WorkflowAttachment }): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
type PickGenericModalType = InstanceType<typeof PickGenericDocModal>;
|
type PickGenericModalType = InstanceType<typeof PickGenericDocModal>;
|
||||||
@@ -26,49 +28,66 @@ const pickDocModal = useTemplateRef<PickGenericModalType>("pickDocModal");
|
|||||||
const props = defineProps<AppConfig>();
|
const props = defineProps<AppConfig>();
|
||||||
|
|
||||||
const attachedGenericDoc = computed<GenericDocForAccompanyingPeriod[]>(
|
const attachedGenericDoc = computed<GenericDocForAccompanyingPeriod[]>(
|
||||||
() =>
|
() =>
|
||||||
props.attachments
|
props.attachments
|
||||||
.map((a: WorkflowAttachment) => a.genericDoc)
|
.map((a: WorkflowAttachment) => a.genericDoc)
|
||||||
.filter(
|
.filter(
|
||||||
(g: GenericDoc | null) => g !== null,
|
(g: GenericDoc | null) => g !== null,
|
||||||
) as GenericDocForAccompanyingPeriod[],
|
) as GenericDocForAccompanyingPeriod[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const workflow = ref<EntityWorkflow | null>(null);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
workflow.value = await fetchWorkflow(Number(props.workflowId));
|
||||||
|
console.log("workflow", workflow.value);
|
||||||
|
});
|
||||||
|
|
||||||
const openModal = function () {
|
const openModal = function () {
|
||||||
pickDocModal.value?.openModal();
|
pickDocModal.value?.openModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPickGenericDoc = ({
|
const onPickGenericDoc = ({
|
||||||
genericDoc,
|
genericDoc,
|
||||||
}: {
|
}: {
|
||||||
genericDoc: GenericDocForAccompanyingPeriod;
|
genericDoc: GenericDocForAccompanyingPeriod;
|
||||||
}) => {
|
}) => {
|
||||||
emit("pickGenericDoc", { genericDoc });
|
emit("pickGenericDoc", { genericDoc });
|
||||||
};
|
};
|
||||||
|
|
||||||
const onRemoveAttachment = (payload: { attachment: WorkflowAttachment }) => {
|
const onRemoveAttachment = (payload: { attachment: WorkflowAttachment }) => {
|
||||||
emit("removeAttachment", payload);
|
emit("removeAttachment", payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const canEditAttachement = computed<boolean>(() => {
|
||||||
|
if (null === workflow.value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return workflow.value._permissions.CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<pick-generic-doc-modal
|
<pick-generic-doc-modal
|
||||||
:accompanying-period-id="props.accompanyingPeriodId"
|
:workflow="workflow"
|
||||||
:to-remove="attachedGenericDoc"
|
:accompanying-period-id="props.accompanyingPeriodId"
|
||||||
ref="pickDocModal"
|
:to-remove="attachedGenericDoc"
|
||||||
@pickGenericDoc="onPickGenericDoc"
|
ref="pickDocModal"
|
||||||
></pick-generic-doc-modal>
|
@pickGenericDoc="onPickGenericDoc"
|
||||||
<attachment-list
|
></pick-generic-doc-modal>
|
||||||
:attachments="props.attachments"
|
<attachment-list
|
||||||
@removeAttachment="onRemoveAttachment"
|
:workflow="workflow"
|
||||||
></attachment-list>
|
:attachments="props.attachments"
|
||||||
<ul class="record_actions">
|
@removeAttachment="onRemoveAttachment"
|
||||||
<li>
|
></attachment-list>
|
||||||
<button type="button" class="btn btn-create" @click="openModal">
|
<ul v-if="canEditAttachement" class="record_actions">
|
||||||
Ajouter une pièce jointe
|
<li>
|
||||||
</button>
|
<button type="button" class="btn btn-create" @click="openModal">
|
||||||
</li>
|
{{ trans(WORKFLOW_ATTACHMENTS_ADD_AN_ATTACHMENT) }}
|
||||||
</ul>
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss"></style>
|
<style scoped lang="scss"></style>
|
||||||
|
|||||||
@@ -1,52 +1,73 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { WorkflowAttachment } from "ChillMainAssets/types";
|
import {
|
||||||
|
AttachmentWithDocAndStored,
|
||||||
|
EntityWorkflow,
|
||||||
|
isAttachmentWithDocAndStored,
|
||||||
|
WorkflowAttachment,
|
||||||
|
} from "ChillMainAssets/types";
|
||||||
import GenericDocItemBox from "ChillMainAssets/vuejs/WorkflowAttachment/Component/GenericDocItemBox.vue";
|
import GenericDocItemBox from "ChillMainAssets/vuejs/WorkflowAttachment/Component/GenericDocItemBox.vue";
|
||||||
import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue";
|
import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue";
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { trans, WORKFLOW_ATTACHMENTS_NO_ATTACHMENT } from "translator";
|
||||||
|
|
||||||
interface AttachmentListProps {
|
interface AttachmentListProps {
|
||||||
attachments: WorkflowAttachment[];
|
attachments: WorkflowAttachment[];
|
||||||
|
workflow: EntityWorkflow | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
// eslint-disable-next-line @typescript-eslint/prefer-function-type
|
// eslint-disable-next-line @typescript-eslint/prefer-function-type
|
||||||
(e: "removeAttachment", payload: { attachment: WorkflowAttachment }): void;
|
(e: "removeAttachment", payload: { attachment: WorkflowAttachment }): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const props = defineProps<AttachmentListProps>();
|
const props = defineProps<AttachmentListProps>();
|
||||||
|
|
||||||
|
const notNullAttachments = computed<AttachmentWithDocAndStored[]>(() =>
|
||||||
|
props.attachments.filter(
|
||||||
|
(a: WorkflowAttachment): a is AttachmentWithDocAndStored =>
|
||||||
|
isAttachmentWithDocAndStored(a),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const canRemove = computed<boolean>((): boolean => {
|
||||||
|
if (null === props.workflow) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.workflow._permissions.CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<p
|
<p
|
||||||
v-if="props.attachments.length === 0"
|
v-if="notNullAttachments.length === 0"
|
||||||
class="chill-no-data-statement text-center"
|
class="chill-no-data-statement text-center"
|
||||||
>
|
>
|
||||||
Aucune pièce jointe
|
{{ trans(WORKFLOW_ATTACHMENTS_NO_ATTACHMENT) }}
|
||||||
</p>
|
</p>
|
||||||
<!-- TODO translate -->
|
<div v-else class="flex-table">
|
||||||
<div else class="flex-table">
|
<div v-for="a in notNullAttachments" :key="a.id" class="item-bloc">
|
||||||
<div v-for="a in props.attachments" :key="a.id" class="item-bloc">
|
<generic-doc-item-box
|
||||||
<generic-doc-item-box
|
:generic-doc="a.genericDoc"
|
||||||
v-if="a.genericDoc !== null"
|
></generic-doc-item-box>
|
||||||
:generic-doc="a.genericDoc"
|
<div class="item-row separator">
|
||||||
></generic-doc-item-box>
|
<ul class="record_actions">
|
||||||
<div class="item-row separator">
|
<li>
|
||||||
<ul class="record_actions">
|
<document-action-buttons-group
|
||||||
<li v-if="a.genericDoc?.storedObject !== null">
|
:stored-object="a.genericDoc.storedObject"
|
||||||
<document-action-buttons-group
|
></document-action-buttons-group>
|
||||||
:stored-object="a.genericDoc.storedObject"
|
</li>
|
||||||
></document-action-buttons-group>
|
<li v-if="canRemove">
|
||||||
</li>
|
<button
|
||||||
<li>
|
type="button"
|
||||||
<button
|
class="btn btn-delete"
|
||||||
type="button"
|
@click="emit('removeAttachment', { attachment: a })"
|
||||||
class="btn btn-delete"
|
></button>
|
||||||
@click="emit('removeAttachment', { attachment: a })"
|
</li>
|
||||||
></button>
|
</ul>
|
||||||
</li>
|
</div>
|
||||||
</ul>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss"></style>
|
<style scoped lang="scss"></style>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user