Merge branch 'master' into ticket-app-master

# Conflicts:
#	.junie/guidelines.md
#	src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/SocialIssuesAcc.vue
#	src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/App.vue
#	src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/App2.vue
#	src/Bundle/ChillDocStoreBundle/Resources/public/types/generic_doc.ts
#	src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/DropFileWidget/DropFileModal.vue
#	src/Bundle/ChillDocStoreBundle/Resources/public/vuejs/StoredObjectButton/HistoryButton/HistoryButtonListItem.vue
#	src/Bundle/ChillMainBundle/Resources/public/chill/js/date.ts
#	src/Bundle/ChillMainBundle/Resources/public/vuejs/DownloadExport/App.vue
#	src/Bundle/ChillMainBundle/Resources/public/vuejs/WorkflowAttachment/App.vue
#	src/Bundle/ChillMainBundle/Resources/public/vuejs/WorkflowAttachment/Component/AttachmentList.vue
#	src/Bundle/ChillMainBundle/Resources/public/vuejs/WorkflowAttachment/Component/GenericDocItemBox.vue
#	src/Bundle/ChillMainBundle/Resources/public/vuejs/WorkflowAttachment/Component/PickGenericDoc.vue
#	src/Bundle/ChillMainBundle/Resources/public/vuejs/WorkflowAttachment/Component/PickGenericDocModal.vue
#	src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/EntityWorkflow/EntityWorkflowVueSubscriber.vue
#	src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Modal.vue
#	src/Bundle/ChillMainBundle/Resources/views/layout.html.twig
#	src/Bundle/ChillMainBundle/translations/messages+intl-icu.fr.yaml
#	src/Bundle/ChillPersonBundle/Resources/public/mod/DuplicateSelector/AccompanyingPeriodWorkSelector.ts
#	src/Bundle/ChillPersonBundle/Resources/public/types.ts
#	src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/App.vue
#	src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/FormEvaluation.vue
#	src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AccompanyingPeriodWorkSelector/AccompanyingPeriodWorkList.vue
#	src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AccompanyingPeriodWorkSelector/AccompanyingPeriodWorkSelectorModal.vue
#	src/Bundle/ChillPersonBundle/Resources/public/vuejs/_js/i18n.ts
#	src/Bundle/ChillPersonBundle/translations/messages.fr.yml
This commit is contained in:
2025-11-05 13:19:05 +01:00
195 changed files with 8111 additions and 4168 deletions

View 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

View File

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

View 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

View 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

View 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

View 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

View 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

View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
## v4.6.1 - 2025-10-27
### Fixed
* Fix export case where no 'reason' is picked within the PersonHavingActivityBetweenDateFilter.php

View File

@@ -237,6 +237,7 @@ This must be a decision made by a human, not by an AI. Every AI task must abort
The tests are run from the project's root (not from the bundle's root: so, do not change the directory to any bundle directory before running tests).
```bash
# Run a specific test file
vendor/bin/phpunit path/to/TestFile.php
@@ -244,6 +245,9 @@ vendor/bin/phpunit path/to/TestFile.php
vendor/bin/phpunit --filter methodName path/to/TestFile.php
```
When writing tests, only test specific files. Do not run all tests or the full
test suite.
#### Test Structure
Tests are organized by bundle and follow the same structure as the bundle itself:

View File

@@ -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).
## 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
### Fixed
* 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 regroupement: par type d'activité associé au parcours;
- 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"
## 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 service des intervenants 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 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);

View File

@@ -14,7 +14,7 @@
"ext-openssl": "*",
"ext-redis": "*",
"ext-zlib": "*",
"champs-libres/wopi-bundle": "dev-master@dev",
"champs-libres/wopi-bundle": "dev-symfony-v5@dev",
"champs-libres/wopi-lib": "dev-master@dev",
"doctrine/data-fixtures": "^1.8",
"doctrine/doctrine-bundle": "^2.1",

View File

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

View File

@@ -1,6 +1,13 @@
chill_main:
available_languages: [ '%env(resolve:LOCALE)%', 'en' ]
available_countries: ['BE', 'FR']
top_banner:
visible: false
text:
fr: 'Vous travaillez actuellement avec la version de PRÉ-PRODUCTION.'
nl: 'Je werkt momenteel in de PRE-PRODUCTIE versie'
color: '#353535'
background_color: '#d8bb48'
notifications:
from_email: '%env(resolve:NOTIFICATION_FROM_EMAIL)%'
from_name: '%env(resolve:NOTIFICATION_FROM_NAME)%'

View File

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

View File

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

View File

@@ -38,7 +38,7 @@ Certaines données sont historisées:
- les référents d'un parcours;
- les statuts d'un parcours;
- la liaison entre les centres et les usagers;
- la liaison entre les territoires et les usagers;
- etc.
Dans ces cas-là, Chill crée généralement deux colonnes, qui sont habituellement nommées :code:`startDate` et :code:`endDate`. Lorsque la colonne :code:`endDate` est à :code:`NULL`, cela signifie que la période n'est pas "fermée". La colonne :code:`startDate` n'est pas nullable.

View File

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

View File

@@ -55,6 +55,7 @@
"@tsconfig/node20": "^20.1.4",
"@types/dompurify": "^3.0.5",
"@types/leaflet": "^1.9.3",
"@vueuse/core": "^13.9.0",
"bootstrap-icons": "^1.11.3",
"dropzone": "^5.7.6",
"es6-promise": "^4.2.8",

View File

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

View File

@@ -90,7 +90,9 @@ class ActivityReasonFilter implements ExportElementValidatedInterface, FilterInt
public function getFormDefaultData(): array
{
return [];
return [
'reasons' => [],
];
}
public function describeAction($data, ExportGenerationContext $context): string|\Symfony\Contracts\Translation\TranslatableInterface|array

View File

@@ -42,6 +42,8 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
public function alterQuery(QueryBuilder $qb, $data, ExportGenerationContext $exportGenerationContext): void
{
error_log('alterQuery called with data: '.json_encode(array_keys($data)));
// create a subquery for activity
$sqb = $qb->getEntityManager()->createQueryBuilder();
$sqb->select('1')
@@ -59,7 +61,6 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
if (\in_array('activity', $qb->getAllAliases(), true)) {
$sqb->andWhere('activity_person_having_activity.id = activity.id');
}
if (isset($data['reasons']) && [] !== $data['reasons']) {
// add clause activity reason
$sqb->join('activity_person_having_activity.reasons', 'reasons_person_having_activity');
@@ -124,12 +125,38 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
public function normalizeFormData(array $formData): array
{
return ['date_from_rolling' => $formData['date_from_rolling']->normalize(), 'date_to_rolling' => $formData['date_to_rolling']->normalize()];
$normalized = [
'date_from_rolling' => $formData['date_from_rolling']->normalize(),
'date_to_rolling' => $formData['date_to_rolling']->normalize(),
'reasons' => [],
];
if (isset($formData['reasons']) && [] !== $formData['reasons']) {
$normalized['reasons'] = array_map(
fn (ActivityReason $reason) => $reason->getId(),
$formData['reasons']
);
}
return $normalized;
}
public function denormalizeFormData(array $formData, int $fromVersion): array
{
return ['date_from_rolling' => RollingDate::fromNormalized($formData['date_from_rolling']), 'date_to_rolling' => RollingDate::fromNormalized($formData['date_to_rolling'])];
$denormalized = [
'date_from_rolling' => RollingDate::fromNormalized($formData['date_from_rolling']),
'date_to_rolling' => RollingDate::fromNormalized($formData['date_to_rolling']),
'reasons' => [],
];
if (isset($formData['reasons']) && [] !== $formData['reasons']) {
$denormalized['reasons'] = array_map(
fn ($id) => $this->activityReasonRepository->find($id),
$formData['reasons']
);
}
return $denormalized;
}
public function getFormDefaultData(): array
@@ -143,10 +170,12 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
public function describeAction($data, ExportGenerationContext $context): array
{
$reasons = $data['reasons'] ?? [];
return [
[] === $data['reasons'] ?
'export.filter.person_between_dates.describe_action_with_no_subject'
: 'export.filter.person_between_dates.describe_action_with_subject',
[] === $reasons ?
'export.filter.activity.describe_action_with_no_subject'
: 'export.filter.activity.describe_action_with_subject',
[
'date_from' => $this->rollingDateConverter->convert($data['date_from_rolling']),
'date_to' => $this->rollingDateConverter->convert($data['date_to_rolling']),
@@ -154,7 +183,7 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
', ',
array_map(
fn (ActivityReason $r): string => '"'.$this->translatableStringHelper->localize($r->getName()).'"',
$data['reasons']
$reasons
)
),
],
@@ -168,6 +197,7 @@ final readonly class PersonHavingActivityBetweenDateFilter implements ExportElem
public function validateForm($data, ExecutionContextInterface $context): void
{
error_log('validateForm called with data: '.json_encode(array_keys($data)));
if ($this->rollingDateConverter->convert($data['date_from_rolling'])
>= $this->rollingDateConverter->convert($data['date_to_rolling'])) {
$context->buildViolation('export.filter.activity.person_between_dates.date mismatch')

View File

@@ -49,7 +49,9 @@
</div>
<div class="col-8">
<div v-if="actionIsLoading === true">
<i class="chill-green fa fa-circle-o-notch fa-spin fa-lg"></i>
<i
class="chill-green fa fa-circle-o-notch fa-spin fa-lg"
></i>
</div>
<span
@@ -62,7 +64,8 @@
<template
v-else-if="
socialActionsList.length > 0 &&
(socialIssuesSelected.length || socialActionsSelected.length)
(socialIssuesSelected.length ||
socialActionsSelected.length)
"
>
<div
@@ -85,7 +88,9 @@
</template>
<span
v-else-if="actionAreLoaded && socialActionsList.length === 0"
v-else-if="
actionAreLoaded && socialActionsList.length === 0
"
class="inline-choice chill-no-data-statement mt-3"
>
{{ trans(ACTIVITY_SOCIAL_ACTION_LIST_EMPTY) }}
@@ -131,8 +136,14 @@ export default {
issueIsLoading: false,
actionIsLoading: false,
actionAreLoaded: false,
socialIssuesClassList: `col-form-label ${document.querySelector("input#chill_activitybundle_activity_socialIssues").getAttribute("required") ? "required" : ""}`,
socialActionsClassList: `col-form-label ${document.querySelector("input#chill_activitybundle_activity_socialActions").getAttribute("required") ? "required" : ""}`,
socialIssuesClassList: {
"col-form-label": true,
required: false,
},
socialActionsClassList: {
"col-form-label": true,
required: false,
},
};
},
computed: {
@@ -153,6 +164,21 @@ export default {
},
},
mounted() {
/* Load classNames after element is present */
const socialActionsEl = document.querySelector(
"input#chill_activitybundle_activity_socialActions",
);
if (socialActionsEl && socialActionsEl.hasAttribute("required")) {
this.socialActionsClassList.required = true;
}
const socialIssuesEl = document.querySelector(
"input#chill_activitybundle_activity_socialIssues",
);
if (socialIssuesEl && socialIssuesEl.hasAttribute("required")) {
this.socialIssuesClassList.required = true;
}
/* Load other issues in multiselect */
this.issueIsLoading = true;
this.actionAreLoaded = false;
@@ -164,7 +190,8 @@ export default {
/* 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.socialIssuesList.filter((i) => i.id === issue.id)
.length !== 1
) {
this.$store.commit("addIssueInList", issue);
}

View File

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

View File

@@ -25,6 +25,7 @@ final class ChillAsideActivityExtension extends Extension implements PrependExte
$config = $this->processConfiguration($configuration, $configs);
$container->setParameter('chill_aside_activity.form.time_duration', $config['form']['time_duration']);
$container->setParameter('chill_aside_activity.show_concerned_persons_count', 'visible' === $config['show_concerned_persons_count']);
$loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../config'));
$loader->load('services.yaml');
@@ -38,6 +39,24 @@ final class ChillAsideActivityExtension extends Extension implements PrependExte
{
$this->prependRoute($container);
$this->prependCruds($container);
$this->prependTwigConfig($container);
}
protected function prependTwigConfig(ContainerBuilder $container)
{
// Get the configuration for this bundle
$chillAsideActivityConfig = $container->getExtensionConfig($this->getAlias());
$config = $this->processConfiguration($this->getConfiguration($chillAsideActivityConfig, $container), $chillAsideActivityConfig);
// Add configuration to twig globals
$twigConfig = [
'globals' => [
'chill_aside_activity_config' => [
'show_concerned_persons_count' => 'visible' === $config['show_concerned_persons_count'],
],
],
];
$container->prependExtensionConfig('twig', $twigConfig);
}
protected function prependCruds(ContainerBuilder $container)

View File

@@ -141,6 +141,12 @@ class Configuration implements ConfigurationInterface
->end()
->end()
->end()
->end()
->enumNode('show_concerned_persons_count')
->values(['hidden', 'visible'])
->defaultValue('hidden')
->info('Show the concerned persons count field in aside activity forms and views')
->end()
->end();
return $treeBuilder;

View File

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

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\AsideActivityBundle\Export\Aggregator;
use Chill\AsideActivityBundle\Export\Declarations;
use Chill\MainBundle\Export\AggregatorInterface;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Form\FormBuilderInterface;
class ByConcernedPersonsCountAggregator implements AggregatorInterface
{
public function addRole(): ?string
{
return null;
}
public function alterQuery(QueryBuilder $qb, $data, \Chill\MainBundle\Export\ExportGenerationContext $exportGenerationContext): void
{
$qb->addSelect('aside.concernedPersonsCount AS by_concerned_persons_count_aggregator')
->addGroupBy('by_concerned_persons_count_aggregator');
}
public function applyOn(): string
{
return Declarations::ASIDE_ACTIVITY_TYPE;
}
public function buildForm(FormBuilderInterface $builder): void
{
// No form needed
}
public function getNormalizationVersion(): int
{
return 1;
}
public function normalizeFormData(array $formData): array
{
return [];
}
public function denormalizeFormData(array $formData, int $fromVersion): array
{
return [];
}
public function getFormDefaultData(): array
{
return [];
}
public function getLabels($key, array $values, $data): callable
{
return function ($value): string {
if ('_header' === $value) {
return 'export.aggregator.Concerned persons count';
}
if (null === $value) {
return 'export.aggregator.No concerned persons count specified';
}
return (string) $value;
};
}
public function getQueryKeys($data): array
{
return ['by_concerned_persons_count_aggregator'];
}
public function getTitle(): string
{
return 'export.aggregator.Group by concerned persons count';
}
}

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\AsideActivityBundle\Export\Export;
use Chill\AsideActivityBundle\Export\Declarations;
use Chill\AsideActivityBundle\Repository\AsideActivityRepository;
use Chill\AsideActivityBundle\Security\AsideActivityVoter;
use Chill\MainBundle\Export\ExportInterface;
use Chill\MainBundle\Export\FormatterInterface;
use Chill\MainBundle\Export\GroupedExportInterface;
use Doctrine\ORM\Query;
use Symfony\Component\Form\FormBuilderInterface;
class SumConcernedPersonsCountAsideActivity implements ExportInterface, GroupedExportInterface
{
public function __construct(private readonly AsideActivityRepository $repository) {}
public function buildForm(FormBuilderInterface $builder) {}
public function getNormalizationVersion(): int
{
return 1;
}
public function normalizeFormData(array $formData): array
{
return [];
}
public function denormalizeFormData(array $formData, int $fromVersion): array
{
return [];
}
public function getFormDefaultData(): array
{
return [];
}
public function getAllowedFormattersTypes(): array
{
return [FormatterInterface::TYPE_TABULAR];
}
public function getDescription(): string
{
return 'export.Sum concerned persons count for aside activities';
}
public function getGroup(): string
{
return 'export.Exports of aside activities';
}
public function getLabels($key, array $values, $data)
{
if ('export_sum_concerned_persons_count' !== $key) {
throw new \LogicException("the key {$key} is not used by this export");
}
$labels = array_combine($values, $values);
$labels['_header'] = $this->getTitle();
return static fn ($value) => $labels[$value];
}
public function getQueryKeys($data): array
{
return ['export_sum_concerned_persons_count'];
}
public function getResult($query, $data, \Chill\MainBundle\Export\ExportGenerationContext $context): array
{
return $query->getQuery()->getResult(Query::HYDRATE_SCALAR);
}
public function getTitle(): string
{
return 'export.Sum concerned persons count for aside activities';
}
public function getType(): string
{
return Declarations::ASIDE_ACTIVITY_TYPE;
}
public function initiateQuery(array $requiredModifiers, array $acl, array $data, \Chill\MainBundle\Export\ExportGenerationContext $context): \Doctrine\ORM\QueryBuilder
{
$qb = $this->repository->createQueryBuilder('aside');
$qb->select('SUM(COALESCE(aside.concernedPersonsCount, 0)) as export_sum_concerned_persons_count');
return $qb;
}
public function requiredRole(): string
{
return AsideActivityVoter::STATS;
}
public function supportsModifiers(): array
{
return [
Declarations::ASIDE_ACTIVITY_TYPE,
];
}
}

View File

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

View File

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

View File

@@ -38,6 +38,11 @@
<dt class="inline">{{ 'Duration'|trans }}</dt>
<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>
{%- if entity.note is empty -%}
<dd>
@@ -55,5 +60,6 @@
</dl>
{% endblock %}
{% block content_view_actions_duplicate_link %}{% endblock %}
{% endembed %}
{% endblock %}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\AsideActivityBundle\Tests\Export\Aggregator;
use Chill\AsideActivityBundle\Entity\AsideActivity;
use Chill\AsideActivityBundle\Export\Aggregator\ByConcernedPersonsCountAggregator;
use Chill\MainBundle\Test\Export\AbstractAggregatorTest;
use Doctrine\ORM\EntityManagerInterface;
/**
* @internal
*
* @coversNothing
*/
class ByConcernedPersonsCountAggregatorTest extends AbstractAggregatorTest
{
public function getAggregator()
{
return new ByConcernedPersonsCountAggregator();
}
public static function getFormData(): array
{
return [
[],
];
}
public static function getQueryBuilders(): iterable
{
self::bootKernel();
$em = self::getContainer()->get(EntityManagerInterface::class);
return [
$em->createQueryBuilder()
->select('count(aside.id)')
->from(AsideActivity::class, 'aside'),
];
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\AsideActivityBundle\Tests\Export\Export;
use Chill\AsideActivityBundle\Export\Export\SumConcernedPersonsCountAsideActivity;
use Chill\AsideActivityBundle\Repository\AsideActivityRepository;
use Chill\MainBundle\Test\Export\AbstractExportTest;
/**
* @internal
*
* @coversNothing
*/
final class SumConcernedPersonsCountAsideActivityTest extends AbstractExportTest
{
protected function setUp(): void
{
self::bootKernel();
}
public function getExport()
{
$repository = self::getContainer()->get(AsideActivityRepository::class);
yield new SumConcernedPersonsCountAsideActivity($repository);
}
public static function getFormData(): array
{
return [
[],
];
}
public static function getModifiersCombination(): array
{
return [
['aside_activity'],
];
}
}

View File

@@ -20,6 +20,10 @@ services:
tags:
- { name: chill.export, alias: 'avg_aside_activity_duration' }
Chill\AsideActivityBundle\Export\Export\SumConcernedPersonsCountAsideActivity:
tags:
- { name: chill.export, alias: 'sum_aside_activity_concerned_persons_count' }
## Filters
chill.aside_activity.export.date_filter:
class: Chill\AsideActivityBundle\Export\Filter\ByDateFilter
@@ -70,3 +74,7 @@ services:
Chill\AsideActivityBundle\Export\Aggregator\ByLocationAggregator:
tags:
- { name: chill.export_aggregator, alias: 'aside_activity_location_aggregator' }
Chill\AsideActivityBundle\Export\Aggregator\ByConcernedPersonsCountAggregator:
tags:
- { name: chill.export_aggregator, alias: 'aside_activity_concerned_persons_count_aggregator' }

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
/*
* Chill is a software for social workers
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/
namespace Chill\Migrations\AsideActivity;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20251006113048 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add concernedPersonsCount property to AsideActivity entity';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_asideactivity.asideactivity ADD concernedPersonsCount INT DEFAULT 0');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE chill_asideactivity.AsideActivity DROP concernedPersonsCount');
}
}

View File

@@ -27,6 +27,7 @@ Emergency: Urgent
by: "Par "
location: Lieu
Asideactivity location: Localisation de l'activité
Concerned persons count: Nombre d'usager concernés
# Crud
crud:
@@ -177,7 +178,7 @@ export:
agent_id: Utilisateur
creator_id: Créateur
main_scope: Service principal de l'utilisateur
main_center: Centre principal de l'utilisateur
main_center: Territoire principal de l'utilisateur
aside_activity_type: Catégorie d'activité annexe
date: Date
duration: Durée
@@ -190,6 +191,7 @@ export:
Count aside activities by various parameters.: Compte le nombre d'activités annexes selon divers critères
Average aside activities duration: Durée moyenne des activités annexes
Sum aside activities duration: Durée des activités annexes
Sum concerned persons count for aside activities: Nombre d'usager concernés par les activités annexes
filter:
Filter by aside activity date: Filtrer les activités annexes par date
Filter by aside activity type: Filtrer les activités annexes par type d'activité
@@ -210,6 +212,8 @@ export:
'Filtered by aside activity location: only %location%': "Filtré par localisation: uniquement %location%"
aggregator:
Group by aside activity type: Grouper les activités annexes par type d'activité
Group by concerned persons count: Grouper les activités annexes par nombre d'usagers conernés
Concerned persons count: Nombre d'usagers concernés
Aside activity type: Type d'activité annexe
by_user_job:
Aggregate by user job: Grouper les activités annexes par métier des utilisateurs

View File

@@ -61,14 +61,24 @@
<label class="input-group-text" for="slotDuration"
>Durée des créneaux</label
>
<select v-model="slotDuration" id="slotDuration" class="form-select">
<select
v-model="slotDuration"
id="slotDuration"
class="form-select"
>
<option value="00:05:00">5 minutes</option>
<option value="00:10:00">10 minutes</option>
<option value="00:15:00">15 minutes</option>
<option value="00:30:00">30 minutes</option>
<option value="00:45:00">45 minutes</option>
<option value="00:60:00">60 minutes</option>
</select>
<label class="input-group-text" for="slotMinTime">De</label>
<select v-model="slotMinTime" id="slotMinTime" class="form-select">
<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>
@@ -84,7 +94,11 @@
<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">
<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>
@@ -112,7 +126,9 @@
v-model="hideWeekends"
/>
</span>
<label for="showHideWE" class="form-check-label input-group-text"
<label
for="showHideWE"
class="form-check-label input-group-text"
>Week-ends</label
>
</div>
@@ -128,7 +144,9 @@
<b v-else-if="arg.event.extendedProps.is === 'range'"
>{{ arg.timeText }}
{{ arg.event.extendedProps.locationName }}
<small>{{ arg.event.extendedProps.userLabel }}</small></b
<small>{{
arg.event.extendedProps.userLabel
}}</small></b
>
<b v-else-if="arg.event.extendedProps.is === 'current'"
>{{ arg.timeText }} {{ $t("current_selected") }}
@@ -136,7 +154,9 @@
<b v-else-if="arg.event.extendedProps.is === 'local'">{{
arg.event.title
}}</b>
<b v-else>{{ arg.timeText }} {{ $t("current_selected") }} </b>
<b v-else
>{{ arg.timeText }} {{ $t("current_selected") }}
</b>
</span>
</template>
</FullCalendar>
@@ -250,7 +270,9 @@ export default {
this.$store.state.activity.endDate !== null)
) {
if (
!window.confirm(this.$t("change_main_user_will_reset_event_data"))
!window.confirm(
this.$t("change_main_user_will_reset_event_data"),
)
) {
return;
}
@@ -258,9 +280,13 @@ export default {
// 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));
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.$data.previousUser.push(
this.$store.getters.getMainUser,
);
}
}
@@ -290,7 +316,8 @@ export default {
// show an alert if changing mainUser
if (
(this.$store.getters.getMainUser !== null &&
this.$store.state.me.id !== this.$store.getters.getMainUser.id) ||
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"))) {
@@ -334,7 +361,9 @@ export default {
this.$store.getters.getMainUser.id
) {
if (
!window.confirm(this.$t("this_calendar_range_will_change_main_user"))
!window.confirm(
this.$t("this_calendar_range_will_change_main_user"),
)
) {
return;
}

View File

@@ -23,14 +23,24 @@
<label class="input-group-text" for="slotDuration"
>Durée des créneaux</label
>
<select v-model="slotDuration" id="slotDuration" class="form-select">
<select
v-model="slotDuration"
id="slotDuration"
class="form-select"
>
<option value="00:05:00">5 minutes</option>
<option value="00:10:00">10 minutes</option>
<option value="00:15:00">15 minutes</option>
<option value="00:30:00">30 minutes</option>
<option value="00:45:00">45 minutes</option>
<option value="00:60:00">60 minutes</option>
</select>
<label class="input-group-text" for="slotMinTime">De</label>
<select v-model="slotMinTime" id="slotMinTime" class="form-select">
<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>
@@ -46,7 +56,11 @@
<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">
<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>
@@ -74,7 +88,9 @@
v-model="showWeekends"
/>
</span>
<label for="showHideWE" class="form-check-label input-group-text"
<label
for="showHideWE"
class="form-check-label input-group-text"
>Week-ends</label
>
</div>
@@ -84,12 +100,17 @@
<FullCalendar :options="calendarOptions" ref="calendarRef">
<template v-slot:eventContent="{ event }: { event: EventApi }">
<span :class="eventClasses">
<b v-if="event.extendedProps.is === 'remote'">{{ event.title }}</b>
<b v-if="event.extendedProps.is === 'remote'">{{
event.title
}}</b>
<b v-else-if="event.extendedProps.is === 'range'"
>{{ formatDate(event.startStr) }} -
>{{ formatDate(event.startStr, "time") }} -
{{ formatDate(event.endStr, "time") }}:
{{ event.extendedProps.locationName }}</b
>
<b v-else-if="event.extendedProps.is === 'local'">{{ event.title }}</b>
<b v-else-if="event.extendedProps.is === 'local'">{{
event.title
}}</b>
<b v-else>no 'is'</b>
<a
v-if="event.extendedProps.is === 'range'"
@@ -108,7 +129,11 @@
<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">
<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") }}
@@ -117,16 +142,27 @@
</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" />
<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" />
<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">
<button
class="btn btn-action float-end"
@click="copyDay"
>
{{ $t("copy_range") }}
</button>
</div>
@@ -138,7 +174,11 @@
id="copyFromWeek"
class="form-select"
>
<option v-for="w in lastWeeks" :value="w.value" :key="w.value">
<option
v-for="w in lastWeeks"
:value="w.value"
:key="w.value"
>
{{ w.text }}
</option>
</select>
@@ -147,14 +187,25 @@
<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">
<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">
<button
class="btn btn-action float-end"
@click="copyWeek"
>
{{ $t("copy_range") }}
</button>
</div>
@@ -246,9 +297,26 @@ const nextWeeks = computed((): Weeks[] =>
}),
);
const formatDate = (datetime: string) => {
console.log(typeof datetime);
return ISOToDate(datetime);
const formatDate = (datetime: string, format: null | "time" = null) => {
const date = 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>({

View File

@@ -59,7 +59,7 @@ final readonly class StoredObjectVersionApiController
return new JsonResponse(
$this->serializer->serialize(
new Collection($items, $paginator),
new Collection(array_values($items->toArray()), $paginator),
'json',
[AbstractNormalizer::GROUPS => ['read', StoredObjectVersionNormalizer::WITH_POINT_IN_TIMES_CONTEXT, StoredObjectVersionNormalizer::WITH_RESTORED_CONTEXT]]
),

View File

@@ -23,10 +23,14 @@ use Random\RandomException;
* Store each version of StoredObject's.
*
* A version should not be created manually: use the method @see{StoredObject::registerVersion} instead.
*
* Each filename must be unique within the same StoredObject. We add a condition on id to apply this condition only for
* newly created versions when this new index is applied.
*/
#[ORM\Entity]
#[ORM\Table('chill_doc.stored_object_version')]
#[ORM\UniqueConstraint(name: 'chill_doc_stored_object_version_unique_by_object', columns: ['stored_object_id', 'version'])]
#[ORM\UniqueConstraint(name: 'chill_doc_stored_object_version_unique_by_filename', columns: ['filename'], options: ['where' => '(id > 0)'])]
class StoredObjectVersion implements TrackCreationInterface
{
use TrackCreationTrait;

View File

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

View File

@@ -25,7 +25,7 @@ export interface GenericDoc {
type: "doc_store_generic_doc";
uniqueKey: string;
key: string;
identifiers: object;
identifiers: { id: number };
context: "person" | "accompanying-period";
doc_date: DateTime;
metadata: GenericDocMetadata;
@@ -36,6 +36,18 @@ export interface GenericDocForAccompanyingPeriod extends GenericDoc {
context: "accompanying-period";
}
export function isGenericDocForAccompanyingPeriod(
doc: GenericDoc,
): doc is GenericDocForAccompanyingPeriod {
return doc.context === "accompanying-period";
}
export function isGenericDocWithStoredObject(
doc: GenericDoc,
): doc is GenericDoc & { storedObject: StoredObject } {
return doc.storedObject !== null;
}
interface BaseMetadataWithHtml extends BaseMetadata {
html: string;
}
@@ -44,28 +56,33 @@ export interface GenericDocForAccompanyingCourseDocument
extends GenericDocForAccompanyingPeriod {
key: "accompanying_course_document";
metadata: BaseMetadataWithHtml;
storedObject: StoredObject;
}
export interface GenericDocForAccompanyingCourseActivityDocument
extends GenericDocForAccompanyingPeriod {
key: "accompanying_course_activity_document";
metadata: BaseMetadataWithHtml;
storedObject: StoredObject;
}
export interface GenericDocForAccompanyingCourseCalendarDocument
extends GenericDocForAccompanyingPeriod {
key: "accompanying_course_calendar_document";
metadata: BaseMetadataWithHtml;
storedObject: StoredObject;
}
export interface GenericDocForAccompanyingCoursePersonDocument
extends GenericDocForAccompanyingPeriod {
key: "person_document";
metadata: BaseMetadataWithHtml;
storedObject: StoredObject;
}
export interface GenericDocForAccompanyingCourseWorkEvaluationDocument
extends GenericDocForAccompanyingPeriod {
key: "accompanying_period_work_evaluation_document";
metadata: BaseMetadataWithHtml;
storedObject: StoredObject;
}

View File

@@ -4,6 +4,7 @@ import { StoredObject, StoredObjectVersion } from "../../types";
import DropFileWidget from "ChillDocStoreAssets/vuejs/DropFileWidget/DropFileWidget.vue";
import { computed, reactive } from "vue";
import { useToast } from "vue-toast-notification";
import { DOCUMENT_ADD, trans } from "translator";
interface DropFileConfig {
allowRemove: boolean;
@@ -75,11 +76,9 @@ function closeModal(): void {
@click="openModal"
class="btn btn-create"
>
Ajouter un document
</button>
<button v-else @click="openModal" class="btn btn-edit">
Remplacer le document
{{ trans(DOCUMENT_ADD) }}
</button>
<button v-else @click="openModal" class="btn btn-edit"></button>
<modal
v-if="state.showModal"
:modal-dialog-class="modalClasses"

View File

@@ -3,9 +3,9 @@ import {
StoredObject,
StoredObjectPointInTime,
StoredObjectVersionWithPointInTime,
} from "./../../../types";
} from "ChillDocStoreAssets/types";
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 RestoreVersionButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/HistoryButton/RestoreVersionButton.vue";
import DownloadButton from "ChillDocStoreAssets/vuejs/StoredObjectButton/DownloadButton.vue";
@@ -49,7 +49,8 @@ const isRestored = 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<{
@@ -69,9 +70,16 @@ const classes = computed<{
<div :class="classes">
<div
class="col-12 tags"
v-if="isCurrent || isKeptBeforeConversion || isRestored || isDuplicated"
v-if="
isCurrent ||
isKeptBeforeConversion ||
isRestored ||
isDuplicated
"
>
<span class="badge bg-success" v-if="isCurrent"
>Version actuelle</span
>
<span class="badge bg-success" v-if="isCurrent">Version actuelle</span>
<span class="badge bg-info" v-if="isKeptBeforeConversion"
>Conservée avant conversion dans un autre format</span
>
@@ -88,17 +96,21 @@ const classes = computed<{
<span
><strong>&nbsp;#{{ version.version + 1 }}&nbsp;</strong></span
>
<template v-if="version.createdBy !== null && version.createdAt !== null"
<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
><UserRenderBoxBadge
:user="version.createdBy"
></UserRenderBoxBadge
></span>
<strong>à</strong>
{{
$d(ISOToDatetime(version.createdAt.datetime8601), "long")
}}</template
><template v-if="version.createdBy === null && version.createdAt !== null"
><template
v-if="version.createdBy === null && version.createdAt !== null"
><strong v-if="version.version == 0">Créé le</strong
><strong v-else>modifié le</strong>
{{

View File

@@ -46,6 +46,16 @@ abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface
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
$entity = $this->getRepository()->findAssociatedEntityToStoredObject($subject);
@@ -66,7 +76,7 @@ abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface
return match ($workflowPermission) {
WorkflowRelatedEntityPermissionHelper::FORCE_GRANT => true,
WorkflowRelatedEntityPermissionHelper::FORCE_DENIED => false,
WorkflowRelatedEntityPermissionHelper::ABSTAIN => $regularPermission,
WorkflowRelatedEntityPermissionHelper::ABSTAIN => WorkflowRelatedEntityPermissionHelper::FORCE_GRANT === $workflowPermissionAsAttachment || $regularPermission,
};
}
}

View File

@@ -14,6 +14,12 @@ namespace Chill\DocStoreBundle\Security\Authorization;
use Chill\DocStoreBundle\Entity\StoredObject;
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
{
public function supports(StoredObjectRoleEnum $attribute, StoredObject $subject): bool;

View File

@@ -15,6 +15,7 @@ use Chill\DocStoreBundle\Entity\StoredObject;
use Chill\DocStoreBundle\Entity\StoredObjectPointInTime;
use Chill\DocStoreBundle\Entity\StoredObjectPointInTimeReasonEnum;
use Chill\DocStoreBundle\Entity\StoredObjectVersion;
use Chill\DocStoreBundle\Exception\ConversionWithSameMimeTypeException;
use Chill\DocStoreBundle\Exception\StoredObjectManagerException;
use Chill\WopiBundle\Service\WopiConverter;
use Symfony\Component\Mime\MimeTypesInterface;
@@ -44,6 +45,7 @@ class StoredObjectToPdfConverter
* @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 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
{
@@ -56,7 +58,7 @@ class StoredObjectToPdfConverter
$currentVersion = $storedObject->getCurrentVersion();
if ($currentVersion->getType() === $newMimeType) {
throw new \UnexpectedValueException('Already at the same mime type');
throw new ConversionWithSameMimeTypeException($newMimeType);
}
$content = $this->storedObjectManager->read($currentVersion);

View File

@@ -40,6 +40,10 @@ class StoredObjectVersionApiControllerTest extends \PHPUnit\Framework\TestCase
$storedObject->registerVersion();
}
// remove one version in the history
$v5 = $storedObject->getVersions()->get(5);
$storedObject->removeVersion($v5);
$security = $this->prophesize(Security::class);
$security->isGranted(StoredObjectRoleEnum::SEE->value, $storedObject)
->willReturn(true)
@@ -53,6 +57,7 @@ class StoredObjectVersionApiControllerTest extends \PHPUnit\Framework\TestCase
self::assertEquals($response->getStatusCode(), 200);
self::assertIsArray($body);
self::assertArrayHasKey('results', $body);
self::assertIsList($body['results']);
self::assertCount(10, $body['results']);
}

View File

@@ -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,
bool $expected,
bool $canBeAssociatedWithWorkflow,
@@ -105,6 +261,10 @@ class AbstractStoredObjectVoterTest extends TestCase
$security->isGranted('SOME_ROLE', $related)->willReturn($isGrantedRegularPermission);
$workflowRelatedEntityPermissionHelper = $this->prophesize(WorkflowRelatedEntityPermissionHelper::class);
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($storedObject)->willReturn(WorkflowRelatedEntityPermissionHelper::ABSTAIN);
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForWriteOperation($storedObject)->willReturn(WorkflowRelatedEntityPermissionHelper::ABSTAIN);
if (null !== $isGrantedWorkflowPermissionRead) {
$workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($related)
->willReturn($isGrantedWorkflowPermissionRead)->shouldBeCalled();
@@ -123,7 +283,7 @@ class AbstractStoredObjectVoterTest extends TestCase
self::assertEquals($expected, $voter->voteOnAttribute($attribute, $storedObject, $token), $message);
}
public static function dataProviderVoteOnAttribute(): iterable
public static function dataProviderVoteOnAttributeWithoutStoredObjectPermission(): iterable
{
// 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'];

View File

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

View File

@@ -23,6 +23,8 @@ See the document: Voir le document
document:
Any title: Aucun titre
replace: Remplacer
Add: Ajouter un document
generic_doc:
filter:

View File

@@ -246,7 +246,7 @@ final class EventController extends AbstractController
'class' => Center::class,
'choices' => $centers,
'placeholder' => $this->translator->trans('Pick a center'),
'label' => 'To which centre should the event be associated ?',
'label' => 'To which territory should the event be associated ?',
])
->add('submit', SubmitType::class, [
'label' => 'Next step',

View File

@@ -64,7 +64,7 @@ CHILL_EVENT_PARTICIPATION_SEE_DETAILS: Voir le détail d'une participation
# TODO check place to put this
Next step: Étape suivante
To which centre should the event be associated ?: À quel centre doit être associé l'événement ?
To which territory should the event be associated ?: À quel territoire doit être associé l'événement ?
# timeline
past: passé
@@ -151,7 +151,7 @@ event:
filter:
event_types: Par types d'événement
event_dates: Par date d'événement
center: Par centre
center: Par territoire
by_responsable: Par responsable
pick_responsable: Filtrer par responsables
budget:
@@ -188,7 +188,7 @@ event_id: Identifiant
event_name: Nom
event_date: Date
event_type: Type d'évenement
event_center: Centre
event_center: Territoire
event_moderator: Responsable
event_participants_count: Nombre de participants
event_location: Localisation

View File

@@ -118,7 +118,7 @@
{{ entity.notes|chill_print_or_message("Aucune note", 'blockquote') }}
{% endblock crud_content_view_details %}
{% block content_view_actions_duplicate_link %}{% endblock %}
{% block content_view_actions_back %}
<li class="cancel">
<a class="btn btn-cancel" href="{{ chill_return_path_or('chill_job_report_index', { 'person': entity.person.id }) }}">

View File

@@ -46,6 +46,7 @@
</dd>
</dl>
{% endblock crud_content_view_details %}
{% block content_view_actions_duplicate_link %}{% endblock %}
{% block content_view_actions_back %}
<li class="cancel">

View File

@@ -206,6 +206,8 @@
</a>
</li>
{% endblock %}
{% block content_view_actions_duplicate_link %}{% endblock %}
{% block content_view_actions_after %}
<li>
<a class="btn btn-misc" href="{{ chill_return_path_or('chill_crud_immersion_bilan', { 'id': entity.id, 'person_id': entity.person.id }) }}">

View File

@@ -94,6 +94,7 @@
{% endblock crud_content_view_details %}
{% block content_view_actions_duplicate_link %}{% endblock %}
{% block content_view_actions_back %}
<li class="cancel">

View File

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

View File

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

View File

@@ -334,7 +334,7 @@ class ChillImportUsersCommand extends Command
protected function loadUsers()
{
$reader = Reader::createFromPath($this->tempInput->getArgument('csvfile'));
$reader = Reader::from($this->tempInput->getArgument('csvfile'));
$reader->setHeaderOffset(0);
foreach ($reader->getRecords() as $line => $r) {
@@ -362,7 +362,7 @@ class ChillImportUsersCommand extends Command
protected function prepareGroupingCenters()
{
$reader = Reader::createFromPath($this->tempInput->getOption('grouping-centers'));
$reader = Reader::from($this->tempInput->getOption('grouping-centers'));
$reader->setHeaderOffset(0);
foreach ($reader->getRecords() as $r) {
@@ -378,7 +378,7 @@ class ChillImportUsersCommand extends Command
protected function prepareWriter()
{
$this->output = $output = Writer::createFromPath($this->tempInput
$this->output = $output = Writer::from($this->tempInput
->getOption('csv-dump'), 'a+');
$output->insertOne([

View File

@@ -119,7 +119,7 @@ class ChillUserSendRenewPasswordCodeCommand extends Command
protected function getReader()
{
try {
$reader = Reader::createFromPath($this->input->getArgument('csvfile'));
$reader = Reader::from($this->input->getArgument('csvfile'));
} catch (\Exception $e) {
$this->logger->error('The csv file could not be read', [
'path' => $this->input->getArgument('csvfile'),

View File

@@ -48,6 +48,7 @@ class AbsenceController extends AbstractController
$user = $this->security->getUser();
$user->setAbsenceStart(null);
$user->setAbsenceEnd(null);
$em = $this->managerRegistry->getManager();
$em->flush();

View File

@@ -43,7 +43,7 @@ final readonly class UserExportController
$users = $this->userRepository->findAllAsArray($request->getLocale());
$csv = Writer::createFromPath('php://temp', 'r+');
$csv = Writer::from('php://temp', 'r+');
$csv->insertOne(
array_map(
fn (string $e) => $this->translator->trans('admin.users.export.'.$e),
@@ -104,7 +104,7 @@ final readonly class UserExportController
$userPermissions = $this->userRepository->findAllUserACLAsArray();
$csv = Writer::createFromPath('php://temp', 'r+');
$csv = Writer::from('php://temp', 'r+');
$csv->insertOne(
array_map(
fn (string $e) => $this->translator->trans('admin.users.export.'.$e),

View File

@@ -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(),
]);
}
}

View File

@@ -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(),
]));
}
}

View File

@@ -11,6 +11,7 @@ declare(strict_types=1);
namespace Chill\MainBundle\Controller;
use Chill\MainBundle\CRUD\Controller\ApiController;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
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\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) {}

View File

@@ -264,11 +264,12 @@ class WorkflowController extends AbstractController
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_REMEMBERED');
$total = $this->entityWorkflowRepository->countBySubscriber($this->security->getUser());
$total = $this->entityWorkflowRepository->countBySubscriber($this->security->getUser(), false);
$paginator = $this->paginatorFactory->create($total);
$workflows = $this->entityWorkflowRepository->findBySubscriber(
$this->security->getUser(),
false,
['createdAt' => 'DESC'],
$paginator->getItemsPerPage(),
$paginator->getCurrentPageFirstItemNumber()

View File

@@ -44,7 +44,7 @@ final readonly class WorkflowSignatureStateChangeController
$signature,
$request,
EntityWorkflowStepSignatureVoter::CANCEL,
function (EntityWorkflowStepSignature $signature) {$this->signatureStepStateChanger->markSignatureAsCanceled($signature); },
fn (EntityWorkflowStepSignature $signature): string => $this->signatureStepStateChanger->markSignatureAsCanceled($signature),
'@ChillMain/WorkflowSignature/cancel.html.twig',
);
}
@@ -56,11 +56,18 @@ final readonly class WorkflowSignatureStateChangeController
$signature,
$request,
EntityWorkflowStepSignatureVoter::REJECT,
function (EntityWorkflowStepSignature $signature) {$this->signatureStepStateChanger->markSignatureAsRejected($signature); },
fn (EntityWorkflowStepSignature $signature): string => $this->signatureStepStateChanger->markSignatureAsRejected($signature),
'@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(
EntityWorkflowStepSignature $signature,
Request $request,
@@ -79,12 +86,13 @@ final readonly class WorkflowSignatureStateChangeController
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->entityManager->wrapInTransaction(function () use ($signature, $markSignature) {
$markSignature($signature);
});
$expectedStep = $this->entityManager->wrapInTransaction(fn () => $markSignature($signature));
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]
)
);
}

View File

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

View File

@@ -30,6 +30,7 @@ use Chill\MainBundle\Controller\UserGroupAdminController;
use Chill\MainBundle\Controller\UserGroupApiController;
use Chill\MainBundle\Controller\UserJobApiController;
use Chill\MainBundle\Controller\UserJobController;
use Chill\MainBundle\Controller\WorkflowApiController;
use Chill\MainBundle\DependencyInjection\Widget\Factory\WidgetFactoryInterface;
use Chill\MainBundle\Doctrine\DQL\Age;
use Chill\MainBundle\Doctrine\DQL\Extract;
@@ -66,6 +67,7 @@ use Chill\MainBundle\Entity\Regroupment;
use Chill\MainBundle\Entity\User;
use Chill\MainBundle\Entity\UserGroup;
use Chill\MainBundle\Entity\UserJob;
use Chill\MainBundle\Entity\Workflow\EntityWorkflow;
use Chill\MainBundle\Form\CenterType;
use Chill\MainBundle\Form\CivilityType;
use Chill\MainBundle\Form\CountryType;
@@ -79,6 +81,7 @@ use Chill\MainBundle\Form\UserGroupType;
use Chill\MainBundle\Form\UserJobType;
use Chill\MainBundle\Form\UserType;
use Chill\MainBundle\Security\Authorization\ChillExportVoter;
use Chill\MainBundle\Security\Authorization\EntityWorkflowVoter;
use Misd\PhoneNumberBundle\Doctrine\DBAL\Types\PhoneNumberType;
use Ramsey\Uuid\Doctrine\UuidType;
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->load('services.yaml');
$loader->load('services/doctrine.yaml');
@@ -247,6 +255,7 @@ class ChillMainExtension extends Extension implements
'name' => $config['installation_name'], ],
'available_languages' => $config['available_languages'],
'add_address' => $config['add_address'],
'chill_main_config' => $config,
],
'form_themes' => ['@ChillMain/Form/fields.html.twig'],
];
@@ -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,
],
],
],
],
],
]);
}

View File

@@ -168,6 +168,20 @@ class Configuration implements ConfigurationInterface
->end()
->end()
->end()
->arrayNode('top_banner')
->canBeUnset()
->children()
->booleanNode('visible')
->defaultFalse()
->end()
->arrayNode('text')
->useAttributeAsKey('lang')
->scalarPrototype()->end()
->end() // end of text
->scalarNode('color')->defaultNull()->end()
->scalarNode('background_color')->defaultNull()->end()
->end() // end of top_banner children
->end() // end of top_banner
->arrayNode('widgets')
->canBeEnabled()
->canBeUnset()

View File

@@ -24,6 +24,7 @@ use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Annotation as Serializer;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint;
use Symfony\Component\Validator\Constraints as Assert;
/**
* User.
@@ -45,6 +46,8 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
#[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true)]
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.
*/
@@ -157,6 +160,11 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
return $this->absenceStart;
}
public function getAbsenceEnd(): ?\DateTimeImmutable
{
return $this->absenceEnd;
}
/**
* Get attributes.
*
@@ -336,7 +344,13 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
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);
}
/**
@@ -410,6 +424,11 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
$this->absenceStart = $absenceStart;
}
public function setAbsenceEnd(?\DateTimeImmutable $absenceEnd): void
{
$this->absenceEnd = $absenceEnd;
}
public function setAttributeByDomain(string $domain, string $key, $value): self
{
$this->attributes[$domain][$key] = $value;
@@ -633,46 +652,82 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter
return true;
}
public function getNotificationFlags(): array
private function getNotificationFlagData(string $flag): array
{
return $this->notificationFlags;
}
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;
return $this->notificationFlags[$flag] ?? [self::NOTIF_FLAG_IMMEDIATE_EMAIL];
}
public function isNotificationSendImmediately(string $type): bool
{
if ([] === $this->getNotificationFlagData($type) || in_array(User::NOTIF_FLAG_IMMEDIATE_EMAIL, $this->getNotificationFlagData($type), true)) {
return true;
return $this->isNotificationForElement($type, self::NOTIF_FLAG_IMMEDIATE_EMAIL);
}
return false;
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;
}
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
{
if (in_array(User::NOTIF_FLAG_DAILY_DIGEST, $this->getNotificationFlagData($type), true)) {
return true;
}
return false;
return $this->isNotificationForElement($type, self::NOTIF_FLAG_DAILY_DIGEST);
}
public function getLocale(): string
{
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();
}
}
}

View File

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

View File

@@ -23,9 +23,14 @@ class AbsenceType extends AbstractType
{
$builder
->add('absenceStart', ChillDateType::class, [
'required' => true,
'required' => false,
'input' => 'datetime_immutable',
'label' => 'absence.Absence start',
])
->add('absenceEnd', ChillDateType::class, [
'required' => false,
'input' => 'datetime_immutable',
'label' => 'absence.Absence end',
]);
}

View File

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

View File

@@ -11,11 +11,9 @@ declare(strict_types=1);
namespace Chill\MainBundle\Form\Type;
use Chill\MainBundle\Form\DataMapper\NotificationFlagDataMapper;
use Chill\MainBundle\Notification\NotificationFlagManager;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
@@ -30,27 +28,24 @@ class NotificationFlagsType extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->setDataMapper(new NotificationFlagDataMapper($this->notificationFlagProviders));
foreach ($this->notificationFlagProviders as $flagProvider) {
$flag = $flagProvider->getFlag();
$builder->add($flag, FormType::class, [
$flagBuilder = $builder->create($flag, options: [
'label' => $flagProvider->getLabel(),
'required' => false,
'compound' => true,
]);
$builder->get($flag)
$flagBuilder
->add('immediate_email', CheckboxType::class, [
'label' => false,
'required' => false,
'mapped' => false,
])
->add('daily_email', CheckboxType::class, [
->add('daily_digest', CheckboxType::class, [
'label' => false,
'required' => false,
'mapped' => false,
])
;
$builder->add($flagBuilder);
}
}
@@ -58,6 +53,7 @@ class NotificationFlagsType extends AbstractType
{
$resolver->setDefaults([
'data_class' => null,
'compound' => true,
]);
}
}

View File

@@ -11,31 +11,29 @@ declare(strict_types=1);
namespace Chill\MainBundle\Form;
use Chill\MainBundle\Action\User\UpdateProfile\UpdateProfileCommand;
use Chill\MainBundle\Form\Type\ChillPhoneNumberType;
use Chill\MainBundle\Form\Type\NotificationFlagsType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
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
->add('phonenumber', ChillPhoneNumberType::class, [
'required' => false,
])
->add('notificationFlags', NotificationFlagsType::class, [
'label' => false,
'mapped' => false,
])
->add('notificationFlags', NotificationFlagsType::class)
;
}
public function configureOptions(OptionsResolver $resolver)
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => \Chill\MainBundle\Entity\User::class,
'data_class' => UpdateProfileCommand::class,
]);
}
}

View File

@@ -59,7 +59,7 @@ class UserPasswordType extends AbstractType
'invalid_message' => 'The password fields must match',
'constraints' => [
new Length([
'min' => 9,
'min' => 14,
'minMessage' => 'The password must be greater than {{ limit }} characters',
]),
new NotBlank(),

View File

@@ -105,6 +105,11 @@ class UserType extends AbstractType
'required' => false,
'input' => 'datetime_immutable',
'label' => 'absence.Absence start',
])
->add('absenceEnd', ChillDateType::class, [
'required' => false,
'input' => 'datetime_immutable',
'label' => 'absence.Absence end',
]);
// @phpstan-ignore-next-line

View File

@@ -53,11 +53,16 @@ readonly class DailyNotificationDigestCronjob implements CronJobInterface
public function run(array $lastExecutionData): ?array
{
$now = $this->clock->now();
if (isset($lastExecutionData['last_execution'])) {
$lastExecution = \DateTimeImmutable::createFromFormat(
\DateTimeImmutable::ATOM,
$lastExecutionData['last_execution']
);
if (false === $lastExecution) {
$lastExecution = $now->sub(new \DateInterval('P1D'));
}
} else {
$lastExecution = $now->sub(new \DateInterval('P1D'));
}
@@ -96,7 +101,7 @@ readonly class DailyNotificationDigestCronjob implements CronJobInterface
]);
return [
'last_execution' => $now->format('Y-m-d-H:i:s.u e'),
'last_execution' => $now->format(\DateTimeInterface::ATOM),
];
}
}

View File

@@ -57,9 +57,15 @@ class EntityWorkflowRepository implements ObjectRepository
return (int) $qb->getQuery()->getSingleScalarResult();
}
public function countBySubscriber(User $user): int
/**
* @param bool|null $isFinal true to get only the entityWorkflow which is finalized, false to get the workflows that are not finalized, and null to ignore
*
* @throws \Doctrine\ORM\NoResultException
* @throws \Doctrine\ORM\NonUniqueResultException
*/
public function countBySubscriber(User $user, ?bool $isFinal = null): int
{
$qb = $this->buildQueryBySubscriber($user)->select('count(ew)');
$qb = $this->buildQueryBySubscriber($user, $isFinal)->select('count(ew)');
return (int) $qb->getQuery()->getSingleScalarResult();
}
@@ -182,9 +188,14 @@ class EntityWorkflowRepository implements ObjectRepository
return $qb->getQuery()->getResult();
}
public function findBySubscriber(User $user, ?array $orderBy = null, $limit = null, $offset = null): array
/**
* @param bool|null $isFinal true to get only the entityWorkflow which is finalized, false to get the workflows that are not finalized, and null to ignore
* @param mixed|null $limit
* @param mixed|null $offset
*/
public function findBySubscriber(User $user, ?bool $isFinal = null, ?array $orderBy = null, $limit = null, $offset = null): array
{
$qb = $this->buildQueryBySubscriber($user)->select('ew');
$qb = $this->buildQueryBySubscriber($user, $isFinal)->select('ew');
foreach ($orderBy as $key => $sort) {
$qb->addOrderBy('ew.'.$key, $sort);
@@ -312,7 +323,7 @@ class EntityWorkflowRepository implements ObjectRepository
return $qb;
}
private function buildQueryBySubscriber(User $user): QueryBuilder
private function buildQueryBySubscriber(User $user, ?bool $isFinal): QueryBuilder
{
$qb = $this->repository->createQueryBuilder('ew');
@@ -325,6 +336,14 @@ class EntityWorkflowRepository implements ObjectRepository
$qb->setParameter('user', $user);
if (null !== $isFinal) {
if ($isFinal) {
$qb->andWhere(sprintf('EXISTS (SELECT 1 FROM %s step WHERE step.isFinal = true AND ew = step.entityWorkflow)', EntityWorkflowStep::class));
} else {
$qb->andWhere(sprintf('NOT EXISTS (SELECT 1 FROM %s step WHERE step.isFinal = true AND ew = step.entityWorkflow)', EntityWorkflowStep::class));
}
}
return $qb;
}
}

View File

@@ -37,8 +37,13 @@ export const ISOToDate = (str: string | null): Date | 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);
}
// 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);
};
@@ -56,7 +61,9 @@ export const ISOToDatetime = (str: string | null): Date | null => {
[time, timezone] = times.split(times.charAt(8)),
[hours, minutes, seconds] = time.split(":").map((s) => parseInt(s));
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);
@@ -67,20 +74,19 @@ export const ISOToDatetime = (str: string | null): Date | null => {
*
*/
export const datetimeToISO = (date: Date): string => {
let cal, time, offset;
cal = [
const cal = [
date.getFullYear(),
(date.getMonth() + 1).toString().padStart(2, "0"),
date.getDate().toString().padStart(2, "0"),
].join("-");
time = [
const time = [
date.getHours().toString().padStart(2, "0"),
date.getMinutes().toString().padStart(2, "0"),
date.getSeconds().toString().padStart(2, "0"),
].join(":");
offset = [
const offset = [
date.getTimezoneOffset() <= 0 ? "+" : "-",
Math.abs(Math.floor(date.getTimezoneOffset() / 60))
.toString()
@@ -152,7 +158,9 @@ export const intervalISOToDays = (str: string | null): number | null => {
vstring = "";
break;
default:
throw Error("this character should not appears: " + str.charAt(i));
throw Error(
"this character should not appears: " + str.charAt(i),
);
}
}

View File

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

View File

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

View File

@@ -1,5 +1,9 @@
import { GenericDoc } from "ChillDocStoreAssets/types/generic_doc";
import {
GenericDoc,
isGenericDocWithStoredObject,
} from "ChillDocStoreAssets/types/generic_doc";
import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types";
import { Person } from "../../../ChillPersonBundle/Resources/public/types";
export interface DateTime {
datetime: string;
@@ -207,6 +211,77 @@ export interface WorkflowAttachment {
genericDoc: null | GenericDoc;
}
export type AttachmentWithDocAndStored = WorkflowAttachment & {
genericDoc: GenericDoc & { storedObject: StoredObject };
};
export function isAttachmentWithDocAndStored(
a: WorkflowAttachment,
): a is AttachmentWithDocAndStored {
return (
isWorkflowAttachmentWithGenericDoc(a) &&
isGenericDocWithStoredObject(a.genericDoc)
);
}
export function isWorkflowAttachmentWithGenericDoc(
attachment: WorkflowAttachment,
): attachment is WorkflowAttachment & { genericDoc: GenericDoc } {
return attachment.genericDoc !== null;
}
export interface Workflow {
name: string;
text: string;
}
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 {
id: string;
type: "export_generation";
@@ -300,3 +375,8 @@ export interface TabDefinition {
icon: string | null;
counter: () => number;
}
/**
* Possible states for the WaitingScreen Component.
*/
export type WaitingScreenState = "pending" | "failure" | "stopped" | "ready";

View File

@@ -10,7 +10,8 @@ import { computed, onMounted, ref } from "vue";
import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types";
import { fetchExportGenerationStatus } from "ChillMainAssets/lib/api/export";
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 {
exportGenerationId: string;
@@ -34,13 +35,16 @@ const storedObject = computed<null | StoredObject>(() => {
});
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 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
*/
@@ -85,35 +89,26 @@ onMounted(() => {
</script>
<template>
<div id="waiting-screen">
<div v-if="isPending && isFetching" class="alert alert-danger text-center">
<div>
<WaitingScreen :state="state">
<template v-slot:pending>
<p>
{{ trans(EXPORT_GENERATION_EXPORT_GENERATION_IS_PENDING) }}
</p>
</div>
</template>
<div>
<i class="fa fa-cog fa-spin fa-3x fa-fw"></i>
<span class="sr-only">Loading...</span>
</div>
</div>
<div v-if="isPending && !isFetching" class="alert alert-info">
<div>
<template v-slot:stopped>
<p>
{{ trans(EXPORT_GENERATION_TOO_MANY_RETRIES) }}
</p>
</div>
</div>
<div v-if="isFailure" class="alert alert-danger text-center">
<div>
</template>
<template v-slot:failure>
<p>
{{ trans(EXPORT_GENERATION_ERROR_WHILE_GENERATING_EXPORT) }}
</p>
</div>
</div>
<div v-if="isReady" class="alert alert-success text-center">
<div>
</template>
<template v-slot:ready>
<p>
{{ trans(EXPORT_GENERATION_EXPORT_READY) }}
</p>
@@ -124,15 +119,6 @@ onMounted(() => {
:filename="filename"
></document-action-buttons-group>
</p>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
#waiting-screen {
> .alert {
min-height: 350px;
}
}
</style>
</WaitingScreen>
</template>

View File

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

View File

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

View File

@@ -1,10 +1,12 @@
<script setup lang="ts">
import { computed, useTemplateRef } from "vue";
import type { WorkflowAttachment } from "ChillMainAssets/types";
import { computed, onMounted, ref, useTemplateRef } from "vue";
import type { EntityWorkflow, WorkflowAttachment } from "ChillMainAssets/types";
import PickGenericDocModal from "ChillMainAssets/vuejs/WorkflowAttachment/Component/PickGenericDocModal.vue";
import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc";
import AttachmentList from "ChillMainAssets/vuejs/WorkflowAttachment/Component/AttachmentList.vue";
import { GenericDoc } from "ChillDocStoreAssets/types";
import { fetchWorkflow } from "ChillMainAssets/lib/workflow/api";
import { trans, WORKFLOW_ATTACHMENTS_ADD_AN_ATTACHMENT } from "translator";
interface AppConfig {
workflowId: number;
@@ -34,6 +36,13 @@ const attachedGenericDoc = computed<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 () {
pickDocModal.value?.openModal();
};
@@ -49,23 +58,33 @@ const onPickGenericDoc = ({
const onRemoveAttachment = (payload: { attachment: WorkflowAttachment }) => {
emit("removeAttachment", payload);
};
const canEditAttachement = computed<boolean>(() => {
if (null === workflow.value) {
return false;
}
return workflow.value._permissions.CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT;
});
</script>
<template>
<pick-generic-doc-modal
:workflow="workflow"
:accompanying-period-id="props.accompanyingPeriodId"
:to-remove="attachedGenericDoc"
ref="pickDocModal"
@pickGenericDoc="onPickGenericDoc"
></pick-generic-doc-modal>
<attachment-list
:workflow="workflow"
:attachments="props.attachments"
@removeAttachment="onRemoveAttachment"
></attachment-list>
<ul class="record_actions">
<ul v-if="canEditAttachement" class="record_actions">
<li>
<button type="button" class="btn btn-create" @click="openModal">
Ajouter une pièce jointe
{{ trans(WORKFLOW_ATTACHMENTS_ADD_AN_ATTACHMENT) }}
</button>
</li>
</ul>

View File

@@ -1,10 +1,18 @@
<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 DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue";
import { computed } from "vue";
import { trans, WORKFLOW_ATTACHMENTS_NO_ATTACHMENT } from "translator";
interface AttachmentListProps {
attachments: WorkflowAttachment[];
workflow: EntityWorkflow | null;
}
const emit = defineEmits<{
@@ -13,30 +21,43 @@ const emit = defineEmits<{
}>();
const props = defineProps<AttachmentListProps>();
const notNullAttachments = computed<AttachmentWithDocAndStored[]>(() =>
props.attachments.filter(
(a: WorkflowAttachment): a is AttachmentWithDocAndStored =>
isAttachmentWithDocAndStored(a),
),
);
const canRemove = computed<boolean>((): boolean => {
if (null === props.workflow) {
return false;
}
return props.workflow._permissions.CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT;
});
</script>
<template>
<p
v-if="props.attachments.length === 0"
v-if="notNullAttachments.length === 0"
class="chill-no-data-statement text-center"
>
Aucune pièce jointe
{{ trans(WORKFLOW_ATTACHMENTS_NO_ATTACHMENT) }}
</p>
<!-- TODO translate -->
<div else class="flex-table">
<div v-for="a in props.attachments" :key="a.id" class="item-bloc">
<div v-else class="flex-table">
<div v-for="a in notNullAttachments" :key="a.id" class="item-bloc">
<generic-doc-item-box
v-if="a.genericDoc !== null"
:generic-doc="a.genericDoc"
></generic-doc-item-box>
<div class="item-row separator">
<ul class="record_actions">
<li v-if="a.genericDoc?.storedObject !== null">
<li>
<document-action-buttons-group
:stored-object="a.genericDoc.storedObject"
></document-action-buttons-group>
</li>
<li>
<li v-if="canRemove">
<button
type="button"
class="btn btn-delete"

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc";
import { GenericDoc } from "ChillDocStoreAssets/types/generic_doc";
interface GenericDocItemBoxProps {
genericDoc: GenericDocForAccompanyingPeriod;
genericDoc: GenericDoc;
}
const props = defineProps<GenericDocItemBoxProps>();

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