diff --git a/.changes/unreleased/Feature-20240530-160003.yaml b/.changes/unreleased/Feature-20240530-160003.yaml new file mode 100644 index 000000000..6b6baedc5 --- /dev/null +++ b/.changes/unreleased/Feature-20240530-160003.yaml @@ -0,0 +1,6 @@ +kind: Feature +body: | + Upgrade import of address list to the last version of compiled addresses of belgian-best-address +time: 2024-05-30T16:00:03.440767606+02:00 +custom: + Issue: "" diff --git a/.changes/unreleased/Feature-20240531-190242.yaml b/.changes/unreleased/Feature-20240531-190242.yaml new file mode 100644 index 000000000..083298a26 --- /dev/null +++ b/.changes/unreleased/Feature-20240531-190242.yaml @@ -0,0 +1,6 @@ +kind: Feature +body: | + Upgrade CKEditor and refactor configuration with use of typescript +time: 2024-05-31T19:02:42.776662753+02:00 +custom: + Issue: "" diff --git a/.changes/unreleased/Feature-20250808-120802.yaml b/.changes/unreleased/Feature-20250808-120802.yaml new file mode 100644 index 000000000..50d1eb8ba --- /dev/null +++ b/.changes/unreleased/Feature-20250808-120802.yaml @@ -0,0 +1,6 @@ +kind: Feature +body: Create invitation list in user menu +time: 2025-08-08T12:08:02.446361367+02:00 +custom: + Issue: "385" + SchemaChange: No schema change diff --git a/.changes/unreleased/Feature-20251007-155945.yaml b/.changes/unreleased/Feature-20251007-155945.yaml new file mode 100644 index 000000000..9b59e7ea5 --- /dev/null +++ b/.changes/unreleased/Feature-20251007-155945.yaml @@ -0,0 +1,6 @@ +kind: Feature +body: Admin interface for Motive entity +time: 2025-10-07T15:59:45.597029709+02:00 +custom: + Issue: "" + SchemaChange: No schema change diff --git a/.changes/unreleased/Feature-20251022-111552.yaml b/.changes/unreleased/Feature-20251022-111552.yaml new file mode 100644 index 000000000..058e40d82 --- /dev/null +++ b/.changes/unreleased/Feature-20251022-111552.yaml @@ -0,0 +1,6 @@ +kind: Feature +body: Add an admin interface for Motive entity +time: 2025-10-22T11:15:52.13937955+02:00 +custom: + Issue: "" + SchemaChange: Add columns or tables diff --git a/.changes/unreleased/Fixed-20251003-224044.yaml b/.changes/unreleased/Fixed-20251003-224044.yaml deleted file mode 100644 index c5b3e7304..000000000 --- a/.changes/unreleased/Fixed-20251003-224044.yaml +++ /dev/null @@ -1,6 +0,0 @@ -kind: Fixed -body: Fix the rendering of list of StoredObjectVersions, where there are kept version (before converting to pdf) and intermediate versions deleted -time: 2025-10-03T22:40:44.685474863+02:00 -custom: - Issue: "" - SchemaChange: No schema change diff --git a/.changes/unreleased/Fixed-20251106-161605.yaml b/.changes/unreleased/Fixed-20251106-161605.yaml new file mode 100644 index 000000000..3962daf8a --- /dev/null +++ b/.changes/unreleased/Fixed-20251106-161605.yaml @@ -0,0 +1,6 @@ +kind: Fixed +body: Fix suggestion of referrer when creating notification for accompanyingPeriodWorkDocument +time: 2025-11-06T16:16:05.861813041+01:00 +custom: + Issue: "428" + SchemaChange: No schema change diff --git a/.changes/v4.10.0.md b/.changes/v4.10.0.md new file mode 100644 index 000000000..e3ecfbf24 --- /dev/null +++ b/.changes/v4.10.0.md @@ -0,0 +1,6 @@ +## v4.10.0 - 2025-12-09 +### Feature +* [MR 928](https://gitlab.com/Chill-Projet/chill-bundles/-/merge_requests/928) [#462](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/462) Add the future appointments for the person in search results +### Fixed +* Remove dependency to package @symfony/ux-translator + diff --git a/.changes/v4.10.1.md b/.changes/v4.10.1.md new file mode 100644 index 000000000..410d89bf0 --- /dev/null +++ b/.changes/v4.10.1.md @@ -0,0 +1,6 @@ +## v4.10.1 - 2025-12-11 +### Fixed +* Fix missing translation variable in NewLocation component +* ([#476](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/476)) Fix display of header for ByActivityNumberAggregator +* Fix use of ByActivityNumberAggregator in combination with activity count exports +* ([#483](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/483)) Tentatively fix usage of CTRL+C in collabora editor with chrome / edge browser diff --git a/.changes/v4.6.0.md b/.changes/v4.6.0.md new file mode 100644 index 000000000..9c06b1e5a --- /dev/null +++ b/.changes/v4.6.0.md @@ -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 diff --git a/.changes/v4.6.1.md b/.changes/v4.6.1.md new file mode 100644 index 000000000..47cddf537 --- /dev/null +++ b/.changes/v4.6.1.md @@ -0,0 +1,3 @@ +## v4.6.1 - 2025-10-27 +### Fixed +* Fix export case where no 'reason' is picked within the PersonHavingActivityBetweenDateFilter.php diff --git a/.changes/v4.7.0.md b/.changes/v4.7.0.md new file mode 100644 index 000000000..48e6095fc --- /dev/null +++ b/.changes/v4.7.0.md @@ -0,0 +1,21 @@ +## v4.7.0 - 2025-11-10 +### Feature +* ([#385](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/385)) Create invitation list in user menu +* ([#404](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/404)) Add columns for comments linked to an activity in the activity list export +### Fixed +* ([#451](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/451)) Fix: display also social actions linked to parents of the selected social issue +* ([#453](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/453)) Fix: export actions and their results in csv even when action does not have any goals attached to it. +* Fix the possibility to delete a workflow + + **Schema Change**: Drop or rename table or columns, or enforce new constraint that must be manually fixed +* ([#457](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/457)) Fix the fusion of thirdparty properties that are located in another schema than public for TO_ONE relations + add extra loop for MANY_TO_MANY relations where thirdparty is the source instead of the target +* ([#428](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/428)) Fix suggestion of referrer when creating notification for accompanyingPeriodWorkDocument +### DX +* Send notifications log to dedicated channel, if it exists + +### UX +* ([#425](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/425)) Change the terms 'cercle' and 'centre' to 'service', and 'territoire' respectively. +* ([#542](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/542)) Improve the ux for selecting whether user wants to be notified of the final step of a workflow or all steps +* Expand timeSpent choices for evaluation document and translate them to user locale or fallback 'fr' +* ([#455](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/455)) Change the order of display for results and objectives in the social work/action form +* Wrap text when it is too long within badges diff --git a/.changes/v4.8.0.md b/.changes/v4.8.0.md new file mode 100644 index 000000000..48583b6a2 --- /dev/null +++ b/.changes/v4.8.0.md @@ -0,0 +1,9 @@ +## v4.8.0 - 2025-11-17 +### Feature +* ([#461](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/461)) Make a calendar item on the 'mes rendez-vous' page clickable. Clicking will navigate to the edit page of the calendar item. +### Fixed +* ([#463](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/463)) Display calendar items for which an invite was accepted on the mes rendez-vous page +* Improve accessibility on login page + +### UX +* ([#449](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/449)) Remove the label if there is only one scope and no scope picking field is displayed. diff --git a/.changes/v4.8.1.md b/.changes/v4.8.1.md new file mode 100644 index 000000000..81decc30c --- /dev/null +++ b/.changes/v4.8.1.md @@ -0,0 +1,6 @@ +## v4.8.1 - 2025-11-20 +### Fixed +* Insert name of file as the document title when uploading +* Add missing path paramater 'id' for editing multiple participations +* ([#471](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/471)) Hide the display of inactive user groups in the api + diff --git a/.changes/v4.8.2.md b/.changes/v4.8.2.md new file mode 100644 index 000000000..309255feb --- /dev/null +++ b/.changes/v4.8.2.md @@ -0,0 +1,10 @@ +## v4.8.2 - 2025-11-26 +### Fixed +* ([#466](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/466)) Associate activity's creator as a participant by default, and retro-actively append the creator to each activity + + + **Schema Change**: Add columns or tables +* Fix template parameter for update_multiple route on event participations +### UX +* ([#470](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/470)) Alphabetically order userJobs and mainLocations within user creation form +* ([#437](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/437)) Change position and color of confirm parcours button diff --git a/.changes/v4.9.0.md b/.changes/v4.9.0.md new file mode 100644 index 000000000..89ba9de33 --- /dev/null +++ b/.changes/v4.9.0.md @@ -0,0 +1,14 @@ +## v4.9.0 - 2025-12-05 +### Feature +* ([#459](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/459)) Add a counter for invitations awaiting reply +### Fixed +* ([#475](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/475)) Do not suggest a user that is no longer active in the activity form. +* ([#441](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/441)) Remove double display of person id in the banner when there is a deathdate +### DX +* ([#280](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/280)) Add missing fixtures for proper loading of AccompanyingPeriods +* ([#386](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/386)) Use mkdocs with mkdocs-material instead of sphinx to build chill developer documentation +### UX +* ([#456](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/456)) Display whether doc generation template is active or not in admin and order templates alphabetically +* ([#460](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/460)) Display calendar item info on cancel page +* ([#424](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/424)) Display entire comment for activity item within list +* ([#474](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/474)) Improve accessibility of event form diff --git a/.editorconfig b/.editorconfig index bede621e3..49ea12528 100644 --- a/.editorconfig +++ b/.editorconfig @@ -19,11 +19,11 @@ max_line_length = 80 [COMMIT_EDITMSG] max_line_length = 0 -[*.{js, vue, ts}] +[*.{js,vue,ts}] indent_size = 2 indent_style = space -[.rst] -ident_size = 3 -ident_style = space +[*.rst] +indent_size = 3 +indent_style = space diff --git a/.env b/.env index 2ca9eb5ff..98a9af752 100644 --- a/.env +++ b/.env @@ -92,3 +92,7 @@ REDIS_URL="redis://${REDIS_HOST}:${REDIS_PORT}" ###> symfony/ovh-cloud-notifier ### # OVHCLOUD_DSN=ovhcloud://APPLICATION_KEY:APPLICATION_SECRET@default?consumer_key=CONSUMER_KEY&service_name=SERVICE_NAME ###< symfony/ovh-cloud-notifier ### + +###> symfony/loco-translation-provider ### +#LOCO_DSN=loco://API_KEY@default +###< symfony/loco-translation-provider ### diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 149a890ad..18e7c8760 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -38,13 +38,64 @@ variables: TZ: Europe/Brussels # avoid direct deprecations (using symfony phpunit bridge: https://symfony.com/doc/4.x/components/phpunit_bridge.html#internal-deprecations SYMFONY_DEPRECATIONS_HELPER: max[total]=99999999&max[self]=0&max[direct]=45&verbose=0 + # consider the root package at the dev-master version + # this is required to work with packages + # see https://getcomposer.org/doc/articles/troubleshooting.md#dependencies-on-the-root-package + COMPOSER_ROOT_VERSION: dev-master MAILER_DSN: 'null://null' stages: + - mirror - Composer install - Tests - Deploy +mirror_chill_zimbra_bundle: + stage: mirror + image: alpine:latest + + variables: + GIT_DEPTH: 0 # <-- access to the full git history + + rules: + # 1) Allow manual run from GitLab UI, whatever the branch + - if: '$CI_PIPELINE_SOURCE == "web"' + + # 2) Auto-run on commits to master or 472-zimbra-connector + # but only if relevant files changed + - if: '$CI_COMMIT_BRANCH == "master" || $CI_COMMIT_BRANCH == "472-zimbra-connector"' + changes: + - packages/ChillZimbraBundle/**/* + - .gitlab-ci.yml + + # 3) Otherwise: never run + - when: never + + + before_script: + - apk add --no-cache git git-subtree openssh + # Config git + - git config --global user.email "ci@gitlab.com" + - git config --global user.name "GitLab CI" + # Préparation SSH + - mkdir -p ~/.ssh + - cp "$DEPLOY_KEY" ~/.ssh/id_ed25519 + - printf '\n' >> ~/.ssh/id_ed25519 + - chmod 600 ~/.ssh/id_ed25519 + - ssh-keyscan gitlab.com >> ~/.ssh/known_hosts + # Ajout du remote vers le repo dédié + - git remote add chill-zimbra-connector git@gitlab.com:Chill-Projet/chill-zimbra-connector.git || true + + script: + # On s'assure d'être sur la bonne branche (celle qui a déclenché le job, master) + - git checkout "$CI_COMMIT_REF_NAME" + + # Crée une branche temporaire qui contient uniquement l'historique de packages/ChillZimbraBundle + - git subtree split --prefix=packages/ChillZimbraBundle -b chill_zimbra_temp + + # Push vers le repo cible, branche master du repo chill-zimbra-connector + - git push chill-zimbra-connector chill_zimbra_temp:main + build: stage: Composer install image: chill/base-image:8.4-edge @@ -136,6 +187,10 @@ unit_tests: - php bin/console doctrine:fixtures:load -n --env=test script: - composer exec phpunit -- --colors=never --exclude-group dbIntensive,openstack-integration + artifacts: + expire_in: 1 day + paths: + - vendor/ release: stage: Deploy diff --git a/.junie/guidelines.md b/.junie/guidelines.md index a53bfe8cf..ad355eac7 100644 --- a/.junie/guidelines.md +++ b/.junie/guidelines.md @@ -234,22 +234,22 @@ This must be a decision made by a human, not by an AI. Every AI task must abort #### Running Tests -The tests are run from the project's root (not from the bundle's root). +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). + +Tests must be run using the `symfony` command: ```bash -# Run all tests -vendor/bin/phpunit - -# Run tests for a specific bundle -vendor/bin/phpunit --testsuite NameBundle # Run a specific test file -vendor/bin/phpunit path/to/TestFile.php +symfony composer exec phpunit -- path/to/TestFile.php # Run a specific test method -vendor/bin/phpunit --filter methodName path/to/TestFile.php +symfony composer exec phpunit -- --filter methodName path/to/TestFile.php ``` +When writing tests, only test specific files. Do not run all tests or the full +test suite. + #### Test Structure Tests are organized by bundle and follow the same structure as the bundle itself: diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 3360abe85..9e6e76238 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -14,6 +14,7 @@ $finder = PhpCsFixer\Finder::create(); $finder ->in(__DIR__.'/src') ->in(__DIR__.'/utils') + ->in(__DIR__.'/packages') ->append([__FILE__]) ->exclude(['docs/', 'tests/app']) ->notPath('tests/app') diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..222861c34 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,4 @@ +{ + "tabWidth": 2, + "useTabs": false +} diff --git a/.readthedocs.yml b/.readthedocs.yml index cd8f36eba..fc1abe247 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -4,11 +4,11 @@ version: 2 build: os: ubuntu-22.04 tools: - python: "3.7" + python: "3.11" -sphinx: - configuration: docs/source/conf.py +mkdocs: + configuration: docs/mkdocs.yml python: install: - - requirements: docs/requirements.txt \ No newline at end of file + - requirements: docs/requirements.txt diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..d7a227605 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,30 @@ +{ + // Use IntelliSense to learn about possible attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Chill Debug", + "type": "php", + "request": "launch", + "port": 9000, + "pathMappings": { + "/var/www/html": "${workspaceFolder}" + }, + "preLaunchTask": "symfony" + }, + { + "name": "Yarn Encore Dev (Watch)", + "type": "node-terminal", + "request": "launch", + "command": "yarn encore dev --watch", + "cwd": "${workspaceFolder}" + } + ], + "compounds": [ + { + "name": "Chill Debug + Yarn Encore Dev (Watch)", + "configurations": ["Chill Debug", "Yarn Encore Dev (Watch)"] + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 000000000..a652cfe03 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,23 @@ +{ + "tasks": [ + { + "type": "shell", + "command": "symfony", + "args": [ + "server:start", + "--allow-http", + "--no-tls", + "--port=8000", + "--allow-all-ip", + "-d" + ], + "label": "symfony" + }, + { + "type": "shell", + "command": "yarn", + "args": ["encore", "dev", "--watch"], + "label": "webpack" + } + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 359b777c5..5700c47cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,104 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), and is generated by [Changie](https://github.com/miniscruff/changie). +## v4.10.1 - 2025-12-11 +### Fixed +* Fix missing translation variable in NewLocation component +* ([#476](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/476)) Fix display of header for ByActivityNumberAggregator +* Fix use of ByActivityNumberAggregator in combination with activity count exports +* ([#483](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/483)) Tentatively fix usage of CTRL+C in collabora editor with chrome / edge browser + +## v4.10.0 - 2025-12-09 +### Feature +* [MR 928](https://gitlab.com/Chill-Projet/chill-bundles/-/merge_requests/928) [#462](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/462) Add the future appointments for the person in search results +### Fixed +* Remove dependency to package @symfony/ux-translator + + +## v4.9.0 - 2025-12-05 +### Feature +* ([#459](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/459)) Add a counter for invitations awaiting reply +### Fixed +* ([#475](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/475)) Do not suggest a user that is no longer active in the activity form. +* ([#441](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/441)) Remove double display of person id in the banner when there is a deathdate +### DX +* ([#280](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/280)) Add missing fixtures for proper loading of AccompanyingPeriods +* ([#386](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/386)) Use mkdocs with mkdocs-material instead of sphinx to build chill developer documentation +### UX +* ([#456](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/456)) Display whether doc generation template is active or not in admin and order templates alphabetically +* ([#460](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/460)) Display calendar item info on cancel page +* ([#424](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/424)) Display entire comment for activity item within list +* ([#474](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/474)) Improve accessibility of event form + +## v4.8.2 - 2025-11-26 +### Fixed +* ([#466](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/466)) Associate activity's creator as a participant by default, and retro-actively append the creator to each activity + + + **Schema Change**: Add columns or tables +* Fix template parameter for update_multiple route on event participations +### UX +* ([#470](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/470)) Alphabetically order userJobs and mainLocations within user creation form +* ([#437](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/437)) Change position and color of confirm parcours button + +## v4.8.1 - 2025-11-20 +### Fixed +* Insert name of file as the document title when uploading +* Add missing path paramater 'id' for editing multiple participations +* ([#471](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/471)) Hide the display of inactive user groups in the api + + +## v4.8.0 - 2025-11-17 +### Feature +* ([#461](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/461)) Make a calendar item on the 'mes rendez-vous' page clickable. Clicking will navigate to the edit page of the calendar item. +### Fixed +* ([#463](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/463)) Display calendar items for which an invite was accepted on the mes rendez-vous page +* Improve accessibility on login page + +### UX +* ([#449](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/449)) Remove the label if there is only one scope and no scope picking field is displayed. + +## v4.7.0 - 2025-11-10 +### Feature +* ([#385](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/385)) Create invitation list in user menu +* ([#404](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/404)) Add columns for comments linked to an activity in the activity list export +### Fixed +* ([#451](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/451)) Fix: display also social actions linked to parents of the selected social issue +* ([#453](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/453)) Fix: export actions and their results in csv even when action does not have any goals attached to it. +* Fix the possibility to delete a workflow + + **Schema Change**: Drop or rename table or columns, or enforce new constraint that must be manually fixed +* ([#457](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/457)) Fix the fusion of thirdparty properties that are located in another schema than public for TO_ONE relations + add extra loop for MANY_TO_MANY relations where thirdparty is the source instead of the target +* ([#428](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/428)) Fix suggestion of referrer when creating notification for accompanyingPeriodWorkDocument +### DX +* Send notifications log to dedicated channel, if it exists + +### UX +* ([#425](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/425)) Change the terms 'cercle' and 'centre' to 'service', and 'territoire' respectively. +* ([#542](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/542)) Improve the ux for selecting whether user wants to be notified of the final step of a workflow or all steps +* Expand timeSpent choices for evaluation document and translate them to user locale or fallback 'fr' +* ([#455](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/455)) Change the order of display for results and objectives in the social work/action form +* Wrap text when it is too long within badges + +## v4.6.1 - 2025-10-27 +### Fixed +* Fix export case where no 'reason' is picked within the PersonHavingActivityBetweenDateFilter.php + +## 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 diff --git a/CONVENTIONS-fr.md b/CONVENTIONS-fr.md index 028971ef2..4462f4e08 100644 --- a/CONVENTIONS-fr.md +++ b/CONVENTIONS-fr.md @@ -54,7 +54,7 @@ Arborescence: - person - personvendee - household_edit_metadata - - index.js + - index.ts ``` ## Organisation des feuilles de styles diff --git a/assets/translator.ts b/assets/translator.ts index fe4e14ffa..2daba91aa 100644 --- a/assets/translator.ts +++ b/assets/translator.ts @@ -1,7 +1,12 @@ -import { trans, setLocale, setLocaleFallbacks } from "./ux-translator"; +import { + trans, + setLocale, + getLocale, + setLocaleFallbacks, +} from "./ux-translator"; -setLocaleFallbacks({"en": "fr", "nl": "fr", "fr": "en"}); -setLocale('fr'); +setLocaleFallbacks({ en: "fr", nl: "fr", fr: "en" }); +setLocale("fr"); -export { trans }; -export * from '../var/translations'; +export { trans, getLocale }; +export * from "../var/translations"; diff --git a/composer.json b/composer.json index f629faa21..9c8af8580 100644 --- a/composer.json +++ b/composer.json @@ -7,6 +7,13 @@ "chill", "social worker" ], + "repositories": [{ + "type": "path", + "url": "./packages/ChillZimbraBundle", + "options": { + "symlink": true + } + }], "require": { "php": "^8.4", "ext-dom": "*", @@ -110,7 +117,8 @@ "symfony/runtime": "^7.2", "symfony/stopwatch": "^7.2", "symfony/var-dumper": "^7.2", - "symfony/web-profiler-bundle": "^7.2" + "symfony/web-profiler-bundle": "^7.2", + "symfony/loco-translation-provider": "^6.0" }, "conflict": { "symfony/symfony": "*" @@ -133,6 +141,7 @@ "Chill\\TaskBundle\\": "src/Bundle/ChillTaskBundle", "Chill\\ThirdPartyBundle\\": "src/Bundle/ChillThirdPartyBundle", "Chill\\WopiBundle\\": "src/Bundle/ChillWopiBundle/src", + "Chill\\TicketBundle\\": "src/Bundle/ChillTicketBundle/src", "Chill\\Utils\\Rector\\": "utils/rector/src" } }, diff --git a/config/bundles.php b/config/bundles.php index 362edeaa2..3237a8624 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -2,7 +2,6 @@ return [ Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true], - loophp\PsrHttpMessageBridgeBundle\PsrHttpMessageBridgeBundle::class => ['all' => true], ChampsLibres\WopiBundle\WopiBundle::class => ['all' => true], Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], @@ -34,6 +33,8 @@ return [ Chill\ThirdPartyBundle\ChillThirdPartyBundle::class => ['all' => true], Chill\BudgetBundle\ChillBudgetBundle::class => ['all' => true], Chill\WopiBundle\ChillWopiBundle::class => ['all' => true], + 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], ]; diff --git a/config/packages/chill.yaml b/config/packages/chill.yaml index 6ed6b6984..26f91feb5 100644 --- a/config/packages/chill.yaml +++ b/config/packages/chill.yaml @@ -1,6 +1,13 @@ chill_main: - available_languages: [ '%env(resolve:LOCALE)%', 'en' ] + available_languages: [ '%env(resolve:LOCALE)%', 'en', 'nl' ] 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)%' diff --git a/config/packages/chill_aside_activity.yaml b/config/packages/chill_aside_activity.yaml new file mode 100644 index 000000000..eb5c2d70e --- /dev/null +++ b/config/packages/chill_aside_activity.yaml @@ -0,0 +1,2 @@ +chill_aside_activity: + show_concerned_persons_count: hidden diff --git a/config/packages/chill_doc_store.yaml b/config/packages/chill_doc_store.yaml index eb7f6be01..2182b17cc 100644 --- a/config/packages/chill_doc_store.yaml +++ b/config/packages/chill_doc_store.yaml @@ -1,5 +1,5 @@ chill_doc_store: - use_driver: openstack + use_driver: local_storage local_storage: storage_path: '%kernel.project_dir%/var/storage' openstack: diff --git a/config/packages/chill_ticket.yaml b/config/packages/chill_ticket.yaml new file mode 100644 index 000000000..3706d961d --- /dev/null +++ b/config/packages/chill_ticket.yaml @@ -0,0 +1,5 @@ +chill_ticket: + ticket: + person_per_ticket: one # One of "one"; "many" + response_time_exceeded_delay: PT12H + diff --git a/config/packages/doctrine_migrations_chill.yaml b/config/packages/doctrine_migrations_chill.yaml index 8b8bf539b..29acb8a49 100644 --- a/config/packages/doctrine_migrations_chill.yaml +++ b/config/packages/doctrine_migrations_chill.yaml @@ -14,6 +14,7 @@ doctrine_migrations: 'Chill\Migrations\Calendar': '@ChillCalendarBundle/migrations' 'Chill\Migrations\Budget': '@ChillBudgetBundle/migrations' 'Chill\Migrations\Report': '@ChillReportBundle/migrations' + 'Chill\Migrations\Ticket': '@ChillTicketBundle/migrations' all_or_nothing: true diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index 7ca02530f..93af95c65 100644 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -64,6 +64,7 @@ framework: 'Chill\MainBundle\Export\Messenger\ExportRequestGenerationMessage': priority 'Chill\MainBundle\Export\Messenger\RemoveExportGenerationMessage': async 'Chill\MainBundle\Notification\Email\NotificationEmailMessages\ScheduleDailyNotificationDigestMessage': async + 'Chill\TicketBundle\Messenger\PostTicketUpdateMessage': async # end of routes added by chill-bundles recipes # Route your messages to the transports # 'App\Message\YourMessage': async diff --git a/config/packages/translation.yaml b/config/packages/translation.yaml index b3f8f9cfe..928125aef 100644 --- a/config/packages/translation.yaml +++ b/config/packages/translation.yaml @@ -1,7 +1,12 @@ +# config/packages/translation.yaml framework: - default_locale: en + default_locale: '%env(resolve:LOCALE)%' # LOCALE=fr in .env translator: default_path: '%kernel.project_dir%/translations' fallbacks: - - en + - '%env(resolve:LOCALE)%' # fr providers: + loco: + dsn: '%env(LOCO_DSN)%' + domains: [ 'messages' ] + locales: [ 'fr', 'nl' ] diff --git a/config/packages/translation_chill.yaml b/config/packages/translation_chill.yaml deleted file mode 100644 index a0371ff10..000000000 --- a/config/packages/translation_chill.yaml +++ /dev/null @@ -1,4 +0,0 @@ -framework: - default_locale: '%env(resolve:LOCALE)%' - translator: - fallbacks: [ '%env(resolve:LOCALE)%' ] \ No newline at end of file diff --git a/config/routes/chill_ticket.yaml b/config/routes/chill_ticket.yaml new file mode 100644 index 000000000..311a51992 --- /dev/null +++ b/config/routes/chill_ticket.yaml @@ -0,0 +1,2 @@ +chill_ticket_bundle: + resource: '@ChillTicketBundle/config/routes.yaml' diff --git a/config/services.yaml b/config/services.yaml index da12550f4..967dcd9d9 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -23,3 +23,6 @@ services: public: false tags: - { name: 'serializer.circular_reference_handler' } +when@dev: + services: + ChampsLibres\WopiLib\Contract\Service\ProofValidatorInterface: '@Chill\WopiBundle\Service\Wopi\NullProofValidator' diff --git a/docs/Makefile b/docs/Makefile index ec37e687c..c35370d9d 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -1,177 +1,34 @@ -# Makefile for Sphinx documentation +# Makefile for MkDocs documentation # # You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -PAPER = -BUILDDIR = build +MKDOCS = mkdocs +BUILDDIR = site -# User-friendly check for sphinx-build -ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) -$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) +# User-friendly check for mkdocs +ifeq ($(shell which $(MKDOCS) >/dev/null 2>&1; echo $$?), 1) +$(error The '$(MKDOCS)' command was not found. Make sure you have MkDocs installed with 'pip install mkdocs mkdocs-material', then make sure the mkdocs executable is in your PATH.) endif -# Internal variables. -PAPEROPT_a4 = -D latex_paper_size=a4 -PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source -# the i18n builder cannot share the environment and doctrees with the others -I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source - -.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext +.PHONY: help clean html build serve help: @echo "Please use \`make ' where is one of" - @echo " html to make standalone HTML files" - @echo " dirhtml to make HTML files named index.html in directories" - @echo " singlehtml to make a single large HTML file" - @echo " pickle to make pickle files" - @echo " json to make JSON files" - @echo " htmlhelp to make HTML files and a HTML help project" - @echo " qthelp to make HTML files and a qthelp project" - @echo " devhelp to make HTML files and a Devhelp project" - @echo " epub to make an epub" - @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" - @echo " latexpdf to make LaTeX files and run them through pdflatex" - @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" - @echo " text to make text files" - @echo " man to make manual pages" - @echo " texinfo to make Texinfo files" - @echo " info to make Texinfo files and run them through makeinfo" - @echo " gettext to make PO message catalogs" - @echo " changes to make an overview of all changed/added/deprecated items" - @echo " xml to make Docutils-native XML files" - @echo " pseudoxml to make pseudoxml-XML files for display purposes" - @echo " linkcheck to check all external links for integrity" - @echo " doctest to run all doctests embedded in the documentation (if enabled)" + @echo " html to build the documentation (same as build)" + @echo " build to build the documentation" + @echo " serve to start the development server" + @echo " clean to clean the build directory" clean: rm -rf $(BUILDDIR)/* -html: - $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html +html: build + +build: + $(MKDOCS) build @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." + @echo "Build finished. The HTML pages are in $(BUILDDIR)/." -dirhtml: - $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." - -singlehtml: - $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml - @echo - @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." - -pickle: - $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle - @echo - @echo "Build finished; now you can process the pickle files." - -json: - $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json - @echo - @echo "Build finished; now you can process the JSON files." - -htmlhelp: - $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp - @echo - @echo "Build finished; now you can run HTML Help Workshop with the" \ - ".hhp project file in $(BUILDDIR)/htmlhelp." - -qthelp: - $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp - @echo - @echo "Build finished; now you can run "qcollectiongenerator" with the" \ - ".qhcp project file in $(BUILDDIR)/qthelp, like this:" - @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/chill-doc.qhcp" - @echo "To view the help file:" - @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/chill-doc.qhc" - -devhelp: - $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp - @echo - @echo "Build finished." - @echo "To view the help file:" - @echo "# mkdir -p $$HOME/.local/share/devhelp/chill-doc" - @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/chill-doc" - @echo "# devhelp" - -epub: - $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub - @echo - @echo "Build finished. The epub file is in $(BUILDDIR)/epub." - -latex: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo - @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." - @echo "Run \`make' in that directory to run these through (pdf)latex" \ - "(use \`make latexpdf' here to do that automatically)." - -latexpdf: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through pdflatex..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -latexpdfja: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through platex and dvipdfmx..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -text: - $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text - @echo - @echo "Build finished. The text files are in $(BUILDDIR)/text." - -man: - $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man - @echo - @echo "Build finished. The manual pages are in $(BUILDDIR)/man." - -texinfo: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo - @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." - @echo "Run \`make' in that directory to run these through makeinfo" \ - "(use \`make info' here to do that automatically)." - -info: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo "Running Texinfo files through makeinfo..." - make -C $(BUILDDIR)/texinfo info - @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." - -gettext: - $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale - @echo - @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." - -changes: - $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes - @echo - @echo "The overview file is in $(BUILDDIR)/changes." - -linkcheck: - $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck - @echo - @echo "Link check complete; look for any errors in the above output " \ - "or in $(BUILDDIR)/linkcheck/output.txt." - -doctest: - $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest - @echo "Testing of doctests in the sources finished, look at the " \ - "results in $(BUILDDIR)/doctest/output.txt." - -xml: - $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml - @echo - @echo "Build finished. The XML files are in $(BUILDDIR)/xml." - -pseudoxml: - $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml - @echo - @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." +serve: + $(MKDOCS) serve + @echo "Development server started at http://127.0.0.1:8000/" diff --git a/docs/README.md b/docs/README.md index 8ae7f650a..301ccfb6a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,20 +10,26 @@ Compilation into HTML To compile this documentation : -1. Install [sphinx-doc](http://sphinx-doc.org) +1. Install [MkDocs](https://www.mkdocs.org/) and MkDocs Material ``` bash $ virtualenv .venv # creation of the virtual env (only the first time) $ source .venv/bin/activate # activate the virtual env (.venv) $ pip install -r requirements.txt ``` 2. Install submodules : $ git submodule update --init; -3. run `make html` from the root directory -4. The base file is located on build/html/index.html +3. run `make html` or `mkdocs build` from the root directory +4. The base file is located on site/index.html ``` bash - $ cd build/html + $ cd site $ python -m http.server 8888 # will serve the site on the port 8888 ``` +Alternatively, you can use the built-in development server: +``` bash +(.venv) $ mkdocs serve +``` +This will start a development server at http://127.0.0.1:8000/ with live reload. + Contribute =========== diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml new file mode 100644 index 000000000..c1faac741 --- /dev/null +++ b/docs/mkdocs.yml @@ -0,0 +1,137 @@ +site_name: Chill Documentation +site_description: Documentation for Chill - A free software for social workers +site_url: https://docs.chill.social +repo_url: https://gitlab.com/Chill-project/chill-bundles +repo_name: Chill-project/chill-bundles + +docs_dir: source + +copyright: Copyright © 2014-2024 Champs-Libres Cooperative SCRLFS - GNU Free Documentation License v1.3 + +theme: + name: material + language: en + features: + - navigation.tabs + - navigation.tabs.sticky + - navigation.sections + - navigation.expand + - navigation.path + - navigation.top + - search.highlight + - search.share + - search.suggest + - toc.follow + - content.code.copy + - content.code.annotate + + palette: + # Palette toggle for light mode + - scheme: default + primary: blue + accent: blue + toggle: + icon: material/brightness-7 + name: Switch to dark mode + + # Palette toggle for dark mode + - scheme: slate + primary: blue + accent: blue + toggle: + icon: material/brightness-4 + name: Switch to light mode + + font: + text: Roboto + code: Roboto Mono + +plugins: + - search + - autorefs + +markdown_extensions: + - admonition + - pymdownx.details + - pymdownx.superfences + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.tabbed: + alternate_style: true + - pymdownx.tasklist: + custom_checkbox: true + - toc: + permalink: true + - tables + - attr_list + - md_in_html + +nav: + - Home: index.md + - Installation: + - installation/index.md + - Development: installation/installation-development.md + - Production: installation/installation-production.md + - Document Storage: installation/document-storage.md + - Load Addresses: installation/load-addresses.md + - Production Setup: installation/prod.md + - Calendar SMS: installation/prod-calendar-sms-sending.md + - MS Graph Configuration: installation/msgraph-configure.md + - Enable Collabora: installation/enable-collabora-for-dev.md + - Development: + - development/index.md + - Access Control: development/access_control_model.md + - API: development/api.md + - Assets: development/assets.md + - Code Quality: development/code-quality.md + - Create Bundle: development/create-a-new-bundle.md + - Cronjobs: development/cronjob.md + - CRUD: development/crud.md + - Database Principles: development/database-principles.md + - Embeddable Comments: development/embeddable-comments.md + - Entity Info: development/entity-info.md + - ESLint: development/es-lint.md + - Exports: development/exports.md + - FAQ: development/FAQ.md + - Forms: development/forms.md + - Localisation: development/localisation.md + - Logging: development/logging.md + - Manual: + - development/manual/index.md + - Routing and Menus: development/manual/routing-and-menus.md + - Menus: development/menus.md + - Messages to Users: development/messages-to-users.md + - Migrations: development/migrations.md + - Pagination: development/pagination.md + - Render Entity: development/render-entity.md + - Routing: development/routing.md + - Run Tests: development/run-tests.md + - Searching: development/searching.md + - Timelines: development/timelines.md + - Useful Snippets: development/useful-snippets.md + - User Interface: + - CSS Classes: development/user-interface/css-classes.md + - JS Functions: development/user-interface/js-functions.md + - Layout Template: development/user-interface/layout-template-usage.md + - Widgets: development/user-interface/widgets.md + - Bundles: + - bundles/index.md + - Activity: bundles/activity.md + - Custom Fields: bundles/custom-fields.md + - Event: bundles/event.md + - Group: bundles/group.md + - LDAP: bundles/ldap.md + - Main: bundles/main.md + - Person: bundles/person.md + - Report: bundles/report.md + +extra: + social: + - icon: fontawesome/brands/gitlab + link: https://gitlab.com/Chill-project + - icon: fontawesome/solid/comments + link: https://app.element.io/#/room/#chill-social-admin:matrix.org diff --git a/docs/requirements.txt b/docs/requirements.txt index a8d979a2b..82fb39971 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,7 +1,4 @@ -docutils==0.13.1 -Pygments==2.2.0 -sphinx==1.8.5 -Jinja2<3.1 -git+https://github.com/fabpot/sphinx-php.git@v2.0.2#egg_name=sphinx-php -jsx-lexer===0.0.8 -sphinx_rtd_theme==0.5.0 +mkdocs>=1.5.3 +mkdocs-material>=9.4.0 +pymdown-extensions>=10.3.0 +mkdocs-autorefs>=0.5.0 diff --git a/docs/source/_static/bundles/docStore/doc_store_classes.puml b/docs/source/_static/bundles/docStore/doc_store_classes.puml index 431d268c0..f0c61f44c 100644 --- a/docs/source/_static/bundles/docStore/doc_store_classes.puml +++ b/docs/source/_static/bundles/docStore/doc_store_classes.puml @@ -23,8 +23,8 @@ class "Document" { - text description - ArrayCollection_DocumentCategory categories - varchar_150 content #link to openstack - - Center center - - Cercle cercle + - Territoire territoire + - Service service - User user - DateTime date # Creation date } diff --git a/docs/source/bundles/activity.md b/docs/source/bundles/activity.md new file mode 100644 index 000000000..f98f9b009 --- /dev/null +++ b/docs/source/bundles/activity.md @@ -0,0 +1,49 @@ +# Activity bundle + +This bundle provides the ability to record people in the software. This bundle is required by other bundle. + +###### Entities provided + + Describe the entities provided. + +###### Configuration options + +Those options are available under `chill_activity` key. + +Example of configuration: + +```yaml + chill_activity: + form: + time_duration: + - { label: '12 minutes', seconds: 720 } + - { label: '30 minutes', seconds: 1800 } +``` + +form.time_duration *array* + The duration which might be suggested when the user create or update an activity. The value must be an array of object, where each object must have a `label` and a `seconds` key. The label provide which is shown to user (the label will be translated, if possible) and the seconds the duration. + + Example: see the example above + + Default value: the values available are 5, 10, 15, 20, 25, 30, 45 minutes, and 1 hour, 1 hour 15, 1 hour 30, 1 hour 45 and 2 hours. + +###### Macros + +## Activity reason sticker + +Macro file + `ChillActivityBundle:ActivityReason:macro.html.twig` +Macro envelope + `reason(r)` + + `p` is an instance of :class:`Chill\ActivityBundle\Entity\ActivityReason` + +When to use this macro ? + When you want to represent an activity reason. +Example usage : + + ```jinja + {% import 'ChillActivityBundle:ActivityReason:macro.html.twig' as m %} + + {{ m.reason(r) }} + ``` diff --git a/docs/source/bundles/activity.rst b/docs/source/bundles/activity.rst deleted file mode 100644 index 011983e0a..000000000 --- a/docs/source/bundles/activity.rst +++ /dev/null @@ -1,71 +0,0 @@ -.. Copyright (C) 2014 Champs Libres Cooperative SCRLFS - Permission is granted to copy, distribute and/or modify this document - under the terms of the GNU Free Documentation License, Version 1.3 - or any later version published by the Free Software Foundation; - with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. - A copy of the license is included in the section entitled "GNU - Free Documentation License". - -.. _activity-bundle: - -Activity bundle -############### - -This bundle provides the ability to record people in the software. This bundle is required by other bundle. - -.. contents:: Table of content - :local: - -Entities provided -***************** - -.. todo:: - - Describe the entities provided. - - -Configuration options -********************* - -Those options are available under `chill_activity` key. - -Example of configuration: - -.. code-block:: yaml - - chill_activity: - form: - time_duration: - - { label: '12 minutes', seconds: 720 } - - { label: '30 minutes', seconds: 1800 } - -form.time_duration *array* - The duration which might be suggested when the user create or update an activity. The value must be an array of object, where each object must have a :code:`label` and a :code:`seconds` key. The label provide which is shown to user (the label will be translated, if possible) and the seconds the duration. - - Example: see the example above - - Default value: the values available are 5, 10, 15, 20, 25, 30, 45 minutes, and 1 hour, 1 hour 15, 1 hour 30, 1 hour 45 and 2 hours. - -.. _activity-bundle-macros: - -Macros -****** - -Activity reason sticker -======================= - -Macro file - `ChillActivityBundle:ActivityReason:macro.html.twig` -Macro envelope - :code:`reason(r)` - - :code:`p` is an instance of :class:`Chill\ActivityBundle\Entity\ActivityReason` - -When to use this macro ? - When you want to represent an activity reason. -Example usage : - .. code-block:: html+jinja - - {% import 'ChillActivityBundle:ActivityReason:macro.html.twig' as m %} - - {{ m.reason(r) }} diff --git a/docs/source/bundles/custom-fields.rst b/docs/source/bundles/custom-fields.md similarity index 79% rename from docs/source/bundles/custom-fields.rst rename to docs/source/bundles/custom-fields.md index f56b0dc23..646de34f6 100644 --- a/docs/source/bundles/custom-fields.rst +++ b/docs/source/bundles/custom-fields.md @@ -1,15 +1,4 @@ -.. Copyright (C) 2014 Champs Libres Cooperative SCRLFS - Permission is granted to copy, distribute and/or modify this document - under the terms of the GNU Free Documentation License, Version 1.3 - or any later version published by the Free Software Foundation; - with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. - A copy of the license is included in the section entitled "GNU - Free Documentation License". - -.. _custom-fields-bundle: - -Custom fields bundle -==================== +## Custom fields bundle This bundle provides the ability to add custom fields to existing entities. @@ -17,79 +6,63 @@ Those custom fields contains extra data and will be stored in the DB, along with In the database, custom fields are stored in json format. -.. seealso:: + The full specification discussed [here](https://redmine.champs-libres.coop/issues/239) - The full specification discussed `here. `_ - - `JSON Type on postgresql documentation `_ + [JSON Type on postgresql documentation](http://www.postgresql.org/docs/9.3/static/datatype-json.html) The documentation of json type, which is used to store data in the database. - -.. contents:: Table of contents - :depth: 4 - :local: -Custom Fields concepts ----------------------- +### Custom Fields concepts Custom fields are extra data which may be added to entities by user. If a developer implements custom fields on a entity, users will be able to add more fields on this entity. -Example: the `person bundle` allows to record `firstname`, `lastname`, `date of birth` fields. But users need to store information about the kind of house he has (if he owns his house, if he rents it, ...). Custom fields allows to create those fields. +Example: the [person bundle` allows to record `firstname`, `lastname`, `date of birth` fields. But users need to store information about the kind of house he has (if he owns his house, if he rents it, ...). Custom fields allows to create those fields. Automatically, those fields are added at the person form. They are also printed in the person view and in exports. -Custom fields and custom fields group -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +##### Custom fields and custom fields group -Custom fields are associated to a custom fields group. When a user want to add custom fields on an entity, he must first create a custom fields group and associate this field with an entity. Then he may add add custom fields to this groups. +Custom fields are associated to a custom fields group. When a user want to add custom fields on an entity, he must first create a custom fields group and associate this field with an entity. Then he may add add custom fields to this groups. Some entities needs a **default** custom fields group. For instance, the default custom fields group will be printed on the main form for person, and will be appended on the main person view. Some bundle does not use this feature (i.e. the `report` bundle). -.. note:: - In the future of the `person bundle`, other custom fields group will be added in forms accessible from the menu, allowing users to completely customize and separate their entities. - -Allow custom fields on an entity --------------------------------- + +### Allow custom fields on an entity As a developer, you must allow your users to add custom fields on your entities. -.. warning:: For having custom fields, the class of the entity must contain a variable for storing the custom data. **By convention this variable must be called $cFData** - -Create a json field on your entity -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +##### Create a json field on your entity Declare a json field in your database : - -.. code-block:: yaml - +```bash Chill\CustomFieldsBundle\Entity\BlopEntity: type: entity # ... fields: cFData: type: json_array - +``` + Create the field accordingly in the class logic : -.. code-block:: php - +```bash namespace Chill\CustomFieldsBundle\Entity; - + /** * BlopEntity */ class BlopEntity { - + /** * @var array */ private $cFData; - + /** - * You must set a setter in order to save automatically custom + * You must set a setter in order to save automatically custom * fields from forms, using Form Component * * @param array $cFData @@ -100,9 +73,9 @@ Create the field accordingly in the class logic : $this->cFData = $cFData; return $this; } - + /** - * You also must create a getter in order to let Form + * You also must create a getter in order to let Form * component populate form fields * * @return array @@ -111,9 +84,9 @@ Create the field accordingly in the class logic : { return $this->cFData; } - -Declare your customizable entity in configuration -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +``` + +##### Declare your customizable entity in configuration This step is necessary to allow user to create custom fields group associated with this entity. @@ -130,26 +103,25 @@ This method is discouraged but explained first as it helps to undersand the reco Add those file under `chill_custom_fields` section : -.. code-block:: yaml - +```yaml chill_custom_fields: customizables_entities: - { class: Chill\YourBundleBundle\Entity\BlopEntity, name: blop_entity } - +``` + * The `name` allow you to define a string which is translatable. This string will appears when chill's admin will add/retrieve new customFieldsGroup. * The class, which is a full FQDN class path Automatically, in DependencyInjection/Extension class (recommended) """"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" -This is the recommended way for declaring customizable classes. +This is the recommended way for declaring customizable classes. You can prepend configuration of `custom fields bundle` from the class `YourBundle\DependencyInjection\YourBundleExtension`. **Note** that you also have to implements `Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface` on this class to make the `prepend` function being taken into account. -Example here : - -.. code-block:: php +Example here : +```php class ChillYourBundleExtension extends Extension implements PrependExtensionInterface { /** @@ -163,48 +135,44 @@ Example here : } $container->prependExtensionConfig('chill_custom_fields', - array('customizables_entities' => + array('customizables_entities' => array( array( - 'class' => 'Chill\YourBundleBundle\Entity\BlopEntity', + 'class' => 'Chill\YourBundleBundle\Entity\BlopEntity', 'name' => 'blop_entity',) ) ) ); } } +``` * The `name` allow you to define a string which is translatable. This string will appears when chill's admin will add/retrieve new customFieldsGroup. * The class, which is a full FQDN class path -.. seealso:: - - `How to simplify configuration of multiple bundles `_ + `How to simplify configuration of multiple bundles ](http://symfony.com/doc/current/cookbook/bundles/prepend_extension.html) A cookbook page about prepending configuration. - -Adding options to your custom fields groups -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +##### Adding options to your custom fields groups You may add options to the groups associated with an entity. -In `config.yml` the declaration should be : - -.. code-block:: yaml +In `config.yml` the declaration should be : +```yaml chill_custom_fields: customizables_entities: - - + - class: Chill\YourBundleBundle\Entity\BlopEntity name: BlopEntity options: # this will create a "myFieldKey" field as text, with a maxlength attribute to 150 (see http://symfony.com/doc/master/reference/forms/types/text.html) - myFieldKey: {form_type: text, form_options: {attr: [maxlength: 150]}} + myFieldKey: {form_type: text, form_options: {attr: [maxlength: 150]}} +``` -In the `PrependExtensionInterface::prepend` function, the options key will be added in the configuration definition : - -.. code-block:: php +In the `PrependExtensionInterface::prepend` function, the options key will be added in the configuration definition : +```php class ChillYourBundleExtension extends Extension implements PrependExtensionInterface { /** @@ -218,10 +186,10 @@ In the `PrependExtensionInterface::prepend` function, the options key will be ad } $container->prependExtensionConfig('chill_custom_fields', - array('customizables_entities' => + array('customizables_entities' => array( array( - 'class' => 'Chill\YourBundleBundle\Entity\BlopEntity', + 'class' => 'Chill\YourBundleBundle\Entity\BlopEntity', 'name' => 'BlopEntity', 'options' => array( 'myFieldKey' => [ 'form_type' => 'text', 'form_options' => [ 'attr' => [ 'maxlength' => 150 ] ] @@ -231,16 +199,15 @@ In the `PrependExtensionInterface::prepend` function, the options key will be ad ); } } - -**Example :** the entity `Report` from **ReportBundle** has to pick some custom fields belonging to a group to print them in *summaries* the timeline page. The definition will use the special type `custom_fields_group_linked_custom_field` which will add a select input with all fields associated with the current custom fields group : +``` -.. code-block:: php +**Example :** the entity `Report` from **ReportBundle** has to pick some custom fields belonging to a group to print them in *summaries* the timeline page. The definition will use the special type `custom_fields_group_linked_custom_field` which will add a select input with all fields associated with the current custom fields group : +```php class ChillReportExtension extends Extension implements PrependExtensionInterface { /** - * - * +###### * * @param ContainerBuilder $container */ public function prepend(ContainerBuilder $container) @@ -251,15 +218,15 @@ In the `PrependExtensionInterface::prepend` function, the options key will be ad } $container->prependExtensionConfig('chill_custom_fields', - array('customizables_entities' => + array('customizables_entities' => array( array( - 'class' => 'Chill\ReportBundle\Entity\Report', + 'class' => 'Chill\ReportBundle\Entity\Report', 'name' => 'ReportEntity', 'options' => array( 'summary_fields' => array( 'form_type' => 'custom_fields_group_linked_custom_fields', - 'form_options' => + 'form_options' => [ 'multiple' => true, 'expanded' => false @@ -271,14 +238,13 @@ In the `PrependExtensionInterface::prepend` function, the options key will be ad ); } } +``` Note that `custom_fields_group_linked_custom_fields` does not create any input on `CustomFieldsGroup` creation : there aren't any fields associated with the custom fields just after the group creation... You have to add custom fields and associate them with the newly created group to see them appears. -Rendering custom fields and custom fields group in a template -------------------------------------------------------------- +### Rendering custom fields and custom fields group in a template -.. warning:: - Each custom field can be `active` or not. Only `active` custom fields has to be dislayed. +Each custom field can be `active` or not. Only `active` custom fields has to be dislayed. For rendering custom fields, two function are available : @@ -290,8 +256,7 @@ For rendering custom fields group, a function is available : * `chill_custom_fields_group_widget` to render the widget. It will display the custom fields of the group in a dd / dt structure. -chill_custom_field_label -^^^^^^^^^^^^^^^^^^^^^^^^ +##### chill_custom_field_label The signature is : @@ -300,29 +265,23 @@ The signature is : Examples -.. code-block:: jinja - {{ chill_custom_field_label(customField) }} - - -chill_custom_field_widget -^^^^^^^^^^^^^^^^^^^^^^^^^ +##### chill_custom_field_widget The signature is : * array **$fields** the array raw, as stored in the db -* CustomField **$customField** a customField instance +* CustomField **$customField** a customField instance * string **$documentType** the type of document. Default to `html`. Examples: -.. code-block:: jinja - +```bash {{ chill_custom_field_widget(entity.customFields, customField) }} +``` -chill_custom_field_is_empty -^^^^^^^^^^^^^^^^^^^^^^^^^^^ +##### chill_custom_field_is_empty The signature is : @@ -331,13 +290,9 @@ The signature is : Examples : -.. code-block:: jinja - {%- if chill_custom_field_is_empty(cFData, customField) == false -%} - -chill_custom_fields_group_widget -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +##### chill_custom_fields_group_widget This function only display custom fields that are `active`. @@ -346,19 +301,17 @@ The signature is : * array **$fields** the array raw, as stored in the db * CustomFieldsGroup **$customFieldsGroup** the custom field group to render -.. code-block:: jinja - +```bash {{ chill_custom_fields_group_widget(entity.cFData, entity.customFieldsGroup) }} +``` -Custom Fields's form --------------------- +### Custom Fields's form You should simply use the 'custom_field' type in a template, with the group you would like to render in the `group` option's type. -Example : - -.. code-block:: php +Example : +```php namespace Chill\ReportBundle\Form; use Symfony\Component\Form\AbstractType; @@ -377,14 +330,14 @@ Example : $builder ->add('user') - ->add('date', 'date', + ->add('date', 'date', array('required' => true, 'widget' => 'single_text', 'format' => 'dd-MM-yyyy')) #add the custom fields : - ->add('cFData', 'custom_field', + ->add('cFData', 'custom_field', array('attr' => array('class' => 'cf-fields'), 'group' => $options['cFGroup'])) ; } - + /** * @param OptionsResolverInterface $resolver */ @@ -413,29 +366,25 @@ Example : return 'chill_reportbundle_report'; } } +``` - -Available configuration ------------------------- +### Available configuration Those options are available in the configuration, under the `chill_custom_field` key. Example : - -.. code-block:: yaml - +```yaml chill_custom_field: show_empty_values_in_views: false +``` show_empty_values_in_views *boolean*: Allow to hide / show empty values in views. The aim of this configuration parameter is to hide (or show) empty values when :term:`custom fields group` are rendered. Default value : `true` -Glossary --------- -.. glossary:: +### Glossary custom fields group A group of custom fields diff --git a/docs/source/bundles/event.rst b/docs/source/bundles/event.md similarity index 63% rename from docs/source/bundles/event.rst rename to docs/source/bundles/event.md index b902ee444..2fdd77ee7 100644 --- a/docs/source/bundles/event.rst +++ b/docs/source/bundles/event.md @@ -1,24 +1,18 @@ -.. Copyright (C) 2016 Champs Libres Cooperative SCRLFS - Permission is granted to copy, distribute and/or modify this document +Permission is granted to copy, distribute and/or modify this document under the terms of the GNU Free Documentation License, Version 1.3 or any later version published by the Free Software Foundation; with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. A copy of the license is included in the section entitled "GNU Free Documentation License". -.. _event-bundle: +# Event bundle -Event bundle -############ - -Template & Menu -=============== +## Template & Menu The event bundle has a special template with a specific menu for actions on events. This menu is called `event`. -ChillEventBundle::layout.html.twig ----------------------------------- +### ChillEventBundle::layout.html.twig This layout extends `ChillMainBundle::layoutWithVerticalMenu.html.twig` and add the menu `event` @@ -26,4 +20,4 @@ It proposes a new block : * event_content - * where to display content relative to the event. + * where to display content relative to the event. \ No newline at end of file diff --git a/docs/source/bundles/group.md b/docs/source/bundles/group.md new file mode 100644 index 000000000..1a69b9cdc --- /dev/null +++ b/docs/source/bundles/group.md @@ -0,0 +1,33 @@ +Permission is granted to copy, distribute and/or modify this document + under the terms of the GNU Free Documentation License, Version 1.3 + or any later version published by the Free Software Foundation; + with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. + A copy of the license is included in the section entitled "GNU + Free Documentation License". + +# Group bundle + +Allow to group people in a group. This group may be a family, an activity group, ... + +###### Entities + +###### Macros + +## Group sticker + +Macro file + `ChillGroupBundle:Group:macro.html.twig` +Macro name + `_render` +Macro envelope + `group`, instance of :class:`Chill\GroupBundle\Entity\CGroup` + +When to use this macro ? + When you want to represent group. +Example usage : + + ```jinja + {% import 'ChillGroupBundle:Group:macro.html.twig' as m %} + + {{ m._render(g) }} + ``` diff --git a/docs/source/bundles/group.rst b/docs/source/bundles/group.rst deleted file mode 100644 index 5d601084d..000000000 --- a/docs/source/bundles/group.rst +++ /dev/null @@ -1,49 +0,0 @@ -.. Copyright (C) 2016 Champs Libres Cooperative SCRLFS - Permission is granted to copy, distribute and/or modify this document - under the terms of the GNU Free Documentation License, Version 1.3 - or any later version published by the Free Software Foundation; - with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. - A copy of the license is included in the section entitled "GNU - Free Documentation License". - -.. _group-bundle: - -Group bundle -############ - -Allow to group people in a group. This group may be a family, an activity group, ... - -.. contents:: Table of content - :local: - -Entities -******** - -.. figure:: /_static/bundles/group/group_classes_uml.png - - -.. _group-bundle-macros: - -Macros -****** - -Group sticker -============== - -Macro file - `ChillGroupBundle:Group:macro.html.twig` -Macro name - :code:`_render` -Macro envelope - :code:`group`, instance of :class:`Chill\GroupBundle\Entity\CGroup` - -When to use this macro ? - When you want to represent group. -Example usage : - .. code-block:: html+jinja - - {% import 'ChillGroupBundle:Group:macro.html.twig' as m %} - - {{ m._render(g) }} - - diff --git a/docs/source/bundles/index.md b/docs/source/bundles/index.md new file mode 100644 index 000000000..045223396 --- /dev/null +++ b/docs/source/bundles/index.md @@ -0,0 +1,23 @@ +Permission is granted to copy, distribute and/or modify this document + under the terms of the GNU Free Documentation License, Version 1.3 + or any later version published by the Free Software Foundation; + with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. + A copy of the license is included in the section entitled "GNU + Free Documentation License". + +# Bundles documentation + +You will find here documentation about bundles working with Chill. + +- [Main bundle](main.md) +- [Custom Fields bundle](custom-fields.md) +- [Person bundle](person.md) +- [Report bundle](report.md) +- [Activity bundle](activity.md) +- [Group bundle](group.md) +- [Event bundle](event.md) +- [Ldap bundle (synchronisation between ldap and database)](ldap.md) + +### Your bundle here ? + +The contributors still do not have a policy about those bundle integration, but we would like to be very open on this subject. Please write to us [or open an issue ](https://redmine.champs-libres.coop/projects/chill/issues). diff --git a/docs/source/bundles/index.rst b/docs/source/bundles/index.rst deleted file mode 100644 index 2c4f3f17e..000000000 --- a/docs/source/bundles/index.rst +++ /dev/null @@ -1,29 +0,0 @@ -.. Copyright (C) 2014 Champs Libres Cooperative SCRLFS - Permission is granted to copy, distribute and/or modify this document - under the terms of the GNU Free Documentation License, Version 1.3 - or any later version published by the Free Software Foundation; - with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. - A copy of the license is included in the section entitled "GNU - Free Documentation License". - -Bundles documentation -############################### - -You will find here documentation about bundles working with Chill. - -.. toctree:: - :maxdepth: 2 - - Main bundle - Custom Fields bundle - Person bundle - Report bundle - Activity bundle - Group bundle - Event bundle - Ldap bundle (synchronisation between ldap and database) - -Your bundle here ? -------------------- - -The contributors still do not have a policy about those bundle integration, but we would like to be very open on this subject. Please write to us `or open an issue `_. diff --git a/docs/source/bundles/ldap.rst b/docs/source/bundles/ldap.md similarity index 64% rename from docs/source/bundles/ldap.rst rename to docs/source/bundles/ldap.md index ccea046a8..69b84baff 100644 --- a/docs/source/bundles/ldap.rst +++ b/docs/source/bundles/ldap.md @@ -1,94 +1,70 @@ -.. Copyright (C) 2014 Champs Libres Cooperative SCRLFS - Permission is granted to copy, distribute and/or modify this document +Permission is granted to copy, distribute and/or modify this document under the terms of the GNU Free Documentation License, Version 1.3 or any later version published by the Free Software Foundation; with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. A copy of the license is included in the section entitled "GNU Free Documentation License". -.. _ldap-bundle: - -LDAP bundle -########### +# LDAP bundle This bundle binds the database with an ldap directory. The bundle synchronize the ldap directory with users in the database. It also provides a way to check user credentials against the ldap directory. - -.. contents:: Table of content :local: -Current limitations -******************* +###### Current limitations - The length of the ldap dn must be < 255 characters - if the username extracted from the ldap is updated, the changes are not reflected in the database and remains the same +###### Entities provided -Entities provided -***************** +This bundle provides only one entity : :class:[Chill\\LdapBundle\\Entity\\UserLdapBinding` -This bundle provides only one entity : :class:`Chill\\LdapBundle\\Entity\\UserLdapBinding` +###### How the synchronizer works ? -How the synchronizer works ? -**************************** - -#. The synchronizer performs a query on :code:`dn` and :code:`query` defined in the :ref:`configuration `. -#. For each entry returned by the query, it looks if the :code:`dn` exists in the database +#. The synchronizer performs a query on `dn` and `query` defined in the [configuration ](configuration.md). +#. For each entry returned by the query, it looks if the `dn` exists in the database #. If the entry does not exists : - #. the synchronizer looks for user with same username as defined by :code:`username_attr`, and bind it with the :code:`dn` if it exists. - #. else, a user is created with username defined by :code:`username_attr` (if the ldap contains more than one attribute, the first attribute returned is used) + #. the synchronizer looks for user with same username as defined by `username_attr`, and bind it with the `dn` if it exists. + #. else, a user is created with username defined by `username_attr` (if the ldap contains more than one attribute, the first attribute returned is used) - #. if a user exists which is already binded with the :code:`dn`, the entry is ignored. + #. if a user exists which is already binded with the `dn`, the entry is ignored. -#. The synchronizer looks for dn existing in database and which were not returned by the query performed in 1. +#. The synchronizer looks for dn existing in database and which were not returned by the query performed in 1. - #. If they exists, those user are set to :code:`enabled=false`: they are not allowed to login. + #. If they exists, those user are set to `enabled=false`: they are not allowed to login. -Installation -************ +###### Installation -This bundle requires : +This bundle requires : - PHP LDAP ext -- :code:`symfony/ldap` with minimal version 3.1. Note that, currently, Chill uses Symfony 2.8: you should add the dependency on this single package manually +- `symfony/ldap` with minimal version 3.1. Note that, currently, Chill uses Symfony 2.8: you should add the dependency on this single package manually -In your composer.json, for stable version : - -.. code-block:: json +In your composer.json, for stable version : "require": { - // .. other dependencies + // .. other dependencies "symfony/ldap" : "~3.1", "chill-project/ldap": "~1.0" } - - And for dev version : -.. code-block:: json - "require": { - // .. other dependencies + // .. other dependencies "symfony/ldap" : "~3.1", "chill-project/ldap": "dev-master@dev" } +###### Configuration -.. _configuration: - -Configuration -************** - -Configuration of the bundle -============================ - -.. code-block:: yaml +## Configuration of the bundle # Default configuration for extension with alias: "chill_ldap" chill_ldap: @@ -125,10 +101,7 @@ Configuration of the bundle # The attribute which will provide username (=login) username_attr: cn - -Example : - -.. code-block:: yaml +Example : chill_ldap: server: @@ -140,16 +113,12 @@ Example : dn: dc=champs-libres,dc=coop query: "(&(objectClass=inetOrgPerson)(userPassword=*))" - -Configuration of the security part of chill -============================================ +## Configuration of the security part of chill Simply add the following config in the firewall of the security bundle : `chill_ldap_form_login: ~`. This config is located in `app/config/security.yml` -Example of a configuration : - -.. code-block:: yaml +Example of a configuration : # in app/config/security.yml @@ -168,37 +137,23 @@ Example of a configuration : # enable the login check by a form, against the ldap chill_ldap_form_login: ~ # this is the line you should add - -Note that, if you enable the login check by form **and** by the ldap, +Note that, if you enable the login check by form **and** by the ldap, the password will be checked against the database **and** against the ldap. If one of them match, the login will succeed. -If you want to completely disable login check against the database, -simply remove the :code:`form_login` entry and all his options. +If you want to completely disable login check against the database, +simply remove the `form_login` entry and all his options. -.. _command-and-crontab: +## Command and crontab -Command and crontab -=================== - -Synchronize the database : - -.. code-block:: bash +Synchronize the database : php app/console chill:ldap:synchronize - -For getting more debug message : - -.. code-block:: bash +For getting more debug message : php app/console chill:ldap:synchronize -vvv - - -You should run this command regularly (using crontab or -`systemd timer `_) +You should run this command regularly (using crontab or +`systemd timer ](https://www.freedesktop.org/software/systemd/man/systemd.timer.html#)) to synchronize ldap and database automatically. - - - diff --git a/docs/source/bundles/main.rst b/docs/source/bundles/main.md similarity index 57% rename from docs/source/bundles/main.rst rename to docs/source/bundles/main.md index da195c98e..4cadf32d5 100644 --- a/docs/source/bundles/main.rst +++ b/docs/source/bundles/main.md @@ -1,15 +1,12 @@ -.. Copyright (C) 2014 Champs Libres Cooperative SCRLFS - Permission is granted to copy, distribute and/or modify this document +Permission is granted to copy, distribute and/or modify this document under the terms of the GNU Free Documentation License, Version 1.3 or any later version published by the Free Software Foundation; with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. A copy of the license is included in the section entitled "GNU Free Documentation License". -.. _main-bundle: - -Main bundle -########### + +# Main bundle This bundle is **required** for running Chill. @@ -18,32 +15,24 @@ This bundle provide : * Access control model (users, groups, and all concepts) * ... - -.. warning:: - this section is incomplete. -.. _main-bundle-macros: +###### Macros -Macros -****** - -Address sticker -=============== +## Address sticker Macro file `ChillMainBundle:Address:macro.html.twig` Macro name - :code:`_render` + `_render` Macro envelope - :code:`address`, instance of :class:`Chill\MainBundle\Entity\Address` + `address`, instance of :class:`Chill\MainBundle\Entity\Address` When to use this macro ? When you want to represent an address. Example usage : - .. code-block:: html+jinja - - {% import 'ChillMainBundle:Address:macro.html.twig' as m %} - - {{ m._render(address) }} + ```jinja + {% import 'ChillMainBundle:Address:macro.html.twig' as m %} + {{ m._render(address) }} + ``` diff --git a/docs/source/bundles/person.rst b/docs/source/bundles/person.md similarity index 68% rename from docs/source/bundles/person.rst rename to docs/source/bundles/person.md index f4199e52f..1d9b0ad2c 100644 --- a/docs/source/bundles/person.rst +++ b/docs/source/bundles/person.md @@ -1,65 +1,41 @@ -.. Copyright (C) 2014 Champs Libres Cooperative SCRLFS - Permission is granted to copy, distribute and/or modify this document - under the terms of the GNU Free Documentation License, Version 1.3 - or any later version published by the Free Software Foundation; - with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. - A copy of the license is included in the section entitled "GNU - Free Documentation License". - -.. _person-bundle: - -Person bundle -############# +# Person bundle This bundle provides the ability to record people in the software. This bundle is required by other bundle. -.. contents:: Table of content - :local: - -Entities provided -***************** - -.. todo:: +###### Entities provided describe entities provided by person bundle - - -Search terms -************ -The class `Chill\PersonBundle\Search\PersonSearch` provide the search module. -Domain -====== +###### Search terms + +The class [Chill\PersonBundle\Search\PersonSearch` provide the search module. + +## Domain The search upon "person" is provided by default. The `@person` domain search may be omitted. * `@person` is the domain search for people. - -Arguments -========= +## Arguments * `firstname` : provide the search on firstname. Example : `firstname:Depardieu`. May match part of the firstname (`firsname:dep` will match Depardieu) * `lastname` : provide the search on lastname. May match part of the lastname. * `birthdate` : provide the search on the birthdate. Example : `birthdate:1996-01-19` * `gender`: performs search on man/woman. The accepted values are `man` or `woman`. -* `nationality` : performs search on nationality. Value must be a country code `as described in ISO 3166 `_. Example : `nationality:FR`. +* `nationality` : performs search on nationality. Value must be a country code `as described in ISO 3166 ](http://www.iso.org/iso/fr/home/standards/country_codes.htm). Example : [nationality:FR`. -Default -======= +## Default The default search is performed on firstname and/or lastname. Both are concatened before search. If values are separated by spaces, the clause `AND` is used : the search `dep ge` will match 'Gérard Depardieu` or 'Jean Depagelles', but not 'Charline Depardieu' (missing 'Ge' in word). -Configuration options -********************* +###### Configuration options Those options are available under `chill_person` key. Example of configuration: -.. code-block:: yaml - +```yaml chill_person: validation: birthdate_not_after: P15Y @@ -73,17 +49,18 @@ Example of configuration: marital_status: visible spoken_languages: hidden address: visible +``` birthdate_not_after *string* - The period duration before today during which encoding birthdate is not possible. The period is a string matching the format of `ISO_8601`, which is also use to build `DateInterval classes `_. + The period duration before today during which encoding birthdate is not possible. The period is a string matching the format of `ISO_8601`, which is also use to build `DateInterval classes ](http://php.net/manual/en/dateinterval.construct.php). - Example: `P1D`, `P18Y` + Example: [P1D`, `P18Y` Default value: `P1D` which means that birthdate before the current day (= yesterday) are allowed. person_fields *array* - This define the visibility of some fields. By default, all fields are visible, but you can choose to hide some of them. Available keys are : - + This define the visibility of some fields. By default, all fields are visible, but you can choose to hide some of them. Available keys are : + * `nationality` * `country_of_birth` * `place_of_birth` @@ -97,73 +74,62 @@ person_fields *array* Default value : `visible`, which means that all fields are visible. - Example: - - .. code-block:: yaml + Example: +```yaml chill_person: person_fields: nationality: hidden email: hidden phonenumber: hidden +``` -.. note:: If all the field of a "box" are hidden, the whole box does not appears. Example: if the fields `phonenumber` and `email` are hidden, the title `Contact information` will be hidden in the UI. -.. note:: - If you hide multiple fields, for a better integration you may want to override the template, for a better appeareance. See `the symfony documentation `_ about this feature. + If you hide multiple fields, for a better integration you may want to override the template, for a better appeareance. See `the symfony documentation ](http://symfony.com/doc/current/book/templating.html#overriding-bundle-templates) about this feature. -.. _person-bundle-macros: +###### Macros -Macros -****** - -Sticker for a person -===================== +## Sticker for a person Macro file `ChillPersonBundle:Person:macro.html.twig` Macro envelope - :code:`render(p, withLink=false)` + `render(p, withLink=false)` - :code:`p` is an instance of :class:`Chill\PersonBundle\Entity\Person` + `p` is an instance of `Chill\PersonBundle\Entity\Person` - :code:`withLink` :class:`boolean` + `withLink`: `boolean` When to use this macro ? When you want to represent a person. Example usage : - .. code-block:: html+jinja + ```jinja + {% import "ChillPersonBundle:Person:macro.html.twig" as person_ %} - {% import "ChillPersonBundle:Person:macro.html.twig" as person_ %} + {{ person_.render(person, true) }} + ``` - {{ person_.render(person, true) }} +###### Layout events and delegated blocks -Layout events and delegated blocks -*********************************** - -:code:`chill_block.person_post_vertical_menu` event -==================================================== +## `chill_block.person_post_vertical_menu` event This event is available to add content below of the vertical menu (on the right). -The context is : +The context is : -- :code:`person` : the current person which is rendered. Instance of :class:`Chill\PersonBundle\Entity\Person` +- `person` : the current person which is rendered. Instance of `Chill\PersonBundle\Entity\Person` -Widgets -******* +###### Widgets -Add a list of person on homepage -================================ +## Add a list of person on homepage The bundle provide a way to add a list of accompanyied person on the homepage: -.. code-block:: yaml - +```yaml chill_main: widgets: - homepage: - - + homepage: + - order: 10 widget_alias: person_list person_list: @@ -180,15 +146,11 @@ The bundle provide a way to add a list of accompanyied person on the homepage: # when the view is overriden, you can add some custom fields # to the view custom_fields: [school-2fb5440e-192c-11e6-b2fd-74d02b0c9b55] - -Commands -******** +``` +###### Commands -:code:`chill:person:move` -========================= - -.. code-block:: txt +## `chill:person:move` Usage: chill:person:move [options] @@ -213,21 +175,17 @@ Commands Move all the entities associated to a person onto another one, and remove the old person. -.. warning:: - - Some entities are ignored and will be deleted: + Some entities are ignored and will be deleted: - the accompanying periods ; - the data attached to a person entity: name, address, date of birth, etc. Thos should be merge before the move. -It is advised to run first the command with the :code:`dump-sql` option and, then, use the :code:`force` option. +It is advised to run first the command with the `dump-sql` option and, then, use the `force` option. The moving and suppression is executed inside a transaction, ensuring no data loss if the migration fails. -.. note:: + Using bash and awk, it is easy to use a TSV file (values separated by a tab, not a comma) to create move commands. Assuming our file is named `twins.tsv` and contains two columns: the first one with `from` ids, and the second one with `to` ids: - Using bash and awk, it is easy to use a TSV file (values separated by a tab, not a comma) to create move commands. Assuming our file is named :code:`twins.tsv` and contains two columns: the first one with :code:`from` ids, and the second one with :code:`to` ids: - - .. code-block:: bash - - awk '{ print "php app/console chill:person:move --dump-sql --from " $1 " --to " $2;}' twins.tsv + ```bash + awk '{ print "php app/console chill:person:move --dump-sql --from " $1 " --to " $2;}' twins.tsv +``` diff --git a/docs/source/bundles/report.md b/docs/source/bundles/report.md new file mode 100644 index 000000000..717f9e9d6 --- /dev/null +++ b/docs/source/bundles/report.md @@ -0,0 +1,23 @@ +# Report bundle + +This bundle provides the ability to record report about people. We use custom fields to let user add fields to reports. + +The documentation about report is not written + +## Concepts + +## Search + +### Domain + +* `@report` is the domain search for reports. + +### Arguments + +* `date` : The date of the report + +### Default + +The report's date is the default value. + +An error is thrown if an argument `date` and a default is used. diff --git a/docs/source/bundles/report.rst b/docs/source/bundles/report.rst deleted file mode 100644 index d71caa0be..000000000 --- a/docs/source/bundles/report.rst +++ /dev/null @@ -1,46 +0,0 @@ -.. Copyright (C) 2014 Champs Libres Cooperative SCRLFS - Permission is granted to copy, distribute and/or modify this document - under the terms of the GNU Free Documentation License, Version 1.3 - or any later version published by the Free Software Foundation; - with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. - A copy of the license is included in the section entitled "GNU - Free Documentation License". - -.. _report-bundle: - -Report bundle -############# - -This bundle provides the ability to record report about people. We use custom fields to let user add fields to reports. - -.. contents:: Table of content - :local: - -.. todo:: - - The documentation about report is not writtend - -Concepts -======== - - -Search -====== - -Domain ------- - -* `@report` is the domain search for reports. - - -Arguments ---------- - -* `date` : The date of the report - -Default -------- - -The report's date is the default value. - -An error is thrown if an argument `date` and a default is used. diff --git a/docs/source/development/FAQ.md b/docs/source/development/FAQ.md new file mode 100644 index 000000000..ffda670b8 --- /dev/null +++ b/docs/source/development/FAQ.md @@ -0,0 +1,23 @@ +# Frequently asked questions + +## Continuous integration + +Pipeline fails, but php-cs-fixer doesn't alert me when running it locally? +======================================== + +It is possible that you run php-cs-fixer on your local instance of chill and no fixes are made. +Everything seems fine, so you push. However, once the pipeline is run in gitlab, you're notified that it failed due to php +cs errors. + +In this case it's likely that you have to update your version of php-cs-fixer. +php-cs-fixer is installed when building the docker image: https://gitea.champs-libres.be/Chill-project/chill-skeleton-basic/src/branch/main/Dockerfile#L50 + +Consequently, to update php-cs-fixer, we have to update the image by building it again. + +For this the following commands can be used. + +``` + docker compose build --pull php + # replace existing containers + docker compose up -d --force-recreate php +``` diff --git a/docs/source/development/FAQ.rst b/docs/source/development/FAQ.rst deleted file mode 100644 index c0b7e37e8..000000000 --- a/docs/source/development/FAQ.rst +++ /dev/null @@ -1,36 +0,0 @@ -.. Copyright (C) 2014 Champs Libres Cooperative SCRLFS -Permission is granted to copy, distribute and/or modify this document -under the terms of the GNU Free Documentation License, Version 1.3 -or any later version published by the Free Software Foundation; -with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. -A copy of the license is included in the section entitled "GNU -Free Documentation License". - -.. _faq: - - -Frequently asked questions -#################### - -Continuous integration -*********** - -Pipeline fails, but php-cs-fixer doesn't alert me when running it locally ? -======================================== - -It is possible that you run php-cs-fixer on your local instance of chill and no fixes are made. -Everything seems fine, so you push. However once the pipeline is run in gitlab, you're notified that it failed due to php -cs errors. - -In this case it's likely that you have to update your version of php-cs-fixer. -php-cs-fixer is installed when building the docker image: https://gitea.champs-libres.be/Chill-project/chill-skeleton-basic/src/branch/main/Dockerfile#L50 - -Consequently, to update php-cs-fixer we have to update the image by building it again. - -For this the following commands can be used, - -.. code-block:: php - - docker compose build --pull php - # replace existing containers - docker compose up -d --force-recreate php diff --git a/docs/source/development/access_control_model.rst b/docs/source/development/access_control_model.md similarity index 91% rename from docs/source/development/access_control_model.rst rename to docs/source/development/access_control_model.md index e159f7fb6..a05b84766 100644 --- a/docs/source/development/access_control_model.rst +++ b/docs/source/development/access_control_model.md @@ -1,19 +1,15 @@ -.. Copyright (C) 2015 Champs Libres Cooperative SCRLFS - Permission is granted to copy, distribute and/or modify this document +Permission is granted to copy, distribute and/or modify this document under the terms of the GNU Free Documentation License, Version 1.3 or any later version published by the Free Software Foundation; with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. A copy of the license is included in the section entitled "GNU Free Documentation License". -Access controle model -********************** +###### Access controle model -.. contents:: Table of content :local: -Concepts -======== +## Concepts Every time an entity is created, viewed or updated, the software check if the user has the permission to make this action. The decision is made with three parameters : @@ -23,20 +19,15 @@ Every time an entity is created, viewed or updated, the software check if the us The user must be granted access to the action on this particular entity, with this scope and center. -TL;DR -===== +## TL;DR -Resolve scope and center ------------------------- +### Resolve scope and center In a service, resolve the center and scope of an entity -.. code-block:: php - use Chill\MainBundle\Security\Resolver\CenterResolverDispatcher; use Chill\MainBundle\Security\Resolver\ScopeResolverDispatcher; - class MyService { private ScopeResolverDispatcher $scopeResolverDispatcher; private CenterResolverDispatcher $centerResolverDispatcher; @@ -58,8 +49,6 @@ In a service, resolve the center and scope of an entity In twig template, resolve the center: -.. code-block:: twig - {# resolve a center #} {% if person|chill_resolve_center is not null%} @@ -81,8 +70,6 @@ In twig template, resolve the center: In twig template, resolve the scope: -.. code-block:: twig - {% if entity|chill_is_scope_concerned %} {% if entity|chill_resolve_scope is iterable %} @@ -98,10 +85,7 @@ In twig template, resolve the scope: {% endfor %} {%- endif -%} -Build a ``Voter`` ------------------ - -.. code-block:: php +### Build a ``Voter`` build(); } - protected function supports($attribute, $subject) { return $this->voterHelper->supports($attribute, $subject); @@ -194,25 +177,19 @@ Build a ``Voter`` return array(); } - public function getRolesWithHierarchy() { return ['PersonDocument' => $this->getRoles() ]; } } - - - -From an user point of view -========================== +## From an user point of view The software is design to allow fine tuned access rights for complicated installation and team structure. The administrators may also decide that every user has the right to see all resources, where team have a more simple structure. Here is an overview of the model. -Chill can be multi-center -------------------------- +### Chill can be multi-center Chill is designed to be installed once for social center who work with multiple teams separated, or for social services's federation who would like to share the same installation of the software for all their members. @@ -222,8 +199,7 @@ Otherwise, it is not required to create multiple center: Chill can also work for Obviously, users working in the different centers are not allowed to see the entities (_persons_, _reports_, _activities_) of other centers. But users may be attached to multiple centers: consequently they will be able to see the entities of the multiple centers they are attached to. -Inside center, scope divide team --------------------------------- +### Inside center, scope divide team Users are attached to one or more center and, inside to those center, there may exists differents scopes. The aim of those _scopes_ is to divide the whole team of social worker amongst different departement, for instance: the social team, the psychologist team, the nurse team, the administrative team, ... Each team is granted of different rights amongst scope. For instance, the social team may not see the _activities_ of the psychologist team. The administrative team may see the date & time's activities, but is not allowed to see the detail of those entities (the personal notes, ...). @@ -233,9 +209,7 @@ As entities have only one scopes, if some entities must be shared across two dif Example: if some activities must be seen and updated between nurses and psychologists, the administrator will create a scope "nurse and psy" and add the ability for both team "nurse" and "psychologist" to "create", "see", and "update" the activities belonging to scope "nurse and psy". - -Where does the ``scope`` and ``center`` comes from ? -==================================================== +## Where does the [`scope`` and ``center`` comes from ? Most often, scope and center comes from user's input: @@ -256,30 +230,23 @@ But sometimes, this implementation does not fits the needs: For this reasons, associated center and scopes must be resolved programmatically. The default implementation rely on the model association, as described above. But it becomes possible to change the behaviour on different implementations. -Is my entity "concerned" by scopes ? ------------------------------------- +### Is my entity "concerned" by scopes ? Some entities are concerned by scope, some not. This is also programmatically resolved. -The concepts translated into code -=================================== +## The concepts translated into code -.. figure:: /_static/access_control_model.png Schema of the access control model Chill handle **entities**, like *persons*, *reports* (associated to *persons*), *activities* (also associated to *_persons*), ... On creation, those entities are linked to one center and, eventually, to one scope. They implements the interface `HasCenterInterface`. -.. note:: - Somes entities are linked to a center through the entity they are associated with. For instance, *activities* or *reports* are associated to a *person*, and the person is associated to a *center*. The *report*'s *center* is always the *person*'s *center*. Entities may be associated with a scope. In this case, they implement the `HasScopeInterface`. -.. note:: - Currently, only the *person* entity is not associated with a scope. At each step of his lifetime (creation, view of the entity and eventually of his details, update and, eventually, deletion), the right of the user are checked. To decide wether the user is granted right to execute the action, the software must decide with those elements : @@ -291,13 +258,10 @@ At each step of his lifetime (creation, view of the entity and eventually of his All those action are executed through symfony voters and helpers. -How to check authorization ? -============================ +## How to check authorization ? Just use the symfony way-of-doing, but do not forget to associate the entity you want to check access. For instance, in controller : -.. code-block:: php - class MyController extends Controller { @@ -311,12 +275,9 @@ Just use the symfony way-of-doing, but do not forget to associate the entity you And in template : -.. code-block:: twig - {{ if is_granted('CHILL_ENTITY_SEE', entity) %}print something{% endif %} -Retrieving reachable scopes and centers for a user --------------------------------------------------- +### Retrieving reachable scopes and centers for a user The class :class:`Chill\\MainBundle\\Security\\Authorization\\AuthorizationHelperInterface` helps you to get centers and scope reachable by a user. @@ -325,9 +286,7 @@ Those methods are intentionnaly build to give information about user rights: - getReachableCenters: to get reachable centers for a user - getReachableScopes : to get reachable scopes for a user - -Adding your own roles ---------------------- +### Adding your own roles Extending Chill will requires you to define your own roles and rules for your entities. You will have to define your own voter to do so. @@ -336,28 +295,20 @@ To create your own roles, you should: * implement your own voter. This voter will have to extends the :class:`Chill\\MainBundle\\Security\\AbstractChillVoter`. As defined by Symfony, this voter must be declared as a service and tagged with `security.voter`; * declare the role through implementing a service tagged with `chill.role` and implementing :class:`Chill\\MainBundle\\Security\\ProvideRoleInterface`. -.. note:: - Both operation may be done through a simple class: you can implements :class:`Chill\\MainBundle\\Security\\ProvideRoleInterface` and :class:`Chill\\MainBundle\\Security\\AbstractChillVoter` on the same class. See live example: :class:`Chill\\ActivityBundle\\Security\\Authorization\\ActivityVoter`, and similar examples in the `PersonBundle` and `ReportBundle`. -.. seealso:: - - `How to Use Voters to Check User Permissions `_ + `How to Use Voters to Check User Permissions ](http://symfony.com/doc/current/cookbook/security/voters_data_permission.html) From the symfony cookbook - `New in Symfony 2.6: Simpler Security Voters `_ + [New in Symfony 2.6: Simpler Security Voters ](http://symfony.com/blog/new-in-symfony-2-6-simpler-security-voters) From the symfony blog - -Declare your role -^^^^^^^^^^^^^^^^^^ +##### Declare your role To declare new role, implement the class :class:`Chill\\MainBundle\\Security\\ProvideRoleInterface`. -.. code-block:: php - interface ProvideRoleInterface { /** @@ -375,21 +326,15 @@ To declare new role, implement the class :class:`Chill\\MainBundle\\Security\\Pr public function getRolesWithoutScope(); } - Then declare your service with a tag `chill.role`. Example : -.. code-block:: yaml - your_service: class: Chill\YourBundle\Security\Authorization\YourVoter tags: - { name: chill.role } - Example of an implementation of :class:`Chill\\MainBundle\\Security\\ProvideRoleInterface`: -.. code-block:: php - namespace Chill\PersonBundle\Security\Authorization; use Chill\MainBundle\Security\ProvideRoleInterface; @@ -412,13 +357,10 @@ Example of an implementation of :class:`Chill\\MainBundle\\Security\\ProvideRole } -Adding role hierarchy -^^^^^^^^^^^^^^^^^^^^^ +##### Adding role hierarchy You should prepend Symfony's security component directly from your code. -.. code-block:: php - namespace Chill\ReportBundle\DependencyInjection; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\Config\FileLocator; @@ -450,10 +392,7 @@ You should prepend Symfony's security component directly from your code. } } - - -Implement your voter -^^^^^^^^^^^^^^^^^^^^ +##### Implement your voter Most of the time, Voter will check that: @@ -464,7 +403,6 @@ Most of the time, Voter will check that: Thats what we call the "autorization logic". But this logic may be replace by a new one, and developers should take care of it. - Then voter implementation should take care of: * check the access to associated entities. For instance, if an ``Activity`` is associated to a ``Person``, the voter should first check that the user can show the associated ``Person``; @@ -472,9 +410,6 @@ Then voter implementation should take care of: This is an example of implementation: - -.. code-block:: php - build(); } - protected function supports($attribute, $subject) { return $this->voterHelper->supports($attribute, $subject); @@ -553,7 +487,6 @@ This is an example of implementation: // ... } - public function getRolesWithHierarchy() { // ... @@ -562,8 +495,6 @@ This is an example of implementation: Then, you will have to declare the service and tag it as a voter : -.. code-block:: yaml - services: chill.report.security.authorization.report_voter: class: Chill\ReportBundle\Security\Authorization\ReportVoter @@ -572,18 +503,13 @@ Then, you will have to declare the service and tag it as a voter : tags: - { name: security.voter } - -How to resolve scope and center programmatically ? -================================================== +## How to resolve scope and center programmatically ? In a service, resolve the center and scope of an entity -.. code-block:: php - use Chill\MainBundle\Security\Resolver\CenterResolverDispatcher; use Chill\MainBundle\Security\Resolver\ScopeResolverDispatcher; - class MyService { private ScopeResolverDispatcher $scopeResolverDispatcher; private CenterResolverDispatcher $centerResolverDispatcher; @@ -605,8 +531,6 @@ In a service, resolve the center and scope of an entity In twig template, resolve the center: -.. code-block:: twig - {# resolve a center #} {% if person|chill_resolve_center is not null%} @@ -628,8 +552,6 @@ In twig template, resolve the center: In twig template, resolve the scope: -.. code-block:: twig - {% if entity|chill_is_scope_concerned %} {% if entity|chill_resolve_scope is iterable %} @@ -645,8 +567,7 @@ In twig template, resolve the scope: {% endfor %} {%- endif -%} -What is the default implementation of Scope and Center resolver ? ------------------------------------------------------------------ +### What is the default implementation of Scope and Center resolver ? By default, the implementation rely on association into entities. @@ -657,16 +578,14 @@ By default, the implementation rely on association into entities. Then, the default implementation will resolve the center and scope based on the implementation in your model. -How to change the default behaviour ? -------------------------------------- +### How to change the default behaviour ? Implements those interface into services: * ``Chill\MainBundle\Security\Resolver\CenterResolverInterface``; * ``Chill\MainBundle\Security\Resolver\ScopeResolverInterface``; -Authorization into lists and index pages -======================================== +## Authorization into lists and index pages Due to the fact that authorization model may be overriden, "list" and "index" pages should not rely on center and scope from controller. This must be delegated to dedicated service, which will be aware of the authorization model. We call them ``ACLAwareRepository``. This service must implements an interface, in order to allow to change the implementation. @@ -674,14 +593,11 @@ The controller **must not** performs any DQL or SQL query. Example in a controller: -.. code-block:: php - namespace Chill\TaskBundle\Controller; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Chill\TaskBundle\Repository\SingleTaskAclAwareRepositoryInterface; - final class SingleTaskController extends AbstractController { @@ -729,11 +645,9 @@ Example in a controller: } } -Writing ``ACLAwareRepository`` ------------------------------- +### Writing ``ACLAwareRepository`` -The ACLAwareRepository should rely on interfaces -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +##### The ACLAwareRepository should rely on interfaces As described above, the ACLAwareRepository will perform the query for listing entities, and take care of authorization. @@ -743,9 +657,6 @@ The service must rely on this interface, and not on the default implementation. Example: at first, we design an interface for listing ``SingleTask`` entities: - -.. code-block:: php - `_ - * `How to create your custom normalizer `_ + * [How to use annotation to configure serialization ](https://symfony.com/doc/current/serializer.html) + * [How to create your custom normalizer ](https://symfony.com/doc/current/serializer/custom_normalizer.html) -Auto-loading the routes -======================= +## Autoloading the routes Ensure that those lines are present in your file `app/config/routing.yml`: - -.. code-block:: yaml - +```yaml chill_cruds: resource: 'chill_main_crud_route_loader:load' type: service +``` +## Create your model -Create your model -================= +Create your model in the usual way: -Create your model on the usual way: - -.. code-block:: php - - namespace Chill\PersonBundle\Entity\AccompanyingPeriod; +```php +namespace Chill\PersonBundle\Entity\AccompanyingPeriod; use Chill\PersonBundle\Entity\AccompanyingPeriod\OriginRepository; use Doctrine\ORM\Mapping as ORM; @@ -87,16 +67,14 @@ Create your model on the usual way: // .. getters and setters } +``` +## Configure api -Configure api -============= +Configure the api using YAML (see the full configuration: [api_full_configuration](api_full_configuration.md)): -Configure the api using Yaml (see the full configuration: :ref:`api_full_configuration`): - -.. code-block:: yaml - - # config/packages/chill_main.yaml +```yaml +# config/packages/chill_main.yaml chill_main: apis: accompanying_period_origin: @@ -113,15 +91,13 @@ Configure the api using Yaml (see the full configuration: :ref:`api_full_configu methods: GET: true HEAD: true +``` + If you are working on a shared bundle (aka "The chill bundles"), you should define your configuration inside the class `ChillXXXXBundleExtension`, using the "prependConfig" feature: -.. note:: - - If you are working on a shared bundle (aka "The chill bundles"), you should define your configuration inside the class :code:`ChillXXXXBundleExtension`, using the "prependConfig" feature: - - .. code-block:: php - +```php namespace Chill\PersonBundle\DependencyInjection; + use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; use Symfony\Component\HttpFoundation\Request; @@ -155,13 +131,13 @@ Configure the api using Yaml (see the full configuration: :ref:`api_full_configu 'base_role' => 'ROLE_USER', 'actions' => [ '_index' => [ - 'methods' => [ + 'methods' => [ Request::METHOD_GET => true, Request::METHOD_HEAD => true ], ], '_entity' => [ - 'methods' => [ + 'methods' => [ Request::METHOD_GET => true, Request::METHOD_HEAD => true ] @@ -172,37 +148,35 @@ Configure the api using Yaml (see the full configuration: :ref:`api_full_configu ]); } } +``` -The :code:`_index` and :code:`_entity` action -********************************************* +###### The `_index` and `_entity` action -The :code:`_index` and :code:`_entity` action are default actions: +The `_index` and `_entity` action are default actions: * they will call a specific method in the default controller; * they will generate defined routes: Index: - Name: :code:`chill_api_single_accompanying_period_origin__index` + Name: `chill_api_single_accompanying_period_origin__index` - Path: :code:`/api/1.0/person/accompanying-period/origin.{_format}` + Path: `/api/1.0/person/accompanying-period/origin.{_format}` Entity: - Name: :code:`chill_api_single_accompanying_period_origin__entity` + Name: `chill_api_single_accompanying_period_origin__entity` - Path: :code:`/api/1.0/person/accompanying-period/origin/{id}.{_format}` + Path: `/api/1.0/person/accompanying-period/origin/{id}.{_format}` -Role -**** +###### Role -By default, the key `base_role` is used to check ACL. Take care of creating the :code:`Voter` required to take that into account. +By default, the key `base_role` is used to check ACL. Take care of creating the `Voter` required to take that into account. -For index action, the role will be called with :code:`NULL` as :code:`$subject`. The retrieved entity will be the subject for single queries. +For index action, the role will be called with `NULL` as `$subject`. The retrieved entity will be the subject for single queries. You can also define a role for each method. In this case, this role is used for the given method, and, if any, the base role is taken into account. -.. code-block:: yaml - - # config/packages/chill_main.yaml +```yaml +# config/packages/chill_main.yaml chill_main: apis: accompanying_period_origin: @@ -217,16 +191,13 @@ You can also define a role for each method. In this case, this role is used for roles: GET: MY_ROLE_SEE HEAD: MY ROLE_SEE +``` -Customize the controller -************************ - -You can customize the controller by hooking into the default actions. Take care of extending :code:`Chill\MainBundle\CRUD\Controller\ApiController`. - - -.. code-block:: php +###### Customize the controller +You can customize the controller by hooking into the default actions. Take care of extending `Chill\MainBundle\CRUD\Controller\ApiController`. +```php namespace Chill\PersonBundle\Controller; use Chill\MainBundle\CRUD\Controller\ApiController; @@ -240,14 +211,14 @@ You can customize the controller by hooking into the default actions. Take care $qb->where($qb->expr()->gt('e.noActiveAfter', ':now')) ->orWhere($qb->expr()->isNull('e.noActiveAfter')); $qb->setParameter('now', new \DateTime('now')); - } + } } +``` And set your controller in configuration: -.. code-block:: yaml - - chill_main: +```yaml + chill_main: apis: accompanying_period_origin: base_path: '/api/1.0/person/accompanying-period/origin' @@ -265,15 +236,15 @@ And set your controller in configuration: methods: GET: true HEAD: true +``` -Create your own actions -*********************** +###### Create your own actions You can add your own actions: -.. code-block:: yaml +```yaml - chill_main: +chill_main: apis: - class: Chill\PersonBundle\Entity\AccompanyingPeriod @@ -281,7 +252,7 @@ You can add your own actions: base_path: /api/1.0/person/accompanying-course controller: Chill\PersonBundle\Controller\AccompanyingCourseApiController actions: - # add a custom participation: + # add custom participation: participation: methods: POST: true @@ -296,13 +267,13 @@ You can add your own actions: HEAD: null PUT: null single-collection: single +``` -The key :code:`single-collection` with value :code:`single` will add a :code:`/{id}/ + "action name"` (in this example, :code:`/{id}/participation`) into the path, after the base path. If the value is :code:`collection`, no id will be set, but the action name will be append to the path. +The key `single-collection` with value `single` will add a `/{id}/ + "action name"` (in this example, `/{id}/participation`) into the path, after the base path. If the value is `collection`, no id will be set, but the action name will be append to the path. Then, create the corresponding action into your controller: -.. code-block:: php - +```php namespace Chill\PersonBundle\Controller; use Chill\MainBundle\CRUD\Controller\ApiController; @@ -326,10 +297,10 @@ Then, create the corresponding action into your controller: $this->eventDispatcher = $eventDispatcher; $this->validator = $validator; } - + public function participationApi($id, Request $request, $_format) { - /** @var AccompanyingPeriod $accompanyingPeriod */ + /** @var AccompanyingPeriod $accompanyingPeriod */ $accompanyingPeriod = $this->getEntity('participation', $id, $request); $person = $this->getSerializer() ->deserialize($request->getContent(), Person::class, $_format, []); @@ -363,20 +334,18 @@ Then, create the corresponding action into your controller: return $this->json($participation); } } +``` -Managing association -******************** +###### Managing association -ManyToOne association -===================== +## ManyToOne association -In ManyToOne association, you can add associated entities using the :code:`PATCH` request. By default, the serializer deserialize entities only with their id and discriminator type, if any. +In ManyToOne association, you can add associated entities using the `PATCH` request. By default, the serializer deserialize entities only with their id and discriminator type, if any. Example: -.. code-block:: bash - - curl -X 'PATCH' \ +``` +curl -X 'PATCH' \ 'http://localhost:8001/api/1.0/person/accompanying-course/2668.json' \ -H 'accept: */*' \ -H 'Content-Type: application/json' \ @@ -386,16 +355,15 @@ Example: "id": 2668, "origin": { "id": 11 } }' +``` -ManyToMany associations -======================= +## ManyToMany associations -In OneToMany association, you can easily create route for adding and removing entities, using :code:`POST` and :code:`DELETE` requests. +In OneToMany association, you can easily create route for adding and removing entities, using `POST` and `DELETE` requests. -Prepare your entity, creating the methods :code:`addYourEntity` and :code:`removeYourEntity`: - -.. code-block:: php +Prepare your entity, creating the methods `addYourEntity` and `removeYourEntity`: +```php namespace Chill\PersonBundle\Entity; use Chill\MainBundle\Entity\Scope; @@ -437,12 +405,12 @@ Prepare your entity, creating the methods :code:`addYourEntity` and :code:`remov { $this->scopes->removeElement($scope); } - +``` Create your route into the configuration: -.. code-block:: yaml - +```yaml + # config/packages/chill_main.yaml` chill_main: apis: - @@ -469,14 +437,12 @@ Create your route into the configuration: controller_action: null path: null single-collection: single +``` This will create a new route, which will accept two methods: DELETE and POST: -.. code-block:: raw - +--------------+---------------------------------------------------------------------------------------+ | Property | Value | - +--------------+---------------------------------------------------------------------------------------+ | Route Name | chill_api_single_accompanying_course_scope | | Path | /api/1.0/person/accompanying-course/{id}/scope.{_format} | | Path Regex | {^/api/1\.0/person/accompanying\-course/(?P[^/]++)/scope\.(?P<_format>[^/]++)$}sD | @@ -488,14 +454,10 @@ This will create a new route, which will accept two methods: DELETE and POST: | Class | Symfony\Component\Routing\Route | | Defaults | _controller: csapi_accompanying_course_controller:scopeApi | | Options | compiler_class: Symfony\Component\Routing\RouteCompiler | - +--------------+---------------------------------------------------------------------------------------+ - - Then, create the controller action. Call the method: -.. code-block:: php - +```php namespace Chill\PersonBundle\Controller; use Chill\MainBundle\CRUD\Controller\ApiController; @@ -510,14 +472,14 @@ Then, create the controller action. Call the method: return $this->addRemoveSomething('scope', $id, $request, $_format, 'scope', Scope::class, [ 'groups' => [ 'read' ] ]); } } +``` -This will allow to add a scope by his id, and delete them. +This will allow adding a scope by his id and deleting them. Curl requests: -.. code-block:: bash - - # add a scope with id 5 +``` +# add a scope with id 5 curl -X 'POST' \ 'http://localhost:8001/api/1.0/person/accompanying-course/2868/scope.json' \ -H 'accept: */*' \ @@ -536,14 +498,13 @@ Curl requests: "id": 5, "type": "scope" }' +``` -Deserializing an association where multiple types are allowed -============================================================= +## Deserializing an association where multiple types are allowed -Sometimes, multiples types are allowed as association to one entity: - -.. code-block:: php +Sometimes, multiple types are allowed as association to one entity: +```php namespace Chill\PersonBundle\Entity\AccompanyingPeriod; use Chill\PersonBundle\Entity\Person; @@ -553,7 +514,6 @@ Sometimes, multiples types are allowed as association to one entity: class Resource { - /** * @ORM\ManyToOne(targetEntity=ThirdParty::class) * @ORM\JoinColumn(nullable=true) @@ -566,7 +526,6 @@ Sometimes, multiples types are allowed as association to one entity: */ private $person; - /** * * @param $resource Person|ThirdParty @@ -575,8 +534,8 @@ Sometimes, multiples types are allowed as association to one entity: { // ... } - - + + /** * @return ThirdParty|Person * @Groups({"read", "write"}) @@ -586,13 +545,13 @@ Sometimes, multiples types are allowed as association to one entity: return $this->person ?? $this->thirdParty; } } +``` This is not well taken into account by the Symfony serializer natively. You must, then, create your own CustomNormalizer. You can help yourself using this: -.. code-block:: php - +```php namespace Chill\PersonBundle\Serializer\Normalizer; use Chill\PersonBundle\Entity\Person; @@ -606,7 +565,6 @@ You must, then, create your own CustomNormalizer. You can help yourself using th use Symfony\Component\Serializer\Exception; use Chill\MainBundle\Serializer\Normalizer\DiscriminatedObjectDenormalizer; - class AccompanyingPeriodResourceNormalizer implements DenormalizerInterface, DenormalizerAwareInterface { use DenormalizerAwareTrait; @@ -632,35 +590,34 @@ You must, then, create your own CustomNormalizer. You can help yourself using th DiscriminatedObjectDenormalizer::TYPE, $format, // into the context, we add the list of allowed types: - [ - DiscriminatedObjectDenormalizer::ALLOWED_TYPES => - [ + [ + DiscriminatedObjectDenormalizer::ALLOWED_TYPES => + [ Person::class, ThirdParty::class ] ] ); $resource->setResource($res); - } + } return $resource; } - + public function supportsDenormalization($data, string $type, string $format = null) { return $type === Resource::class; - } + } } +``` -Serialization for collection -**************************** +###### Serialization for collection A specific model has been defined for returning collection: -.. code-block:: json - - { +``` +{ "count": 49, "results": [ ], @@ -672,13 +629,13 @@ A specific model has been defined for returning collection: "items_per_page": 1 } } +``` Where this is relevant, this model should be re-used in custom controller actions. -In custom actions, this can be achieved quickly by assembling results into a :code:`Chill\MainBundle\Serializer\Model\Collection`. The pagination information is given by using :code:`Paginator` (see :ref:`Pagination `). - -.. code-block:: php +In custom actions, this can be achieved quickly by assembling results into a `Chill\MainBundle\Serializer\Model\Collection`. The pagination information is given by using `Paginator` (see [Pagination ](pagination-ref.md)). +```php use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Chill\MainBundle\Pagination\PaginatorInterface; @@ -692,15 +649,11 @@ In custom actions, this can be achieved quickly by assembling results into a :co return $this->json($model, Response::HTTP_OK, [], $context); } } +``` +###### Full configuration example -.. _api_full_configuration: - -Full configuration example -************************** - -.. code-block:: yaml - +```yaml apis: - class: Chill\PersonBundle\Entity\AccompanyingPeriod @@ -743,5 +696,4 @@ Full configuration example path: null single-collection: single base_role: null - - +``` diff --git a/docs/source/development/assets.rst b/docs/source/development/assets.md similarity index 79% rename from docs/source/development/assets.rst rename to docs/source/development/assets.md index 3584ef3f6..e197fe7f0 100644 --- a/docs/source/development/assets.rst +++ b/docs/source/development/assets.md @@ -1,91 +1,61 @@ - -.. Copyright (C) 2014 Champs Libres Cooperative SCRLFS - Permission is granted to copy, distribute and/or modify this document +Permission is granted to copy, distribute and/or modify this document under the terms of the GNU Free Documentation License, Version 1.3 or any later version published by the Free Software Foundation; with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. A copy of the license is included in the section entitled "GNU Free Documentation License". -.. _forms: - -Assets -####### +# Assets The Chill assets (js, css, images, …) can be managed by `Webpack Encore`_, which is a thin wrapper for `Webpack`_ in Symfony applications. -Installation -************ +###### Installation Webpack Encore needs to be run in a Node.js environment, using the Yarn package manager. This Node.js environment can be set up using a node docker image. The bash script `docker-node.sh` set up a Node.js environment with an adequate configuration. Launch this container by typing: - -.. code-block:: bash - $ bash docker-node.sh In this NodeJS environment, install all the assets required by Chill with: -.. code-block:: bash - node@b91cab4f7cfc:/app$ yarn install This command will install all the packages that are listed in `package.json`. Any further required dependencies can be installed using the Yarn package. For instance, jQuery is installed by: -.. code-block:: bash - node@b91cab4f7cfc:/app$ yarn add jquery --dev +###### Usage -Usage -***** - -Organize your assets --------------------- +### Organize your assets Chill assets usually lives under the `/Resources/public` folder of each Chill bundle. The Webpack configuration set up in `webpack.config.js` automatically loads the required assets from the Chill bundles that are used. For adding your own assets to Webpack, you must add an entry in the `webpack.config.js` file. For instance, the following entry will output a file `main.js` collecting the js code (and possibly css, image, etc.) from `./assets/main.js`. -.. code-block:: js - .addEntry('main', './assets/main.js') To gather the css files, simply connect them to your js file, using `require`. The css file is seen as a dependency of your js file. : -.. code-block:: js - // assets/js/main.js require('../css/app.css'); For finer configuration of webpack encore, we refer to the above-linked documentation. - -Compile the assets ------------------- +### Compile the assets To compile the assets, run this line in the NodeJS container: -.. code-block:: bash - node@b91cab4f7cfc:/app$ yarn run encore dev While developing, you can tell Webpack Encore to continuously watch the files you are modifying: -.. code-block:: bash - node@b91cab4f7cfc:/app$ yarn run encore dev --watch - -Use the assets in the templates --------------------------------- +### Use the assets in the templates Any entry defined in the webpack.config.js file can be linked to your application using the symfony `asset` helper: -.. code-block:: html - ... @@ -93,12 +63,4 @@ Any entry defined in the webpack.config.js file can be linked to your applicatio ... - - - - - - - -.. _Webpack Encore: https://www.npmjs.com/package/@symfony/webpack-encore -.. _Webpack: https://webpack.js.org/ + \ No newline at end of file diff --git a/docs/source/development/code-quality.md b/docs/source/development/code-quality.md new file mode 100644 index 000000000..6a43fe300 --- /dev/null +++ b/docs/source/development/code-quality.md @@ -0,0 +1,23 @@ +# Code style, code quality and other tools + +## PHP-cs-fixer + +For development, you will also have to install: + +- [php-cs-fixer ](https://cs.symfony.com/) + +We also encourage you to use tools like [phpstan ](https://phpstan.org) and [rector ](https://getrector.com). + +For running php-cs-fixer: + + symfony composer php-cs-fixer + +## Execute tests + + symfony composer exec phpunit -- /path/to_your_test.php + +Note that IDE like PhpStorm should be able to run tests, even KernelTestcase or WebTestCase, [from within their interfaces ](https://www.jetbrains.com/help/phpstorm/using-phpunit-framework.html#run_phpunit_tests). + +## Execute rector + + symfony composer exec rector -- process \ No newline at end of file diff --git a/docs/source/development/code-quality.rst b/docs/source/development/code-quality.rst deleted file mode 100644 index 7e37ebf25..000000000 --- a/docs/source/development/code-quality.rst +++ /dev/null @@ -1,34 +0,0 @@ -Code style, code quality and other tools -######################################## - -PHP-cs-fixer -============ - -For development, you will also have to install: - -- `php-cs-fixer `_ - -We also encourage you to use tools like `phpstan `_ and `rector `_. - -For running php-cs-fixer: - -.. code-block:: bash - - symfony composer php-cs-fixer - -Execute tests -============= - -.. code-block:: bash - - symfony composer exec phpunit -- /path/to_your_test.php - -Note that IDE like PhpStorm should be able to run tests, even KernelTestcase or WebTestCase, `from within their interfaces `_. - -Execute rector -============== - -.. code-block:: bash - - symfony composer exec rector -- process - diff --git a/docs/source/development/create-a-new-bundle.md b/docs/source/development/create-a-new-bundle.md new file mode 100644 index 000000000..7d942ac28 --- /dev/null +++ b/docs/source/development/create-a-new-bundle.md @@ -0,0 +1,90 @@ +# Create a new bundle {#create-new-bundle} + +:::: warning +::: title +Warning +::: + +This part of the doc is not yet tested +:::: + +## Create a new directory with Bundle class + +``` bash +mkdir -p src/Bundle/ChillSomeBundle/src/config +mkdir -p src/Bundle/ChillSomeBundle/src/Controller +``` + +Add a bundle file + +``` php + ['all' => true], +``` + +And import routes in `config/routes/chill_some_bundle.yaml`: + +``` yaml +chill_ticket_bundle: + resource: '@ChillSomeBundle/config/routes.yaml' +``` + +## Add the doctrine_migration namespace + +Add the namespace to `config/packages/doctrine_migrations_chill.yaml` + +``` diff +doctrine_migrations: + migrations_paths: ++ 'Chill\Some\Ticket': '@ChillSomeBundle/migrations' +``` + +## Dump autoloading + +``` bash +symfony composer dump-autoload +``` diff --git a/docs/source/development/create-a-new-bundle.rst b/docs/source/development/create-a-new-bundle.rst deleted file mode 100644 index 087487ff0..000000000 --- a/docs/source/development/create-a-new-bundle.rst +++ /dev/null @@ -1,34 +0,0 @@ -.. Copyright (C) 2014 Champs Libres Cooperative SCRLFS - Permission is granted to copy, distribute and/or modify this document - under the terms of the GNU Free Documentation License, Version 1.3 - or any later version published by the Free Software Foundation; - with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. - A copy of the license is included in the section entitled "GNU - Free Documentation License". - -.. _create-new-bundle: - -Create a new bundle -******************* - -Create your own bundle is not a trivial task. - -The easiest way to achieve this is seems to be : - -1. Prepare a fresh installation of the chill project, in a new directory -2. Create a new bundle in this project, in the src directory -3. Initialize a git repository **at the root bundle**, and create your initial commit. -4. Register the bundle with composer/packagist. If you do not plan to distribute your bundle with packagist, you may use a custom repository for achieve this [#f1]_ -5. Move to a development installation, made as described in the :ref:`installation-for-development` section, and add your new repository to the composer.json file -6. Work as :ref:`usual ` - -.. warning:: - - This part of the doc is not yet tested - -TODO - - -.. rubric:: Footnotes - -.. [#f1] Be aware that we use the Affero GPL Licence, which ensure that all users must have access to derivative works done with this software. diff --git a/docs/source/development/cronjob.rst b/docs/source/development/cronjob.md similarity index 73% rename from docs/source/development/cronjob.rst rename to docs/source/development/cronjob.md index bda32b5c8..255802e4b 100644 --- a/docs/source/development/cronjob.rst +++ b/docs/source/development/cronjob.md @@ -1,37 +1,26 @@ - -.. Copyright (C) 2014-2023 Champs Libres Cooperative SCRLFS - Permission is granted to copy, distribute and/or modify this document +Permission is granted to copy, distribute and/or modify this document under the terms of the GNU Free Documentation License, Version 1.3 or any later version published by the Free Software Foundation; with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. A copy of the license is included in the section entitled "GNU Free Documentation License". -.. _cronjob: - -Cron jobs -********* +###### Cron jobs Some tasks must be executed regularly: refresh some materialized views, remove old data, ... For this purpose, one can programmatically implements a "cron job", which will be scheduled by a specific command. -The command :code:`chill:cron-job:execute` -========================================== +## The command `chill:cron-job:execute` -The command :code:`chill:cron-job:execute` will schedule a task, one by one. In a classical implementation, it should +The command `chill:cron-job:execute` will schedule a task, one by one. In a classical implementation, it should be executed every 15 minutes (more or less), to ensure that every task can be executed. -.. warning:: - This command should not be executed in parallel. The installer should ensure that two job are executed concurrently. -How to implements a cron job ? -============================== +## How to implements a cron job ? -Implements a :code:`Chill\MainBundle\Cron\CronJobInterface`. Here is an example: - -.. code-block:: php +Implements a `Chill\MainBundle\Cron\CronJobInterface`. Here is an example: namespace Chill\MainBundle\Service\Something; @@ -83,19 +72,15 @@ Implements a :code:`Chill\MainBundle\Cron\CronJobInterface`. Here is an example: } } -How are cron job scheduled ? -============================ +## How are cron job scheduled ? -If the command :code:`chill:cron-job:execute` is run with one or more :code:`job` argument, those jobs are run, **without checking that the job can run** (the method :code:`canRun` is not executed). +If the command `chill:cron-job:execute` is run with one or more `job` argument, those jobs are run, **without checking that the job can run** (the method `canRun` is not executed). -If any :code:`job` argument is given, the :code:`CronManager` schedule job with those steps: +If any `job` argument is given, the `CronManager` schedule job with those steps: * the tasks are ordered, with: * a priority is given for tasks that weren't never executed; * then, the tasks are ordered, the last executed are the first in the list -* then, for each tasks, and in the given order, the first task where :code:`canRun` return :code:`TRUE` will be executed. - -The command :code:`chill:cron-job:execute` execute **only one** task. - - +* then, for each tasks, and in the given order, the first task where `canRun` return `TRUE` will be executed. +The command `chill:cron-job:execute` execute **only one** task. diff --git a/docs/source/development/crud.rst b/docs/source/development/crud.md similarity index 86% rename from docs/source/development/crud.rst rename to docs/source/development/crud.md index 1f666cade..2796dff8e 100644 --- a/docs/source/development/crud.rst +++ b/docs/source/development/crud.md @@ -1,15 +1,11 @@ -.. Copyright (C) 2014 Champs Libres Cooperative SCRLFS - Permission is granted to copy, distribute and/or modify this document +Permission is granted to copy, distribute and/or modify this document under the terms of the GNU Free Documentation License, Version 1.3 or any later version published by the Free Software Foundation; with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. A copy of the license is included in the section entitled "GNU Free Documentation License". -.. _crud: - -CRUD -#### +# CRUD Chill provide an API to create a basic CRUD. @@ -20,30 +16,21 @@ One can follow those steps to create a CRUD for one entity: 3. customize the templates if required ; 4. customize some steps of the controller if required ; +An example with the [`ClosingMotive`` (PersonBundle) in the admin part of Chill: -An example with the ``ClosingMotive`` (PersonBundle) in the admin part of Chill: - -Auto-loading the routes -*********************** +###### Auto-loading the routes Ensure that those lines are present in your file `app/config/routing.yml`: - -.. code-block:: yaml - chill_cruds: resource: 'chill_main_crud_route_loader:load' type: service - - -Create your model -***************** +###### Create your model Create your model on the usual way (in this example, ORM informations are stored in yaml file): -.. code-block:: php - +```php namespace Chill\PersonBundle\Entity\AccompanyingPeriod; use Doctrine\Common\Collections\Collection; @@ -62,41 +49,40 @@ Create your model on the usual way (in this example, ORM informations are stored * @var array */ private $name; - + /** * * @var boolean */ private $active = true; - + /** * * @var self */ private $parent = null; - + /** * child Accompanying periods * * @var Collection */ private $children; - + /** * * @var float */ private $ordering = 0.0; - // getters and setters come here } +``` The form: -.. code-block:: php - +```php namespace Chill\PersonBundle\Form; use Symfony\Component\Form\AbstractType; @@ -108,7 +94,6 @@ The form: use Symfony\Component\Form\Extension\Core\Type\NumberType; /** - * * */ class ClosingMotiveType extends AbstractType @@ -145,22 +130,21 @@ The form: ; } } +``` -Configure the crud -****************** +###### Configure the crud The crud is configured using the key ``crud`` under ``chill_main`` -.. code-block:: yaml - +```yaml chill_main: cruds: - - + - # the class which is concerned by the CRUD class: '\Chill\PersonBundle\Entity\AccompanyingPeriod\ClosingMotive::class' # give a name for the crud. This will be used internally name: closing_motive - # add a base path for the + # add a base path for the base_path: /admin/closing-motive # this is the form class form_class: 'Chill\PersonBundle\Form\ClosingMotiveType::class' @@ -170,8 +154,8 @@ The crud is configured using the key ``crud`` under ``chill_main`` # this is a list of action you can configure # by default, the actions `index`, `view`, `new` and `edit` are automatically create # you can add more actions or configure some details about them - actions: - index: + actions: + index: # the default template for index is very poor, # you will need to override it template: '@ChillPerson/ClosingMotive/index.html.twig' @@ -180,16 +164,16 @@ The crud is configured using the key ``crud`` under ``chill_main`` new: role: ROLE_ADMIN # by default, the template will only show the form - # you can override it + # you can override it template: '@ChillPerson/ClosingMotive/new.html.twig' edit: role: ROLE_ADMIN template: '@ChillPerson/ClosingMotive/edit.html.twig' +``` -To leave the bundle auto-configure the ``chill_main`` bundle, you can `prepend the configuration of the ChillMain Bundle `_: - -.. code-block:: php +To leave the bundle autoconfigure the ``chill_main`` bundle, you can `prepend the configuration of the ChillMain Bundle ](https://symfony.com/doc/current/bundles/prepend_extension.html): +```php namespace Chill\PersonBundle\DependencyInjection; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -204,13 +188,13 @@ To leave the bundle auto-configure the ``chill_main`` bundle, you can `prepend t { // skipped here } - - - public function prepend(ContainerBuilder $container) + + + public function prepend(ContainerBuilder $container) { $this->prependCruds($container); } - + protected function prependCruds(ContainerBuilder $container) { $container->prependExtensionConfig('chill_main', [ @@ -240,20 +224,17 @@ To leave the bundle auto-configure the ``chill_main`` bundle, you can `prepend t ]); } } +``` +###### Customize templates - -Customize templates -******************* - -The current template are quite basic. You can override and extends them. +The current template is quite basic. You can override and extends them. For a better inclusion, you can embed them instead of extending them. For index. Note that we extend here the `admin` layout, not the default one: -.. code-block:: html+jinja - +```php {% extends '@ChillMain/Admin/layout.html.twig' %} {% block admin_content %} @@ -288,11 +269,11 @@ For index. Note that we extend here the `admin` layout, not the default one: {% endblock %} {% endembed %} {% endblock %} +``` For edit template: -.. code-block:: html+jinja - +```php {% extends '@ChillMain/Admin/layout.html.twig' %} {% block title %} @@ -309,8 +290,6 @@ For edit template: For new template: -.. code-block:: html+jinja - {% extends '@ChillMain/Admin/layout.html.twig' %} {% block title %} @@ -322,17 +301,16 @@ For new template: {% block content_form_actions_save_and_show %}{% endblock %} {% endembed %} {% endblock %} +``` -Customize some steps in the controller -************************************** +###### Customize some steps in the controller Some steps may be customized by overriding the default controller and some methods. Here, we will override the way the entity is created, and the ordering of the "index" page: * we will associate a parent ClosingMotive to the element if a parameter `parent_id` is found ; * we will order the ClosingMotive by the ``ordering`` property -.. code-block:: php - +```php namespace Chill\PersonBundle\Controller; use Chill\MainBundle\CRUD\Controller\CRUDController; @@ -376,14 +354,13 @@ Some steps may be customized by overriding the default controller and some metho return $query->orderBy('e.ordering', 'ASC'); } } +``` -How-to and questions -******************** +###### How-to and questions -Which role is required for each action ? -======================================== +## Which role is required for each action ? -By default, each action will use: +By default, each action will use: 1. the role defined under the action key ; 2. the base role as upper, with the action name appended: @@ -396,22 +373,21 @@ By default, each action will use: The entity will be passed to the role: * for the ``view`` and ``edit`` action: the entity fetched from database -* for the ``new`` action: the entity which is created (you can override default values using -* for index action (or if you re-use the ``indexAction`` method: ``null`` - -How to add some route and actions ? -=================================== +* for the ``new`` action: the entity which is created (you can override default values using +* for index action (or if you re-use the ``indexAction`` method: ``null`` + +## How to add some route and actions ? Add them under the action key: -.. code-block:: yaml - +```yaml chill_main: cruds: - - + - # snipped actions: myaction: ~ +``` The method `myactionAction` will be called by the parameter. @@ -419,13 +395,12 @@ Inside this action, you can eventually call another internal method: * ``indexAction`` for a list of items ; * ``viewAction`` for a view -* ``editFormAction`` for an edition +* ``editFormAction`` for an edition * ``createFormAction`` for a creation Example: -.. code-block:: php - +```php namespace CSConnectes\SPBundle\Controller; use Chill\PersonBundle\CRUD\Controller\OneToOneEntityPersonCRUDController; @@ -456,34 +431,29 @@ Example: } } +``` -How to create a CRUD for entities associated to persons -======================================================= +## How to create a CRUD for entities associated to persons The bundle person provide some controller and template you can override, instead of the ones present in the mainbundle: -* :code:`Chill\PersonBundle\CRUD\Controller\EntityPersonCRUDController` for entities linked with a one-to-may association to :code:`Person` class ; -* :code:`Chill\PersonBundle\CRUD\Controller\OneToOneEntityPersonCRUDController` for entities linked with a one-to-one association to :code:`Person` class. +* `Chill\PersonBundle\CRUD\Controller\EntityPersonCRUDController` for entities linked with a one-to-may association to `Person` class ; +* `Chill\PersonBundle\CRUD\Controller\OneToOneEntityPersonCRUDController` for entities linked with a one-to-one association to `Person` class. There are also template defined under ``@ChillPerson/CRUD/`` namespace. -Those controller assume that: +Those controllers assume that: -* the entity provide the method :code:`getPerson` and :code:`setPerson` ; +* the entity provide the method `getPerson` and `setPerson` ; * the `index`'s id path will be the id of the person, and the ids in `view` and `edit` path will be the id of the entity ; -This bundle also use by default the templates inside ``@ChillPerson/CRUD/``. +This bundle also uses by default the templates inside ``@ChillPerson/CRUD/``. +###### Reference -Reference -********* - -Configuration reference -======================= - - -.. code-block:: txt +## Configuration reference +```yaml chill_main: cruds: @@ -514,9 +484,8 @@ Configuration reference # the template to render the view template: null +``` -Twig default block -================== +## Twig default block This part should be documented. - diff --git a/docs/source/development/database-principles.md b/docs/source/development/database-principles.md new file mode 100644 index 000000000..c952ec584 --- /dev/null +++ b/docs/source/development/database-principles.md @@ -0,0 +1,70 @@ +# Database Principles + +This page provides a global understanding of the Chill database and explains some implementation details that help accelerate processing from the database or exploit it more easily. + +!!! warning "Database Schema Stability" + The stability of the database schema is not guaranteed. + + However, it evolves relatively little. It is rare for tables or columns to be deleted or renamed, but it is not guaranteed that this cannot happen. + +## Overview + +A commented list of all tables is available in CSV format at `./database/table_list.csv`. + +### Schema and naming conventions + +At the beginning of Chill's history, PostgreSQL schemas were not used. Data was stored in the `public` schema. + +Later, new bundles appeared, and tables were classified into dedicated schemas. + +Currently: + +- for older bundles, those that already have tables in the public schema, new tables are added to this schema. They are prefixed with `chill__`; +- for more recent bundles, tables are created in the dedicated schema + +### Historical data + +Some data is historized: + +- the referents of an accompanying period; +- the statuses of an accompanying period; +- the link between territories and users; +- etc. + +In these cases, Chill generally creates two columns, which are usually named `startDate` and `endDate`. When the `endDate` column is `NULL`, it means that the period is not "closed". The `startDate` column is not nullable. + +In some cases, the current data (referent of an accompanying period, for example) is also repeated at the table level itself. For example, the accompanying periods table `chill_person_accompanying_period` has a `step` column (the status of the period) and `user_id` (referent id) in addition to the history. Although redundant, this simplifies processing. + +## Special relationships + +### Users, households, addresses + +Users have an address through households: in the interface, the address is recorded in the household file, and it is "given" to users who are members of the household, **and** who share the address of this household. Indeed, it is possible that users "belong" to a household without being domiciled there: this is the case, for example, for children in shared custody. + +The history of users' membership in the household is preserved, as well as the history of addresses for the same household. + +The tables involved are as follows: + +- the `chill_person_person` table lists the users; +- the `chill_person_household_members` table lists household memberships: this is the junction between users and households: + - the `startDate` and `endDate` columns indicate the start and end date of membership; + - the `shareHousehold` column indicates whether the user shares the household address (if yes, its value is `TRUE`) +- the `chill_person_household` table lists households +- the `chill_person_household_to_addresses` table associates households with addresses; +- the `chill_main_address` table contains addresses, indicating the validity start date (`validFrom`) and the validity end date (`validTo`). + +To simplify the resolution of addresses and users, two views have been implemented: + +- the `view_chill_person_household_address` view takes up, for each user, the history of household memberships broken down by the address history of a household. + In other words, a line is created each time a user changes household, or a household changes address. It is therefore possible to find the complete address history for a given user via this table. +- the `view_chill_person_current_address` view takes up the current address of users. + +### Addresses and geographical units + +Chill provides statistics on the location of addresses relative to geographical zones (`chill_main_geographical_unit`). + +Since the geographical resolution of addresses is costly in CPU and processing time, a materialized view has been created: `view_chill_main_address_geographical_unit`. It is refreshed daily in the production database. + +## Table list and comments + +A commented list of all tables is available in CSV format at `./database/table_list.csv`. diff --git a/docs/source/development/database-principles.rst b/docs/source/development/database-principles.rst deleted file mode 100644 index 455354934..000000000 --- a/docs/source/development/database-principles.rst +++ /dev/null @@ -1,84 +0,0 @@ - -.. database-principles: - -Principes de la base de données -############################### - -Cette page donne une compréhension globale de la base de donnée de Chill, et explique quelques détails d'implémentations qui permettent d'accélérer les traitements à partir de la base de donnée, ou de l'exploiter plus aisément. - -Cette page est rédigée en français. - -.. note:: - - La stabilité du schéma de la base de donnée n'est pas garantie. - - Toutefois, ce dernier évolue relativement peu. Il est rare que des tables ou des colonnes soient supprimées ou renommées. Mais il n'est pas garanti que cela puisse arriver. - -Généralités -=========== - -Une liste commentée de toutes les tables :download:`est disponible au format CSV <./database/table_list.csv`. - -Schéma et conventions de nommage --------------------------------- - -Au début de l'histoire de Chill, les schémas postgresql n'étaient pas exploités. Les données étaient stockées dans le schéma :code:`public`. - -Par la suite, des nouveaux bundles sont apparus, et les tables ont été classées dans des schémas dédiés. - -A l'heure actuelle: - -- pour les anciens bundle, ceux qui ont déjà des tables dans le schéma public, les nouvelles tables sont ajoutées à ce schéma. Elles sont préfixées par :code:`chill__`; -- pour les bundles plus récents, les tables sont créées dans le schéma dédié - -Données avec de l'historicité ------------------------------ - -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; -- etc. - -Dans ces cas-là, Chill crée généralement deux colonnes, qui sont habituellement nommées :code:`startDate` et :code:`endDate`. Lorsque la colonne :code:`endDate` est à :code:`NULL`, cela signifie que la période n'est pas "fermée". La colonne :code:`startDate` n'est pas nullable. - -Dans certains cas, la donnée actuelle (référent d'un parcours, par exemple) est également répétée au niveau de la table en elle-même. Par exemple, la table des parcours :code:`chill_person_accompanying_period` comporte une colonne :code:`step` (le statut du parcours) et :code:`user_id` (id du référent) en plus de l'historique. Bien que redondant, cela simplifie les traitements. - -Relations particulières -======================= - -Usagers, ménages, adresses --------------------------- - -Les usagers ont une adresse au travers des ménages: dans l'interface, l'adresse est inscrite dans le dossier du ménage, et elle est "donnée" aux usagers membres du ménage, **et** qui partagent l'adresse de ce ménage. En effet, il est possible que des usagers "appartiennent" à un ménage sans y être domicilié: c'est le cas, par exemple, des enfants en garde alternée. - -L'historique de l'appartenance des usagers au ménage est conservée, de même que l'historique des adresses pour un même ménage. - -Les tables en jeu sont les suivantes: - -- la table :code:`chill_person_person` liste les usagers; -- la table :code:`chill_person_household_members` liste les appartenances au ménage: il s'agit de la jointure entre les usagers et les ménages: - - les colonnes :code:`startDate` et :code:`endDate` indiquent la date de début et la date de fin de l'appartenance; - - la colonne :code:`shareHousehold` indique si l'utilisateur partage l'adresse du ménage (si oui, sa valeur est :code:`TRUE`) -- la table :code:`chill_person_household` liste les ménages -- la table :code:`chill_person_household_to_addresses` associe les ménages aux adresses; -- la table :code:`chill_main_address` contient les adresses, en indiquant la date de début de validité (:code:`validFrom`) et la fin de validité (:code:`validTo`). - -Pour simplifier la résolution des adresses et des usagers, deux vues ont été mises en œuvre: - -- la vue :code:`view_chill_person_household_address` reprend, pour chaque usager, l'historique des appartenances au ménage découpée par l'historique des adresses d'un ménage. - Autrement dit, une ligne est créée à chaque fois qu'un usager change de ménage, ou qu'un ménage change d'adresse. Il est donc possible de retrouver l'historique complet des adresses pour un usager donné via cette table. -- la vue :code:`view_chill_person_current_address` reprend l'adresse actuelle des usagers. - -Adresses et unités géographiques --------------------------------- - -Chill propose des statistiques sur la localisation des adresses par rapport à des zones géographiques (:code:`chill_main_geographical_unit`). - -Comme la résolution géographique des adresses est coûteuse en CPU et en temps de traitement, une vue matérialisée a été créée: :code:`view_chill_main_address_geographical_unit`. Elle est rafraichie quotidiennement dans la base de donnée de production. - -Liste des tables et commentaires -================================ - -Une liste commentée de toutes les tables :download:`est disponible au format CSV <./database/table_list.csv`. diff --git a/docs/source/development/database/table_list.csv b/docs/source/development/database/table_list.csv index fe688318d..be72a52ab 100644 --- a/docs/source/development/database/table_list.csv +++ b/docs/source/development/database/table_list.csv @@ -1,6 +1,6 @@ order,table_schema,table_name,commentaire 1,chill_3party,party_category,Catégorie de tiers -2,chill_3party,party_center,Association entre les tiers et les centres (déprécié) +2,chill_3party,party_center,Association entre les tiers et les territoires (déprécié) 3,chill_3party,party_profession,Profession du tiers (déprécié) 4,chill_3party,third_party,Tiers 5,chill_3party,thirdparty_category,association tiers - catégories @@ -54,7 +54,7 @@ order,table_schema,table_name,commentaire 53,public,activitytpresence,Présence aux échanges 54,public,activitytype,Types d'échanges 55,public,activitytypecategory,Catégories de types d'échanges -56,public,centers,"Centres (territoires, agences, etc.)" +56,public,centers,"Territoires (territoires, agences, etc.)" 57,public,chill_activity_activity_chill_person_socialaction, 58,public,chill_activity_activity_chill_person_socialissue 59,public,chill_docgen_template,Gabarits de documents @@ -111,7 +111,7 @@ order,table_schema,table_name,commentaire 110,public,chill_person_marital_status,Etats civils 111,public,chill_person_not_duplicate, 112,public,chill_person_person,Usagers -113,public,chill_person_person_center_history,Historique des centres d'un usagers +113,public,chill_person_person_center_history,Historique des territoires d'un usagers 114,public,chill_person_persons_to_addresses,Déprécié 115,public,chill_person_phone,Numéros d etéléphone supplémentaires d'un usager 116,public,chill_person_relations,Types de relations de filiation @@ -142,7 +142,7 @@ order,table_schema,table_name,commentaire 141,public,permission_groups 142,public,permissionsgroup_rolescope 143,public,persons_spoken_languages -144,public,regroupment,Regroupement de centres +144,public,regroupment,Regroupement de territoires 145,public,regroupment_center, 146,public,role_scopes, 147,public,scopes,Services diff --git a/docs/source/development/embeddable-comments.rst b/docs/source/development/embeddable-comments.md similarity index 63% rename from docs/source/development/embeddable-comments.rst rename to docs/source/development/embeddable-comments.md index e14e8e84b..ae1e70819 100644 --- a/docs/source/development/embeddable-comments.rst +++ b/docs/source/development/embeddable-comments.md @@ -1,30 +1,17 @@ - -.. Copyright (C) 2016 Champs Libres Cooperative SCRLFS - Permission is granted to copy, distribute and/or modify this document - under the terms of the GNU Free Documentation License, Version 1.3 - or any later version published by the Free Software Foundation; - with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. - A copy of the license is included in the section entitled "GNU - Free Documentation License". - - -Embeddable comments -################### +# Embeddable comments Those embeddable comments is a comment with some metadata: * the one who updated the comment (the comment itself, and not the whole entity); * the date and time for the last update (again, the comment itself, and not the whole entity). -We make usage of `embeddables `_. +We make usage of [embeddable ](https://www.doctrine-project.org/projects/doctrine-orm/en/2.8/tutorials/embeddables.html). -Embed the comment -================= +## Embed the comment The comment may be embedded into the entity: -.. code-block:: php - +```php namespace Chill\ActivityBundle\Entity; use Chill\MainBundle\Entity\Embeddable\CommentEmbeddable; @@ -51,7 +38,6 @@ The comment may be embedded into the entity: */ private $comment; - /** * @return \Chill\MainBundle\Entity\Embeddalbe\CommentEmbeddable */ @@ -68,27 +54,21 @@ The comment may be embedded into the entity: $this->comment = $comment; } } +``` -Note on relation to :class:`User` -================================= +## Note on relation to :class:`User` The embeddable proposed by Doctrine does not support relationship to other entities. The entity Comment is able to render a user's id, but not an User object. + `$activity->getComment()->getUserId(); // return user id of the last author` -.. code-block:: php + `$activity->getComment()->getUser(); // does not work !` - $activity->getComment()->getUserId(); // return user id of the last author +## Usage into form - $activity->getComment()->getUser(); // does not work ! - - -Usage into form -=============== - -Use the :class:`Chill\MainBundle\Form\Type\CommentType` to load the form widget: - -.. code-block:: php +Use the `Chill\MainBundle\Form\Type\CommentType` to load the form widget: +```php namespace Chill\ActivityBundle\Form; use Chill\MainBundle\Form\Type\CommentType; @@ -110,12 +90,8 @@ Use the :class:`Chill\MainBundle\Form\Type\CommentType` to load the form widget: ; } } +``` -Render the comment -================== - -.. code-block:: twig - - {{ activity.comment|chill_entity_render_box }} - +## Render the comment + `{{ activity.comment|chill_entity_render_box }}` diff --git a/docs/source/development/entity-info.rst b/docs/source/development/entity-info.md similarity index 84% rename from docs/source/development/entity-info.rst rename to docs/source/development/entity-info.md index 72d8b70ea..0e8e2b33b 100644 --- a/docs/source/development/entity-info.rst +++ b/docs/source/development/entity-info.md @@ -1,16 +1,4 @@ - -.. Copyright (C) 2014 Champs Libres Cooperative SCRLFS -Permission is granted to copy, distribute and/or modify this document -under the terms of the GNU Free Documentation License, Version 1.3 -or any later version published by the Free Software Foundation; -with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. -A copy of the license is included in the section entitled "GNU -Free Documentation License". - -.. _entity-info: - -Stats about event on entity in php world -######################################## +# Stats about event on entity in php world It is necessary to be able to gather information about events for some entities: @@ -18,11 +6,9 @@ It is necessary to be able to gather information about events for some entities: - who did it; - ... -Those "infos" are not linked with right management, like describe in :ref:`timelines`. +Those "infos" are not linked with right management, like describe in [timelines`. - -“infos” for some stats and info about an entity ------------------------------------------------ +### “infos” for some stats and info about an entity Building an info means: @@ -32,10 +18,10 @@ Building an info means: A framework api is built to be able to build multiple “infos” entities through “union” views: -- use a command ``bin/console chill:db:sync-views`` to synchronize view (create view if it does not exists, or update +- use a command ``bin/console chill:db:sync-views`` to synchronize view (create view if it does not exist, or update views when new SQL parts are added in the UNION query. Internally, this command call a new ``ViewEntityInfoManager``, which iterate over available views to build the SQL; -- one can create a new “view entity info” by implementing a +- one can create new “view entity info” by implementing a ``ViewEntityInfoProviderInterface`` - this implementation of the interface is free to create another interface for building each part of the UNION query. This interface @@ -45,14 +31,13 @@ through “union” views: So, converting new “events” into rows for ``AccompanyingPeriodInfo`` is just implementing this interface! -Implementation for AccompanyingPeriod (``AccompanyingPeriod/AccompanyingPeriodInfo``) -------------------------------------------------------------------------------------- +### Implementation for AccompanyingPeriod (``AccompanyingPeriod/AccompanyingPeriodInfo``) A class is created for computing some statistical info for an AccompanyingPeriod: ``AccompanyingPeriod/AccompanyingPeriodInfo``. This contains information about “something happens”, who did it and when. -Having those info in table answer some questions like: +Having that info in the table answers some questions like: - when is the last and the first action (AccompanyingPeriodWork, Activity, AccompanyingPeriodWorkEvaluation, …) on the period; @@ -60,46 +45,43 @@ Having those info in table answer some questions like: user. The AccompanyingPeriod info is mapped to a SQL view, not a table. The -sql view is built dynamically (see below), and gather infos from +SQL view is built dynamically (see below), and gathers info from ActivityBundle, PersonBundle, CalendarBundle, … It is possible to create -custom bundle and add info on this view. - -.. code:: php + a custom bundle and add info on this view. +```php /** * * @ORM\Entity() - * @ORM\Table(name="view_chill_person_accompanying_period_info") <==== THIS IS A VIEW, NOT A TABLE + * @ORM\Table(name="view_chill_person_accompanying_period_info") ](==== THIS IS A VIEW, NOT A TABLE */ class AccompanyingPeriodInfo { // ... } +``` -Why do we need this ? -~~~~~~~~~~~~~~~~~~~~~ +#### Why do we need this? -For multiple jobs in PHP world: +For multiple jobs in a PHP world: -- moving the accompanying period to another steps when inactive, +- moving the accompanying period to another step when inactive, automatically; - listing all the users which are intervening on the action on a new “Liste des intervenants” page; - filtering on exports -Later, we will launch automatic anonymise for accompanying period and +Later we will launch automatic anonymize for an accompanying period and all related entities through this information. -How is built the SQL views which is mapped to “info” entities ? -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +#### How are the SQL views built that are mapped to “info” entities? The AccompanyingPeriodInfo entity is mapped by a SQL view (not a regular table). -The sql view is built dynamically, it is a SQL view like this, for now (April 2023): - -.. code:: sql +The SQL view is built dynamically; it is a SQL view like this, for now (April 2023): +```sql create view view_chill_person_accompanying_period_info (accompanyingperiod_id, relatedentity, relatedentityid, user_id, infodate, discriminator, metadata) as SELECT w.accompanyingperiod_id, @@ -191,13 +173,14 @@ The sql view is built dynamically, it is a SQL view like this, for now (April 20 FROM activity LEFT JOIN activity_user au ON activity.id = au.activity_id WHERE activity.accompanyingperiod_id IS NOT NULL; +``` -As you can see, the view gather multiple SELECT queries and bind them +As you can see, the view gathers multiple SELECT queries and binds them with UNION. Each SELECT query is built dynamically, through a class implementing an interface: ``Chill\PersonBundle\Service\EntityInfo\AccompanyingPeriodInfoUnionQueryPartInterface``, `like -here `__ +here `_ +`https://eslint.vuejs.org/rules/ ](https://eslint.vuejs.org/rules/) -Manual Rule Configuration -------------------------- +### Manual Rule Configuration We can also manually configure certain rules or override rules that are part of the ruleset specified above. For example, if we want to turn off a certain rule, we can do so as follows: -.. code-block:: javascript - rules: { 'vue/multi-word-component': 'off' } @@ -64,8 +57,6 @@ We could also change the severity of a certain rule from 'error' to 'warning', f Within specific ``.js`` or ``.vue`` files, we can also override a certain rule only for that specific file by adding a comment: -.. code-block:: javascript - /* eslint multi-word-component: "off", no-child-content: "error" -------- Here's a description about why this configuration is necessary. */ diff --git a/docs/source/development/export-sequence.puml b/docs/source/development/export-sequence.puml deleted file mode 100644 index 0d0c77c14..000000000 --- a/docs/source/development/export-sequence.puml +++ /dev/null @@ -1,84 +0,0 @@ -@startuml -'https://plantuml.com/sequence-diagram - -autonumber - -User -> ExportController: configure export using form -activate ExportController -ExportController -> ExportForm: build form -activate ExportForm - -loop for every ExportElement (Filter, Aggregator) - ExportForm -> ExportElement: `buildForm` - activate ExportElement - ExportElement -> ExportForm: add form to builders - deactivate ExportElement -end - -ExportForm -> ExportController -deactivate ExportForm - -ExportController -> User: show form -deactivate ExportController - -note left of User: Configure the export:\ncheck filters, aggregators, … - -User -> ExportController: post configuration of the export -activate ExportController - -ExportController -> ExportForm: `getData` -activate ExportForm -ExportForm -> ExportController: return data: list of entities, etc. -deactivate ExportForm - -loop for every ExportElement (Filter, Aggregator) - ExportController -> ExportElement: serializeData (data) - activate ExportElement - ExportElement -> ExportController: return serializedData (simple array with string, int, …) - deactivate ExportElement -end - -ExportController -> Database: `INSERT INTO RequestGeneration_table` (insert new entity) -ExportController -> MessageQueue: warn about a new request -activate MessageQueue -ExportController -> User: "ok, generation is in process" -deactivate ExportController - -note left of User: The user see a waiting screen - -MessageQueue -> MessengerConsumer: forward the message to the MessengerConsumer -deactivate MessageQueue -activate MessengerConsumer -MessengerConsumer -> Database: `SELECT * FROM RequestGeneration_table WHERE id = %s` -activate Database -Database -> MessengerConsumer: return RequestGeneration with serializedData -deactivate Database - -loop for every ExportElement (Filter, Aggregator) - MessengerConsumer -> ExportElement: deserializeData - activate ExportElement - ExportElement -> MessengerConsumer: return data (list of entities, etc.) from the serialized array - deactivate ExportElement - MessengerConsumer -> ExportElement: alter the sql query (`ExportElement::alterQuery`) - activate ExportElement - ExportElement -> MessengerConsumer: return the query with WHERE and GROUP BY clauses - deactivate ExportElement -end - -MessengerConsumer -> MessengerConsumer: prepare the export -MessengerConsumer -> MessengerConsumer: save the export as a stored object -MessengerConsumer -> Database: `UPDATE RequestGeneration_table SET ready = true` -deactivate MessengerConsumer - -User -> ExportController: pull every 5s to know if the export is generated -activate ExportController -ExportController -> User: warn the export is generated -deactivate ExportController - -User -> ExportController: download the export from object storage - - - - - -@enduml diff --git a/docs/source/development/exports.rst b/docs/source/development/exports.md similarity index 60% rename from docs/source/development/exports.rst rename to docs/source/development/exports.md index 7cb04f32e..7335490f2 100644 --- a/docs/source/development/exports.rst +++ b/docs/source/development/exports.md @@ -1,66 +1,49 @@ -.. Copyright (C) 2014 Champs Libres Cooperative SCRLFS - Permission is granted to copy, distribute and/or modify this document - under the terms of the GNU Free Documentation License, Version 1.3 - or any later version published by the Free Software Foundation; - with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. - A copy of the license is included in the section entitled "GNU - Free Documentation License". - - -Exports -******* +###### Exports Export is an important issue within the Chill software : users should be able to : - compute statistics about their activity ; - list "things" which are a part of their activities. -The `main bundle`_ provides a powerful framework to build custom queries with re-usable parts across differents bundles. +The [main bundle`_ provides a powerful framework to build custom queries with re-usable parts across differents bundles. -.. contents:: Table of content :local: -.. seealso:: - - `The issue where this framework was discussed `_ + `The issue where this framework was [discussed](https://git.framasoft.org/Chill-project/Chill-Main/issues/9) Provides some information about the pursued features and architecture. -Concepts -======== +## Concepts - -Some vocabulary: 3 "Export elements" ------------------------------------- +### Some vocabulary: 3 "Export elements" Four terms are used for this framework : Exports provide some basic operation on the data. Two kinds of exports are available : - - computed data : it may be "the number of people", "the number of activities", "the duration of activities", ... - - list data : it may be "the list of people", "the list of activities", ... + - computed data: it may be "the number of people," "the number of activities," "the duration of activities," ... + - list data: it may be "the list of people," "the list of activities," ... Filters The filters create a filter on the data: it removes some information the user doesn't want to introduce in the computation done by the export. - Example of a filter: "people under 18 years olds", "activities between the 1st of June and the 31st December", ... + Example of a filter: "people under 18-year-olds," "activities between the 1st of June and the 31st December," ... Aggregators The aggregator aggregates the data into some group (some software use the term 'bucket'). - Example of an aggregator : "group people by gender", "group people by nationality", "group activity by type", ... + Example of an aggregator: "group people by gender," "group people by nationality," "group activity by type," ... Formatters The formatters format the data into a :class:`Symfony\Component\HttpFoundation\Response`, which will be returned "as is" by the controller to the web client. - Example of a formatter: "format data as CSV", "format data as an ods spreadsheet", ... + Example of a formatter: "format data as CSV", "format data as an ods spreadsheet," ... -Anatomy of an export ---------------------- +### Anatomy of an export An export can be thought of as a sentence where each part of this sentence refers to one or multiple export elements. Examples : -**Example 1**: Count the number of people having at least one activity in the last 12 month, and group them by nationality and gender, and format them in a CSV spreadsheet. +**Example 1**: Count the number of people having at least one activity in the last month 12, and group them by nationality and gender, and format them in a CSV spreadsheet. Here : @@ -73,21 +56,16 @@ Here : Note that : - Aggregators, filters, exports and formatters are cross-bundle. Here the bundle *activity* provides a filter which is applied on an export provided by the person bundle ; -- Multiple aggregator or filter for one export may exist. Currently, only one export is allowed. +- Multiple aggregators or filters for one export may exist. Currently, only one export is allowed. The result might be : -+-----------------------+----------------+---------------------------+ -| Nationality | Gender | Number of people | -+=======================+================+===========================+ -| Russian | Male | 12 | -+-----------------------+----------------+---------------------------+ -| Russian | Female | 24 | -+-----------------------+----------------+---------------------------+ -| France | Male | 110 | -+-----------------------+----------------+---------------------------+ -| France | Female | 150 | -+-----------------------+----------------+---------------------------+ ++-------------+--------+------------------+ +| Nationality | Gender | Number of people | +| Russian | Male | 12 | +| Russian | Female | 24 | +| France | Male | 110 | +| France | Female| 150 | **Example 2**: Count the average duration of an activity with type "meeting", which occurs between the 1st of June and the 31st of December, group them by week, and format the data in an OpenDocument spreadsheet. @@ -103,122 +81,97 @@ The result might be : +-----------------------+----------------------+ | Week | Number of activities | -+=======================+======================+ | 2015-10 | 10 | -+-----------------------+----------------------+ | 2015-11 | 12 | -+-----------------------+----------------------+ | 2015-12 | 10 | -+-----------------------+----------------------+ | 2015-13 | 9 | -+-----------------------+----------------------+ -Authorization and exports -------------------------- +### Authorization and exports -Exports, filters and aggregators should not show data the user is not allowed to see within the application. +Exports, filters, and aggregators should not show data the user is not allowed to see within the application. In other words, developers are required to take care of user authorization for each export. There should be a specific role that grants permission to users who are allowed to build exports. For more simplicity, this role should apply on a center, and should not require special circles. -How does the magic work ? -=========================== +## How does the magic work? To build an export, we rely on the capacity of the database to execute queries with aggregate (i.e. GROUP BY) and filter (i.e. WHERE) instructions. -An export is an SQL query which is initiated by an export, and modified by aggregators and filters. +An export is an SQL query that is initiated by an export and modified by aggregators and filters. -.. note:: - - **Example**: Count the number of people having at least one activity in the last 12 month, and group them by nationality and gender + **Example**: Count the number of people having at least one activity in the last month 12, and group them by nationality and gender 1. The report initiates the query - .. code-block:: SQL - + ```SQL SELECT count(people.*) FROM people +``` 2. The filter adds a where and join clause : - .. code-block:: SQL - + ```SQL SELECT count(people.*) FROM people RIGHT JOIN activity WHERE activity.date IS BETWEEN now AND 6 month ago +``` 3. The aggregator "nationality" adds a GROUP BY clause and a column in the SELECT statement: - .. code-block:: sql - + ```sql SELECT people.nationality, count(people.*) FROM people RIGHT JOIN activity WHERE activity.date IS BETWEEN now AND 6 month ago GROUP BY nationality +``` 4. The aggregator "gender" does the same job as the nationality aggregator : it adds a GROUP BY clause and a column in the SELECT statement : - .. code-block:: sql - + ```sql SELECT people.nationality, people.gender, count(people.*) FROM people RIGHT JOIN activity WHERE activity.date IS BETWEEN now AND 6 month ago GROUP BY nationality, gender +``` -Each filter, aggregator and filter may collect parameters from the user through a form. This form is appended to the export form. Here is an example. - -.. figure:: /_static/screenshots/development/export_form-fullpage.png +Each filter, aggregator, and filter may collect parameters from the user through a form. This form is appended to the export form. Here is an example. The screenshot shows the export form for ``CountPeople`` (Nombre de personnes). The filter by date of birth is checked (*Filtrer par date de naissance de la personne*), which triggers a subform, which is provided by the :class:`Chill\PersonBundle\Export\Filter\BirthdateFilter`. The other unchecked filter does not show the subform. - Two aggregators are also checked : by Country of birth (*Aggréger les personnes par pays de naissance*, the corresponding class is :class:`Chill\PersonBundle\Export\Aggregator\CountryOfBirthAggregator`, which also triggers a subform. The aggregator by gender (*Aggréger les personnes par genre*) is also checked, but there is no corresponding subform. + Two aggregators are also checked: by Country of birth (*Aggréger les personnes par pays de naissance*, the corresponding class is :class:`Chill\PersonBundle\Export\Aggregator\CountryOfBirthAggregator`, which also triggers a subform. The aggregator by gender (*Aggréger les personnes par genre*) is also checked, but there is no corresponding subform. -The Export Manager ------------------- +### The Export Manager -The Export manager (:class:`Chill\MainBundle\Export\ExportManager` is the central class which registers all exports, aggregators, filters and formatters. +The Export manager (:class:`Chill\MainBundle\Export\ExportManager` is the central class which registers all exports, aggregators, filters, and formatters. The export manager is also responsible for orchestrating the whole export process, producing a :class:`Symfony\FrameworkBundle\HttpFoundation\Request` for each export request. - -The export form step --------------------- +### The export form step The form step allows you to build a form, combining different parts of the module. The building of forms is split into different subforms, where each one is responsible for rendering their part of the form (aggregators, filters, and export). -.. figure:: /_static/puml/exports/form_steps.png :scale: 40% -The formatter form step ------------------------ +### The formatter form step The formatter form is processed *after* the user filled the export form. It is built the same way, but receives the data entered by the user on the previous step as parameters (i.e. export form). It may then adapt it accordingly (example: show a list of columns selected in aggregators). -Processing the export ---------------------- +### Processing the export -The export process can be explained by this schema : +This schema can explain the export process : -.. figure:: /_static/puml/exports/processing_export.png :scale: 40% (Click to enlarge) +## Export, formatters, and filters explained -Export, formatters and filters explained -======================================== - -Exports -------- +### Exports This is an example of the ``CountPerson`` export : -.. literalinclude:: /_static/code/exports/CountPerson.php - :language: php - :linenos: - * **Line 36**: the ``getType`` function returns a string. This string will be used to find the aggregtors and filters which will apply to this export. * **Line 41**: a simple description to help users understand what your export does. * **Line 46**: The title of the export. A summary of what your export does. @@ -226,26 +179,17 @@ This is an example of the ``CountPerson`` export : * **Line 56**: We initiate the query here... * **Line 59**: We have to filter the query with centers the users checked in the form. We process the $acl variable to get all ``Center`` objects in one array * **Line 63**: We create the query with a query builder. -* **Line 74**: We return the result, but make sure to hydrate the results as an array. +* **Line 74**: We return the result but make sure to hydrate the results as an array. * **Line 103**: return the list of formatter types which are allowed to be applied on this filter -Filters -------- +### Filters -This is an example of the *filter by birthdate*. This filter asks some information through a form (`buildForm` is not empty), and this form must be validated. To perform this validation, we implement a new Interface: :class:`Chill\MainBundle\Export\ExportElementValidatedInterface`: - -.. literalinclude:: /_static/code/exports/BirthdateFilter.php - :language: php - -.. todo:: +This is an example of the *filter by birthdate*. This filter asks some information through a form (`buildForm` is not empty), and this form must be validated. To perform this validation, we implement a new Interface: `Chill\MainBundle\Export\ExportElementValidatedInterface`: Continue to explain the export framework -.. _main bundle: https://git.framasoft.org/Chill-project/Chill-Main - - -With many-to-* relationship, why should we set WHERE clauses in an EXISTS subquery instead of a JOIN ? -`````````````````````````````````````````````````````````````````````````````````````````````````````` +With many-to-* relationship, why should we set WHERE clauses in an EXISTS subquery instead of a JOIN? +----------------------------------------------------------------------------------------------------- As we described above, the doctrine builder is converted into a sql query. Let's see how to compute the "number of course which count at least one activity type with the id 7". For the purpose of this demonstration, we will restrict this on @@ -253,79 +197,65 @@ two accompanying period only: the ones with id 329 and 334. Let's see the list of activities associated with those accompanying period: -.. code-block:: sql - SELECT id, accompanyingperiod_id, type_id FROM activity WHERE accompanyingperiod_id IN (329, 334) AND type_id = 7 ORDER BY accompanyingperiod_id; We see that we have 6 activities for the accompanying period with id 329, and only one for the 334's one. -.. csv-table:: - :header: id, accompanyingperiod_id, type_id - - 990,329,7 - 986,329,7 - 987,329,7 - 993,329,7 - 991,329,7 - 992,329,7 - 1000,334,7 +| id | accompanyingperiod_id | type_id | +|------|----------------------|---------| +| 990 | 329 | 7 | +| 986 | 329 | 7 | +| 987 | 329 | 7 | +| 993 | 329 | 7 | +| 991 | 329 | 7 | +| 992 | 329 | 7 | +| 1000 | 334 | 7 | Let's calculate the average duration for those accompanying periods, and the number of period: -.. code-block:: sql - SELECT AVG(age(COALESCE(closingdate, CURRENT_DATE), openingdate)), COUNT(id) from chill_person_accompanying_period WHERE id IN (329, 334); The result of this query is: -.. csv-table:: - :header: AVG, COUNT +| AVG | COUNT | +|--------------------------------------------------|-------| +| 2 years 2 mons 21 days 12 hours 0 mins 0.0 secs | 2 | - 2 years 2 mons 21 days 12 hours 0 mins 0.0 secs,2 - -Now, we count the number of accompanying period, adding a :code:`JOIN` clause which make a link to the :code:`activity` table, and add a :code:`WHERE` clause to keep +Now, we count the number of accompanying period, adding a `JOIN` clause which make a link to the `activity` table, and add a `WHERE` clause to keep only the accompanying period which contains the given activity type: -.. code-block:: sql - SELECT COUNT(chill_person_accompanying_period.id) from chill_person_accompanying_period JOIN activity ON chill_person_accompanying_period.id = activity.accompanyingperiod_id WHERE chill_person_accompanying_period.id IN (329, 334) AND activity.type_id = 7; -What are the results here ? +What are the results here? -.. csv-table:: - :header: COUNT +| COUNT | +|-------| +| 7 | - 7 - -:code:`7` ! Why this result ? Because the number of lines is duplicated for each activity. Let's see the list of rows which +`7` ! Why this result? Because the number of lines is duplicated for each activity. Let's see the list of rows which are taken into account for the computation: -.. code-block:: sql - SELECT chill_person_accompanying_period.id, activity.id from chill_person_accompanying_period JOIN activity ON chill_person_accompanying_period.id = activity.accompanyingperiod_id WHERE chill_person_accompanying_period.id IN (329, 334) AND activity.type_id = 7; -.. csv-table:: - :header: accompanyingperiod.id, activity.id +| accompanyingperiod.id | activity.id | +|----------------------|-------------| +| 329 | 993 | +| 334 | 1000 | +| 329 | 987 | +| 329 | 990 | +| 329 | 991 | +| 329 | 992 | +| 329 | 986 | - 329,993 - 334,1000 - 329,987 - 329,990 - 329,991 - 329,992 - 329,986 - -For each activity, a row is created and, as we count the number of non-null :code:`accompanyingperiod.id` columns, we +For each activity, a row is created and, as we count the number of non-null `accompanyingperiod.id` columns, we count one entry for each activity (actually, we count the number of activities). -So, let's use the :code:`DISTINCT` keyword to count only once the equal ids: - -.. code-block:: +So, let's use the `DISTINCT` keyword to count only once the equal ids: SELECT COUNT(DISTINCT chill_person_accompanying_period.id) from chill_person_accompanying_period JOIN activity ON chill_person_accompanying_period.id = activity.accompanyingperiod_id @@ -333,38 +263,31 @@ So, let's use the :code:`DISTINCT` keyword to count only once the equal ids: Now, it works again... -.. csv-table:: - :header: COUNT +| COUNT | +|-------| +| 2 | - 2 +But, for the average duration, this won't work: the duration which are equals (because the `openingdate` is the same and +`closingdate` is still `NULL`, for instance) will be counted only once, which will give unexpected result. -But, for the average duration, this won't work: the duration which are equals (because the :code:`openingdate` is the same and -:code:`closingdate` is still :code:`NULL`, for instance) will be counted only once, which will give unexpected result. - -The solution is to move the condition "having an activity with activity type with id 7" in a :code:`EXISTS` clause: - -.. code-block:: sql +The solution is to move the condition "having an activity with activity type with id 7" in a `EXISTS` clause: SELECT COUNT(chill_person_accompanying_period.id) from chill_person_accompanying_period WHERE chill_person_accompanying_period.id IN (329, 334) AND EXISTS (SELECT 1 FROM activity WHERE type_id = 7 AND accompanyingperiod_id = chill_person_accompanying_period.id); -The result is correct without :code:`DISTINCT` keyword: +The result is correct without `DISTINCT` keyword: -.. csv-table:: - :header: COUNT - - 2 +| COUNT | +|-------| +| 2 | And we can now compute the average duration without fear: -.. code-block:: sql - SELECT AVG(age(COALESCE(closingdate, CURRENT_DATE), openingdate)) from chill_person_accompanying_period WHERE chill_person_accompanying_period.id IN (329, 334) AND EXISTS (SELECT 1 FROM activity WHERE type_id = 7 AND accompanyingperiod_id = chill_person_accompanying_period.id); Give the result: -.. csv-table:: - :header: AVG - - 2 years 2 mons 21 days 12 hours 0 mins 0.0 secs +| AVG | +|--------------------------------------------------| +| 2 years 2 mons 21 days 12 hours 0 mins 0.0 secs | diff --git a/docs/source/development/forms.md b/docs/source/development/forms.md new file mode 100644 index 000000000..40ed3d9e2 --- /dev/null +++ b/docs/source/development/forms.md @@ -0,0 +1,25 @@ +# Forms and form types + +###### Date picker + +Class + `Chill\MainBundle\Form\Type\ChillDateType` +Extend + `Symfony\Component\Form\Extension\Core\Type\DateType` + +Usage : + +``` + use Chill\MainBundle\Form\Type\ChillDateType; + + $builder->add('date' ChillDateType::class); +``` + +###### Text editor + +Add a text editor (by default). + +Class + `Chill\MainBundle\Form\Type\ChillTextareaType` +Options + * `disable_editor` to disable text editor diff --git a/docs/source/development/forms.rst b/docs/source/development/forms.rst deleted file mode 100644 index 038240664..000000000 --- a/docs/source/development/forms.rst +++ /dev/null @@ -1,42 +0,0 @@ - -.. Copyright (C) 2014 Champs Libres Cooperative SCRLFS - Permission is granted to copy, distribute and/or modify this document - under the terms of the GNU Free Documentation License, Version 1.3 - or any later version published by the Free Software Foundation; - with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. - A copy of the license is included in the section entitled "GNU - Free Documentation License". - -.. _forms: - -Forms and form types -#################### - -Date picker -*********** - -Class - :class:`Chill\MainBundle\Form\Type\ChillDateType` -Extend - :class:`Symfony\Component\Form\Extension\Core\Type\DateType` - - -Usage : - -.. code-block:: php - - use Chill\MainBundle\Form\Type\ChillDateType; - - $builder->add('date' ChillDateType::class); - -Text editor -*********** - -Add a text editor (by default). - -Class - :class:`Chill\MainBundle\Form\Type\ChillTextareaType` -Options - * :code:`disable_editor` to disable text editor - - diff --git a/docs/source/development/index.md b/docs/source/development/index.md new file mode 100644 index 000000000..bb685dbd2 --- /dev/null +++ b/docs/source/development/index.md @@ -0,0 +1,43 @@ +# Development + +As Chill relies on the [symfony ](http://symfony.com) framework, reading the framework's documentation should answer most of your questions. We are explaining here some tips to work with Chill, and help with things we've encountered. + +- [Instructions to create a new bundle](create-a-new-bundle.md) +- [CRUD (Create - Update - Delete) for one entity](crud.md) +- [Helpers for building a REST API](api.md) +- [Routing](routing.md) +- [Menus](menus.md) +- [Forms](forms.md) +- [Access control model](access_control_model.md) +- [Messages to users](messages-to-users.md) +- [Pagination](pagination.md) +- [Localisation](localisation.md) +- [Translation directives](translation_directives.md) +- [Translation provider](translation_provider.md) +- [Logging](logging.md) +- [Database migrations](migrations.md) +- [Searching](searching.md) +- [Timelines](timelines.md) +- [Exports](exports.md) +- [Embeddable comments](embeddable-comments.md) +- [Run tests](run-tests.md) +- [ESLint](es-lint.md) +- [Useful snippets](useful-snippets.md) +- [Manual](manual/index.md) +- [Assets](assets.md) +- [Cron Jobs](cronjob.md) +- [Info about entities](entity-info.md) +- [Info about database (in French)](database-principles.md) +- [Developer FAQ](FAQ.md) + +###### Layout and UI + +- [Render entities automatically](render-entity.md) +- [Layout / Template usage](user-interface/layout-template-usage.md) +- [Classes and mixins](user-interface/css-classes.md) +- [Widgets](user-interface/widgets.md) +- [Javascript function](user-interface/js-functions.md) + +###### Help, I am lost! + +Write an email at info@champs-libres.coop, and we will help you! diff --git a/docs/source/development/index.rst b/docs/source/development/index.rst deleted file mode 100644 index d3aefc1b0..000000000 --- a/docs/source/development/index.rst +++ /dev/null @@ -1,59 +0,0 @@ -.. Copyright (C) 2014 Champs Libres Cooperative SCRLFS - Permission is granted to copy, distribute and/or modify this document - under the terms of the GNU Free Documentation License, Version 1.3 - or any later version published by the Free Software Foundation; - with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. - A copy of the license is included in the section entitled "GNU - Free Documentation License". - -Development -########### - -As Chill relies on the `symfony `_ framework, reading the framework's documentation should answer most of your questions. We are explaining here some tips to work with Chill, and help with things we've encountered. - -.. toctree:: - :maxdepth: 2 - - Instructions to create a new bundle - CRUD (Create - Update - Delete) for one entity - Helpers for building a REST API - Routing - Menus - Forms - Access control model - Messages to users - Pagination - Localisation - Logging - Database migrations - Searching - Timelines - Exports - Embeddable comments - Run tests - ESLint - Useful snippets - manual/index.rst - Assets - Cron Jobs - Info about entities - Info about database (in French) - Developer FAQ - -Layout and UI -************** - -.. toctree:: - :maxdepth: 2 - - Render entities automatically - Layout / Template usage - Classes and mixins - Widgets - Javascript function - - -Help, I am lost ! -***************** - -Write an email at info@champs-libres.coop, and we will help you ! diff --git a/docs/source/development/localisation.md b/docs/source/development/localisation.md new file mode 100644 index 000000000..b412954d5 --- /dev/null +++ b/docs/source/development/localisation.md @@ -0,0 +1,30 @@ +###### Localisation + +## Language in url + +Language should be present in URL, conventionally as the first argument. + + `/fr/your/url/here` + +This allows users to change from one language to another one on each page, which may be useful in multilanguages teams. If the installation is single-language, the language switcher will not appears. + +This is an example of routing defined in YAML : + +```yaml + chill_person_general_edit: + pattern: /{_locale}/person/{person_id}/general/edit + defaults: {_controller: ChillPersonBundle:Person:edit } +``` + +## Date and time + +The [Intl extension ](http://twig.sensiolabs.org/doc/extensions/intl.html) is enabled on the Chill application. + +You may format date and time using the [localizeddate` function : + + `date|localizeddate('long', 'none')` + +By default, we prefer using the `long` format for date formatting. + + [Documentation for Intl Extension](http://twig.sensiolabs.org/doc/extensions/intl.html) + Read the complete doc for the Intl extension. diff --git a/docs/source/development/localisation.rst b/docs/source/development/localisation.rst deleted file mode 100644 index fca5ad8ba..000000000 --- a/docs/source/development/localisation.rst +++ /dev/null @@ -1,49 +0,0 @@ -.. Copyright (C) 2014 Champs Libres Cooperative SCRLFS - Permission is granted to copy, distribute and/or modify this document - under the terms of the GNU Free Documentation License, Version 1.3 - or any later version published by the Free Software Foundation; - with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. - A copy of the license is included in the section entitled "GNU - Free Documentation License". - -Localisation -************* - -Language in url -=============== - -Language should be present in URL, conventionnaly as first argument. - -.. code-block:: none - - /fr/your/url/here - -This allow users to change from one language to another one on each page, which may be useful in multilanguages teams. If the installation is single-language, the language switcher will not appears. - -This is an example of routing defined in yaml : - -.. code-block:: yaml - - chill_person_general_edit: - pattern: /{_locale}/person/{person_id}/general/edit - defaults: {_controller: ChillPersonBundle:Person:edit } - - -Date and time -============== - -The `Intl extension `_ is enabled on the Chill application. - -You may format date and time using the `localizeddate` function : - -.. code-block:: jinja - - date|localizeddate('long', 'none') - -By default, we prefer using the `long` format for date formatting. - -.. seealso:: - - `Documentation for Intl Extension `_ - Read the complete doc for the Intl extension. - diff --git a/docs/source/development/logging.rst b/docs/source/development/logging.md similarity index 68% rename from docs/source/development/logging.rst rename to docs/source/development/logging.md index 5cace02c7..6cd0b0a20 100644 --- a/docs/source/development/logging.rst +++ b/docs/source/development/logging.md @@ -1,50 +1,41 @@ -.. Copyright (C) 2016 Champs Libres Cooperative SCRLFS - Permission is granted to copy, distribute and/or modify this document +Permission is granted to copy, distribute and/or modify this document under the terms of the GNU Free Documentation License, Version 1.3 or any later version published by the Free Software Foundation; with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. A copy of the license is included in the section entitled "GNU Free Documentation License". +###### Logging -Logging -******* -.. seealso:: - - Symfony documentation: `How to user Monolog to write logs `_ + Symfony documentation: [How to user Monolog to write logs ](http://symfony.com/doc/current/cookbook/logging/monolog.html) The symfony cookbook page about logging. +A channel for custom logging has been created to store sensitive data. -A channel for custom logging has been created to store sensitive data. - -The channel is named ``chill``. +The channel is named ``chill``. The installer of chill should be aware that this channel may contains sensitive data and encrypted during backup. -Logging to channel `chill` -============================ +## Logging to channel `chill` -You should use the service named ``chill.main.logger``, as this : +You should use the service named ``chill.main.logger``, as this : -.. code-block:: php + `$logger = $this->get('chill.main.logger');` - $logger = $this->get('chill.main.logger'); +You should store data into context, not in the log himself, which should remains the same for the action. -You should store data into context, not in the log himself, which should remains the same for the action. - -Example of usage : - -.. code-block:: php +Example of usage : +```php $logger->info("An action has been performed about a person", array( 'person_lastname' => $person->getLastName(), 'person_firstname' => $person->getFirstName(), 'person_id' => $person->getId(), 'by_user' => $user->getUsername() )); +``` For further processing, it is a good idea to separate all fields (like firstname, lastname, ...) into different context keys. By convention, you should store the username of the user performing the action under the ``by_user`` key. - diff --git a/docs/source/development/manual/index.rst b/docs/source/development/manual/index.md similarity index 58% rename from docs/source/development/manual/index.rst rename to docs/source/development/manual/index.md index d93fa3a7e..070db381f 100644 --- a/docs/source/development/manual/index.rst +++ b/docs/source/development/manual/index.md @@ -1,17 +1,10 @@ -.. Copyright (C) 2014 Champs Libres Cooperative SCRLFS - Permission is granted to copy, distribute and/or modify this document +Permission is granted to copy, distribute and/or modify this document under the terms of the GNU Free Documentation License, Version 1.3 or any later version published by the Free Software Foundation; with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. A copy of the license is included in the section entitled "GNU Free Documentation License". -Developer manual -***************** - -.. toctree:: - :maxdepth: 2 - - routing-and-menus.rst - +###### Developer manual +- [Routing and Menus](routing-and-menus.md) diff --git a/docs/source/development/manual/routing-and-menus.rst b/docs/source/development/manual/routing-and-menus.md similarity index 55% rename from docs/source/development/manual/routing-and-menus.rst rename to docs/source/development/manual/routing-and-menus.md index c3b8b61cb..36eb34b83 100644 --- a/docs/source/development/manual/routing-and-menus.rst +++ b/docs/source/development/manual/routing-and-menus.md @@ -1,143 +1,103 @@ -.. Copyright (C) 2014 Champs Libres Cooperative SCRLFS - Permission is granted to copy, distribute and/or modify this document - under the terms of the GNU Free Documentation License, Version 1.3 - or any later version published by the Free Software Foundation; - with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. - A copy of the license is included in the section entitled "GNU - Free Documentation License". +###### Routing and menus -Routing and menus -***************** - - -The *Chill*'s architecture allows to choose bundle on each installation. This may lead to a huge diversity of installations, and a the developper challenge is to make his code working with all those possibles installations. +The architecture of *Chill* allows choosing a bundle on each installation. This may lead to a huge diversity of installations, and the developper challenge is to make his code work with all those possibles installations. *Chill* uses menus to let users access easily to the most used functionalities. For instance, when you land on a "Person" page, you may access directly to his activities, notes, documents, ... in a single click on a side menu. For a developer, it is easy to extend this menu with his own entries. -.. seealso:: - - `Symfony documentation about routing `_ + [Symfony documentation about routing ](http://symfony.com/doc/current/book/routing.html) This documentation should be read before diving into those lines - `Routes dans Chill `_ (FR) + [Routes dans Chill ](https://redmine.champs-libres.coop/issues/179) (FR) The issue where we discussed routes. In French. -Create routes -============== +## Create routes -.. note:: + We recommend using `yaml` to define routes. We have not tested the existing other ways to create routes (annotations, ...). Help wanted. - We recommand using `yaml` to define routes. We have not tested the existing other ways to create routes (annotations, ...). Help wanted. - -The first step is as easy as create a route in symfony, and add some options in his description : - -.. code-block:: yaml +The first step is as easy as creating a route in symfony, and add some options in his description : +```yaml +#src/CL/ChillBundle/Resources/config/routing.yml chill_main_dummy_0: pattern: /dummy/{personId} defaults: { _controller: CLChillMainBundle:Default:index } options: #we begin menu information here : - menus: - foo: #must appears in menu named 'foo' + menus: + foo: #must appear in a menu named 'foo' order: 500 #the order will be '500' - label: foolabel #the label shown on menu. Will be translated - otherkey: othervalue #you may add other informations, as needed by your layout - bar: #must also appears in menu named 'bar' + label: foolabel #the label shown on a menu. Will be translated + otherkey: othervalue #you may add other information, as needed by your layout + bar: #must also appear in a menu named 'bar' order: 500 label: barlabel +``` -The mandatory parameters under the `menus` definition are : +The mandatory parameters under the `menus` definition are : -* `name`: the menu's name, defined as an key for the following entries -* `order`. Note: if we have duplicate order's values, the order will be incremented. We recommand using big intervals within orders and publishing the orders in your documentation -* `label`: the text which will be rendered inside the `` tag. The label should be processed trough the `trans` filter (`{{ route.label|trans }}`) +* `name`: the menu's name, defined as a key for the following entries +* `order`. Note: if we have duplicate order's values, the order will be incremented. We recommend using big intervals within orders and publishing the orders in your documentation +* `label`: the text which will be rendered inside the `[ tag. The label should be processed trough the `trans` filter (`{{ route.label|trans }}`) -You *may* also add other keys, which will be used optionally in the way the menu is rendered. See +You *may* also add other keys, which will be used optionally in the way the menu is rendered. See -.. warning:: + Although all keys will be kept from your `yaml` definition to your menu template, we recommend not using those keys, which are reserved for a future implementations of Chill : - Although all keys will be kept from your `yaml` definition to your menu template, we recommend not using those keys, which are reserved for a future implementations of Chill : + * `helper`, a text to help user or add more information to him + * `access` : which will run a test with `Expression Langage ](http://symfony.com/doc/current/components/expression_language/index.html) to determine if the user has the ACL to show the menu entry ; + * `condition`, which will test with the menu context if the entry must appear - * `helper`, a text to help user or add more informations to him - * `access` : which will run a test with `Expression Langage `_ to determine if the user has the ACL to show the menu entry ; - * `condition`, which will test with the menu context if the entry must appears - -Show menu in twig templates -=========================== +## Show menu in twig templates To show our previous menu in the twig template, we invoke the `chill_menu` function. This will render the `foo` menu : -.. code-block:: jinja + `{{ chill_menu('foo') }}` - {{ chill_menu('foo') }} - -Passing variables -^^^^^^^^^^^^^^^^^ +##### Passing variables If your routes need arguments, i.e. an entity id, you should pass the as argument to the chill_menu function. If your route's pattern is `/person/{personId}`, your code become : -.. code-block:: jinja - - {{ chill_menu('foo', { 'args' : { 'personId' : person.id } } ) }} + `{{ chill_menu('foo', { 'args' : { 'personId' : person.id } } ) }}` Of course, `person` is a variable you must define in your code, which should have an `id` accessible property (i.e. : `$person->getId()`). -.. note:: - Be aware that your arguments will be passed to all routes in a menu. If a route does not require `personId` in his pattern, the route will become `/pattern?personId=XYZ`. This should not cause problem in your application. -.. warning:: - - It is a good idea to reuse the same parameter's name in your pattern, to avoid collision. Prefer `/person/{personId}` to `/person/{id}`. + It is a good idea to reuse the same parameter's name in your pattern, to avoid collision. Prefer `/person/{personId}` to `/person/{id}`. If you don't do that and another developer create a bundle with `person/{personId}/{id}` where `{id}` is the key for something else, this will cause a lot of trouble... -Rendering active entry -^^^^^^^^^^^^^^^^^^^^^^ +##### Rendering active entry -Now, you want to render differently the *active* route of the menu [#f1]_. You should, in your controller or template, add the active route in your menu : +Now, you want to render differently the *active* route of the menu [#f1]_. You should, in your controller or template, add the active route in your menu : -.. code-block:: jinja - - {{ chill_menu('foo', { 'activeRouteKey' : 'chill_main_dummy_0' } ) }} + `{{ chill_menu('foo', { 'activeRouteKey' : 'chill_main_dummy_0' } ) }}` On menu creation, the route wich has the key `chill_main_dummy_0` will be rendered on a different manner. -Define your own template -------------------------- +### Define your own template By default, the menu is rendered with the default template, which is a simple `ul` list. You may create your own templates : -.. code-block:: html+jinja - +``` #MyBundle/Resources/views/Menu/MyMenu.html.twig +``` Arguments available in your template : * The `args` value are the value passed in the 'args' arguments requested by the `chill_menu` function. -* `activeRouteKey` is the key of the currently active route. +* `activeRouteKey` is the key of the currently active route. * `routes` is an array of routes. The array has this structure: `routes[order] = { 'key' : 'the_route_key', 'label' : 'the route label' }` The order is *resolved*: in case of collision (two routes from different bundles having the same order), the order will be incremented. You may find in the array your own keys (`{ 'otherkey' : 'othervalue'}` in the example above). Then, you will call your own template with the `layout` argument : -.. code-block:: jinja - - {{ chill_menu('foo', { 'layout' : 'MyBundle:Menu:MyMenu.html.twig' } ) }} - -.. note:: + `{{ chill_menu('foo', { 'layout' : 'MyBundle:Menu:MyMenu.html.twig' } ) }}` Take care of specifying the absolute path to layout in the function. - - - -.. rubric:: Footnotes - -.. [#f1] In the default template, the currently active entry will be rendered with an "active" class : `
  • ...
  • ` diff --git a/docs/source/development/menus.rst b/docs/source/development/menus.md similarity index 52% rename from docs/source/development/menus.rst rename to docs/source/development/menus.md index 977591f1d..e4904ac54 100644 --- a/docs/source/development/menus.rst +++ b/docs/source/development/menus.md @@ -1,44 +1,25 @@ -.. Copyright (C) 2014 Champs Libres Cooperative SCRLFS - Permission is granted to copy, distribute and/or modify this document - under the terms of the GNU Free Documentation License, Version 1.3 - or any later version published by the Free Software Foundation; - with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. - A copy of the license is included in the section entitled "GNU - Free Documentation License". - -.. _menus : - -Menus -***** +###### Menus Chill has created his own menu system -.. seealso:: - - `Routes dans Chill [specification] `_ + [Routes dans Chill [specification] ](https://redmine.champs-libres.coop/issues/179) The issue wich discussed the implementation of routes. -Concepts -======== - -.. warning:: +## Concepts to be written - - -Add a menu in a template -======================== +## Add a menu in a template In your twig template, use the `chill_menu` function : -.. code-block:: html+jinja - +```php {{ chill_menu('person', { 'layout': 'ChillPersonBundle::menu.html.twig', - 'args' : {'id': person.id }, + 'args': {'id': person.id }, 'activeRouteKey': 'chill_person_view' }) }} +``` The available arguments are: @@ -46,78 +27,61 @@ The available arguments are: * `args` : those arguments will be passed through the url generator. * `activeRouteKey` must be the route key name. -.. note:: - The argument `activeRouteKey` may be a twig variable, defined elsewhere in your template, even in child templates. - - -Create an entry in an existing menu -=================================== +## Create an entry in an existing menu If a route belongs to a menu, you simply add this to his definition in routing.yml : -.. code-block:: yaml - +```yaml chill_person_history_list: pattern: /person/{person_id}/history defaults: { _controller: ChillPersonBundle:History:list } options: #declare menus - menus: - # the route should be in 'person' menu : + menus: + # the route should be in the 'person' menu : person: #and have those arguments : order: 100 label: menu.person.history +``` -* `order` (mandatory) : the order in the menu. It is preferrable to increment by far more than 1. -* `label` (mandatory) : a translatable string. -* `helper` (optional) : a text to help people to understand what does the menu do. Not used in default implementation. -* `condition` (optional) : an `Expression Language `_ which will make the menu appears or not. Typically, it may be used to say "show this menu only if the person concerned is more than 18". **Not implemented yet**. -* `access` (optional) : an Expression Language to evalute the possibility, for the user, to show this menu according to Access Control Model. **Not implemented yet.** +* `order` (mandatory): the order in the menu. It is preferrable to increment by far more than 1. +* `label` (mandatory): a translatable string. +* `helper` (optional): a text to help people to understand what does the menu do. Not used in default implementation. +* `condition` (optional): an `Expression Language `_ which will make the menu appears or not. Typically, it may be used to say "show this menu only if the person concerned is more than 18". **Not implemented yet**. +* `access` (optional): an Expression Language to evalute the possibility, for the user, to show this menu according to Access Control Model. **Not implemented yet.** You may add additional keys, but should not use the keys described above. You may add the same route to multiple menus : -.. code-block:: yaml - +```yaml chill_person_history_list: pattern: /person/{person_id}/history defaults: { _controller: ChillPersonBundle:History:list } options: - menus: + menus: menu1: order: 100 label: menu.person.history menu2: order: 100 label: another.label +``` - - -Customize menu rendering -======================== +## Customize menu rendering You may customize menu rendering by using the `layout` option. -.. warning :: - - TODO : this part should be written. + TODO: this part should be written. - - - - -.. _caveats : - -Caveats -======= +## Caveats Currently, you may pass arguments globally to each menu, and they will be all passed to route url. This means that : -* the argument name in the route entry must match the argument key in menu declaration in twig template +* the argument name in the route entry must match the argument key in the menu declaration in the twig template * if an argument is missing to generate an url, the url generator will throw a `Symfony\Component\Routing\Exception\MissingMandatoryParametersException` * if the argument name is not declared in route entry, it will be added to the url, (example: `/my/route?additional=foo`) diff --git a/docs/source/development/messages-to-users.md b/docs/source/development/messages-to-users.md new file mode 100644 index 000000000..dabbf1ae5 --- /dev/null +++ b/docs/source/development/messages-to-users.md @@ -0,0 +1,93 @@ +###### Messages to users, flashbags, and buttons + +## Flashbags + +The four following levels are defined : + ++-----------+----------------------------------------------------------------------------------------------+ +|Key|Intent | +|success|The user action succeeds. | +|notice|A simple message to give information to the user. The message may be linked or not linked with | +||the user action. | +|error|The user's action failed: he must correct something to process the action. | + +We can use [TranslatableMessage` (and other `TranslatableMessageInterface` instances) into the controller: + +```php + // in a controller action: + if (($session = $request->getSession()) instanceof Session) { + $session->getFlashBag()->add( + 'success', + new TranslatableMessage('saved_export.Saved export is saved!') + ); + } +``` + + `Flash Messages on Symfony documentation ](http://symfony.com/doc/current/book/controller.html#flash-messages) + Learn how to use flash messages in a controller. + +## Buttons + +Some actions are available to decorate ``a`` links and ``buttons``. + +To add the action on button, use them as class along with ``sc-button`` : + + Create an entity + + + ++-----------+----------------+------------------------------------------------------------------------------+ +| Action | Class | Description | +| Submit | ``bt-submit`` | Submit a form. Use only if the action is not "save." | +| Create | ``bt-create`` | - Link to a form to create an entity (alias: ``bt-new``) | +| | or ``bt-new`` | - Submitting this form will create a new entity | +| Reset | ``bt-reset`` | Reset a form | +| Delete | ``bt-delete`` | - Link to a form to delete an entity | +| | | - Submitting this form will remove the entity | +| Edit | ``bt-edit`` or | Link to a form to edit an entity | +| | ``bt-update`` | | +| Save | ``bt-save`` | Submitting this form will save change on the entity | +| Action | ``bt-action`` | Generic link to an action | +| Cancel | ``bt-cancel`` | Cancel an action and go back to another page | + +### Styling buttons + +Small buttons, mainly to use inline + + `

    You button

    ` + +You can omit content and show a button with an icon only : + + `` + +You can hide content and show it only on hover + + `Showed when mouse pass on` + +You can customize the icon : + + `Button with custom icon` + +### Grouping buttons + +Grouping buttons can be done using ``ul.record_actions`` element (an ``ul`` list with class ``record_actions``): + +```html + +``` + +The element with the ``cancel`` class will be set in first position. + +Inside the table, the space between elements will be shorter. + +You can add the class ``record_actions_small`` if you want shorter space between elements. diff --git a/docs/source/development/messages-to-users.rst b/docs/source/development/messages-to-users.rst deleted file mode 100644 index 244638d0b..000000000 --- a/docs/source/development/messages-to-users.rst +++ /dev/null @@ -1,136 +0,0 @@ -.. Copyright (C) 2014 Champs Libres Cooperative SCRLFS - Permission is granted to copy, distribute and/or modify this document - under the terms of the GNU Free Documentation License, Version 1.3 - or any later version published by the Free Software Foundation; - with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. - A copy of the license is included in the section entitled "GNU - Free Documentation License". - -Messages to users, flashbags and buttons -**************************************** - - -.. _flashbags : - -Flashbags -========== - -The four following levels are defined : - -+-----------+----------------------------------------------------------------------------------------------+ -|Key |Intent | -+===========+==============================================================================================+ -|success |The user action succeeds. | -+-----------+----------------------------------------------------------------------------------------------+ -|notice |A simple message to give information to the user. The message may be linked or not linked with| -| |the user action. | -+-----------+----------------------------------------------------------------------------------------------+ -|error |The user's action failed: he must correct something to process the action. | -+-----------+----------------------------------------------------------------------------------------------+ - -We can use :code:`TranslatableMessage` (and other :code:`TranslatableMessageInterface` instances) into the controller: - -.. code-block:: php - - // in a controller action: - if (($session = $request->getSession()) instanceof Session) { - $session->getFlashBag()->add( - 'success', - new TranslatableMessage('saved_export.Saved export is saved!') - ); - } - -.. seealso:: - - `Flash Messages on Symfony documentation `_ - Learn how to use flash messages in controller. - - -Buttons -======== - -Some actions are available to decorate ``a`` links and ``buttons``. - -To add the action on button, use them as class along with ``sc-button`` : - -.. code-block:: html - - Create an entity - - - -+-----------+----------------+------------------------------------------------------------------------------+ -| Action | Class | Description | -+===========+================+==============================================================================+ -| Submit | ``bt-submit`` | Submit a form. Use only if action is not "save". | -+-----------+----------------+------------------------------------------------------------------------------+ -| Create | ``bt-create`` | - Link to a form to create an entity (alias: ``bt-new``) | -| | or ``bt-new`` | - Submitting this form will create a new entity | -+-----------+----------------+------------------------------------------------------------------------------+ -| Reset | ``bt-reset`` | Reset a form | -+-----------+----------------+------------------------------------------------------------------------------+ -| Delete | ``bt-delete`` | - Link to a form to delete an entity | -| | | - Submitting this form will remove the entity | -+-----------+----------------+------------------------------------------------------------------------------+ -| Edit | ``bt-edit`` or | Link to a form to edit an entity | -| | ``bt-update`` | | -+-----------+----------------+------------------------------------------------------------------------------+ -| Save | ``bt-save`` | Submitting this form will save change on the entity | -+-----------+----------------+------------------------------------------------------------------------------+ -| Action | ``bt-action`` | Generic link to an action | -+-----------+----------------+------------------------------------------------------------------------------+ -| Cancel | ``bt-cancel`` | Cancel an action and go back to another page | -+-----------+----------------+------------------------------------------------------------------------------+ - -Styling buttons ---------------- - -Small buttons, mainly to use inline - -.. code-block:: html - -

    You button

    - -You can omit content and show a button with an icon only : - -.. code-block:: html - - - -You can hide content and show it only on hover - -.. code-block:: html - - Showed when mouse pass on - -You can customize the icon : - -.. code-block:: html - - Button with custom icon - -Grouping buttons ----------------- - -Grouping buttons can be done using ``ul.record_actions`` element (an ``ul`` list with class ``record_actions``): - -.. code-block:: html - - - -The element with the ``cancel`` class will be set in first position. - -Inside table, the space between elements will be shorter. - -You can add the class ``record_actions_small`` if you want shorter space between elements. - diff --git a/docs/source/development/migrations.md b/docs/source/development/migrations.md new file mode 100644 index 000000000..83ce52c67 --- /dev/null +++ b/docs/source/development/migrations.md @@ -0,0 +1,70 @@ +###### Database Migrations + +Every bundle potentially brings his own database operations: to persist entities, developers have to create database schema, creating indexes,... + +Those schemas might be changed (the less is the better) from time to time. + +Consequence: each bundle should bring his own migration files, which will bring the database consistent with the operation you will run in your code. They will be gathered into the app installation, ready to be executed by the chill's installer. + +Currently, we use `doctrine migration`_ to manage those migration files. A `composer`_ script located in the **chill standard** component will copy the migrations from your bundle to the doctrne migration's excepted directory after each install and/or update operation. + + The `doctrine migration`_ documentation + Learn concepts about migration files and scripts and the doctrine ORM + + The `doctrine migration bundle`_ documentation + Learn about doctrine migration integration with a Symfony framework + +## Shipping migration files + +Migration files should be shipped under the Resource/migrations directory. You could customize the migration directory by adding extra information in your composer.json: + +``` + "extra": { + "migration-source": "path/to/my/dir" + } +``` + +The class namespace should be `Application\Migrations`, as expected by doctrine migration. Only the files which will be executed by doctrine migration will be moved: they must have the pattern `VersionYYYYMMDDHHMMSS.php` where YYYY is the year, MM the month, DD the day, HH the hour, MM the month and SS the second of creation. + +They will be moved automatically by composer when you install or update a bundle. + +## Executing migration files + +The installers will have to execute migration files manually, running + + `php app/console doctrine:migrations:status #will give the current status of the database` + `php app/console doctrine:migrations:migrate #process the update` + +## Updating migration files + + After an installation, migration files will be executed and registered as executed in the database (the version timestamp is recorded into the :title:`migrations_versions` table). If you update your migration file code, the file will still be considered as "executed" by doctrine migration, which will not offers the possibility to run the migration again. + + Consequently, updating a migration file should only be considered during the development phase and not published on public git branches. If you want to edit your database schema, you should create a new migration file, with a new timestamp, which will proceed to your schema adaptations. + +Every time a migration file is discovered, the composer script will check if the migration exists in the local migration directory. If yes, the script will compare two file for changes (using a md5 hash). If migrations are discovered, the script will ask the installer to know if he must replace the file or ignore it. + + You can manually run a composer script by launching `composer run-script post-update-cmd` from your root chill installation's directory. + +## Tips for development + +### Migration and data + +Each time you create a migration script, you should ensure that it will not lead to data losing. Eventually, feel free to use intermediate steps. + +### Generation + +You can generate a migration file from the command line, using those commands: + +* `php app/console doctrine:migrations:diff` to generate a migration file by comparing your current database to your mapping information +* `php app/console doctrine:migrations:generate` to generate a blank migration file. + +Those files will be located into `app/DoctrineMigrations` directory. You will have to copy those file to your the directory `Resources/migrations` into your bundle directory. + +### Comments and documentation + +As files are copied from your bundle to the `app/DoctrineMigrations` directory, the link between your bundle and the copied file will be unclear. Please add all relevant documentation which will allow future developers to make a link between your file and your bundle. + +### Inside the script + +The script that moves the migration files to app directory `might be found here + diff --git a/docs/source/development/migrations.rst b/docs/source/development/migrations.rst deleted file mode 100644 index db4745bd3..000000000 --- a/docs/source/development/migrations.rst +++ /dev/null @@ -1,101 +0,0 @@ -.. Copyright (C) 2014 Champs Libres Cooperative SCRLFS - Permission is granted to copy, distribute and/or modify this document - under the terms of the GNU Free Documentation License, Version 1.3 - or any later version published by the Free Software Foundation; - with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. - A copy of the license is included in the section entitled "GNU - Free Documentation License". - -Database Migrations -******************** - -Every bundle potentially brings his own database operations : to persist entities, developers have to create database schema, creating indexes,... - -Those schema might be changed (the less is the better) from time to time. - -Consequence: each bundle should bring his own migration files, which will bring the database consistent with the operation you will run in your code. They will be gathered into the app installation, ready to be executed by the chill's installer. - -Currently, we use `doctrine migration`_ to manage those migration files. A `composer`_ script located in the **chill standard** component will copy the migrations from your bundle to the doctrne migration's excepted directory after each install and/or update operation. - -.. seealso:: - - The `doctrine migration`_ documentation - Learn concepts about migrations files and scripts and the doctrine ORM - - The `doctrine migration bundle`_ documentation - Learn about doctrine migration integration with Symfony framework - -Shipping migration files -======================== - -Migrations files should be shipped under the Resource/migrations directory. You could customize the migration directory by adding extra information in your composer.json: - -.. code-block:: json - - "extra": { - "migration-source": "path/to/my/dir" - } - -The class namespace should be `Application\Migrations`, as expected by doctrine migration. Only the files which will be executed by doctrine migration will be moved: they must have the pattern `VersionYYYYMMDDHHMMSS.php` where YYYY is the year, MM the month, DD the day, HH the hour, MM the month and SS the second of creation. - -They will be moved automatically by composer when you install or update a bundle. - -Executing migration files -========================== - -The installers will have to execute migrations files manually, running - -.. code-block:: bash - - php app/console doctrine:migrations:status #will give the current status of the database - php app/console doctrine:migrations:migrate #process the update - - -Updating migration files -========================= - -.. warning:: - - After an installation, migration files will be executed and registered as executed in the database (the version timestamp is recorded into the :title:`migrations_versions` table). If you update your migration file code, the file will still be considered as "executed" by doctrine migration, which will not offers the possibility to run the migration again. - - Consequently, updating migration file should only be considered during development phase, and not published on public git branches. If you want to edit your database schema, you should create a new migration file, with a new timestamp, which will proceed to your schema adaptations. - -Every time a migration file is discovered, the composer'script will check if the migration exists in the local migration directory. If yes, the script will compare two file for changes (using a md5 hash). If migrations are discovered, the script will ask the installer to know if he must replace the file or ignore it. - -.. note:: - - You can manually run composer script by launching `composer run-script post-update-cmd` from your root chill installation's directory. - - -.. _doctrine migration: http://www.doctrine-project.org/projects/migrations.html -.. _doctrine migration bundle : http://symfony.com/doc/master/bundles/DoctrineMigrationsBundle/index.html -.. _composer : https://getcomposer.org - -Tips for development -==================== - -Migration and data ------------------- - -Each time you create a migration script, you should ensure that it will not lead to data losing. Eventually, feel free to use intermediate steps. - -Generation ----------- - -You can generate migration file from the command line, using those commands: - -* `php app/console doctrine:migrations:diff` to generate a migration file by comparing your current database to your mapping information -* `php app/console doctrine:migrations:generate` to generate a blank migration file. - -Those files will be located into `app/DoctrineMigrations` directory. You will have to copy those file to your the directory `Resources/migrations` into your bundle directory. - -Comments and documentation --------------------------- - -As files are copied from your bundle to the `app/DoctrineMigrations` directory, the link between your bundle and the copied file will be unclear. Please add all relevant documentation which will allow future developers to make a link between your file and your bundle. - -Inside the script ------------------ - -The script which move the migrations files to app directory `might be found here - diff --git a/docs/source/development/pagination.md b/docs/source/development/pagination.md new file mode 100644 index 000000000..720678ccc --- /dev/null +++ b/docs/source/development/pagination.md @@ -0,0 +1,153 @@ +# Pagination + +The Bundle `Chill\MainBundle` provides a **Pagination** api which allow you to easily divide result list on different pages. + +###### A simple example + +In the controller, get the `Chill\Main\Pagination\PaginatorFactory` from the `Container` and use this `PaginatorFactory` to create a `Paginator` instance. + +```php + +Then, render the pagination using the dedicated twig function. + + {% extends "@ChillPerson/Person/layout.html.twig" %} + + {% block title 'Item list'|trans %} + + {% block content %} + + + + {# ... your items here... #} + +
    + + {% if items|length < paginator.getTotalItems %} + {{ chill_pagination(paginator) }} + {% endif %} +``` + +The function `chill_pagination` will, by default, render a link to the 10 previous page (if they exists) and the 10 next pages (if they exists). Assuming that we are on page 5, the function will render a list to :: + + Previous 1 2 3 4 **5** 6 7 8 9 10 11 12 13 14 Next + +## Understanding the magic + +### Where does the `$paginator` get the page number ? + +Internally, the `$paginator` object has a link to the `Request` object, and it reads the `page` parameter which contains the current page number. If this parameter is not present, the `$paginator` assumes that we are on page 1. + + The `$paginator` get the current page from the request. + +### Where does the `$paginator` get the number of items per page ? + +As above, the `$paginator` can get the number of items per page from the `Request`. If none is provided, this is given by the configuration which is, by default, 50 items per page. + +###### `PaginatorFactory`, `Paginator` and `Page` + +## `PaginatorFactory` + +The `PaginatorFactory` may create more than one `Paginator` in a single action. Those `Paginator` instance may redirect to different routes and/or routes parameters. + + // create a paginator for the route 'my_route' with some parameters (arg1 and arg2) + `$paginatorMyRoute = $paginatorFactory->create($total, 'my_route', array('arg1' => 'foo', 'arg2' => $bar);` +Those parameters will override the current parameters. + +The `PaginatorFactory` has also some useful shortcuts : + + // get current page number + `$paginatorFactory->getCurrentPageNumber()` + // get the number of items per page **for the current request** + `$paginatorFactory->getCurrentItemsPerPage()` + // get the number of the first item **for the current page** + `$paginatorFactory->getCurrentPageFirstItemNumber()` + +## Working with `Paginator` and `Page` + +The paginator has a function to give the number of pages that are required to display all the results and give some information about the number of items per page : + + // how many page count this paginator ? + `$paginator->countPages(); // return 20 in our example` + + // we may get the number of items per page + `$paginator->getItemsPerPage(); // return 20 in our example` + +A `Paginator` instance create instance of `Page`, each `Page`, which is responsible for generating the URL to the page number it represents. Here are some possibilities using `Page` and `Paginator` : + + Get the current page + `$page = $paginator->getCurrentPage();` + On which page are we? + `$page->getNumber(); // return 5 in this example (we are on page 5)` + Generate the url for page 5 + `$page->generateUrl(); // return '/?page=5` + What is the first item number on this page ? + `$page->getFistItemNumber(); // return 101 in our example (20 items per page)` + What is the last item number on this page? + `$page->getLastItemNumber(); // return 120 in our example` + + We can access directly the next and current page +``` +if ($paginator->hasNextPage()) { + $next = $paginator->getNextPage(); + } + if ($paginator->hasPreviousPage()) { + $previous = $paginator->getPreviousPage(); + } +``` + + We can access directly to a given page number +``` + $page10 = $paginator->getPage(10);`` + if ($paginator->hasPage(10)) { + $page10 = $paginator->getPage(10); + } +``` + + We can iterate over our pages through a generator +``` + foreach ($paginator->getPagesGenerator() as $page) { + $page->getNumber(); + } +``` + + Check that a page object is the current page + + `$paginator->isCurrentPage($page); // return false` + + When calling a page which does not exist, the [Paginator` will throw a `RuntimeException`. Example : + + Our last page is 10 + + `$paginator->getPage(99); // out of range => throw RuntimeException` + + Our current page is 1 (the first page) + `$paginator->getPreviousPage; // does not exists (the fist page is always 1) => throw RuntimeException` + + When you create a `Paginator` for the current route and route parameters, the `Page` instances will keep the same parameters and routes : + +```php + // assuming our route is 'my_route', for the pattern '/my/{foo}/route', + // and the current route is '/my/value/route?arg2=bar' +``` + +Create a paginator for the current route and route parameters : + `$paginator = $paginatorFactory->create($total);` + +Get the next page +``` +if ($paginator->hasNext()) { + $next = $paginator->getNextPage(); + + // get the route to the page + $page->generateUrl(); // will print 'my/value/route?arg2=bar&page=2' + } +``` +Having a look at the `full classes' documentation may provide some [ useful information ](http://api.chill.social/Chill-Main/master/namespace-Chill.MainBundle.Pagination.html). + +###### Customizing the rendering of twig's [chill_pagination` + +You can provide your own layout for rendering the pagination: provides your twig template as a second argument : + + {{ chill_pagination(paginator, 'MyBundle:Pagination:MyTemplate.html.twig') }} + +The template will receive the `$paginator` as `paginator` variable. Let's have a look `at the [ current template ](https://framagit.org/Chill-project/Chill-Main/blob/master/Resources/views/Pagination/long.html.twig). diff --git a/docs/source/development/pagination.rst b/docs/source/development/pagination.rst deleted file mode 100644 index a9df72805..000000000 --- a/docs/source/development/pagination.rst +++ /dev/null @@ -1,191 +0,0 @@ -.. Copyright (C) 2016 Champs Libres Cooperative SCRLFS - Permission is granted to copy, distribute and/or modify this document - under the terms of the GNU Free Documentation License, Version 1.3 - or any later version published by the Free Software Foundation; - with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. - A copy of the license is included in the section entitled "GNU - Free Documentation License". - - -.. _pagination-ref: - -Pagination -########## - -The Bundle :code:`Chill\MainBundle` provides a **Pagination** api which allow you to easily divide results list on different pages. - -A simple example -**************** - -In the controller, get the :code:`Chill\Main\Pagination\PaginatorFactory` from the `Container` and use this :code:`PaginatorFactory` to create a :code:`Paginator` instance. - - -.. literalinclude:: pagination/example.php - :language: php - - -Then, render the pagination using the dedicated twig function. - -.. code-block:: html+twig - - {% extends "@ChillPerson/Person/layout.html.twig" %} - - {% block title 'Item list'|trans %} - - {% block content %} - - - - {# ... your items here... #} - -
    - - {% if items|length < paginator.getTotalItems %} - {{ chill_pagination(paginator) }} - {% endif %} - - -The function :code:`chill_pagination` will, by default, render a link to the 10 previous page (if they exists) and the 10 next pages (if they exists). Assuming that we are on page 5, the function will render a list to :: - - Previous 1 2 3 4 **5** 6 7 8 9 10 11 12 13 14 Next - -Understanding the magic -======================= - -Where does the :code:`$paginator` get the page number ? -------------------------------------------------------- - -Internally, the :code:`$paginator` object has a link to the :code:`Request` object, and it reads the :code:`page` parameter which contains the current page number. If this parameter is not present, the :code:`$paginator` assumes that we are on page 1. - -.. figure:: /_static/puml/pagination-sequence.png - - The :code:`$paginator` get the current page from the request. - -Where does the :code:`$paginator` get the number of items per page ? --------------------------------------------------------------------- - -As above, the :code:`$paginator` can get the number of items per page from the :code:`Request`. If none is provided, this is given by the configuration which is, by default, 50 items per page. - -:code:`PaginatorFactory`, :code:`Paginator` and :code:`Page` -************************************************************ - -:code:`PaginatorFactory` -======================== - -The :code:`PaginatorFactory` may create more than one :code:`Paginator` in a single action. Those :code:`Paginator` instance may redirect to different routes and/or routes parameters. - -.. code-block:: php - - // create a paginator for the route 'my_route' with some parameters (arg1 and arg2) - $paginatorMyRoute = $paginatorFactory->create($total, 'my_route', array('arg1' => 'foo', 'arg2' => $bar); - -Those parameters will override the current parameters. - -The :code:`PaginatorFactory` has also some useful shortcuts : - -.. code-block:: php - - // get current page number - $paginatorFactory->getCurrentPageNumber( ) - // get the number of items per page **for the current request** - $paginatorFactory->getCurrentItemsPerPage( ) - // get the number of the first item **for the current page** - $paginatorFactory->getCurrentPageFirstItemNumber( ) - - -Working with :code:`Paginator` and :code:`Page` -=============================================== - -The paginator has some function to give the number of pages are required to displayed all the results, and give some information about the number of items per page : - -.. code-block:: php - - // how many page count this paginator ? - $paginator->countPages(); // return 20 in our example - - // we may get the number of items per page - $paginator->getItemsPerPage(); // return 20 in our example - -A :code:`Paginator` instance create instance of :code:`Page`, each :code:`Page`, which is responsible for generating the URL to the page number it represents. Here are some possibilities using :code:`Page` and :code:`Paginator` : - -.. code-block:: php - - // get the current page - $page = $paginator->getCurrentPage(); - // on which page are we ? - $page->getNumber(); // return 5 in this example (we are on page 5) - // generate the url for page 5 - $page->generateUrl(); // return '/?page=5 - // what is the first item number on this page ? - $page->getFistItemNumber(); // return 101 in our example (20 items per page) - // what is the last item number on this page ? - $page->getLastItemNumber(); // return 120 in our example - - // we can access directly the next and current page - if ($paginator->hasNextPage()) { - $next = $paginator->getNextPage(); - } - if ($paginator->hasPreviousPage()) { - $previous = $paginator->getPreviousPage(); - } - - // we can access directly to a given page number - if ($paginator->hasPage(10)) { - $page10 = $paginator->getPage(10); - } - - // we can iterate over our pages through a generator - foreach ($paginator->getPagesGenerator() as $page) { - $page->getNumber(); - } - - // check that a page object is the current page - $paginator->isCurrentPage($page); // return false - -.. warning:: - - When calling a page which does not exists, the :code:`Paginator` will throw a `RuntimeException`. Example : - - .. code-block:: php - - // our last page is 10 - $paginator->getPage(99); // out of range => throw `RuntimeException` - - // our current page is 1 (the first page) - $paginator->getPreviousPage; // does not exists (the fist page is always 1) => throw `RuntimeException` - -.. note:: - - When you create a :code:`Paginator` for the current route and route parameters, the :code:`Page` instances will keep the same parameters and routes : - - .. code-block:: php - - // assuming our route is 'my_route', for the pattern '/my/{foo}/route', - // and the current route is '/my/value/route?arg2=bar' - - // create a paginator for the current route and route parameters : - $paginator = $paginatorFactory->create($total); - - // get the next page - if ($paginator->hasNext()) { - $next = $paginator->getNextPage(); - - // get the route to the page - $page->generateUrl(); // will print 'my/value/route?arg2=bar&page=2' - } - - -Having a look to the `full classes documentation may provide some useful information `_. - - -Customizing the rendering of twig's :code:`chill_pagination` -************************************************************ - -You can provide your own layout for rendering the pagination: provides your twig template as a second argument : - -.. code-block:: html+jinja - - {{ chill_pagination(paginator, 'MyBundle:Pagination:MyTemplate.html.twig') }} - -The template will receive the :code:`$paginator` as :code:`paginator` variable. Let's have a look `at the current template `_. - diff --git a/docs/source/development/render-entity.rst b/docs/source/development/render-entity.md similarity index 61% rename from docs/source/development/render-entity.rst rename to docs/source/development/render-entity.md index bfc579682..d1a4dad01 100644 --- a/docs/source/development/render-entity.rst +++ b/docs/source/development/render-entity.md @@ -1,36 +1,28 @@ - -Rendering entity automatically -############################## +# Rendering entity automatically Some entity need to be rendered automatically for a couple of times: a person, a user, ... One can use some twig filter to render those entities: -.. code-block:: twig - {{ person|chill_entity_render_box }} -Define a renderer -================= +## Define a renderer -By default, the object passed through the renderer will be rendered using the :code:`__toString()` method. To customize this behaviour, you have to define a service and tag it using :code:`chill.render_entity`. +By default, the object passed through the renderer will be rendered using the `__toString()` method. To customize this behaviour, you have to define a service and tag it using `chill.render_entity`. The rendered is implemented using :class:`Chill\MainBundle\Templating\Entity\ChillEntityRenderInterface`. This interface has 3 methods: -* :code:`public function supports($entity, array $options): bool`: return true if the :code:`$entity` given in parameter, with custom options, is supported by this renderer; -* :code:`public function renderString($entity, array $options): string`: render the entity as a single string, for instance in a select list; -* :code:`public function renderBox($entity, array $options): string`: render the entity in an html box. +* `public function supports($entity, array $options): bool`: return true if the `$entity` given in parameter, with custom options, is supported by this renderer; +* `public function renderString($entity, array $options): string`: render the entity as a single string, for instance in a select list; +* `public function renderBox($entity, array $options): string`: render the entity in a HTML box. -.. warning:: + The HTML returned by `renderBox` **MUST BE SAFE** of any XSS injection. - The HTML returned by :code:`renderBox` **MUST BE SAFE** of any XSS injection. - -:class:`Chill\MainBundle\Templating\Entity\AbstractChillEntityRender` provides some useful methods to get the opening and closing boxes that should be used. +`Chill\MainBundle\Templating\Entity\AbstractChillEntityRender` provides some useful methods to get the opening and closing boxes that should be used. Usage about rendering comment: -.. code-block:: php - +```php namespace Chill\MainBundle\Templating\Entity; use Chill\MainBundle\Entity\Embeddable\CommentEmbeddable; @@ -108,47 +100,41 @@ Usage about rendering comment: return $entity instanceof CommentEmbeddable; } } +``` Logic inside the template: -.. code-block:: twig - +```twig + {# @var opening_box: string #} + {# @var closing_box: string #} {{ opening_box|raw }}
    {# logic for rendering #} -
    + {{ closing_box|raw }} +``` -Usage in templates -================== +## Usage in templates -For rendering entity as a box: +For rendering an entity as a box: -.. code-block:: twig + `{{ entity|chill_entity_render_box }}` - {{ entity|chill_entity_render_box }} +For rendering an entity as a string: -For rendering entity as a string: + `{{ entity|chill_entity_render_string }}` -.. code-block:: twig +## Available renderer and options - {{ entity|chill_entity_render_string }} - -Available renderer and options -============================== - -:code:`Person` (Person Bundle) ------------------------------- +### `Person` (Person Bundle) * no options -:code:`CommentEmbeddable` (Main Bundle) ---------------------------------------- +### `CommentEmbeddable` (Main Bundle) -Options: - -* :code:`user`: options which will be passed to "user" renderer -* :code:`disable_markdown`: disable markdown renderer, default to :code:`FALSE` -* :code:`limit_lines` (integer) limit the number of lines. Default to :code:`NULL`. May be an integer. -* :code:`metadata` (boolean): show the last updating user and last updating date. Default to :code:`TRUE`. +Options : +* `user`: options, which will be passed to "user" renderer +* `disable_markdown`: disable markdown renderer, default to `FALSE` +* `limit_lines` (integer) limit the number of lines. Default to `NULL`. Can be an integer. +* `metadata` (boolean): show the last updating user and last updating date. Default to `TRUE`. diff --git a/docs/source/development/routing.rst b/docs/source/development/routing.md similarity index 52% rename from docs/source/development/routing.rst rename to docs/source/development/routing.md index 519490918..2d635d039 100644 --- a/docs/source/development/routing.rst +++ b/docs/source/development/routing.md @@ -1,40 +1,26 @@ -.. Copyright (C) 2014 Champs Libres Cooperative SCRLFS - Permission is granted to copy, distribute and/or modify this document - under the terms of the GNU Free Documentation License, Version 1.3 - or any later version published by the Free Software Foundation; - with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. - A copy of the license is included in the section entitled "GNU - Free Documentation License". +# Routing +Our goal is to ease the installation of the different bundles. Users should not have to dive into complicated config files to install bundles. -Routing -####### +## A routing loader is available for all bundles -Our goal is to ease the installation of the different bundle. Users should not have to dive into complicated config files to install bundles. - -A routing loader available for all bundles -=========================================== - -A Chill bundle may rely on the Routing Loader defined in ChillMain. +A Chill bundle may rely on the Routing Loader defined in ChillMain. The loader will load `yml` or `xml` files. You simply have to add them into `chill_main` config -.. code-block:: yaml - +```yaml chill_main: # ... other stuff here routing: resources: - @ChillMyBundle/Resources/config/routing.yml +``` -Load routes automatically -------------------------- +### Load routes automatically -But this force users to modify config files. To avoid this, you may prepend config implementing the `PrependExtensionInterface` in the `YourBundleExtension` class. This is an example from **chill main** bundle : - - -.. code-block:: php +But this forces users to modify config files. To avoid this, you may prepend config implementing the `PrependExtensionInterface` in the `YourBundleExtension` class. This is an example from **chill main** bundle : +```php namespace Chill\MainBundle\DependencyInjection; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -49,7 +35,7 @@ But this force users to modify config files. To avoid this, you may prepend conf // ... } - public function prepend(ContainerBuilder $container) + public function prepend(ContainerBuilder $container) { //add current route to chill main @@ -64,5 +50,4 @@ But this force users to modify config files. To avoid this, you may prepend conf )); } } - - +``` diff --git a/docs/source/development/run-tests.md b/docs/source/development/run-tests.md new file mode 100644 index 000000000..4c2ee7e16 --- /dev/null +++ b/docs/source/development/run-tests.md @@ -0,0 +1,58 @@ +Permission is granted to copy, distribute and/or modify this document + under the terms of the GNU Free Documentation License, Version 1.3 + or any later version published by the Free Software Foundation; + with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. + A copy of the license is included in the section entitled "GNU + Free Documentation License". + +###### Run tests + +In reason of the Chill architecture, test should be runnable from the bundle's directory and works correctly: this will allow continuous integration tools to run tests automatically. + +## From chill app + +This is the most convenient method for developer: run test for chill bundle from the main app. + +```bash + # run into a container + `docker-compose exec --user $(id -u) php bash` + # execute all tests suites + `bin/phpunit` + # ... or execute a single test + `bin/phpunit vendor/chill-project/chill-bundles/src/Bundle/ChillMainBundle/Tests/path/to/FileTest.php` +``` + +You can also run tests in a single command: + + `docker-compose exec --user $(id -u) php bin/phpunit` + +### Tests from a bundle (chill-bundles) + +Those tests need the whole symfony app to execute Application Tests (which test html page). + +For ease, the app is cloned using a `git submodule`, which clone the main app into `tests/app`, and tests are bootstrapped to this app. The dependencies are also installed into `tests/app/vendor` to ensure compliance with relative path from this symfony application. + +You may boostrap the tests for the chill bundle this way: + +```bash + # ensure to be located into the environment (provided by docker suits well) + `docker-compose exec --user $(id -u) php bash` + # go to chill subdirectory + `cd vendor/chill-project/chill-bundles` + # install submodule + `git submodule init` + `git submodule update` + # install composer and dependencies + `curl -sS https://getcomposer.org/installer | php` + # run tests + `bin/phpunit` +``` + + If you are on a fresh installation, you will need to migrate database schema. + + The path to the console tool must be adapted to the app. To load migration and add fixtures, one can execute the following commands: + +```bash + tests/app/bin/console doctrine:migrations:migrate + tests/app/bin/console doctrine:fixtures:load +``` diff --git a/docs/source/development/run-tests.rst b/docs/source/development/run-tests.rst deleted file mode 100644 index 1dd944e58..000000000 --- a/docs/source/development/run-tests.rst +++ /dev/null @@ -1,68 +0,0 @@ -.. Copyright (C) 2014 Champs Libres Cooperative SCRLFS - Permission is granted to copy, distribute and/or modify this document - under the terms of the GNU Free Documentation License, Version 1.3 - or any later version published by the Free Software Foundation; - with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. - A copy of the license is included in the section entitled "GNU - Free Documentation License". - -Run tests -********* - -In reason of the Chill architecture, test should be runnable from the bundle's directory and works correctly: this will allow continuous integration tools to run tests automatically. - -From chill app -============== - -This is the most convenient method for developer: run test for chill bundle from the main app. - -.. code-block:: bash - - # run into a container - docker-compose exec --user $(id -u) php bash - # execute all tests suites - bin/phpunit - # .. or execute a single test - bin/phpunit vendor/chill-project/chill-bundles/src/Bundle/ChillMainBundle/Tests/path/to/FileTest.php - -You can also run tests in a single command: - -.. code-block:: bash - - docker-compose exec --user $(id -u) php bin/phpunit - - -Tests from a bundle (chill-bundles) ------------------------------------ - -Those tests needs the whole symfony app to execute Application Tests (which test html page). - -For ease, the app is cloned using a :code:`git submodule`, which clone the main app into :code:`tests/app`, and tests are bootstrapped to this app. The dependencies are also installed into `tests/app/vendor` to ensure compliance with relative path from this symfony application. - -You may boostrap the tests fro the chill bundle this way: - -.. code-block:: bash - - # ensure to be located into the environement (provided by docker suits well) - docker-compose exec --user $(id -u) php bash - # go to chill subdirectory - cd vendor/chill-project/chill-bundles - # install submodule - git submodule init - git submodule update - # install composer and dependencies - curl -sS https://getcomposer.org/installer | php - # run tests - bin/phpunit - -.. note:: - - If you are on a fresh install, you will need to migrate database schema. - - The path to console tool must be adapted to the app. To load migration and add fixtures, one can execute the following commands: - - .. code-block:: bash - - tests/app/bin/console doctrine:migrations:migrate - tests/app/bin/console doctrine:fixtures:load - diff --git a/docs/source/development/searching.md b/docs/source/development/searching.md new file mode 100644 index 000000000..d1e9d8dd3 --- /dev/null +++ b/docs/source/development/searching.md @@ -0,0 +1,222 @@ +###### Searching + +Chill should provide information needed by users when they need it. Searching within bundle, entities, ... is an important feature to achieve this goal. + +The Main Bundle provides interfaces to ease the developer's work. It will also attempt that search will work in the same way accross bundles. + + :local: + + [Our blog post about searching (in French) ](http://blog.champs-libres.coop/vie-des-champs/2015/01/06/va-chercher-chill-la-recherche-dans-chill-logiciel-libre-service-social.html) + This blog post gives some information for end-users about searching. + + [The issue about search behaviour ](https://redmine.champs-libres.coop/issues/377) + Where the search behavior is defined. + +## Searching at a glance for developers + +Chill suggests using an easy-to-learn language search. + + We are planning to provide a form to create an automatic search pattern according to this language. Watch the [issue regarding this feature ](https://redmine.champs-libres.coop/issues/389). + +The language is an association of search terms. Search terms may contain : + +- **a domain**: this is "the domain you want to search": it may some entities like people, reports, ... Example: [@person` to search accross people, `@report` to browse reports, ... The search pattern may have **a maximum of one** domain by search, providing more should throw an error, and trigger a warning for users. +- **arguments and their values** : This is "what you search." Arguments narrow the search to specific fields: username, date of birth, nationality, ... The syntax is `argument:value`. I.e.: ` birthdate:2014-12-15`, `firstname:Depardieu`, ... **Arguments are optional**. If the value of an argument contains spaces or characters like punctuation, quotes ("), the value should be provided between parenthesis : `firstname:(Van de snoeck)`, `firstname:(M'bola)`, ... +- **default value** : this the "rest" of the search, not linked with any arguments or domain. Example : `@person dep` (`dep` is the "default value"), or simply `dep` if any domain is provided (which is perfectly acceptable). If a string is not idenfied as argument or domain, it will be present in the "default" term. + +If a search pattern (provided by the user) does not contain any domain, the search must be run across default domain/search modules. + +A domain may be supported by different search modules. For instance, if you provide the domain `@person`, the end-user may receive results of exact firstname/lastname, but also result with spelling suggestion, ... **But** if results do not fit into the first page (if you have 75 results and the screen show only 50 results), the next page should contains only the results from the required module. + +For instance: a user searches across people by firstname/lastname, the exact spelling contains 10 results, the "spelling suggestion" results contain 75 names, but show only the first 50. If the user want to see the last 25, the next screen should not contains the results by firstname/lastname. + +## Allowed characters as arguments + +To execute regular expression, the allowed characters in arguments are a-z characters, numbers, and the sign '-'. Spaces and special characters like accents are note allowed (the accents are removed during parsing). + +## Special characters and uppercase + +The search should not care about lowercase/uppercase and accented characters. Currently, they are removed automatically by the `chill.main.search_provider`. + +## Implementing a search module for dev + +To implement a search module, you should : + +- create a class which implements the `Chill\MainBundle\Search\SearchInterface` class. An abstract class `Chill\MainBundle\Search\AbstractSearch` will provide useful assertions for parsing date string to `DateTime` objects, ... +- register the class as a service, and tag the service with `chill.search` and an appropriate alias + +The search logic is provided under the `/search` route. + + `The implementation of a search module in the Person bundle ](https://github.com/Chill-project/Person/blob/master/Search/PersonSearch.php) + An example of implementation https://github.com/Chill-project/Main/blob/master/DependencyInjection/SearchableServicesCompilerPass.php + + **Internals explained** : the services tagged with [chill.search` are gathered into the `chill.main.search_provider` service during compilation (`see the compiler pass ](https://github.com/Chill-project/Main/blob/master/DependencyInjection/SearchableServicesCompilerPass.php)). + + The [chill.main.search_provider` service allows to : + + - retrieve all results (as HTML string) for all search modules concerned by the search (according to the domain provided or modules marked as default) + - retrieve result for one search module + +### The SearchInterface class + +```php + namespace Chill\PersonBundle\Search; + + use Chill\MainBundle\Search\AbstractSearch; + use Doctrine\ORM\EntityManagerInterface; + use Chill\PersonBundle\Entity\Person; + use Symfony\Component\DependencyInjection\ContainerInterface; + use Symfony\Component\DependencyInjection\ContainerAware; + use Symfony\Component\DependencyInjection\ContainerAwareTrait; + use Chill\MainBundle\Search\ParsingException; + + class PersonSearch extends AbstractSearch + { + + // indicate which domain you support + // you may respond TRUE to multiple domain, according to your logic + public function supports($domain, $format='html') + { + return 'person' === $domain; + } + + // if your domain must be called when no domain is provided, should return true + public function isActiveByDefault() + { + return true; + } + + // if multiple modules respond to the same domain, indicate an order for your search. + public function getOrder() + { + return 100; + } + + // This is where your search logic should be executed. + // This method must return an HTML string (a string with HTML tags) + // see below about the structure of the $term array + public function renderResult(array $terms, $start = 0, $limit = 50, array $options = array(), $format = 'html') + { + return $this->container->get('templating')->render('ChillPersonBundle:Person:list.html.twig', + array( + // you should implement the `search` function somewhere :-) + 'persons' => $this->search($terms, $start, $limit, $options), + // recomposePattern is available in AbstractSearch class + 'pattern' => $this->recomposePattern($terms, array('nationality', + 'firstname', 'lastname', 'birthdate', 'gender'), $terms['_domain']), + // you should implement the `count` function somewhere :-) + 'total' => $this->count($terms) + )); + } + } +``` + +##### Values for `$options` + +`$options` is an array with the following keys: + +- `SearchInterface::SEARCH_PREVIEW_OPTION` (bool): if the current view is a preview (the first 5 results) or not ; +- `SearchInterface::REQUEST_QUERY_PARAMETERS` (bool): some parameters added to the query (under the key `SearchInterface::REQUEST_QUERY_KEY_ADD_PARAMETERS`) and that can be interpreted. Used, for instance, when calling a result in json format when searching for interactive picker form. + +##### Structure of array `$term` + +The array term is parsed automatically by the `main.chill.search_provider` service. + + If you need to parse a search pattern, you may use the function `parse($pattern)` provided by the service. + +The array `$term` have the following structure after parsing : + +```php + array( + '_domain' => 'person', //the domain, without the '@' + 'argument1' => 'value', //the argument1, with his value + 'argument2' => 'my value with spaces', //the argument2 + '_default' => 'abcde ef' // the default term + ); +``` + +The original search would have been : `@person argument1:value argument2:(my value with spaces) abcde ef` + + The search values are always unaccented. + +##### Returning a result in JSON + +The JSON format is mainly used by "select2" widgets. + +When returning a result in JSON, the SearchInterface should only return an array with following keys: + +- `more` (bool): if the search has more result than the current page ; +- `results` (array): a list of a result, where: + + - `text` (string): the text that should be displayed in browser ; + - `id` (string): the id of the entity. + +### Register the service + +You should add your service in the configuration, and add a `chill.search` tag and an alias. + +Example : + +```yaml + services: + chill.person.search_person: + class: Chill\PersonBundle\Search\PersonSearch + #your logic here + tags: + - { name: chill.search, alias: 'person_regular' } +``` + +The alias will be used to get the results narrowed to this search module, in case of pagination (see above). + +## Parsing date + +The class `Chill\MainBundle\Search\AbstractSearch` provides a method to parse date : + + //from subclasses + `$date = $this->parseDate($string);` + +`$date` will be an instance of `DateTime ](http://php.net/manual/en/class.datetime.php). + + [The possibility to add periods instead of date ](https://redmine.champs-libres.coop/issues/390) + Which may be a future improvement for search with date. + +## Exceptions + +The logic of the search is handled by the controller for the `/search` path. + +You should throw those Exceptions from your instance of `SearchInterface` if needed : + +Chill\MainBundle\Search\ParsingException + If the terms do not fit your search logic (for instance, conflicting terms) + +## Expected behaviour + +### Operators between multiple terms + +Multiple terms should be considered are "AND" instructions : + +@person nationality:RU firstname:dep + the people having the Russian nationality AND having DEP in their name + +@person birthdate:2015-12-12 charles + the people having 'charles' in their name or firstname AND born on December 12, 2015 + +### Spaces in default + +Spaces in default terms should be considered as "AND" instruction + +@person charle dep + people have "dep" AND "charles" in their firstname or lastname. Match "Charles Depardieu" but not "Gérard Depardieu" ('charle' is not present) + +### Rendering + +The rendering should contain : + +- the total number of results ; +- the search pattern in the search language. The aim of this is to let users learn the search language easily. +- a title + +## Frequently Asked Questions (FAQ) + +Why does renderResults return an HTML string and not a structured array? + It seems that the form of results may vary (according to access-right logic, ...) and is not easily structurable diff --git a/docs/source/development/searching.rst b/docs/source/development/searching.rst deleted file mode 100644 index 023d16a6e..000000000 --- a/docs/source/development/searching.rst +++ /dev/null @@ -1,276 +0,0 @@ -.. Copyright (C) 2014 Champs Libres Cooperative SCRLFS - Permission is granted to copy, distribute and/or modify this document - under the terms of the GNU Free Documentation License, Version 1.3 - or any later version published by the Free Software Foundation; - with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. - A copy of the license is included in the section entitled "GNU - Free Documentation License". - - -Searching -********* - -Chill should provide information needed by users when they need it. Searching within bundle, entities,... is an important feature to achieve this goal. - -The Main Bundle provide interfaces to ease the developer work. It will also attempt that search will work in the same way accross bundles. - -.. contents:: Table of content - :local: - -.. seealso:: - - `Our blog post about searching (in French) `_ - This blog post give some information for end-users about searching. - - `The issue about search behaviour `_ - Where the search behaviour is defined. - -Searching in a glance for developers -==================================== - -Chill suggests to use an easy-to-learn language search. - -.. note:: - - We are planning to provide a form to create automatically search pattern according to this language. Watch the `issue regarding this feature `_. - -The language is an association of search terms. Search terms may contains : - -- **a domain**: this is "the domain you want to search" : it may some entities like people, reports, ... Example : `@person` to search accross people, `@report` to browse reports, ... The search pattern may have **a maximum of one** domain by search, providing more should throw an error, and trigger a warning for users. -- **arguments and their values** : This is "what you search". Arguments narrow the search to specific fields : username, date of birth, nationality, ... The syntax is `argument:value`. I.e.: ` birthdate:2014-12-15`, `firstname:Depardieu`, ... **Arguments are optional**. If the value of an argument contains spaces or characters like punctuation, quotes ("), the value should be provided between parenthesis : `firstname:(Van de snoeck)`, `firstname:(M'bola)`, ... -- **default value** : this the "rest" of the search, not linked with any arguments or domain. Example : `@person dep` (`dep` is the "default value"), or simply `dep` if any domain is provided (which is perfectly acceptable). If a string is not idenfied as argument or domain, it will be present in the "default" term. - -If a search pattern (provided by the user) does not contains any domain, the search must be run across default domain/search modules. - -A domain may be supported by different search modules. For instance, if you provide the domain `@person`, the end-user may receive results of exact firstname/lastname, but also result with spelling suggestion, ... **But** if results do not fit into the first page (if you have 75 results and the screen show only 50 results), the next page should contains only the results from the required module. - -For instance : a user search across people by firstname/lastname, the exact spelling contains 10 results, the "spelling suggestion" results contains 75 names, but show only the first 50. If the user want to see the last 25, the next screen should not contains the results by firstname/lastname. - -Allowed characters as arguments -=============================== - -In order to execute regular expression, the allowed chararcters in arguments are a-z characters, numbers, and the sign '-'. Spaces and special characters like accents are note allowed (the accents are removed during parsing). - -Special characters and uppercase -================================ - -The search should not care about lowercase/uppercase and accentued characters. Currently, they are removed automatically by the `chill.main.search_provider`. - -Implementing search module for dev -=================================== - -To implement a search module, you should : - -- create a class which implements the `Chill\MainBundle\Search\SearchInterface` class. An abstract class `Chill\MainBundle\Search\AbstractSearch` will provide useful assertions for parsing date string to `DateTime` objects, ... -- register the class as a service, and tag the service with `chill.search` and an appropriate alias - -The search logic is provided under the `/search` route. - -.. seealso:: - - `The implementation of a search module in Person bundle `_ - An example of implementationhttps://github.com/Chill-project/Main/blob/master/DependencyInjection/SearchableServicesCompilerPass.php - -.. note:: - - **Internals explained** : the services tagged with `chill.search` are gathered into the `chill.main.search_provider` service during compilation (`see the compiler pass `_). - - The `chill.main.search_provider` service allow to : - - - retrieve all results (as html string) for all search module concerned by the search (according to the domain provided or modules marked as default) - - retrieve result for one search module - -The SearchInterface class -------------------------- - -.. code-block:: php - - namespace Chill\PersonBundle\Search; - - use Chill\MainBundle\Search\AbstractSearch; - use Doctrine\ORM\EntityManagerInterface; - use Chill\PersonBundle\Entity\Person; - use Symfony\Component\DependencyInjection\ContainerInterface; - use Symfony\Component\DependencyInjection\ContainerAware; - use Symfony\Component\DependencyInjection\ContainerAwareTrait; - use Chill\MainBundle\Search\ParsingException; - - class PersonSearch extends AbstractSearch - { - - // indicate which domain you support - // you may respond TRUE to multiple domain, according to your logic - public function supports($domain, $format='html') - { - return 'person' === $domain; - } - - // if your domain must be called when no domain is provided, should return true - public function isActiveByDefault() - { - return true; - } - - // if multiple module respond to the same domain, indicate an order for your search. - public function getOrder() - { - return 100; - } - - - // This is where your search logic should be executed. - // This method must return an HTML string (a string with HTML tags) - // see below about the structure of the $term array - public function renderResult(array $terms, $start = 0, $limit = 50, array $options = array(), $format = 'html') - { - return $this->container->get('templating')->render('ChillPersonBundle:Person:list.html.twig', - array( - // you should implements the `search` function somewhere :-) - 'persons' => $this->search($terms, $start, $limit, $options), - // recomposePattern is available in AbstractSearch class - 'pattern' => $this->recomposePattern($terms, array('nationality', - 'firstname', 'lastname', 'birthdate', 'gender'), $terms['_domain']), - // you should implement the `count` function somewhere :-) - 'total' => $this->count($terms) - )); - } - } - - -Values for :code:`$options` -^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -:code:`$options` is an array with the following keys: - -- :code:`SearchInterface::SEARCH_PREVIEW_OPTION` (bool): if the current view is a preview (the first 5 results) or not ; -- :code:`SearchInterface::REQUEST_QUERY_PARAMETERS` (bool): some parameters added to the query (under the key :code:`SearchInterface::REQUEST_QUERY_KEY_ADD_PARAMETERS`) and that can be interpreted. Used, for instance, when calling a result in json format when searching for interactive picker form. - - - - -Structure of array `$term` -^^^^^^^^^^^^^^^^^^^^^^^^^^ - -The array term is parsed automatically by the `main.chill.search_provider` service. - -.. note:: - If you need to parse a search pattern, you may use the function `parse($pattern)` provided by the service. - -The array `$term` have the following structure after parsing : - -.. code-block:: php - - array( - '_domain' => 'person', //the domain, without the '@' - 'argument1' => 'value', //the argument1, with his value - 'argument2' => 'my value with spaces', //the argument2 - '_default' => 'abcde ef' // the default term - ); - -The original search would have been : `@person argument1:value argument2:(my value with spaces) abcde ef` - -.. warning:: - The search values are always unaccented. - -Returning a result in json -^^^^^^^^^^^^^^^^^^^^^^^^^^ - -The json format is mainly used by "select2" widgets. - -When returning a result in json, the SearchInterface should only return an array with following keys: - -- :code:`more` (bool): if the search has more result than the current page ; -- :code:`results` (array): a list of result, where: - - - :code:`text` (string): the text that should be displayed in browser ; - - :code:`id` (string): the id of the entity. - - - -Register the service --------------------- - -You should add your service in the configuration, and add a `chill.search` tag and an alias. - -Example : - -.. code-block:: yaml - - services: - chill.person.search_person: - class: Chill\PersonBundle\Search\PersonSearch - #your logic here - tags: - - { name: chill.search, alias: 'person_regular' } - -The alias will be used to get the results narrowed to this search module, in case of pagination (see above). - -Parsing date -============ - -The class `Chill\MainBundle\Search\AbstractSearch` provides a method to parse date : - -.. code-block:: php - - //from subclasses - $date = $this->parseDate($string); - -`$date` will be an instance of `DateTime `_. - -.. seealso:: - - `The possibility to add periods instead of date `_ - Which may be a future improvement for search with date. - -Exceptions -========== - -The logic of the search is handled by the controller for the `/search` path. - -You should throw those Exception from your instance of `SearchInterface` if needed : - -Chill\MainBundle\Search\ParsingException - If the terms does not fit your search logic (for instance, conflicting terms) - -Expected behaviour -================== - -Operators between multiple terms --------------------------------- - -Multiple terms should be considered are "AND" instructions : - -@person nationality:RU firstname:dep - the people having the Russian nationality AND having DEP in their name - -@person birthdate:2015-12-12 charles - the people having 'charles' in their name or firstname AND born on December 12 2015 - -Spaces in default ------------------ - -Spaces in default terms should be considered as "AND" instruction - -@person charle dep - people having "dep" AND "charles" in their firstname or lastname. Match "Charles Depardieu" but not "Gérard Depardieu" ('charle' is not present) - -Rendering ---------- - -The rendering should contains : - -- the total number of results ; -- the search pattern in the search language. The aim of this is to let users learn the search language easily. -- a title - -Frequently Asked Questions (FAQ) -================================ - -Why renderResults returns an HTML string and not structured array ? - It seems that the form of results may vary (according to access-right logic, ...) and is not easily structurable - - - - - - diff --git a/docs/source/development/timelines.rst b/docs/source/development/timelines.md similarity index 76% rename from docs/source/development/timelines.rst rename to docs/source/development/timelines.md index 51c0a1bad..c5dea7432 100644 --- a/docs/source/development/timelines.rst +++ b/docs/source/development/timelines.md @@ -1,49 +1,31 @@ -.. Copyright (C) 2015 Champs Libres Cooperative SCRLFS - Permission is granted to copy, distribute and/or modify this document - under the terms of the GNU Free Documentation License, Version 1.3 - or any later version published by the Free Software Foundation; - with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. - A copy of the license is included in the section entitled "GNU - Free Documentation License". +###### Timelines -.. _timelines: - -Timelines -********* - -.. contents:: Table of content :local: -Concept -======= +## Concept -From an user point of view --------------------------- +### From a user point of view -Chill has two objectives : +Chill has two goals : -* make the administrative tasks more lightweight ; +* it makes the administrative tasks more lightweight ; * help social workers to have all information they need to work -To reach this second objective, Chill provides a special view: **timeline**. On a timeline view, information is gathered and shown on a single page, from the most recent event to the oldest one. +To reach this second goal, Chill provides a special view: **timeline**. On a timeline view, information is gathered and shown on a single page, from the most recent event to the oldest one. The information gathered is linked to a *context*. This *context* may be, for instance : -* a person : events linked to this person are shown on the page ; +* a person: events linked to this person are shown on the page ; * a center: events linked to a center are shown. They may concern different peoples ; * ... -In other word, the *context* is the kind of argument that will be used in the event's query. +In another word, the *context* is the kind of argument that will be used in the event's query. Let us recall that only the data the user has allowed to see should be shown. -.. seealso:: + [The issue where the subject was first discussed ](https://redmine.champs-libres.coop/issues/224) - `The issue where the subject was first discussed `_ - - -For developers --------------- +### For developers The `Main` bundle provides interfaces and services to help to build timelines. @@ -51,45 +33,38 @@ If a bundle wants to *push* information in a timeline, it should be create a ser If a bundle wants to provide a new context for a timeline, the service `chill.main.timeline_builder` will helps to gather timeline's services supporting the defined context, and run queries across the models. -.. _understanding-queries : - -Understanding queries -^^^^^^^^^^^^^^^^^^^^^ +##### Understanding queries Due to the fact that timelines should show only the X last events from Y differents tables, queries for a timeline may consume a lot of resources: at first on the database, and then on the ORM part, which will have to deserialize DB data to PHP classes, which may not be used if they are not part of the "last X events". -To avoid such load on database, the objects are queried in two steps : +To avoid such a load on a database, the objects are queried in two steps : -1. An UNION request which gather the last X events, ordered by date. The data retrieved are the ID, the date, and a string key: a type. This type discriminates the data type. -2. The PHP objects are queried by ID, the type helps the program to link id with the kind of objects. +1. A UNION request that gathers the last X events, ordered by date. The data retrieved are the ID, the date, and a string key: a type. This type discriminates the data type. +2. ID queries the PHP objects, the type helps the program to link id with the kind of objects. -Those methods should ensure that only X PHP objects will be gathered and build by the ORM. +Those methods should ensure that only X PHP objects will be gathered and built by the ORM. -What does the master timeline builder service ? -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +##### What does the master timeline builder service? When the service `chill.main.timeline_builder` is instanciated, the service is informed of each service taggued with `chill.timeline` tags. Then, 1. The service build an UNION query by assembling column and tables names provided by the `fetchQuery` result ; -2. The UNION query is run, the result contains an id and a type for each row (see :ref:`above `) +2. The UNION query is run, the result contains an id and a type for each row (see [above ](understanding-queries.md)) 3. The master service gather all id with the same type. Then he searches for the `chill.timeline`'s service which will be able to get the entities. Then, the entities will be fetched using the `fetchEntities` function. All entities are gathered in one query ; 4. The information to render entities in HTML is gathered by passing entity, one by one, on `getEntityTemplate` function. -Pushing events to a timeline -============================= +## Pushing events to a timeline To push events on a timeline : 1. Create a class which implements `Chill\MainBundle\Timeline\TimelineProviderInterface` ; 2. Define the class as a service, and tag the service with `chill.timeline`, and define the context associated with this timeline (you may add multiple tags for different contexts). -Implementing the TimelineProviderInterface ------------------------------------------- +### Implementing the TimelineProviderInterface The has the following signature : -.. code-block:: php - +```php namespace Chill\MainBundle\Timeline; interface TimelineProviderInterface @@ -135,7 +110,6 @@ The has the following signature : * - `template` : the template FQDN * - `template_data`: the data required by the template * - * * Example: * * ``` @@ -160,51 +134,42 @@ The has the following signature : public function getEntityTemplate($entity, $context, array $args); } +``` +##### The `fetchQuery` function -The `fetchQuery` function -^^^^^^^^^^^^^^^^^^^^^^^^^ - -The fetchQuery function help to build the UNION query to gather events. This function should return an instance of :code:`TimelineSingleQuery`. For you convenience, this object may be build using an associative array with the following keys: +The fetchQuery function helps to build the UNION query to gather events. This function should return an instance of `TimelineSingleQuery`. For you convenience, this object may be build using an associative array with the following keys: * `id` : the name of the id column * `type`: a string to indicate the type * `date`: the name of the datetime column, used to order entities by date -* `FROM`: the FROM clause. May contains JOIN instructions +* `FROM`: the FROM clause. May contain JOIN instructions * `WHERE`: the WHERE clause; * `parameters`: the parameters to pass to the query -The parameters should be replaced into the query by :code:`?`. They will be replaced into the query using prepared statements. +The parameters should be replaced into the query by `?`. They will be replaced into the query using prepared statements. `$context` and `$args` are defined by the bundle which will call the timeline rendering. You may use them to build a different query depending on this context. For instance, if the context is `'person'`, the args will be this array : -.. code-block:: php - array( 'person' => $person //a \Chill\PersonBundle\Entity\Person entity ); -For the context :code:`center`, the args will be: - -.. code-block:: php +For the context `center`, the args will be: array( 'centers' => [ ] // an array of \Chill\MainBundle\Entity\Center entities ); - You should find in the bundle documentation which contexts are arguments the bundle defines. -.. note:: - We encourage to use `ClassMetaData` to define column names arguments. If you change your column names, changes will be reflected automatically during the execution of your code. Example of an implementation : -.. code-block:: php - +```php namespace Chill\ReportBundle\Timeline; use Chill\MainBundle\Timeline\TimelineProviderInterface; @@ -250,19 +215,16 @@ Example of an implementation : //.... - } +``` -The `supportsType` function -^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +##### The `supportsType` function -This function indicate to the master `chill.main.timeline_builder` service (which orchestrate the build of UNION queries) that the service supports the type indicated in the result's array of the `fetchQuery` function. +This function indicates to the master `chill.main.timeline_builder` service (which orchestrate the build of UNION queries) that the service supports the type indicated in the result's array of the `fetchQuery` function. The implementation of our previous example will be : -.. code-block:: php - - +```php namespace Chill\ReportBundle\Timeline; use Chill\MainBundle\Timeline\TimelineProviderInterface; @@ -284,16 +246,15 @@ The implementation of our previous example will be : //... } +``` -The `getEntities` function -^^^^^^^^^^^^^^^^^^^^^^^^^^^ +##### The `getEntities` function This is where the service must fetch entities from database and return them to the master service. The results **must be** an array where the id given by the UNION query (remember `fetchQuery`). -.. code-block:: php - +```php namespace Chill\ReportBundle\Timeline; use Chill\MainBundle\Timeline\TimelineProviderInterface; @@ -316,9 +277,9 @@ The results **must be** an array where the id given by the UNION query (remember } } +``` -The `getEntityTemplate` function -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +##### The `getEntityTemplate` function This is where the master service will collect information to render the entity. @@ -329,8 +290,7 @@ The result must be an associative array with : Example : -.. code-block:: php - +```php array( 'template' => 'ChillMyBundle:timeline:template.html.twig', 'template_data' => array( @@ -338,23 +298,19 @@ Example : 'person' => $args['person'] ) ); +``` -The template must, obviously, exists. Example : - -.. code-block:: jinja +The template must exist. Example :

     {{ 'An accompanying period is opened for %person% on %date%'|trans({'%person%': person, '%date%': period.dateOpening|localizeddate('long', 'none') } ) }}

    - -Create a timeline with his own context -====================================== +## Create a timeline with his own context You have to create a Controller which will execute the service `chill.main.timeline_builder`. Using the `Chill\MainBundle\Timeline\TimelineBuilder::getTimelineHTML` function, you will get an HTML representation of the timeline, which you may include with twig `raw` filter. Example : -.. code-block:: php - +```php namespace Chill\PersonBundle\Controller; use Symfony\Component\HttpFoundation\Response; @@ -384,3 +340,4 @@ Example : } } +``` diff --git a/docs/source/development/translation_directives.md b/docs/source/development/translation_directives.md new file mode 100644 index 000000000..ce7561904 --- /dev/null +++ b/docs/source/development/translation_directives.md @@ -0,0 +1,376 @@ +# Translation Key Directives + +These directives are meant to ensure better consistency across bundles, avoid duplication, and make keys more predictable. + +## General Principles + +1. **Use lowercase snake_case for all keys** + +2. **Use dot-separated namespaces** + The dot is used to reflect: + - bundle + - feature + - sub-feature + - key type + +3. **Do not use spaces in keys** + +4. **Avoid duplicating the same text in multiple places** + When a translation is needed, try a search for the translation value first and see if it exists elsewhere + +5. **If a key is used across multiple bundles, it must live in ChillMainBundle.** + +6. **If a key is used across multiple bundles and is a generic term, it must be placed in the `common` namespace.** + +## Key Structure + +We use the following structure: + +``` +... +``` + +Where: + +- `` identifies the bundle or shared context +- `` identifies the part of the module using the translation +- `` describes the text purpose +- `` for a multi-level element (e.g., activity.export.person.count.description) + +### Examples of scopes + +- `activity` — ChillActivityBundle +- `person` — ChillPersonBundle +- `common` — neutral shared translation values + +## Naming Scopes + +### 1. Bundle-specific keys + +For most things inside a bundle: + +``` +activity.. +``` + +Example: + +``` +activity.form.save +activity.list.title +activity.entity.type +activity.menu.activities +activity.controller.success_created +``` + +### 2. Shared UI elements (buttons, labels, generic text) + +These belong in the `common` namespace in ChillMainBundle: + +``` +common.save +common.delete +common.edit +common.filter +common.duration_time +``` + +## Translation Workflow + +Use the following workflow when deciding where a key belongs: + +1. **Is this text used in more than one bundle?** + → Place in `main` or `common` + +2. **Is this text generic UI (button, label, pagination, yes/no)?** + → Place in `common` + +3. **Is this text specific to one bundle and one feature?** + → Place in `.feature.` + +4. **Is this text related to an entity or value object?** + → Place in `.entity..` + +5. **Is this text used in forms?** + → `.form.` or `.form.` + +6. **Is this text related to exports?** + → `.export..` + +7. **Is it related to filtering, searching or parameters?** + → `.filter.` or + → `.filter..` for nested filters + +## Examples Based on Translations Within ChillActivityBundle + +Below are concrete examples from `ChillActivityBundle`, refactored according to the guidelines. + +### General activity keys + +Instead of scattered keys like: + +``` +Show the activity +Edit the activity +Activity +Duration time +... +``` + +We use: + +``` +activity.general.show +activity.general.edit +activity.general.title +activity.general.duration +activity.general.travel_time +activity.general.attendee +activity.general.remark +activity.general.no_comments +``` + +### Forms + +Instead of keys like: + +``` +Activity creation +Save activity +Reset form +Choose a type +``` + +Use: + +``` +activity.form.title_create +activity.form.save +activity.form.reset +activity.form.choose_type +activity.form.choose_duration +``` + +Long lists (like durations) should be grouped: + +``` +activity.form.duration.5min +activity.form.duration.10min +activity.form.duration.15min +activity.form.duration.1h +activity.form.duration.1h30 +activity.form.duration.2h +... +``` + +### Entities + +Entity fields should follow: + +``` +activity.entity.activity.date +activity.entity.activity.comment +activity.entity.activity.deleted +activity.entity.location.name +activity.entity.location.type +``` + +### Controller messages + +Instead of strings as keys: + +``` +'Success : activity created!' +'The form is not valid. The activity has not been created !' +``` + +Use: + +``` +activity.controller.success_created +activity.controller.error_invalid_create +activity.controller.success_updated +activity.controller.error_invalid_update +``` + +### Roles + +Access control keys should be: + +``` +activity.role.create +activity.role.update +activity.role.see +activity.role.see_details +activity.role.delete +activity.role.stats +activity.role.list +``` + +### Admin + +``` +activity.admin.configuration +activity.admin.types +activity.admin.reasons +activity.admin.reason_category +activity.admin.presence +``` + +### CRUD + +``` +activity.crud.type.title_new +activity.crud.type.title_edit +activity.crud.presence.title_new +``` + +### Activity Reason + +``` +activity.reason.list +activity.reason.create +activity.reason.active +activity.reason.category +activity.reason.entity_title +``` + +### Exports + +Group them logically: + +``` +activity.export.person.count.title +activity.export.person.count.description +activity.export.person.count.header + +activity.export.period.sum_duration.title +activity.export.period.sum_duration.description +activity.export.period.sum_duration.header +``` + +### Filters + +Use hierarchical filters: + +``` +activity.filter.by_reason +activity.filter.by_type +activity.filter.by_date +activity.filter.by_location +activity.filter.by_sent_received +activity.filter.by_user +``` + +### Aggregators + +``` +activity.aggregator.reason.by_category +activity.aggregator.reason.level +activity.aggregator.user.by_scope +activity.aggregator.user.by_job +``` + +## Global/Shared Keys + +Keys like the following **must not be redeclared** in each bundle: + +- First name +- Last name +- Username +- ID +- Type +- Duration +- Comment +- Date +- Location +- Present / Not present +- Add / Edit / Delete / Save / Update + +These belong in `common` or `main`: + +``` +common.firstname +common.lastname +common.username +common.id +common.type +common.comment +common.date +common.location +common.present +common.absent +common.add +common.edit +common.delete +common.save +common.update +``` + +## Naming Directives Summary + +- **snake_case** +- **namespaced with dots** +- **bundle prefix for bundle-specific concepts** +- **common or main for shared concepts** +- **avoid free-floating keys (without namespace)** +- **reuse common keys wherever possible** + +## Migration Strategy (Optional) + +To apply this structure progressively: + +1. New keys must follow these guidelines. +2. Existing keys may remain as-is until refactored. +3. When refactoring: + - Move cross-bundle keys to ChillMainBundle and possible `common` namespace. + - Replace duplicated keys with shared ones. + +--- + +# Avoiding Duplicate Translations + +## 1. Use Shared Namespaces + +Two namespaces must be used for shared translations: + +- `common.*` — generic UI concepts (save, delete, date, name, etc.) + +If a translation may be reused in multiple bundles, it must be placed in the `common` namespace or in ChillMainBundle. + +## 2. Bundle-Specific Keys + +Keys belonging only to one bundle or one feature are namespaced inside that bundle: + +``` +activity.. +person.. +``` + +## 3. Search Before Creating + +Before adding a new translation key, developers must: + +1. For common translations like "enregistrer/opslaan" look in the `common` namespace. +2. Search in Loco or translations for existing values. + +If a suitable key exists, reuse it. + +## 4. Only Create a New Key When Necessary + +Create a new key only when the text is: + +- specific to the bundle +- specific to the feature +- not reusable elsewhere + +## 5. Progressive Cleanup + +Old duplicates may remain temporarily. When updating code in an area, clean duplicate values by moving them into `common` or `main`. + +## General Workflow + +- **Reuse shared keys** within `common` namespace. +- **Search before creating** new keys. +- **Namespace bundle-specific keys** under their bundle. +- **Refactor progressively** when touching old code. diff --git a/docs/source/development/translation_provider.md b/docs/source/development/translation_provider.md new file mode 100644 index 000000000..29301f366 --- /dev/null +++ b/docs/source/development/translation_provider.md @@ -0,0 +1,139 @@ +# Managing Translations Within CHILL Using Loco as a Translation Provider + +Within CHILL we make use of Symfony's translation component together with *Loco* as an external translation provider. Using this setup centralises translations in a single online location (Loco), while still allowing developers to create and update translation keys locally in the project (YAML files). + +## Workflow + +We use the following workflow: + +- Developers create translation keys in YAML files inside each bundle. +- Keys are written in **English**. +- Application UI defaults to **French**, with **Dutch** as an additional locale (other languages can be added in the future). +- Loco acts as the central translation memory and synchronisation source. +- Loco Symfony package was installed so that built-in translation commands can be used to push/pull content between Loco and the local project. + +## Translation Directory Structure + +Each bundle contains its own `translations` directory, for example: + +``` +chill-bundles/ + ChillCoreBundle/ + translations/ + messages.fr.yml + messages.nl.yml + ChillPersonBundle/ + translations/ + messages.fr.yml + messages.nl.yml + ... +``` + +## Configuration + +The translation configuration is defined in `config/packages/translation.yaml`: + +```yaml +framework: + default_locale: '%env(resolve:LOCALE)%' + translator: + default_path: '%kernel.project_dir%/translations' + fallbacks: + - '%env(resolve:LOCALE)%' + - 'en' + providers: + loco: + dsn: '%env(LOCO_DSN)%' + domains: [ 'messages' ] + locales: [ 'fr', 'nl' ] +``` + +Note: + +- `en` is the **source locale** in Loco. +- `fr` and `nl` are the **application locales**. +- `domains: [messages]` means only `messages.*.yml` files are pushed. + +### Environment Variables + +In `.env`: + +``` +LOCALE=fr +``` + +In `.env.local`: + +``` +LOCO_DSN="loco://API_KEY@default" +``` + +Replace `API_KEY` with the key provided by Loco. + +## Working with Loco + +Loco shows all translation keys under three languages: + +- **English (source)** — keys are listed but remain "untranslated" +- **French** — translated strings for French users +- **Dutch** — translated strings for Dutch users + +Note: Don't add translations directly in the English column. This column simply represents the *key*. + +## Pushing Translations to Loco + +You can push local translations to Loco using: + +```bash +symfony console translation:push loco --locales=fr --locales=nl --force +``` + +This will: + +- Upload all French and Dutch translation values from `*.fr.yml` and `*.nl.yml` files +- Ensure Loco stays in sync with local YAML files +- Create any missing keys in Loco + +## Pulling Translations from Loco + +When translators update strings in Loco, developers can fetch updates with: + +```bash +symfony console translation:pull loco --locales=fr --locales=nl --force +``` + +This will: + +- Download the latest French and Dutch translations +- Overwrite the local YAML files with Loco's content +- Keep everything consistent across the team + +## Adding New Translation Keys (Developer Workflow) + +1. Add a new key directly in the appropriate YAML file, for example: + + ``` + chill-bundles/ChillPersonBundle/translations/messages.fr.yml + ``` + + Example key: + + ```yaml + person.form.submit: "Envoyer" + ``` + +2. Add Dutch translation as well if you can (otherwise leave empty to be translated within Loco later): + + ```yaml + person.form.submit: "Verzenden" + ``` + +3. Run a push to send the new key to Loco: + + ```bash + symfony console translation:push loco --locales=fr --locales=nl --force + ``` + +4. The key will now appear in Loco for translation management. + +Note: English appears as "untranslated", because it is merely the source language. diff --git a/docs/source/development/useful-snippets.rst b/docs/source/development/useful-snippets.md similarity index 69% rename from docs/source/development/useful-snippets.rst rename to docs/source/development/useful-snippets.md index 98d07a1d1..0fa9edb7d 100644 --- a/docs/source/development/useful-snippets.rst +++ b/docs/source/development/useful-snippets.md @@ -1,26 +1,17 @@ +# Useful snippets +###### Dependency Injection - - -Useful snippets -############### - -Dependency Injection -******************** - -Configure route automatically -============================= +## Configure route automatically Add the route for the current bundle automatically on the main app. -.. code-block:: php - +```php namespace Chill\MyBundle\DependencyInjection; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; - class ChillMyExtension extends Extension implements PrependExtensionInterface { // ... @@ -30,8 +21,8 @@ Add the route for the current bundle automatically on the main app. $this->prependRoutes($container); } - - public function prependRoutes(ContainerBuilder $container) + + public function prependRoutes(ContainerBuilder $container) { //add routes for custom bundle $container->prependExtensionConfig('chill_main', array( @@ -42,32 +33,26 @@ Add the route for the current bundle automatically on the main app. ) )); } +``` +###### Security -Security -******** - -Get the circles a user can reach -================================ - -.. code-block:: php +## Get the circles a user can reach +```php use Symfony\Component\Security\Core\Role\Role; $authorizationHelper = $this->get('chill.main.security.authorization.helper'); $circles = $authorizationHelper ->getReachableCircles( $this->getUser(), # from a controller - new Role('CHILL_ROLE'), - $center + new Role('CHILL_ROLE'), + $center ); +``` +###### Controller -Controller -********** +## Secured controller for person -Secured controller for person -============================= - -.. literalinclude:: useful-snippets/controller-secured-for-person.php - :language: php + [controller](useful-snippets/controller-secured-for-person.php) diff --git a/docs/source/development/user-interface/css-classes.md b/docs/source/development/user-interface/css-classes.md new file mode 100644 index 000000000..9ed5064a0 --- /dev/null +++ b/docs/source/development/user-interface/css-classes.md @@ -0,0 +1,63 @@ +Permission is granted to copy, distribute and/or modify this document + under the terms of the GNU Free Documentation License, Version 1.3 + or any later version published by the Free Software Foundation; + with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. + A copy of the license is included in the section entitled "GNU + Free Documentation License". + +# CSS classes and mixins + +The stylesheet are based on the framework [ScratchCSS ](https://github.com/Champs-Libres/ScratchCSS). + +We added some useful classes and mixins for the Chill usage. + +###### CSS Classes + +## Statement "empty data" + +CSS Selector + `.chill-no-data-statement` +In which case will you use this selector ? + When a list is empty, and a message fill the list to inform that the data is empty +Example usage + ```jinja + {{ 'No reason associated'|trans }} + ``` + +## Quotation of user text + +CSS Selector + `blockquote.chill-user-quote` +In which case will you use this selector ? + When you quote text that were filled by the user in a form. +Example usage + ```jinja +
    {{ entity.remark|nl2br }}
    + ``` + +## Boxes + +CSS Selector + `chill__box` +In which case will you use this selector ? + When displaying some data in a nice box +Example usage + ```html + A nice box with green background + A nice box with red background + ``` + +###### Mixins + +## Entity decorator + +Mixin + `@mixin entity($background-color, $color: white)` +In which case including this mixin ? + When you create a `sticker`, a sort of label to represent a text in a way that the user can associate immediatly with a certain type of class / entity. +Example usage + ```sass + span.entity.entity-activity.activity-reason { + @include entity($chill-pink, white); + } +``` diff --git a/docs/source/development/user-interface/js-functions.md b/docs/source/development/user-interface/js-functions.md new file mode 100644 index 000000000..88cb52d67 --- /dev/null +++ b/docs/source/development/user-interface/js-functions.md @@ -0,0 +1,234 @@ +Permission is granted to copy, distribute and/or modify this document + under the terms of the GNU Free Documentation License, Version 1.3 + or any later version published by the Free Software Foundation; + with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. + A copy of the license is included in the section entitled "GNU + Free Documentation License". + +# Javascript functions + +Some function may be useful to manipulate elements on the page. + +###### Show-hide elements according to a form state + +The module ``ShowHide`` will allow you to show/hide part of your page using a specific test. + +This must be use inside a javascript module. + +## Usage + +In this module, the module will listen to all input given in the ``container_from`` div, and will show or hide the content of the ``container_target`` according to the result of the ``test`` function. + +
    + {{ form_row(form.accompagnementRQTHDate) }} +
    + +
    + {{ form_row(form.accompagnementComment) }} +
    + + import { ShowHide } from 'ShowHide/show_hide.js'; + + var + from = document.getElementById("container_from"), + target = document.getElementById("container_target") + ; + + new ShowHide({ + froms: [from], // the value of from should be an iterable + container: [target], // the value of container should be an iterable + test: function(froms, event) { + // iterate over each element of froms + for (let f of froms.values()) { + // get all input inside froms + for (let input of f.querySelectorAll('input').values()) { + if (input.value === 'autre') { + return input.checked; + } + } + } + + return false; + } + }); + +Once instantiated, the class ``ShowHide`` will: + +1. get all input from each element inside the ``froms`` values +2. attach an event listener (by default, ``change``) to each input inside each entry in ``froms`` +3. each time the event is fired, launch the function ``test`` +4. show the element in the container given in ``container``, if the result of ``test`` is true, or hide them otherwise. + +The test is also launched when the page is loaded. + +## Show/hide while the user enter data: using the ``input`` event + +One can force to use another event on the input elements, instead of the default ``'change'`` event. + +For achieving this, use the `event_name` option. + + new ShowHide({ + froms: froms, + test: test_function, + container: containers , + // using this option, we use the event `input` instead of `change` + event_name: 'input' + }); + +### Examples + + :language: javascript + +## Using Show/Hide in collections forms + +Using show / hide in collection forms implies: + +* to launch show/hide manually for each entry when the page is loaded ; +* to catch when an entry is added to the form ; + +As the show/hide is started manually and not on page load, we add the option ``load_event: null`` to the options: + + new ShowHide({ + load_event: null, + froms: [ from ], + container: [ container ], + test: my_test_function + }); + + When using ``load_event: null`` inside the options, the value of event will be ``null`` as second argument for the test function. + + ```javascript + my_test_function(froms, event) { + // event will be null on first launch + } +``` + +Example usage: here, we would like to catch for element inside a CV form, where the user may add multiple formation entries. + + import { ShowHide } from 'ShowHide/show_hide.js'; + + // we factorize the creation of show hide element in this function. + var make_show_hide = function(entry) { + let + obtained = entry.querySelector('[data-diploma-obtained]'), + reconnue = entry.querySelector('[data-diploma-reconnue]') + ; + new ShowHide({ + load_event: null, + froms: [ obtained ], + container: [ reconnue ], + test: my_test_function + }); + }; + + // this code is fired when an entry is added on the page + window.addEventListener('collection-add-entry', function(e) { + // if the form contains multiple collection, we filter them here: + if (e.detail.collection.dataset.collectionName === 'formations') { + make_show_hide(e.detail.entry); + } + }); + + // on page load, we create a show/hide + window.addEventListener('load', function(_e) { + let + formations = document.querySelectorAll('[data-formation-entry]') + ; + + for (let f of formations.values()) { + make_show_hide(f); + } + }); + +## Handling encapsulated show/hide elements + +This module allow to handle encapsulated show/hide elements. For instance : + +* in a first checkbox list, a second checkbox list is shown if some element is checked ; +* in this second checkbox list, a third input is shown if some element is checked inside the second checkbox list. + +As a consequence, if the given element in the first checkbox list is unchecked, the third input must also be hidden. + +Example: when a situation professionnelle is ``en activite``, the second element ``type contrat`` must be shown if ``en_activite`` is checked. Inside ``type_contrat``, ``type_contrat_aide`` should be shown when ``contrat_aide`` is checked. + +
    + + + +
    + + +
    + + + +
    + +
    + +
    + +The JS code will be: + + import { ShowHide } from 'ShowHide/show_hide.js'; + // we search for the element within the DOM + // NOTE: all the elements should be searched before instanciating the showHides. + // if not, the elements **may** have disappeared from the DOM + + var + situation_prof = document.getElementById('situation_prof'), + type_contrat = document.getElementById('type_contrat'), + type_contrat_aide = document.getElementById('type_contrat_aide'), + ; + + // the first show/hide will apply on situation_prof + new ShowHide({ + // the id will help us to keep a track of the element + id: 'situation_prof_type_contrat', + froms: [situation_prof], + container: [type_contrat], + test: function(froms) { + for (let f of froms.values()) { + for (let input of f.querySelectorAll('input').values()) { + if (input.value === 'en_activite') { + return input.checked; + } + } + } + + return false; + } + }); + + // the show/hide will apply on "contrat aide" + var show_hide_contrat_aide = new ShowHide({ + froms: [type_contrat], + container: [type_contrat_aide], + test: function(froms) { + for (let f of froms.values()) { + for (let input of f.querySelectorAll('input').values()) { + if (input.value === 'contrat_aide') { + return input.checked; + } + } + } + + return false; + } + }); + + // we handle here the case when the first show-hide is changed: the third input must also disappears + window.addEventListener('show-hide-hide', function (e) { + if (e.detail.id = 'situation_prof_type_contrat') { + // we force the 3rd element to disappears + show_hide_contrat_aide.forceHide(); + } + }); + + // when the first show-hide is changed, it makes appears the second one. + // we check here that the second show-hide is processed. + window.addEventListener('show-hide-show', function (e) { + if (e.detail.id = 'situation_prof_type_contrat') { + show_hide_contrat_aide.forceCompute(); + } + }); \ No newline at end of file diff --git a/docs/source/development/user-interface/layout-template-usage.md b/docs/source/development/user-interface/layout-template-usage.md new file mode 100644 index 000000000..385833850 --- /dev/null +++ b/docs/source/development/user-interface/layout-template-usage.md @@ -0,0 +1,196 @@ +Permission is granted to copy, distribute and/or modify this document + under the terms of the GNU Free Documentation License, Version 1.3 + or any later version published by the Free Software Foundation; + with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. + A copy of the license is included in the section entitled "GNU + Free Documentation License". + +# Layout / Template usage + +We recommand the use of the existing layouts to ensure the consistency of the design. This section explains the different templates and how to use it. + +The layouts are twig templates. + +## Twig templating helper + +### `chill_print_or_message` + +Print a value or use a default template if the value is empty. + +The template can be customized. + +Two default templates are registered: + +- `default`, do not decorate the value ; +- `blockquote`: wrap the value into a blockquote if exists + + {{ "This is a message"|chill_print_or_message("No message") }} + + + {{ ""|chill_print_or_message("No message")}} + + + {{ "This is a comment\n with multiples lines"|chill_print_or_message("No comment", 'blockquote') }} + + +When customizing the template, two arguments are passed to the template: + +- `value`: the actual value ; +- `message`: the message, given as argument + +### Routing with return path + +Three twig function are available for creating routes and handling "return path". + +**Rationale**: When building a "CRUD" (CReate, Update, Delete of an entity), you would like to allow people to go back on some custom cancel page when coming for another place of an application. For instance, if you are in the timeline and show an activity, you would like that the user go back to the timeline when pressing the "return" button at the bottom of the page, instead of going back to the "activity list" page. + +Using those function, a `returnPath` parameter will be added in the path generated. It will be used instead of the default one in the subsequente pages. + +- `chill_path_add_return_path(name, parameters = [], relative = false)`: will create a path with current page as return path (users will go back to the current page on the next page) ; +- `chill_return_path_or(name, parameters = [], relative = false)`: will create a path to the return path if present, or build the path according to the given parameters ; +- `chill_path_forward_return_path(name, parameters = [], relative = false)`: will create a path and adding the return path that is present for the current page, *forwarding* the return path to the next page. This is useful if you are on a "show" page with a return path (by instance, the "timeline" page), you place a link to the *edit* page and want users to go back to the "timeline" page on the edit page. + +- `chill_return_path_or`: + +## Organisation of the layouts + +### ChillMainBundle::layout.html.twig + +This is the base layout. It includes the most import css / js files. It display a page with + +* a horizontal navigation menu +* a place for content +* a footer + +The layout containts blocks, that are : + +* title + + * to display title + +* css + + * where to add some custom css + +* navigation_section_menu + + * place where to insert the section menu in the navigation menu (by default the navigation menu is inserted) + +* navigation_search_bar + + * place where to insert a search bar in the navigation menu (by default the search bar is inserted) + +* top_banner + + * place where to display a banner below the navigation menu (this place is use to display the details of the person) + +* sublayout_containter + + * place between the header and the footer that can be used to create a new layout (with vertical menu for example) + +* content + + * place where to display the content (flash message are included outside of this block) + +* js + + * where to add some custom javascript + +### ChillMainBundle::layoutWithVerticalMenu.html.twig + +This layout extends `ChillMainBundle::layout.html.twig`. It replaces the block `layout_content` and divides this block for displaying a vertical menu and some content. + +It proposes 2 new blocks : + +* layout_wvm_content + + * where to display the page content + +* vertical_menu_content + + * where to place the vertical menu + +### ChillMainBundle::Admin/layout.html.twig + +This layout extends `ChillMainBundle::layout.html.twig`. It hides the search bar, remplaces the `section menu` with the `admin section menu`. + +It proposes a new block : + +* admin_content + + * where to display the admin content + +### ChillMainBundle::Admin/layoutWithVerticalMenu.html.twig + +This layout extends `ChillMainBundle::layoutWithVerticalMenu.html.twig`. It do the same changes than `ChillMainBundle::Admin/layout.html.twig` : hiding the search bar, remplacing the `section menu` with the `admin section menu`. + +It proposes a new block : + +* admin_content + + * where to display the admin content + +@ChillPersonBundle/Person/layout.html.twig +----------------------------------- + +This layout extend `ChillMainBundle::layoutWithVerticalMenu.html.twig` add the person details in the block `top_banner`, set the menu `person` as the vertical menu. + +It proposes 1 new block : + +* content + + * where to display the information of the person + +### ChillMainBundle::Export/layout.html.twig + +This layout extends `ChillMainBundle::layoutWithVerticalMenu.html.twig` and set the menu `export` as the vertical menu. + +It proposes 1 new block : + +* export_content + + * where to display the content of the export + +## Useful template and helpers + +### Macros + +Every bundle may bring their own macro to print resources with uniformized styles. + +See : + +- [Macros in person bundle ](person-bundle-macros.md) ; +- [Macros in activity bundle ](activity-bundle-macros.md) ; +- [Macros in group bundle ](group-bundle-macros.md) ; +- [Macros in main bundle ](main-bundle-macros.md) ; + +### Templates + +##### ChillMainBundle::Util:confirmation_template.html.twig + +This template show a confirmation template before making dangerous things. You can add your own message and title, or define those message by yourself in another template. + +The accepted parameters are : + +- `title` (string) a title for the page. Not mandatory (it won't be rendered if not defined) +- `confirm_question` (string) a confirmation question. This question will not be translated into the template, and may be printed as raw. Not mandatory (it won't be rendered if not defined) +- `form` : (:class:`Symfony\Component\Form\FormView`) a form wich **must** contains an input named `submit`, which must be a :class:`Symfony\Component\Form\Extension\Core\Type\SubmitType`. Mandatory +- `cancel_route` : (string) the name of a route if the user want to cancel the action +- `cancel_parameters` (array) the parameters for the route defined in `cancel_route` + +Usage : + + {{ include('ChillMainBundle:Util:confirmation_template.html.twig', + { + # a title, not mandatory + 'title' : 'Remove membership'|trans, + # a confirmation question, not mandatory + 'confirm_question' : 'Are you sure you want to remove membership ?'|trans + # a route for "cancel" button (mandatory) + 'cancel_route' : 'chill_group_membership_by_person', + # the parameters for 'cancel' route (default to {} ) + 'cancel_parameters' : { 'person_id' : membership.person.id }, + # the form which will send the deletion. This form + # **must** contains a SubmitType + 'form' : form + } ) }} diff --git a/docs/source/development/user-interface/css-classes.rst b/docs/source/development/user-interface/rst/css-classes.rst similarity index 90% rename from docs/source/development/user-interface/css-classes.rst rename to docs/source/development/user-interface/rst/css-classes.rst index 98252cca4..3a13fb186 100644 --- a/docs/source/development/user-interface/css-classes.rst +++ b/docs/source/development/user-interface/rst/css-classes.rst @@ -10,7 +10,7 @@ CSS classes and mixins ###################### -The stylesheet are based on the framework `ScratchCSS `_. +The stylesheet are based on the framework `ScratchCSS `_. We added some useful classes and mixins for the Chill usage. @@ -22,12 +22,12 @@ Statement "empty data" ====================== CSS Selector - :code:`.chill-no-data-statement` + `.chill-no-data-statement` In which case will you use this selector ? When a list is empty, and a message fill the list to inform that the data is empty Example usage .. code-block:: html+jinja - + {{ 'No reason associated'|trans }} @@ -35,7 +35,7 @@ Quotation of user text ======================= CSS Selector - :code:`blockquote.chill-user-quote` + `blockquote.chill-user-quote` In which case will you use this selector ? When you quote text that were filled by the user in a form. Example usage @@ -47,7 +47,7 @@ Boxes ===== CSS Selector - :code:`chill__box` + `chill__box` In which case will you use this selector ? When displaying some data in a nice box Example usage @@ -66,7 +66,7 @@ Entity decorator ================= Mixin - :code:`@mixin entity($background-color, $color: white)` + `@mixin entity($background-color, $color: white)` In which case including this mixin ? When you create a `sticker`, a sort of label to represent a text in a way that the user can associate immediatly with a certain type of class / entity. Example usage diff --git a/docs/source/development/user-interface/js-functions.rst b/docs/source/development/user-interface/rst/js-functions.rst similarity index 100% rename from docs/source/development/user-interface/js-functions.rst rename to docs/source/development/user-interface/rst/js-functions.rst diff --git a/docs/source/development/user-interface/layout-template-usage.rst b/docs/source/development/user-interface/rst/layout-template-usage.rst similarity index 82% rename from docs/source/development/user-interface/layout-template-usage.rst rename to docs/source/development/user-interface/rst/layout-template-usage.rst index 1ddbacafd..2d85253be 100644 --- a/docs/source/development/user-interface/layout-template-usage.rst +++ b/docs/source/development/user-interface/rst/layout-template-usage.rst @@ -22,28 +22,28 @@ Twig templating helper Print a value or use a default template if the value is empty. -The template can be customized. +The template can be customized. -Two default templates are registered: +Two default templates are registered: -- :code:`default`, do not decorate the value ; -- :code:`blockquote`: wrap the value into a blockquote if exists +- `default`, do not decorate the value ; +- `blockquote`: wrap the value into a blockquote if exists .. code-block:: html+twig - {{ "This is a message"|chill_print_or_message("No message") }} + {{ "This is a message"|chill_print_or_message("No message") }} - {{ ""|chill_print_or_message("No message")}} + {{ ""|chill_print_or_message("No message")}} - {{ "This is a comment\n with multiples lines"|chill_print_or_message("No comment", 'blockquote') }} + {{ "This is a comment\n with multiples lines"|chill_print_or_message("No comment", 'blockquote') }} -When customizing the template, two arguments are passed to the template: +When customizing the template, two arguments are passed to the template: -- :code:`value`: the actual value ; -- :code:`message`: the message, given as argument +- `value`: the actual value ; +- `message`: the message, given as argument Routing with return path ------------------------ @@ -52,14 +52,14 @@ Three twig function are available for creating routes and handling "return path" **Rationale**: When building a "CRUD" (CReate, Update, Delete of an entity), you would like to allow people to go back on some custom cancel page when coming for another place of an application. For instance, if you are in the timeline and show an activity, you would like that the user go back to the timeline when pressing the "return" button at the bottom of the page, instead of going back to the "activity list" page. -Using those function, a :code:`returnPath` parameter will be added in the path generated. It will be used instead of the default one in the subsequente pages. +Using those function, a `returnPath` parameter will be added in the path generated. It will be used instead of the default one in the subsequente pages. -- :code:`chill_path_add_return_path(name, parameters = [], relative = false)`: will create a path with current page as return path (users will go back to the current page on the next page) ; -- :code:`chill_return_path_or(name, parameters = [], relative = false)`: will create a path to the return path if present, or build the path according to the given parameters ; -- :code:`chill_path_forward_return_path(name, parameters = [], relative = false)`: will create a path and adding the return path that is present for the current page, *forwarding* the return path to the next page. This is useful if you are on a "show" page with a return path (by instance, the "timeline" page), you place a link to the *edit* page and want users to go back to the "timeline" page on the edit page. +- `chill_path_add_return_path(name, parameters = [], relative = false)`: will create a path with current page as return path (users will go back to the current page on the next page) ; +- `chill_return_path_or(name, parameters = [], relative = false)`: will create a path to the return path if present, or build the path according to the given parameters ; +- `chill_path_forward_return_path(name, parameters = [], relative = false)`: will create a path and adding the return path that is present for the current page, *forwarding* the return path to the next page. This is useful if you are on a "show" page with a return path (by instance, the "timeline" page), you place a link to the *edit* page and want users to go back to the "timeline" page on the edit page. -- :code:`chill_return_path_or`: +- `chill_return_path_or`: Organisation of the layouts @@ -178,9 +178,9 @@ Useful template and helpers Macros ------ -Every bundle may bring their own macro to print resources with uniformized styles. +Every bundle may bring their own macro to print resources with uniformized styles. -See : +See : - :ref:`Macros in person bundle ` ; - :ref:`Macros in activity bundle ` ; @@ -195,7 +195,7 @@ ChillMainBundle::Util:confirmation_template.html.twig This template show a confirmation template before making dangerous things. You can add your own message and title, or define those message by yourself in another template. -The accepted parameters are : +The accepted parameters are : - `title` (string) a title for the page. Not mandatory (it won't be rendered if not defined) - `confirm_question` (string) a confirmation question. This question will not be translated into the template, and may be printed as raw. Not mandatory (it won't be rendered if not defined) @@ -204,7 +204,7 @@ The accepted parameters are : - `cancel_parameters` (array) the parameters for the route defined in `cancel_route` -Usage : +Usage : .. code-block:: html+twig diff --git a/docs/source/development/user-interface/widgets.rst b/docs/source/development/user-interface/rst/widgets.rst similarity index 81% rename from docs/source/development/user-interface/widgets.rst rename to docs/source/development/user-interface/rst/widgets.rst index 1ce4bc591..e635e20e2 100644 --- a/docs/source/development/user-interface/widgets.rst +++ b/docs/source/development/user-interface/rst/widgets.rst @@ -15,12 +15,12 @@ Rationale Widgets are useful if you want to publish content on a page provided by another bundle. -Examples : +Examples : - you want to publish a list of people on the homepage ; - you may want to show the group belonging (see :ref:`group-bundle`) below of the vertical menu, only if the bundle is installed. -The administrator of the chill instance may configure the presence of widget. Although, some widget are defined by default (see :ref:`declaring-widget-by-default`). +The administrator of the chill instance may configure the presence of widget. Although, some widget are defined by default (see :ref:`declaring-widget-by-default`). Concepts ======== @@ -71,32 +71,32 @@ Example : class AddAPersonWidget implements WidgetInterface { public function render( - \Twig_Environment $env, - $place, - array $context, + \Twig_Environment $env, + $place, + array $context, array $config ) { // this will render a link to the page "add a person" return $env->render("ChillPersonBundle:Widget:homepage_add_a_person.html.twig"); } - } + } -Arguments are : +Arguments are : -- :code:`$env` the :class:`\Twig_Environment`, which you can use to render your widget ; -- :code:`$place` a string representing the place where the widget is rendered ; -- :code:`$context` the context given by the template ; -- :code:`$config` the configuration which is, in this case, always an empty array (see :ref:`creating-a-widget-with-config`). +- `$env` the :class:`\Twig_Environment`, which you can use to render your widget ; +- `$place` a string representing the place where the widget is rendered ; +- `$context` the context given by the template ; +- `$config` the configuration which is, in this case, always an empty array (see :ref:`creating-a-widget-with-config`). .. note:: - The html returned by the :code:`render` function will be considered as html safe. You should strip html before returning it. See also `How to escape output in template `_. + The html returned by the `render` function will be considered as html safe. You should strip html before returning it. See also `How to escape output in template `_. Declare your widget ------------------- -Declare your widget as a service and add it the tag :code:`chill_widget`: +Declare your widget as a service and add it the tag `chill_widget`: .. code-block:: yaml @@ -107,17 +107,17 @@ Declare your widget as a service and add it the tag :code:`chill_widget`: - { name: chill_widget, alias: add_person, place: homepage } -The tag must contains those arguments : +The tag must contains those arguments : -- :code:`alias`: an alias, which will be used to reference the widget into the config -- :code:`place`: a place where this widget is authorized +- `alias`: an alias, which will be used to reference the widget into the config +- `place`: a place where this widget is authorized If you want your widget to be available on multiple places, you should add one tag with each place. Conclusion ---------- -Once your widget is correctly declared, your widget should be available in configuration. +Once your widget is correctly declared, your widget should be available in configuration. .. code-block:: bash @@ -134,14 +134,14 @@ Once your widget is correctly declared, your widget should be available in confi # the widget alias (see your installed bundles config). Possible values are (maybe incomplete) : person_list, add_person widget_alias: ~ # Required -If you want to add your widget by default, see :ref:`declaring-widget-by-default`. +If you want to add your widget by default, see :ref:`declaring-widget-by-default`. .. _creating-a-widget-with-config: Creating a widget **with** configuration ======================================== -You can declare some configuration with your widget, which allow administrators to add their own configuration. +You can declare some configuration with your widget, which allow administrators to add their own configuration. To add some configuration, you will : @@ -153,7 +153,7 @@ To add some configuration, you will : Declare your widget class ------------------------- -Declare your widget. You can use some configuration elements in your process, as used here : +Declare your widget. You can use some configuration elements in your process, as used here : .. literalinclude:: ./widgets/ChillPersonAddAPersonWidget.php :language: php @@ -161,7 +161,7 @@ Declare your widget. You can use some configuration elements in your process, as Declare your widget as a service -------------------------------- -You can declare your widget as a service. Not tag is required, as the service will be defined by the :code:`Factory` during next step. +You can declare your widget as a service. Not tag is required, as the service will be defined by the `Factory` during next step. .. code-block:: yaml @@ -187,7 +187,7 @@ The widget factory must implements `Chill\MainBundle\DependencyInjection\Widget\ :language: php .. note:: - You can declare your widget into the container by overriding the `createDefinition` method. By default, this method will return the already existing service definition with the id given by :code:`getServiceId`. But you can create or adapt programmatically the definition. `See the symfony doc on how to do it `_. + You can declare your widget into the container by overriding the `createDefinition` method. By default, this method will return the already existing service definition with the id given by `getServiceId`. But you can create or adapt programmatically the definition. `See the symfony doc on how to do it `_. .. code-block:: php @@ -199,22 +199,22 @@ The widget factory must implements `Chill\MainBundle\DependencyInjection\Widget\ return $definition; } -You must then register your factory into the :code:`Extension` class which provide the place. This is done in the :code: `Bundle` class. +You must then register your factory into the `Extension` class which provide the place. This is done in the `Bundle` class. .. code-block:: php - # Chill/PersonBundle/ChillPersonBundle.php - + # Chill/PersonBundle/ChillPersonBundle.php + use Symfony\Component\HttpKernel\Bundle\Bundle; use Symfony\Component\DependencyInjection\ContainerBuilder; use Chill\PersonBundle\Widget\PersonListWidgetFactory; class ChillPersonBundle extends Bundle { - public function build(ContainerBuilder $container) + public function build(ContainerBuilder $container) { parent::build($container); - + $container->getExtension('chill_main') ->addWidgetFactory(new PersonListWidgetFactory()); } @@ -225,7 +225,7 @@ You must then register your factory into the :code:`Extension` class which provi Declaring a widget by default ============================= -Use the ability `to prepend configuration of other bundle `_. A living example here : +Use the ability `to prepend configuration of other bundle `_. A living example here : .. literalinclude:: ./widgets/ChillPersonExtension.php :language: php @@ -237,10 +237,10 @@ Defining a place Add your place in template -------------------------- -A place should be defined by using the :code:`chill_widget` function, which take as argument : +A place should be defined by using the `chill_widget` function, which take as argument : -- :code:`place` (string) a string defining the place ; -- :code:`context` (array) an array defining the context. +- `place` (string) a string defining the place ; +- `context` (array) an array defining the context. The context should be documented by the bundle. It will give some information about the context of the page. Example: if the page concerns a people, the :class:`Chill\PersonBundle\Entity\Person` class will be in the context. @@ -262,7 +262,7 @@ Declare configuration for you place In order to let other bundle, or user, to define the widgets inside the given place, you should open a configuration. You can use the Trait :class:`Chill\MainBundle\DependencyInjection\Widget\AddWidgetConfigurationTrait`, which provide the method `addWidgetConfiguration($place, ContainerBuilder $container)`. -Example : +Example : .. literalinclude:: ./widgets/ChillMainConfiguration.php :language: php @@ -285,11 +285,11 @@ You should also adapt the :class:`DependencyInjection\*Extension` class to add C Compile the possible widget using Compiler pass ----------------------------------------------- -For your convenience, simply extends :class:`Chill\MainBundle\DependencyInjection\Widget\AbstractWidgetsCompilerPass`. This class provides a `doProcess(ContainerBuildere $container, $extension, $parameterName)` method which will do the job for you: +For your convenience, simply extends :class:`Chill\MainBundle\DependencyInjection\Widget\AbstractWidgetsCompilerPass`. This class provides a `doProcess(ContainerBuildere $container, $extension, $parameterName)` method which will do the job for you: -- :code:`$container` is the container builder -- :code:`$extension` is the extension name -- :code:`$parameterName` is the name of the parameter which contains the configuration for widgets (see :ref:`the example with ChillMain above `. +- `$container` is the container builder +- `$extension` is the extension name +- `$parameterName` is the name of the parameter which contains the configuration for widgets (see :ref:`the example with ChillMain above `. .. code-block:: php @@ -300,10 +300,10 @@ For your convenience, simply extends :class:`Chill\MainBundle\DependencyInjectio /** * Compile the service definition to register widgets. - * + * */ class WidgetsCompilerPass extends AbstractWidgetsCompilerPass { - + public function process(ContainerBuilder $container) { $this->doProcess($container, 'chill_main', 'chill_main.widgets'); diff --git a/docs/source/development/user-interface/widgets.md b/docs/source/development/user-interface/widgets.md new file mode 100644 index 000000000..90be70ae8 --- /dev/null +++ b/docs/source/development/user-interface/widgets.md @@ -0,0 +1,270 @@ +Permission is granted to copy, distribute and/or modify this document + under the terms of the GNU Free Documentation License, Version 1.3 + or any later version published by the Free Software Foundation; + with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. + A copy of the license is included in the section entitled "GNU + Free Documentation License". + +# Widgets + +## Rationale + +Widgets are useful if you want to publish content on a page provided by another bundle. + +Examples : + +- you want to publish a list of people on the homepage ; +- you may want to show the group belonging (see [group-bundle[) below of the vertical menu, only if the bundle is installed. + +The administrator of the chill instance may configure the presence of widget. Although, some widget are defined by default (see [declaring-widget-by-default](declaring-widget-by-default.md)). + +## Concepts + +A bundle may define *place(s)* where a widget may be rendered. + +In a single *place*, zero, one or more *widget* may be displayed. + +Some *widget* may require some *configuration*, and some does not require any configuration. + +Example: + +=========================================== ======== ============================= ======================================= +Use case place place defined by... widget provided by... +=========================================== ======== ============================= ======================================= +Publishing a list of people on the homepage homepage defined by [main-bundle](main-bundle.md) widget provided by [person-bundle](person-bundle.md) +=========================================== ======== ============================= ======================================= + +## Creating a widget without configuration + +To add a widget, you should : + +- define your widget, implementing :class:`Chill\MainBundle\Templating\Widget\WidgetInterface` ; +- declare your widget with tag `chill_widget`. + +### Define the widget class + +Define your widget class by implemeting :class:`Chill\MainBundle\Templating\Widget\WidgetInterface`. + +Example : + + namespace Chill\PersonBundle\Widget; + + use Chill\MainBundle\Templating\Widget\WidgetInterface; + + /** + * Add a button "add a person" + * + */ + class AddAPersonWidget implements WidgetInterface + { + public function render( + \Twig_Environment $env, + $place, + array $context, + array $config + ) { + // this will render a link to the page "add a person" + return $env->render("ChillPersonBundle:Widget:homepage_add_a_person.html.twig"); + } + } + +Arguments are : + +- `$env` the :class:`\Twig_Environment`, which you can use to render your widget ; +- `$place` a string representing the place where the widget is rendered ; +- `$context` the context given by the template ; +- `$config` the configuration which is, in this case, always an empty array (see [creating-a-widget-with-config](creating-a-widget-with-config.md)). + + The html returned by the `render` function will be considered as html safe. You should strip html before returning it. See also `How to escape output in template ](http://symfony.com/doc/current/templating/escaping.html.md)_. + +### Declare your widget + +Declare your widget as a service and add it the tag `chill_widget`: + + service: + chill_person.widget.add_person: + class: Chill\PersonBundle\Widget\AddAPersonWidget + tags: + - { name: chill_widget, alias: add_person, place: homepage } + +The tag must contains those arguments : + +- `alias`: an alias, which will be used to reference the widget into the config +- `place`: a place where this widget is authorized + +If you want your widget to be available on multiple places, you should add one tag with each place. + +### Conclusion + +Once your widget is correctly declared, your widget should be available in configuration. + + $ php app/console config:dump-reference chill_main + # Default configuration for extension with alias: "chill_main" + chill_main: + [...] + # register widgets on place "homepage" + homepage: + + # the ordering of the widget. May be a number with decimal + order: ~ # Required, Example: 10.58 + + # the widget alias (see your installed bundles config). Possible values are (maybe incomplete) : person_list, add_person + widget_alias: ~ # Required + +If you want to add your widget by default, see [declaring-widget-by-default`. + +## Creating a widget **with** configuration + +You can declare some configuration with your widget, which allow administrators to add their own configuration. + +To add some configuration, you will : + +- declare a widget as defined above ; +- optionnaly declare it as a service ; +- add a widget factory, which will add configuration to the bundle which provide the place. + +### Declare your widget class + +Declare your widget. You can use some configuration elements in your process, as used here : + + :language: php + +### Declare your widget as a service + +You can declare your widget as a service. Not tag is required, as the service will be defined by the `Factory` during next step. + + services: + chill_person.widget.person_list: + class: Chill\PersonBundle\Widget\PersonListWidget + arguments: + - "@chill.person.repository.person" + - "@doctrine.orm.entity_manager" + - "@chill.main.security.authorization.helper" + - "@security.token_storage" + # this widget is defined by the PersonListWidgetFactory + +You can eventually skip this step and declare your service into the container through the factory (see above). + +### Declare your widget factory + +The widget factory must implements `Chill\MainBundle\DependencyInjection\Widget\Factory\WidgetFactoryInterface`. For your convenience, an :class:`Chill\MainBundle\DependencyInjection\Widget\Factory\AbstractWidgetFactory` will already implements some easy method. + + :language: php + + You can declare your widget into the container by overriding the `createDefinition` method. By default, this method will return the already existing service definition with the id given by `getServiceId`. But you can create or adapt programmatically the definition. `See the symfony doc on how to do it ](http://symfony.com/doc/current/service_container/definitions.html#working-with-a-definition.md)_. + + ```php + public function createDefinition(ContainerBuilder $containerBuilder, $place, $order, array $config) + { + $definition = new \Symfony\Component\DependencyInjection\Definition('my\Class'); + // create or adapt your definition here +``` + + return $definition; + } + +You must then register your factory into the `Extension` class which provide the place. This is done in the `Bundle` class. + + # Chill/PersonBundle/ChillPersonBundle.php + + use Symfony\Component\HttpKernel\Bundle\Bundle; + use Symfony\Component\DependencyInjection\ContainerBuilder; + use Chill\PersonBundle\Widget\PersonListWidgetFactory; + + class ChillPersonBundle extends Bundle + { + public function build(ContainerBuilder $container) + { + parent::build($container); + + $container->getExtension('chill_main') + ->addWidgetFactory(new PersonListWidgetFactory()); + } + } + +## Declaring a widget by default + +Use the ability `to prepend configuration of other bundle ](http://symfony.com/doc/current/bundles/prepend_extension.html). A living example here : + + :language: php + +## Defining a place + +### Add your place in template + +A place should be defined by using the [chill_widget` function, which take as argument : + +- `place` (string) a string defining the place ; +- `context` (array) an array defining the context. + +The context should be documented by the bundle. It will give some information about the context of the page. Example: if the page concerns a people, the :class:`Chill\PersonBundle\Entity\Person` class will be in the context. + +Example : + + {# an empty context on homepage #} + {{ chill_widget('homepage', {} }} + + {# defining a place 'right column' with the person currently viewed + {{ chill_widget('right_column', { 'person' : person } }} + +### Declare configuration for you place + +In order to let other bundle, or user, to define the widgets inside the given place, you should open a configuration. You can use the Trait :class:`Chill\MainBundle\DependencyInjection\Widget\AddWidgetConfigurationTrait`, which provide the method `addWidgetConfiguration($place, ContainerBuilder $container)`. + +Example : + + :language: php + :emphasize-lines: 17, 30, 32, 52 + :linenos: + +You should also adapt the :class:`DependencyInjection\*Extension` class to add ContainerBuilder and WidgetFactories : + + :language: php + :emphasize-lines: 25-39, 48-49, 56 + :linenos: + +- line 25-39: we implements the method required by :class:`Chill\MainBundle\DependencyInjection\Widget\HasWidgetExtensionInterface` ; +- line 48-49: we record the configuration of widget into container's parameter ; +- line 56 : we create an instance of :class:`Configuration` (declared above) + +### Compile the possible widget using Compiler pass + +For your convenience, simply extends :class:`Chill\MainBundle\DependencyInjection\Widget\AbstractWidgetsCompilerPass`. This class provides a `doProcess(ContainerBuildere $container, $extension, $parameterName)` method which will do the job for you: + +- `$container` is the container builder +- `$extension` is the extension name +- `$parameterName` is the name of the parameter which contains the configuration for widgets (see [the example with ChillMain above ](example-chill-main-extension.md). + + namespace Chill\MainBundle\DependencyInjection\CompilerPass; + + use Symfony\Component\DependencyInjection\ContainerBuilder; + use Chill\MainBundle\DependencyInjection\Widget\AbstractWidgetsCompilerPass; + + /** + * Compile the service definition to register widgets. + * + */ + class WidgetsCompilerPass extends AbstractWidgetsCompilerPass { + + public function process(ContainerBuilder $container) + { + $this->doProcess($container, 'chill_main', 'chill_main.widgets'); + } + } + +As explained `in the symfony docs ](http://symfony.com/doc/current/service_container/compiler_passes.html), you should register your Compiler Pass into your bundle : + + namespace Chill\MainBundle; + + use Symfony\Component\HttpKernel\Bundle\Bundle; + use Symfony\Component\DependencyInjection\ContainerBuilder; + use Chill\MainBundle\DependencyInjection\CompilerPass\WidgetsCompilerPass; + + class ChillMainBundle extends Bundle + { + public function build(ContainerBuilder $container) + { + parent::build($container); + $container->addCompilerPass(new WidgetsCompilerPass()); + } + } diff --git a/docs/source/index.md b/docs/source/index.md new file mode 100644 index 000000000..59aaa87fc --- /dev/null +++ b/docs/source/index.md @@ -0,0 +1,54 @@ +# Welcome to Chill documentation! + +Chill is a free software for social workers. + +Chill relies on the php framework [Symfony](http://symfony.com). + +## Let's talk together! + +You can talk to developers using the matrix room: [https://app.element.io/#/room/#chill-social-admin:matrix.org](https://app.element.io/#/room/#chill-social-admin:matrix.org) + +## Contribute + +* [Issue tracker](https://gitlab.com/groups/Chill-project/issues) You may want to dispatch an issue to multiple projects. If you do not know in which project your bug / feature request should be located, use the project Chill-Main. + +## User manual + +A user manual exists in French and currently focuses on describing the main concept of the software. + +[Read (and contribute) to the manual](https://fr.wikibooks.org/wiki/Chill) + +## Available bundles + +* Chill-app | https://gitlab.com/Chill-project/Chill-app This is the skeleton of the project. It contains only a bit of code but provides plenty of information on the configuration of your instance; +* Chill-bundles: this repository contains all the main bundles. This means: + * MainBundle: the main framework, + * PersonBundle: to deal with persons, + * CustomFieldsBundle: to add custom fields to some entities, + * ActivityBundle: to add activities to people, + * AsideActivityBundle: to add annex activities such as meetings/trainings not related to a person, + * BudgetBundle: to add budget elements to a person's file, + * DocGeneratorBundle: to generate documents from templates, + * JobBundle: to add information related to employment/ professional training to a person's file, + * ReportBundle: to add a report to a person's file, + * EventBundle: to create events and add persons that will be participating, + * DocStoreBundle: to store documents to people, but also to other entities, + * TaskBundle: to register tasks within a person's file or accompanying course, + * ThirdPartyBundle: to register third parties, + +You will also found the following projects : + +* The website https://chill.social : https://gitlab.com/Chill-project/chill.social + +And various projects to build docker containers with Chill. + +## Licence + +The project is available under the [GNU AFFERO GENERAL PUBLIC LICENSE v3](http://www.gnu.org/licenses/agpl-3.0.html). + +This documentation is published under the [GNU Free Documentation License (FDL) v1.3](http://www.gnu.org/licenses/fdl-1.3.html) + +--- + +*Copyright (C) 2014 Champs Libres Cooperative SC +Permission is granted to copy, distribute and/or modify this document under the terms of the GNU Free Documentation License, Version 1.3 or any later version published by the Free Software Foundation; with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. A copy of the license is included in the section entitled "GNU Free Documentation License".* diff --git a/docs/source/index.rst b/docs/source/index.rst deleted file mode 100644 index ddfa23e3b..000000000 --- a/docs/source/index.rst +++ /dev/null @@ -1,85 +0,0 @@ -.. chill-doc documentation master file, created by - sphinx-quickstart on Sun Sep 28 22:04:08 2014. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - -.. Copyright (C) 2014 Champs Libres Cooperative SCRLFS - Permission is granted to copy, distribute and/or modify this document - under the terms of the GNU Free Documentation License, Version 1.3 - or any later version published by the Free Software Foundation; - with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. - A copy of the license is included in the section entitled "GNU - Free Documentation License". - -Welcome to Chill documentation! -===================================== - -Chill is a free software for social workers. - -Chill rely on the php framework `Symfony `_. - -Contents of this documentation: - -.. toctree:: - :maxdepth: 2 - - installation/index.rst - development/index.rst - Bundles - -Let's talk together ! -====================== - -You may talk to developers using the matrix room: `https://app.element.io/#/room/#chill-social-admin:matrix.org`_ - -Contribute -========== - - -* `Issue tracker `_ You may want to dispatch the issue in the multiple projects. If you do not know in which project is located your bug / feature request, use the project Chill-Main. - - -User manual -=========== - -An user manual exists in French and currently focuses on describing the main concept of the software. - -`Read (and contribute) to the manual `_ - -Available bundles -================= - -* Chill-app | https://gitlab.com/Chill-project/Chill-app This is the skeleton of the project. It does contains only few code, but information about configuration of your instance ; -* Chill-bundle: contains the main bundles, the most used in an instance. This means: - * chill-main, the main framework, - * Chill Person, to deal with persons, - * chill custom fields, to add custom fields to some entities, - * chill activity: to add activities to people, - * chill report: to add report to people, - * chill event: to gather people into events, - * chill docs store: to store documents to people, but also entities, - * chill task: to register task with people, - * chill third party: to register third parties, - -You will also found the following projects : - -* The website https://chill.social : https://gitlab.com/Chill-project/chill.social - -And various project to build docker containers with Chill. - -TODO in documentation -===================== - -.. todolist:: - -Licence -======== - -The project is available under the `GNU AFFERO GENERAL PUBLIC LICENSE v3`_. - -This documentation is published under the `GNU Free Documentation License (FDL) v1.3`_ - - -.. _GNU AFFERO GENERAL PUBLIC LICENSE v3: http://www.gnu.org/licenses/agpl-3.0.html -.. _GNU Free Documentation License (FDL) v1.3: http://www.gnu.org/licenses/fdl-1.3.html - diff --git a/docs/source/installation/document-storage.md b/docs/source/installation/document-storage.md new file mode 100644 index 000000000..7849f7662 --- /dev/null +++ b/docs/source/installation/document-storage.md @@ -0,0 +1,74 @@ +# Document storage + +You can store a document in two different ways: + +- on disk +- in the cloud, using object storage: currently only [openstack swift ](https://docs.openstack.org/api-ref/object-store/index.html) is supported. + +## Comparison + +Storing documents within the cloud is particularly suitable for "portable" deployments, like in kubernetes, or within a container +without having to manage volumes to store documents. But you'll have to subscribe to a commercial offer. + +Storing documents on disk is easier to configure but more challenging to manage: if you use container, you will have to +manager volumes to attach documents on disk. You'll have to do some backup of the directory. If chill is load-balanced (and +multiple instances of chill are run), you will have to find a way to share the directories in read-write mode for every instance. + +## On Disk + +Configure Chill like this: + +```yaml + # file config/packages/chill_doc_store.yaml + chill_doc_store: + use_driver: local_storage + local_storage: + storage_path: '%kernel.project_dir%/var/storage' +``` + +In this configuration, documents will be stored in [var/storage` within your app directory. But this path can be +elsewhere on the disk. Be aware that the directory must be writable by the user executing the chill app (php-fpm or www-data). + +Documents will be stored in subpathes within that directory. The files will be encrypted, the key is stored in the database. + +# In the cloud, using openstack object store + +You must subscribe to a commercial offer for an object store. + +Chill uses some features to allow documents to be stored in the cloud without being uploaded first to the chill server: + +- `Form POST Middelware ](https://docs.openstack.org/swift/latest/api/form_post_middleware.html); +- [Temporary URL Middelware ](https://docs.openstack.org/swift/latest/api/temporary_url_middleware.html). + +A secret key must be generated and configured, and CORS must be configured depending on the domain you will use to serve Chill. + +At first, create a container and get the base path to the container. For instance, on OVH, if you create a container named "mychill", +you will be able to retrieve the base path of the container within the OVH interface, like this: + +- base_path: [https://storage.gra.cloud.ovh.net/v1/AUTH_123456789/mychill/` => will be variable `ASYNC_UPLOAD_TEMP_URL_BASE_PATH` +- container: `mychill` => will be variable `ASYNC_UPLOAD_TEMP_URL_CONTAINER` + +You can also generate a key, which should have at least 20 characters. This key will go in the variable `ASYNC_UPLOAD_TEMP_URL_KEY`. + + See the `documentation of symfony ](https://symfony.com/doc/current/configuration.html#config-env-vars) on how to store variables, and how to encrypt them if needed. + +Configure the storage like this: + +```yaml + # file config/packages/chill_doc_store.yaml + chill_doc_store: + use_driver: openstack + openstack: + temp_url: + temp_url_key: '%env(resolve:ASYNC_UPLOAD_TEMP_URL_KEY)%' # Required + container: '%env(resolve:ASYNC_UPLOAD_TEMP_URL_CONTAINER)%' # Required + temp_url_base_path: '%env(resolve:ASYNC_UPLOAD_TEMP_URL_BASE_PATH)%' # Required +``` + +Chill is able to configure the container to store a document. Grab an Openstack Token (for instance, using `openstack token issue` or +the web interface of your openstack provider), and run this command: + + `symfony console async-upload:configure --os_token=OPENSTACK_TOKEN -d https://mychill.mydomain.example` + + # or, without symfony-cli + `bin/console async-upload:configure --os_token=OPENSTACK_TOKEN -d https://mychill.mydomain.example` diff --git a/docs/source/installation/document-storage.rst b/docs/source/installation/document-storage.rst deleted file mode 100644 index 0b87d9eb2..000000000 --- a/docs/source/installation/document-storage.rst +++ /dev/null @@ -1,84 +0,0 @@ -Document storage -################ - -You can store document on two different ways: - -- on disk -- in the cloud, using object storage: currently only `openstack swift `_ is supported. - -Comparison -========== - -Storing documents within the cloud is particularily suitable for "portable" deployments, like in kubernetes, or within container -without having to manage volumes to store documents. But you'll have to subscribe on a commercial offer. - -Storing documents on disk is more easy to configure, but more difficult to manage: if you use container, you will have to -manager volumes to attach documents on disk. You'll have to do some backup of the directory. If chill is load-balanced (and -multiple instances of chill are run), you will have to find a way to share the directories in read-write mode for every instance. - -On Disk -======= - -Configure Chill like this: - -.. code-block:: yaml - - # file config/packages/chill_doc_store.yaml - chill_doc_store: - use_driver: local_storage - local_storage: - storage_path: '%kernel.project_dir%/var/storage' - -In this configuration, documents will be stored in :code:`var/storage` within your app directory. But this path can be -elsewhere on the disk. Be aware that the directory must be writable by the user executing the chill app (php-fpm or www-data). - -Documents will be stored in subpathes within that directory. The files will be encrypted, the key is stored in the database. - -In the cloud, using openstack object store -########################################## - -You must subscribe to a commercial offer for object store. - -Chill use some features to allow documents to be stored in the cloud without being uploaded first to the chill server: - -- `Form POST Middelware `_; -- `Temporary URL Middelware `_. - -A secret key must be generated and configured, and CORS must be configured depending on the domain you will use to serve Chill. - -At first, create a container and get the base path to the container. For instance, on OVH, if you create a container named "mychill", -you will be able to retrieve the base path of the container within the OVH interface, like this: - -- base_path: :code:`https://storage.gra.cloud.ovh.net/v1/AUTH_123456789/mychill/` => will be variable :code:`ASYNC_UPLOAD_TEMP_URL_BASE_PATH` -- container: :code:`mychill` => will be variable :code:`ASYNC_UPLOAD_TEMP_URL_CONTAINER` - -You can also generate a key, which should have at least 20 characters. This key will go in the variable :code:`ASYNC_UPLOAD_TEMP_URL_KEY`. - -.. note:: - - See the `documentation of symfony `_ on how to store variables, and how to encrypt them if needed. - -Configure the storage like this: - -.. code-block:: yaml - - # file config/packages/chill_doc_store.yaml - chill_doc_store: - use_driver: openstack - openstack: - temp_url: - temp_url_key: '%env(resolve:ASYNC_UPLOAD_TEMP_URL_KEY)%' # Required - container: '%env(resolve:ASYNC_UPLOAD_TEMP_URL_CONTAINER)%' # Required - temp_url_base_path: '%env(resolve:ASYNC_UPLOAD_TEMP_URL_BASE_PATH)%' # Required - -Chill is able to configure the container in order to store document. Grab an Openstack Token (for instance, using :code:`openstack token issue` or -the web interface of your openstack provider), and run this command: - -.. code-block:: bash - - symfony console async-upload:configure --os_token=OPENSTACK_TOKEN -d https://mychill.mydomain.example - - # or, without symfony-cli - bin/console async-upload:configure --os_token=OPENSTACK_TOKEN -d https://mychill.mydomain.example - - diff --git a/docs/source/installation/enable-collabora-for-dev.md b/docs/source/installation/enable-collabora-for-dev.md new file mode 100644 index 000000000..8132b7aab --- /dev/null +++ b/docs/source/installation/enable-collabora-for-dev.md @@ -0,0 +1,105 @@ +## Enable CODE for development + +For editing a document, there must be a way to communicate between the collabora server and the symfony server, in +both directions. The domain name should also be the same for collabora server and for the browser which access to the +online editor. + +### Using ngrok (or other http tunnel) + +One can configure a tunnel server to expose your local installation to the web and access to your local server using the +tunnel url. + +##### Start ngrok + +This can be achieved using [ngrok ](https://ngrok.com/). + + The configuration of ngrok is outside the scope of this document. Refers to the ngrok's documentation. + + # ensuring that your server is running through http and port 8000 + ngrok http 8000 + # then open the link given by the ngrok utility, and you should reach your app + +At this step, ensure that you can reach your local app using the ngrok url. + +##### Configure Collabora + +The collabora server must be executed online and configured to access to your ngrok installation. Ensure that the aliasgroup +exists for your ngrok application ([See the CODE documentation: ](https://sdk.collaboraonline.com/docs/installation/Configuration.html#multihost-configuration)). + +##### Configure your app + +Set the `EDITOR_SERVER` variable to point to your collabora server, this should be done in your `.env.local` file. + +At this point, everything must be fine. In case of errors, watch the log from your collabora server, use the [profiler](https://symfony.com/doc/current/profiler.html) +to debug the requests. + + In case of error while validating proof (you'll see those messages in the collabora's logs), you can temporarily disable + the proof validation adding this code snippet in `config/services.yaml`: + +```yaml + when@dev: + # add only in the dev environment to avoid security problems + services: + ChampsLibres\WopiLib\Contract\Service\ProofValidatorInterface: + # this class will always validate proof + alias: Chill\WopiBundle\Service\Wopi\NullProofValidator +``` + +### With a local CODE image + + This configuration is not sure and must be refined. The documentation does not seem entirely valid. + +##### Use a local domain name and https for your app + +Use the proxy feature from the embedded symfony server to run your app. `See the dedicated doc [ + +Configure also the `https certificate ](https://symfony.com/doc/current/setup/symfony_server.html#enabling-tls) + +In this example, your local domain name will be `my-domain` and the url will be `https://my-domain.wip`. + +Ensure that the proxy is running. + +##### Create a certificate database for collabora + +Collabora must validate your certificate generated by the symfony console. For that, you need [to create a NSS database](https://sdk.collaboraonline.com/docs/installation/Configuration.html#validating-digital-signatures) +and configure collabora to use it. + +At first, export the certificate for symfony development. Use the graphical interface from your browser to get the +certificate as a PEM file. + +```bash + # create your database in a custom directory + `mkdir /path/to/your/directory` + `certutil -N -d /path/to/your/directory` + `cat /path/to/your/ca.crt | certutil -d . -A symfony -t -t C,P,C,u,w -a` +``` + +Launch CODE properly configured + +```yaml + collabora: + image: collabora/code:latest + environment: + - SLEEPFORDEBUGGER=0 + - DONT_GEN_SSL_CERT="True" + # add a path to the database + - extra_params=--o:ssl.enable=false --o:ssl.termination=false --o:logging.level=7 -o:certificates.database_path=/etc/custom-certificates/nss-database + - username=admin + - password=admin + - dictionaries=en_US + - aliasgroup1=https://my-domain.wip + ports: + - "127.0.0.1:9980:9980" + volumes: + - "/path/to/your/directory/nss-database:/etc/custom-certificates/nss-database" + extra_hosts: + - "my-domain.wip:host-gateway" +``` + +##### Configure your app + +In your `.env.local` file: + + `EDITOR_SERVER=http://${COLLABORA_HOST}:${COLLABORA_PORT}` + +At this step, you should be able to edit a document through collabora. diff --git a/docs/source/installation/enable-collabora-for-dev.rst b/docs/source/installation/enable-collabora-for-dev.rst deleted file mode 100644 index 17a9ae1cc..000000000 --- a/docs/source/installation/enable-collabora-for-dev.rst +++ /dev/null @@ -1,125 +0,0 @@ - -Enable CODE for development -=========================== - -For editing a document, there must be a way to communicate between the collabora server and the symfony server, in -both direction. The domain name should also be the same for collabora server and for the browser which access to the -online editor. - -Using ngrok (or other http tunnel) ----------------------------------- - -One can configure a tunnel server to expose your local install to the web, and access to your local server using the -tunnel url. - -Start ngrok -^^^^^^^^^^^ - -This can be achieve using `ngrok `_. - -.. note:: - - The configuration of ngrok is outside of the scope of this document. Refers to the ngrok's documentation. - -.. code-block:: bash - - # ensuring that your server is running through http and port 8000 - ngrok http 8000 - # then open the link given by the ngrok utility and you should reach your app - -At this step, ensure that you can reach your local app using the ngrok url. - -Configure Collabora -^^^^^^^^^^^^^^^^^^^ - -The collabora server must be executed online and configure to access to your ngrok installation. Ensure that the aliasgroup -exists for your ngrok application (`See the CODE documentation: `_). - -Configure your app -^^^^^^^^^^^^^^^^^^ - -Set the :code:`EDITOR_SERVER` variable to point to your collabora server, this should be done in your :code:`.env.local` file. - -At this point, everything must be fine. In case of errors, watch the log from your collabora server, use the `profiler `_ -to debug the requests. - -.. note:: - - In case of error while validating proof (you'll see those message in the collabora's logs), you can temporarily disable - the proof validation adding this code snippet in `config/services.yaml`: - - .. code-block:: yaml - - when@dev: - # add only in dev environment, to avoid security problems - services: - ChampsLibres\WopiLib\Contract\Service\ProofValidatorInterface: - # this class will always validate proof - alias: Chill\WopiBundle\Service\Wopi\NullProofValidator - -With a local CODE image ------------------------ - -.. warning:: - - This configuration is not sure, and must be refined. The documentation does not seems to be entirely valid. - -Use a local domain name and https for your app -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Use the proxy feature from embedded symfony server to run your app. `See the dedicated doc ` - -Configure also the `https certificate `_ - -In this example, your local domain name will be :code:`my-domain` and the url will be :code:`https://my-domain.wip`. - -Ensure that the proxy is running. - -Create a certificate database for collabora -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Collabora must validate your certificate generated by symfony console. For that, you need `to create a NSS database ` -and configure collabora to use it. - -At first, export the certificate for symfony development. Use the graphical interface from your browser to get the -certificate as a PEM file. - -.. code-block:: bash - - # create your database in a custom directory - mkdir /path/to/your/directory - certutil -N -d /path/to/your/directory - cat /path/to/your/ca.crt | certutil -d . -A symfony -t -t C,P,C,u,w -a - -Launch CODE properly configured - -.. code-block:: yaml - - collabora: - image: collabora/code:latest - environment: - - SLEEPFORDEBUGGER=0 - - DONT_GEN_SSL_CERT="True" - # add path to the database - - extra_params=--o:ssl.enable=false --o:ssl.termination=false --o:logging.level=7 -o:certificates.database_path=/etc/custom-certificates/nss-database - - username=admin - - password=admin - - dictionaries=en_US - - aliasgroup1=https://my-domain.wip - ports: - - "127.0.0.1:9980:9980" - volumes: - - "/path/to/your/directory/nss-database:/etc/custom-certificates/nss-database" - extra_hosts: - - "my-domain.wip:host-gateway" - -Configure your app -^^^^^^^^^^^^^^^^^^ - -Into your :code:`.env.local` file: - -.. code-block:: env - - EDITOR_SERVER=http://${COLLABORA_HOST}:${COLLABORA_PORT} - -At this step, you should be able to edit a document through collabora. diff --git a/docs/source/installation/index.md b/docs/source/installation/index.md new file mode 100644 index 000000000..674f1525b --- /dev/null +++ b/docs/source/installation/index.md @@ -0,0 +1,43 @@ +# Installation & Usage + +You will learn here how to install a new symfony project with chill, and configure it. + +## Which kind of installation do I need? + +### I want to run chill in production + +See the [instructions about installing Chill for production ](installation-production.md). + +### I want to add features to the main chill bundles + +If you want to add features to chill bundles itself, **and** you want those features to be merged into the chill bundles, +you can use the "development" installation mode. + +See the [instruction for installation for development ](installation-for-dev.md). + +### I want to add features to Chill, but keep those features for my instance + +Follow the same instruction than for production, until the end. + +## Requirements + +The installation is tested on a Debian-like linux distribution. The installation on other operating systems is not documented. + +You have to install the following tools on your computer: + +- [PHP](https://www.php.net/): version 8.3+, with the following extensions: pdo_pgsql, intl, mbstring, zip, bcmath, exif, sockets, redis, ast, gd; +- [composer ](https://getcomposer.org/); +- [symfony cli ](https://symfony.com/download); +- [node, we encourage you to use nvm to configure the correct version ](https://github.com/nvm-sh/nvm): The project contains a + `nvmrc` file which selects automatically the required version of node (if present). +- [yarn](https://classic.yarnpkg.com/lang/en/docs/install/): We use version 1.22+ for now. +- [docker and the plugin compose ](https://docker.com) to run the database + +Chill needs a redis server and a postgresql database, and a few other things like a "relatorio service" which will +generate documents from templates. **All these things are available through docker using the plugin compose**. We do not provide +information on how to run this without docker compose. + +## Instructions + +- [Installation for Development](installation-development.md) +- [Installation for Production](installation-production.md) diff --git a/docs/source/installation/index.rst b/docs/source/installation/index.rst deleted file mode 100644 index fb4cb3cac..000000000 --- a/docs/source/installation/index.rst +++ /dev/null @@ -1,67 +0,0 @@ -.. chill-doc documentation master file, created by - sphinx-quickstart on Sun Sep 28 22:04:08 2014. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - -.. Copyright (C) 2014-2019 Champs Libres Cooperative SCRLFS - Permission is granted to copy, distribute and/or modify this document - under the terms of the GNU Free Documentation License, Version 1.3 - or any later version published by the Free Software Foundation; - with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. - A copy of the license is included in the section entitled "GNU - Free Documentation License". - -Installation & Usage -#################### - - -You will learn here how to install a new symfony project with chill, and configure it. - -Which can of installation do I need ? -===================================== - -I want to run chill in production ---------------------------------- - -See the :ref:`instructions about installing Chill for production `. - -I want to add features to the main chill bundles ------------------------------------------------- - -If you want to add features to chill bundles itself, **and** you want those features to be merged into the chill bundles, -you can use the "development" installation mode. - -See the :ref:`instruction for installation for development `. - -I want to add features to Chill, but keep those features for my instance -------------------------------------------------------------------------- - -Follow the same instruction than for production, until the end. - -Requirements -============ - -The installation is tested on a Debian-like linux distribution. The installation on other operating systems is not documented. - -You have to install the following tools on your computer: - -- `PHP `_, version 8.3+, with the following extensions: pdo_pgsql, intl, mbstring, zip, bcmath, exif, sockets, redis, ast, gd; -- `composer `_; -- `symfony cli `_; -- `node, we encourage you to use nvm to configure the correct version `_. The project contains an - :code:`.nvmrc` file which selects automatically the required version of node (if present). -- `yarn `_. We use the version 1.22+ for now. -- `docker and the plugin compose `_ to run the database - -Chill needs a redis server and a postgresql database, and a few other things like a "relatorio service" which will -generate documents from templates. **All these things are available through docker using the plugin compose**. We do not provide -information on how to run this without docker compose. - -Instructions -============ - -.. toctree:: - :maxdepth: 2 - - installation-development.rst - installation-production.rst diff --git a/docs/source/installation/installation-development.md b/docs/source/installation/installation-development.md new file mode 100644 index 000000000..8c79bc8f5 --- /dev/null +++ b/docs/source/installation/installation-development.md @@ -0,0 +1,89 @@ +## Installation for development or testing purpose only + +⚠️ Use this method for development only. ⚠️ + +You will need: + +- [Composer ](https://getcomposer.org); +- [Symfony-cli tool ](https://symfony.com/download); +- [docker ](https://docs.docker.com/engine/install/) and [docker-compose ](https://docs.docker.com/compose/) +- node > 20 and yarn 1.22 + +### First initialization + +1. clone the repository and move to the cloned directory: + + ```bash + git clone https://gitlab.com/Chill-Projet/chill-bundles.git + cd chill-bundles + ``` + +2. install dependencies using composer + + ```bash + composer install + ``` + +3. Install and compile assets: + + ```bash + yarn install + yarn run encore production + ``` + + **note** double-check that you have the node version > 20 using the `node --version` command. + +4. Configure your project: create a `.env.local` file at the root, and add the admin password: + + **note** for this installation mode, the environment should always be "dev" + ``` + APP_ENV=dev + ADMIN_PASSWORD=\$2y\$13\$iyvJLuT4YEa6iWXyQV4/N.hNHpNG8kXlYDkkt5MkYy4FXcSwYAwmm + ``` + **note**: if you copy-paste the line above, the password will be "admin". + +5. Start the stack using `docker compose up -d`, check the status of the start with `docker compose ps` + +6. Configure all the necessary third-party tools. On the first start, it may last a bit longer. You can always check with `docker compose ps` + + ```bash + # Run migrations + symfony console doctrine:migrations:migrate + + # Setup messenger + symfony console messenger:setup-transports + + # Prepare some views + symfony console chill:db:sync-views + + # Generate jwt token, required for some api features (webdav access, ...) + symfony console lexik:jwt:generate-keypair + ``` + +7. Add some fixtures + + This will truncate all the existing data of the database. But remember, + we are in dev mode! + + ```bash + symfony console doctrine:fixtures:load + ``` + +8. launch symfony dev-server + + ```bash + symfony server:start -d + ``` + +And visit the web page it suggests. You can log in with user +`center a_social` and password `password`, or login `admin` with +the password you set. + +### Stopping the server + + `symfony server:stop` + +### Restart the webserver for a later start + + `symfony server:start -d` + # this will automatically start the full docker compose services diff --git a/docs/source/installation/installation-development.rst b/docs/source/installation/installation-development.rst deleted file mode 100644 index 5977747c6..000000000 --- a/docs/source/installation/installation-development.rst +++ /dev/null @@ -1,101 +0,0 @@ -.. _installation-for-dev: - -Installation for development or testing purpose only -==================================================== - -⚠️ Use this method for development only. ⚠️ - -You will need: - -- `Composer `__; -- `Symfony-cli tool `__; -- `docker `__ and - `docker-compose `__ -- node > 20 and yarn 1.22 - -First initialization --------------------- - -1. clone the repository and move to the cloned directory: - -.. code:: bash - - git clone https://gitlab.com/Chill-Projet/chill-bundles.git - cd chill-bundles - -2. install dependencies using composer - -.. code:: bash - - composer install - -3. Install and compile assets: - -.. code:: bash - - yarn install - yarn run encore production - -**note** double check that you have the node version > 20 using the -``node --version`` command. - -4. configure your project: create a ``.env.local`` file at the root, and - add the admin password: - -.. code:: dotenv - - # for this installation mode, the environment should always be "dev" - APP_ENV=dev - ADMIN_PASSWORD=\$2y\$13\$iyvJLuT4YEa6iWXyQV4/N.hNHpNG8kXlYDkkt5MkYy4FXcSwYAwmm - # note: if you copy-paste the line above, the password will be "admin". - -5. start the stack using ``docker compose up -d``, check the status of - the start with ``docker compose ps`` - -6. configure all the needed third-party tools - - .. code:: bash - - # the first start, it may last some seconds, you can check with docker compose ps - # run migrations - symfony console doctrine:migrations:migrate - # setup messenger - symfony console messenger:setup-transports - # prepare some views - symfony console chill:db:sync-views - # generate jwt token, required for some api features (webdav access, ...) - symfony console lexik:jwt:generate-keypair - -7. add some fixtures - -This will truncate all the existing data of the database. But remember, -we are in dev mode ! - -.. code:: bash - - symfony console doctrine:fixtures:load - -8. launch symfony dev-server - -.. code:: bash - - symfony server:start -d - -And visit the web page it suggest. You can login with user -``center a_social`` and password ``password``, or login ``admin`` with -the password you set. - -Stopping the server -------------------- - -.. code:: bash - - symfony server:stop - -Restart the webserver for subsequent start ------------------------------------------- - -.. code:: bash - - symfony server:start -d - # this will automatically starts the full docker compose services diff --git a/docs/source/installation/installation-production.md b/docs/source/installation/installation-production.md new file mode 100644 index 000000000..b85deb7d4 --- /dev/null +++ b/docs/source/installation/installation-production.md @@ -0,0 +1,310 @@ +# Install Chill for production with or without adding personal features + +Chill is a set of "bundles" for a symfony app. + +To run Chill in production or add new features to it (without merging those features to the chill core), you must create +a symfony app, and eventually add those features into your app. + +Once you are happy with the configuration, [you should follow the dedicated instructions of how to go into production for +Symfony apps ](https://symfony.com/doc/current/deployment.html). + +## Install a new app + +### Install required dependencies: + +- `jq`: https://jqlang.org/ (install it through your package manager); +- `php`, minimal version: 8.3; +- `composer`: https://getcomposer.org/download/ +- `symfony-cli`: https://symfony.com/download +- `docker` with the plugin `compose`: https://docs.docker.com/engine/install/ and https://docs.docker.com/compose/install/ + +### Initialize the project and it's dependencies +```bash + symfony new --version=5.4 my_chill_project + cd my_chill_project +``` + +We strongly encourage you to initialize a git repository at this step, to track further changes. +```bash + # add the flex endpoints required for custom recipes + `cat <<< "$(jq '.extra.symfony += {"endpoint": ["flex://defaults", "https://gitlab.com/api/v4/projects/57371968/repository/files/index.json/raw?ref=main"]}' composer.json)" > composer.json` + # install chill and some dependencies + `symfony composer require -W chill-project/chill-bundles ^3.7.1 champs-libres/wopi-lib dev-master@dev champs-libres/wopi-bundle dev-master@dev symfony/amqp-messenger` +``` + +We encourage you to accept the inclusion of the "Docker configuration from recipes": this is the documented way to run the database. +You must also accept to configure recipes from the contrib repository, unless you want to configure the bundles manually). + +```bash + # fix some configuration + ./post-install-chill.sh + # populate the cache for the first time. This is necessary to dump some translation files, required for the asset compilation + symfony console cache:clear + # install node dependencies + yarn install + # and compile assets + yarn run encore production +``` + + If you encounter this error during asset compilation ([yarn run encore production`) (repeated multiple times): + +```bash + [tsl] ERROR in /tmp/chill/v1/public/bundles/chillcalendar/types.ts(2,65) + TS2307: Cannot find module '../../../ChillMainBundle/Resources/public/types' or its corresponding type declarations. +``` + + run: + +```bash + rm -rf public/bundles/* +``` + + Then restart the compilation of assets (```yarn run encore production```) + +### Configure your project + +You should read the configuration files in `chill/config/packages` carefully, especially if you have +custom developments. But most of the time, this should be fine. + +You have to configure some local variables, which are described in the `.env` file. The secrets should not be stored +in this `.env` file, but instead using the `secrets management tool ](https://symfony.com/doc/current/configuration/secrets.html) +or in the `.env.local` file, which should not be committed to the git repository. + +You do not need to set variables for the smtp server, redis server and relatorio server, as they are generated automatically +by the symfony server, from the docker compose services. + +The required variables are: + +- the `ADMIN_PASSWORD`; +- the `OVHCLOUD_DSN` variable; + +##### `ADMIN_PASSWORD` + +You can generate a hashed and salted admin password using the command: +`symfony console security:hash-password 'Symfony\Component\Security\Core\User\User'` + +Then, you can either: + +- add this password to the `.env.local` file, you must escape the character `$`: if the generated password + is `$2y$13$iyvJLuT4YEa6iWXyQV4/N.hNHpNG8kXlYDkkt5MkYy4FXcSwYAwmm`, your `.env.local` file will be: + + ```bash + ADMIN_PASSWORD=\$2y\$13\$iyvJLuT4YEa6iWXyQV4/N.hNHpNG8kXlYDkkt5MkYy4FXcSwYAwmm + # note: if you copy-paste the line above, the password will be "admin". + ``` + +- add the generated password to the secrets manager (**note**: you must add the generated hashed password to the secrets env, + not the password in clear text). + +##### `OVHCLOUD_DSN` and sending SMS messages + +This is a temporary dependency, for ensuring compatibility for previous behaviour. + +You can set it to `null://null` if you do not plan to use sending SMS. + + OVHCLOUD_DSN=null://null + +If you plan to do it, you can configure the notifier component `as described in the [symfony documentation](https://symfony.com/doc/current/notifier.html#notifier-sms-channel). + +Some environment variables are available for the JWT authentication bundle in the `.env` file. + +### Prepare database, messenger queue, and other configuration + +To continue the installation process, you will have to run migrations: + +```bash + # start databases and other services + docker compose up -d + # the first start, it may last some seconds, you can check with docker compose ps + # run migrations + symfony console doctrine:migrations:migrate + # setup messenger + symfony console messenger:setup-transports + # prepare some views + symfony console chill:db:sync-views + # load languages data + symfony console chill:main:languages:populate + # generate jwt token, required for some api features (webdav access, ...) + symfony console lexik:jwt:generate-keypair +``` + + If you encounter this error: + +``` +No transport supports the given Messenger DSN. +``` + + Please check that you installed the package `symfony/amqp-messenger`. + +### Start your web server locally + +At this step, Chill will be ready to be served locally, but without any configuration. You can run the project +locally using the `local symfony server ](https://symfony.com/doc/current/setup/symfony_server.html): + +```bash + # see the whole possibilities at https://symfony.com/doc/current/setup/symfony_server.html + symfony server:start -d +``` + +If you need to test the instance with accounts and some basic configuration, please install the fixtures (see below). + +## Add capabilities for dev + +If you need to add custom bundles, you can develop them in the `src/` directory, like for any other symfony project. You +can rely on the whole chill framework, meaning there is no need to add them to the original `chill-bundles`. + +You will require some bundles to have the following development tools: + +- add fixtures +- add profiler and debug bundle + +### Install fixtures + +```bash + # generate fixtures for chill + symfony composer require --dev doctrine/doctrine-fixtures-bundle nelmio/alice + # now, you can generate fixtures (this will reset your database) + symfony console doctrine:fixtures:load +``` + +This will generate user accounts, centers, and some basic configuration. + +The accounts created are: `center a_social`, `center b_social`, `center a_direction`, ... + +The full list is visible in the "users" table: `docker compose exec database psql -U app -c "SELECT username FROM users"`. + +The password is always `password`. + +The fixtures are not fully functional. See the `corresponding issue ](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/280). + +### Add web profiler and debugger + +```bash + symfony composer require --dev symfony/web-profiler-bundle symfony/debug-bundle +``` + +### Working on chill bundles + +If you plan to improve the chill-bundles repository, that's great! + +It would be better [to follow the instruction about development ](installation-for-dev.md). But if those features are +deeply linked to some dev you made in the app, it can be easier to develop within the [vendor/` directory. + +You will have to download chill-bundles as a git repository (and not as an archive, which is barely editable). + +In your `composer.json` file, add these lines: + +```bash + { + "config": { + + "preferred-install": { + + "chill-project/chill-bundles": "source", + "*": "dist" + + } + } +``` + +Then, run `symfony composer reinstall chill-project/chill-bundles` to re-install the package from source. + +## Update + +In order to update your app, you must update dependencies: + +- for chill-bundles, you can `set the last version ](https://gitlab.com/Chill-Projet/chill-bundles/-/releases) manually + in the [composer.json` file, or set the version to `^3.0.0` and run `symfony composer update` regularly +- run `composer update` and `yarn update` to maintain your dependencies up-to-date. + +After each update, you must update your database schema: + +```bash + symfony console doctrine:migrations:migrate + symfony console chill:db:sync-views +``` + +## Commit and share your project + +If multiple developers work on a project, you can commit your symfony project and share it with other people. + +When another developer clones your project, they will have to: + +- run `symfony composer install` and `yarn install` to install the same dependencies as the initial developer; +- run `yarn run encore production` to compile assets; +- copy any possible variables from the `.env.local` files; +- start the docker compose stack, using `docker compose`, and run migrations, set up transports, and prepare chill db views + (see the corresponding command above) + +## Operations + +### Build assets + +run those commands: + +```bash + # for production (or in dev, when you don't need to work on your assets and need some speed) + yarn run encore production + # in dev, when you want to reload the assets on each changes + yarn run encore dev --watch` +``` + +### How to execute the console ? + +```bash + # start the console with all required variables + symfony console + # you can add your command after that: + symfony console list +``` + +### How to generate documents + +Documents are generated asynchronously by [consuming messages](https://symfony.com/doc/current/messenger.html#consuming-messages-running-the-worker). + +You must generate them using a dedicated process: + + `symfony console messenger:consume async priority` + +To avoid memory issues, we encourage you to also use the `--limit` parameter of the command. + +### How to read emails sent by the program ? + +In development, there is a built-in "mail catcher". Open it with `symfony open:local:webmail` + +### How to run cron-jobs ? + +Some commands must be executed in [cron jobs ](cronjob.md). To execute them: + + `symfony console chill:cron-job:execute` + +### What about materialized views ? + +There are some materialized views in chill, to speed up some complex computations in the database. + +In order to refresh them, run a cron job or refresh them manually in your database. + +## Troubleshooting + +### Error `An exception has been thrown during the rendering of a template ("Asset manifest file "/var/www/app/web/build/manifest.json" does not exist.").` on first run + +Build assets, see above. + +## Go to production + +Currently, to run this software in production, the *state of the art* is the following : + +1. Run the software locally and tweak the configuration to your needs ; +2. Build the image and store it in a private container registry. + + In production, you **must** set these variables: + + * ``APP_ENV`` to ``prod`` + * ``APP_DEBUG`` to ``false`` + + There are security issues if you keep the same variables as for production. + +## Going further + +- [Production Setup](prod.md) +- [Document Storage](document-storage.md) +- [Load Addresses](load-addresses.md) +- [Production Calendar SMS Sending](prod-calendar-sms-sending.md) +- [Microsoft Graph Configuration](msgraph-configure.md) diff --git a/docs/source/installation/installation-production.rst b/docs/source/installation/installation-production.rst deleted file mode 100644 index 7169af5d8..000000000 --- a/docs/source/installation/installation-production.rst +++ /dev/null @@ -1,365 +0,0 @@ -.. _installation-production: - -Install Chill for production with or without adding personal features -##################################################################### - -Chill is a set of "bundles" for a symfony app. - -To run Chill in production or add new features to it (without merging those features to the chill core), you must create -a symfony app, and eventually add those features into your app. - -Once you are happy with the configuration, `you should follow the dedicated instructions of how to go into production for -Symfony apps `_. - -Install a new app -================= - -Install required dependencies: ------------------------------- - -- `jq`: https://jqlang.org/ (install it through your package manager); -- `php`, minimal version: 8.3; -- `composer`: https://getcomposer.org/download/ -- `symfony-cli`: https://symfony.com/download -- `docker` with the plugin `compose`: https://docs.docker.com/engine/install/ and https://docs.docker.com/compose/install/ - - -Initialize project and dependencies ------------------------------------ - -.. code-block:: bash - - symfony new --version=5.4 my_chill_project - cd my_chill_project - -We strongly encourage you to initialize a git repository at this step, to track further changes. - -.. code-block:: bash - - # add the flex endpoints required for custom recipes - cat <<< "$(jq '.extra.symfony += {"endpoint": ["flex://defaults", "https://gitlab.com/api/v4/projects/57371968/repository/files/index.json/raw?ref=main"]}' composer.json)" > composer.json - # install chill and some dependencies - symfony composer require -W chill-project/chill-bundles ^3.7.1 champs-libres/wopi-lib dev-master@dev champs-libres/wopi-bundle dev-master@dev symfony/amqp-messenger - -We encourage you to accept the inclusion of the "Docker configuration from recipes": this is the documented way to run the database. -You must also accept to configure recipes from the contrib repository, unless you want to configure the bundles manually). - -.. code-block:: bash - - # fix some configuration - ./post-install-chill.sh - # populate the cache for the first time. This is necessary to dump some translation files, required for the assets compilation - symfony console cache:clear - # install node dependencies - yarn install - # and compile assets - yarn run encore production - -.. note:: - - If you encounter this error during assets compilation (:code:`yarn run encore production`) (repeated multiple times): - - .. code-block:: - - [tsl] ERROR in /tmp/chill/v1/public/bundles/chillcalendar/types.ts(2,65) - TS2307: Cannot find module '../../../ChillMainBundle/Resources/public/types' or its corresponding type declarations. - - run: - - .. code-block:: bash - - rm -rf public/bundles/* - - Then restart the compilation of assets (:code:```yarn run encore production```) - -Configure your project ----------------------- - -You should read the configuration files in :code:`chill/config/packages` carefully, especially if you have -custom developments. But most of the time, this should be fine. - -You have to configure some local variables, which are described in the :code:`.env` file. The secrets should not be stored -in this :code:`.env` file, but instead using the `secrets management tool `_ -or in the :code:`.env.local` file, which should not be committed to the git repository. - -You do not need to set variables for the smtp server, redis server and relatorio server, as they are generated automatically -by the symfony server, from the docker compose services. - -The required variables are: - -- the :code:`ADMIN_PASSWORD`; -- the :code:`OVHCLOUD_DSN` variable; - -:code:`ADMIN_PASSWORD` -^^^^^^^^^^^^^^^^^^^^^^ - -You can generate a hashed and salted admin password using the command -:code:`symfony console security:hash-password 'Symfony\Component\Security\Core\User\User'`.Then, -you can either: - -- add this password to the :code:`.env.local` file, you must escape the character :code:`$`: if the generated password - is :code:`$2y$13$iyvJLuT4YEa6iWXyQV4/N.hNHpNG8kXlYDkkt5MkYy4FXcSwYAwmm`, your :code:`.env.local` file will be: - - .. code-block:: bash - - ADMIN_PASSWORD=\$2y\$13\$iyvJLuT4YEa6iWXyQV4/N.hNHpNG8kXlYDkkt5MkYy4FXcSwYAwmm - # note: if you copy-paste the line above, the password will be "admin". - -- add the generated password to the secrets manager (**note**: you must add the generated hashed password to the secrets env, - not the password in clear text). - -:code:`OVHCLOUD_DSN` and sending SMS messages -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -This is a temporary dependency, for ensuring compatibility for previous behaviour. - -You can set it to :code:`null://null` if you do not plan to use sending SMS. - -.. code-block:: bash - - OVHCLOUD_DSN=null://null - -If you plan to do it, you can configure the notifier component `as described in the symfony documentation `_. - - -Some environment variables are available for the JWT authentication bundle in the :code:`.env` file. - -Prepare database, messenger queue, and other configuration ----------------------------------------------------------- - -To continue the installation process, you will have to run migrations: - -.. code-block:: bash - - # start databases and other services - docker compose up -d - # the first start, it may last some seconds, you can check with docker compose ps - # run migrations - symfony console doctrine:migrations:migrate - # setup messenger - symfony console messenger:setup-transports - # prepare some views - symfony console chill:db:sync-views - # load languages data - symfony console chill:main:languages:populate - # generate jwt token, required for some api features (webdav access, ...) - symfony console lexik:jwt:generate-keypair - -.. note:: - - If you encounter this error: - - .. code-block:: - - No transport supports the given Messenger DSN. - - Please check that you installed the package `symfony/amqp-messenger`. - - - -Start your web server locally ------------------------------ - -At this step, Chill will be ready to be served locally, but without any configuration. You can run the project -locally using the `local symfony server `_: - -.. code-block:: bash - - # see the whole possibilities at https://symfony.com/doc/current/setup/symfony_server.html - symfony server:start -d - - -If you need to test the instance with accounts and some basic configuration, please install the fixtures (see below). - - -Add capabilities for dev -======================== - -If you need to add custom bundles, you can develop them in the `src/` directory, like for any other symfony project. You -can rely on the whole chill framework, meaning there is no need to add them to the original `chill-bundles`. - -You will require some bundles to have the following development tools: - -- add fixtures -- add profiler and debug bundle - -Install fixtures ----------------- - -.. code-block:: bash - - # generate fixtures for chill - symfony composer require --dev doctrine/doctrine-fixtures-bundle nelmio/alice - # now, you can generate fixtures (this will reset your database) - symfony console doctrine:fixtures:load - -This will generate user accounts, centers, and some basic configuration. - -The accounts created are: :code:`center a_social`, :code:`center b_social`, :code:`center a_direction`, ... The full list is -visible in the "users" table: :code:`docker compose exec database psql -U app -c "SELECT username FROM users"`. - -The password is always :code:`password`. - -.. warning:: - - The fixtures are not fully functional. See the `corresponding issue `_. - -Add web profiler and debugger ------------------------------ - -.. code-block:: bash - - symfony composer require --dev symfony/web-profiler-bundle symfony/debug-bundle - -Working on chill bundles ------------------------- - -If you plan to improve the chill-bundles repository, that's great! - -It would be better :ref:`to follow the instruction about development `. But if those features are -deeply linked to some dev you made in the app, it can be easier to develop within the :code:`vendor/` directory. - -You will have to download chill-bundles as a git repository (and not as an archive, which is barely editable). - -In your :code:`composer.json` file, add these lines: - -.. code-block:: diff - - { - "config": { - + "preferred-install": { - + "chill-project/chill-bundles": "source", - "*": "dist" - + } - } - -Then, run :code:`symfony composer reinstall chill-project/chill-bundles` to re-install the package from source. - - -Update -====== - -In order to update your app, you must update dependencies: - -- for chill-bundles, you can `set the last version `_ manually - in the :code:`composer.json` file, or set the version to `^3.0.0` and run :code:`symfony composer update` regularly -- run :code:`composer update` and :code:`yarn update` to maintain your dependencies up-to-date. - -After each update, you must update your database schema: - -.. code-block:: bash - - symfony console doctrine:migrations:migrate - symfony console chill:db:sync-views - - -Commit and share your project -============================= - -If multiple developers work on a project, you can commit your symfony project and share it with other people. - -When another developer clones your project, they will have to: - -- run :code:`symfony composer install` and :code:`yarn install` to install the same dependencies as the initial developer; -- run :code:`yarn run encore production` to compile assets; -- copy any possible variables from the :code:`.env.local` files; -- start the docker compose stack, using :code:`docker compose`, and run migrations, set up transports, and prepare chill db views - (see the corresponding command above) - -Operations -========== - -Build assets ------------- - -run those commands: - -.. code-block:: bash - - # for production (or in dev, when you don't need to work on your assets and need some speed) - yarn run encore production - # in dev, when you wan't to reload the assets on each changes - yarn run encore dev --watch - -How to execute the console ? ----------------------------- - -.. code-block:: bash - - # start the console with all required variables - symfony console - # you can add your command after that: - symfony console list - -How to generate documents -------------------------- - -Documents are generated asynchronously by `"consuming messages" `_. - -You must generate them using a dedicated process: - -.. code-block:: bash - - symfony console messenger:consume async priority - -To avoid memory issues, we encourage you to also use the :code:`--limit` parameter of the command. - -How to read emails sent by the program ? -------------------------------------------- - -In development, there is a built-in "mail catcher". Open it with :code:`symfony open:local:webmail` - -How to run cron-jobs ? ----------------------- - -Some commands must be executed in :ref:`cron jobs `. To execute them: - -.. code-block:: bash - - symfony console chill:cron-job:execute - -What about materialized views ? -------------------------------- - -There are some materialized views in chill, to speed up some complex computations in the database. - -In order to refresh them, run a cron job or refresh them manually in your database. - - -Troubleshooting -=============== - -Error `An exception has been thrown during the rendering of a template ("Asset manifest file "/var/www/app/web/build/manifest.json" does not exist.").` on first run --------------------------------------------------------------------------------------------------------------------------------------------------------------------- - -Build assets, see above. - -Go to production -================ - -Currently, to run this software in production, the *state of the art* is the following : - -1. Run the software locally and tweak the configuration to your needs ; -2. Build the image and store it in a private container registry. - -.. warning:: - - In production, you **must** set these variables: - - * ``APP_ENV`` to ``prod`` - * ``APP_DEBUG`` to ``false`` - - There are security issues if you keep the same variables as for production. - - -Going further -============= - -.. toctree:: - :maxdepth: 2 - - prod.rst - document-storage.rst - load-addresses.rst - prod-calendar-sms-sending.rst - msgraph-configure.rst diff --git a/docs/source/installation/load-addresses.md b/docs/source/installation/load-addresses.md new file mode 100644 index 000000000..2e4b193f8 --- /dev/null +++ b/docs/source/installation/load-addresses.md @@ -0,0 +1,47 @@ +###### Addresses + +Chill can store a list of geolocated address references, which are used to suggest address and ensure that the data is correctly stored. + +Those addresses may be loaded from a dedicated source. + +## Countries + +To load addresses into the chill application we first have to make sure that a list of countries is present. +To import, the countries run the following command. + + bin/console chill:main:countries:populate + +## In France + +The address is loaded from the [BANO ](https://bano.openstreetmap.fr/). The postal codes are loaded from [the official list of +postal codes ](https://datanova.laposte.fr/explore/dataset/laposte_hexasmal/information/) + + # first, load postal codes + `bin/console chill:main:postal-code:load:FR` + # then, load all addresses by departement (multiple departements can be loaded by repeating the departement code + `bin/console chill:main:address-ref-from-bano 57 54 51` + +## In Belgium + +Addresses are prepared from the [BeST Address data ](https://www.geo.be/catalog/details/ca0fd5c0-8146-11e9-9012-482ae30f98d9). + +Postal code is loaded from this database. There is no need to load postal codes from another source (actually, this is strongly discouraged). + +The data are prepared for Chill ([See this repository ](https://gitea.champs-libres.be/Chill-project/belgian-bestaddresses-transform/releases)). +One can select postal code by his first number (`1xxx` for postal codes from 1000 to 1999), or a limited list for development purpose. + +The command expects a language code as the first argument. + + # load postal code from 1000 to 3999: + `bin/console chill:main:address-ref-from-best-addresse fr 1xxx 2xxx 3xxx` + + # load only an extract (for dev purposes) + `bin/console chill:main:address-ref-from-best-addresse fr extract` + + # load full addresses (discouraged) + `bin/console chill:main:address-ref-from-best-addresse fr full` + + There is a possibility to load the full list of addresses is discouraged: the loading is optimized with smaller extracts. + + Once you load the full list, it is not possible to load a smaller extract: each extract loaded **after** will not + delete the addresses loaded with the full extract (and some addresses will be present twice). diff --git a/docs/source/installation/load-addresses.rst b/docs/source/installation/load-addresses.rst deleted file mode 100644 index 641325dee..000000000 --- a/docs/source/installation/load-addresses.rst +++ /dev/null @@ -1,62 +0,0 @@ - -.. _addresses: - -Addresses -********* - -Chill can store a list of geolocated address references, which are used to suggest address and ensure that the data is correctly stored. - -Those addresses may be load from a dedicated source. - -Countries -========= - -In order to load addresses into the chill application we first have to make sure that a list of countries is present. -To import the countries run the following command. - -.. code-block:: bash - - bin/console chill:main:countries:populate - -In France -========= - -The address are loaded from the `BANO `_. The postal codes are loaded from `the official list of -postal codes `_ - -.. code-block:: bash - - # first, load postal codes - bin/console chill:main:postal-code:load:FR - # then, load all addresses, by departement (multiple departement can be loaded by repeating the departement code - bin/console chill:main:address-ref-from-bano 57 54 51 - -In Belgium -========== - -Addresses are prepared from the `BeST Address data `_. - -Postal code are loaded from this database. There is no need to load postal codes from another source (actually, this is strongly discouraged). - -The data are prepared for Chill (`See this repository `_). -One can select postal code by his first number (:code:`1xxx` for postal codes from 1000 to 1999), or a limited list for development purpose. - -The command expects a language code as first argument. - -.. code-block:: bash - - # load postal code from 1000 to 3999: - bin/console chill:main:address-ref-from-best-addresse fr 1xxx 2xxx 3xxx - - # load only an extract (for dev purposes) - bin/console chill:main:address-ref-from-best-addresse fr extract - - # load full addresses (discouraged) - bin/console chill:main:address-ref-from-best-addresse fr full - -.. note:: - - There is a possibility to load the full list of addresses is discouraged: the loading is optimized with smaller extracts. - - Once you load the full list, it is not possible to load smaller extract: each extract loaded **after** will not - delete the addresses loaded with the full extract (and some addresses will be present twice). diff --git a/docs/source/installation/msgraph-configure.rst b/docs/source/installation/msgraph-configure.md similarity index 60% rename from docs/source/installation/msgraph-configure.rst rename to docs/source/installation/msgraph-configure.md index 5655e9ff2..e6d6003a9 100644 --- a/docs/source/installation/msgraph-configure.rst +++ b/docs/source/installation/msgraph-configure.md @@ -1,70 +1,59 @@ - -Configure Chill for calendar and absence synchronisation and SSO with Microsoft Graph (Outlook) -=============================================================================================== +## Configure Chill for calendar and absence synchronisation and SSO with Microsoft Graph (Outlook) Chill offers the possibility to: * authenticate users using Microsoft Graph, with relatively small adaptations; -* synchronize calendar in both ways (`see the user manual for a large description of the feature `_). +* synchronize calendar in both ways ([see the user manual for a large description of the feature ](https://gitea.champs-libres.be/Chill-project/manuals)). -Both can be configured separately (synchronising calendars without SSO, or SSO without calendar). +Both can be configured separately (synchronising calendars without SSO, or SSO without a calendar). Please note that the user's email address is the key to associate Chill's users with Microsoft's ones. -Configure SSO -------------- +### Configure SSO -On Azure side -************* +###### On the Azure side -Configure an app with the Azure interface, and give it the name of your choice. +Configure an app with the Azure interface and give it the name of your choice. Grab the tenant's ID for your app, which is visible on the main tab "Vue d'ensemble": -.. figure:: ./saml_login_id_general.png +This is the variable which will be named `SAML_IDP_APP_UUID`. -This the variable which will be named :code:`SAML_IDP_APP_UUID`. +Go to the "Single sign-on" ("Authentication unique") section. Choose "SAML" as a protocol and fill those values: -Go to the "Single sign-on" ("Authentication unique") section. Choose "SAML" as protocol, and fill those values: +1. The `entityId` seems to be arbitrary. This will be your variable `SAML_ENTITY_ID`; +2. The url response must be your Chill's URL appended by `/saml/acs` +3. The only used attributes is `emailaddress`, which must match the user's email one. -.. figure:: ./saml_login_1.png +You must download the certificate, as base64. The format for the download is `cer`: you will remove the first and last line -1. The :code:`entityId` seems to be arbitrary. This will be your variable :code:`SAML_ENTITY_ID`; -2. The url response must be your Chill's URL appended by :code:`/saml/acs` -3. The only used attributes is :code:`emailaddress`, which must match the user's email one. +(the ones with `-----BEGIN CERTIFICATE-----` and `-----END CERTIFICATE-----`), -.. figure:: ./saml_login_2.png +and remove all the return line. The final result should be something as `MIIAbcdef...XyZA=`. -You must download the certificate, as base64. The format for the download is :code:`cer`: you will remove the first and last line (the ones with :code:`-----BEGIN CERTIFICATE-----` and :code:`-----END CERTIFICATE-----`), and remove all the return line. The final result should be something as :code:`MIIAbcdef...XyZA=`. - -This certificat will be your :code:`SAML_IDP_X509_CERT` variable. +This certificat will be your `SAML_IDP_X509_CERT` variable. The url login will be filled automatically with your tenant id. -Do not forget to provider user's accesses to your app, using the "Utilisateurs et groupes" tab: +Remember to provider user's access to your app, using the "Utilisateurs et groupes" tab: -.. figure:: ./saml_login_appro.png - - -You must know have gathered all the required variables for SSO: - -.. code-block:: +You must now have gathered all the required variables for SSO: +```bash SAML_BASE_URL=https://test.chill.be # must be SAML_ENTITY_ID=https://test.chill.be # must match the one entered SAML_IDP_APP_UUID=42XXXXXX-xxxx-xxxx-xxxx-xxxxxxxxxxxx SAML_IDP_X509_CERT: MIIC...E8u3bk # truncated +``` -Configure chill app -******************* +###### Configure chill app -* add the bundle :code:`hslavich/oneloginsaml-bundle` +* add the bundle `hslavich/oneloginsaml-bundle` * add the configuration file (see example above) * configure the security part (see example above) * add a user SAML factory into your src, and register it - -.. code-block:: yaml +```yaml # config/packages/hslavich_onelogin.yaml @@ -74,7 +63,6 @@ Configure chill app saml_idp_x509cert: '%env(resolve:SAML_IDP_X509_CERT)%' saml_idp_app_uuid: '%env(resolve:SAML_IDP_APP_UUID)%' - hslavich_onelogin_saml: # Basic settings idp: @@ -124,16 +112,14 @@ Configure chill app name: 'Example' displayname: 'Example' url: 'http://example.com' +``` - -.. code-block:: yaml - +```yaml # config/security.yaml # merge this with other existing configurations security: - providers: saml_provider: # Loads user from user repository @@ -143,7 +129,6 @@ Configure chill app firewalls: - default: # saml part: saml: @@ -158,10 +143,9 @@ Configure chill app login_path: saml_login logout: path: /saml/logout +``` - -.. code-block:: php - +```php // src/Security/SamlFactory.php namespace App\Security; @@ -185,105 +169,86 @@ Configure chill app return $user; } } +``` - - -Configure sync and calendar access ----------------------------------- +### Configure sync and calendar access The purpose of this configuration is the following: - let user read their calendar and shared calendar within Chill (with the same permissions as the one configured in Outlook / Azure); -- allow chill instance to write appointment ("Rendez-vous") into their calendar, and invite other users to their appointment; -- allow chill instance to be notified if an appoint is added or removed by the user within another interface than Chill: if the appointment match another one created in the Chill interface, the date and time are updated in Chill; +- allow chill instance to write appointment ("Rendez-vous") into their calendar and invite other users to their appointment; +- allow chill instance to be notified if an appointment is added or removed by the user within another interface than Chill: if the appointment matches another one created in the Chill interface, the date and time are updated in Chill; - allow chill instance to read the absence of the user and, if set, mark the user as absent in Chill; -The sync processe might be configured in the same app, or into a different app on the Azure side. +The sync process might be configured in the same app, or into a different app on the Azure side. The synchronization processes use Oauth 2.0 / OpenID Connect for authentication and authorization. -.. note:: - Two flows are in use: - * we authenticate "on behalf of a user", to allow users to see their own calendar or other user's calendar into the web interface. + * we authenticate "on behalf of a user", to allow users to see their own calendar or another user's calendar into the web interface. - Typically, when the page is loaded, Chill first check that an authorization token exists. If not, the user is redirected to Microsoft Azure for authentification and a new token is grabbed (most of the times, this is transparent for users). + Typically, when the page is loaded, Chill first checks that an authorization token exists. If not, the user is redirected to Microsoft Azure for authentification, and a new token is grabbed (most of the times, this is transparent for users). * Chill also acts "as a machine", to synchronize calendars with a daemon background. One can access the configuration using this screen (it is quite well hidden into the multiple of tabs): -.. figure:: ./oauth_app_registration.png + You can find the oauth configuration on the "Securité > Autorisations" tab and click on "application registration" (not translated). - You can find the oauth configuration on the "Securité > Autorisations" tab, and click on "application registration" (not translated). +Add a redirection URI for your authentification: -Add a redirection URI for you authentification: - -.. figure:: ./oauth_api_authentification.png - - The URI must be "your chill public url" with :code:`/connect/azure/check` at the end. + The URI must be "your chill public url" with `/connect/azure/check` at the end. Allow some authorizations for your app: -.. figure:: ./oauth_api_autorisees.png - -Take care of the separation between autorization "on behalf of a user" (déléguée), or "for a machine" (application). +Take care of the separation between authorization "on behalf of a user" (déléguée), or "for a machine" (application). Some explanation: -* Users must be allowed to read their user profile (:code:`User.Read`), and the profile of other users (:code:`User.ReadBasicAll`); -* They must be allowed to read their calendar (:code:`Calendars.Read`), and the calendars shared with them (:code:`Calendars.Read.Shared`); +* Users must be allowed to read their user profile (`User.Read`), and the profile of other users (`User.ReadBasicAll`); +* They must be allowed to read their calendar (`Calendars.Read`), and the calendars shared with them (`Calendars.Read.Shared`); The sync daemon must have write access: -* the daemon must be allowed to read all users and their profile, to establish a link between them and the Chill's users: (:code:`Users.Read.All`); -* it must also be allowed to read and write into the calendars (:code:`Calendars.ReadWrite.All`); -* for sending invitation to other users, the permission (:code:`Mail.Send`) must be granted; -* and, for reading the absence status of the user and sync it with chill, it must be able to read the mailboxSettings (:code:`MailboxSettings.Read`). +* the daemon must be allowed to read all users and their profile, to establish a link between them and the Chill's users: (`Users.Read.All`); +* it must also be allowed to read and write into the calendars (`Calendars.ReadWrite.All`); +* for sending invitation to other users, the permission (`Mail.Send`) must be granted; +* and, for reading the absence status of the user and sync it with chill, it must be able to read the mailboxSettings (`MailboxSettings.Read`). -At this step, you might choose to accept those permissions for all users, or let them do it by yourself. - -Grab your client id: - -.. figure:: ./oauth_api_client_id.png - -This will be your :code:`OAUTH_AZURE_CLIENT_ID` variable. +At this step, you might choose to accept those permissions for all users or let them do it by yourself. -Generate a secret: +Grab your client id: This will be your `OAUTH_AZURE_CLIENT_ID` variable. -.. figure:: ./oauth_api_secret.png +Generate a secret: This will be your `OAUTH_AZURE_CLIENT_SECRET` variable. -This will be your :code:`OAUTH_AZURE_CLIENT_SECRET` variable. - -And get you azure's tenant id, which is the same as the :code:`SAML_IDP_APP_UUID` (see above). +And get you azure's tenant id, which is the same as the `SAML_IDP_APP_UUID` (see above). Your variables will be: -.. code-block:: - +```bash OAUTH_AZURE_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx OAUTH_AZURE_CLIENT_TENANT=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx OAUTH_AZURE_CLIENT_SECRET: 3-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` Then, configure chill: -Enable the calendar sync with microsoft azure: - -.. code-block:: yaml +Enable the calendar sync with Microsoft Azure: +```yaml # config/packages/chill_calendar.yaml chill_calendar: remote_calendars_sync: microsoft_graph: enabled: true +``` and configure the oauth client: -.. code-block:: yaml - +```yaml # config/packages/knp_oauth2_client.yaml knpu_oauth2_client: clients: @@ -296,35 +261,30 @@ and configure the oauth client: tenant: '%env(OAUTH_AZURE_CLIENT_TENANT)%' url_api: 'https://graph.microsoft.com/' default_end_point_version: '2.0' +``` +You can now process for the first api authorization on the application side, (unless you did it in the Azure interface) and get the first token by using : -You can now process for the first api authorization on the application side, (unless you did it in the Azure interface), and get a first token, by using : +`bin/console chill:calendar:msgraph-grant-admin-consent` -:code:`bin/console chill:calendar:msgraph-grant-admin-consent` +This will generate an url that you can use to grant your app for your tenant. The redirection may fail in the browser, but this is not relevant: if you get an authorization token in the CLI, the authentication works. -This will generate a url that you can use to grant your app for your tenant. The redirection may fails in the browser, but this is not relevant: if you get an authorization token in the CLI, the authentication works. +### Run the processes to synchronize -Run the processes to synchronize --------------------------------- +The calendar synchronization is processed using symfony messenger. It seems to be interesting to configure a queue (in the postgresql database it is the simplest way), and to run a worker for synchronization, at least in production. -The calendar synchronization is processed using symfony messenger. It seems to be intersting to configure a queue (in the postgresql database it is the most simple way), and to run a worker for synchronization, at least in production. +This cli command does the association between chill's users and Microsoft's users: -The association between chill's users and Microsoft's users is done by this cli command: - -.. code-block:: - - bin/console chill:calendar:msgraph-user-map-subscribe + `bin/console chill:calendar:msgraph-user-map-subscribe` This command: * will associate the Microsoft's user metadata in our database; -* and, most important, create a subscription to get notification when the user alter his calendar, to sync chill's event and ranges in sync. +* and, most important, create a subscription to get notification when the user alters his calendar, to sync chill's event and ranges in sync. -The subscription least at most 3 days. This command should be runned: +The subscription is at least at most 3 days. This command should be run: * at least each time a user is added; * and, at least, every three days. -In production, we advise to run it at least every day to get the sync working. - - +In production, we advise running it at least every day to get the sync working. diff --git a/docs/source/installation/prod-calendar-sms-sending.md b/docs/source/installation/prod-calendar-sms-sending.md new file mode 100644 index 000000000..2987fa56b --- /dev/null +++ b/docs/source/installation/prod-calendar-sms-sending.md @@ -0,0 +1,21 @@ +## Send short messages (SMS) with the calendar bundle + +To activate the sending of messages, you should run this command on a regular basis (using, for instance, a cronjob): + + `bin/console chill:calendar:send-short-messages` + +A transporter must be configured for the message to be effectively sent. + +### Configure OVH Transporter + +Currently, this is the only one transporter available. + +To configure this, add this config variable in your environment: + +```env +SHORT_MESSAGE_DSN=ovh://applicationKey:applicationSecret@endpoint?consumerKey=xxxx&sender=yyyy&service_name=zzzz +``` + +To generate the application key, secret, and consumerKey, refers to their [documentation ](https://docs.ovh.com/gb/en/api/first-steps-with-ovh-api/). + +Before to be able to send your first sms, you must enable your account, grab some credits, and configure a sender. The service_name is an internal configuration generated by OVH. diff --git a/docs/source/installation/prod-calendar-sms-sending.rst b/docs/source/installation/prod-calendar-sms-sending.rst deleted file mode 100644 index 6529b95d9..000000000 --- a/docs/source/installation/prod-calendar-sms-sending.rst +++ /dev/null @@ -1,27 +0,0 @@ - -Send short messages (SMS) with calendar bundle -============================================== - -To activate the sending of messages, you should run this command on a regularly basis (using, for instance, a cronjob): - -.. code-block:: bash - - bin/console chill:calendar:send-short-messages - -A transporter must be configured for the message to be effectively sent. - -Configure OVH Transporter -------------------------- - -Currently, this is the only one transporter available. - -For configuring this, simply add this config variable in your environment: - -```env -SHORT_MESSAGE_DSN=ovh://applicationKey:applicationSecret@endpoint?consumerKey=xxxx&sender=yyyy&service_name=zzzz -``` - -In order to generate the application key, secret, and consumerKey, refers to their `documentation `_. - -Before to be able to send your first sms, you must enable your account, grab some credits, and configure a sender. The service_name is an internal configuration generated by OVH. - diff --git a/docs/source/installation/prod.md b/docs/source/installation/prod.md new file mode 100644 index 000000000..50a51b378 --- /dev/null +++ b/docs/source/installation/prod.md @@ -0,0 +1,62 @@ +Permission is granted to copy, distribute and/or modify this document + under the terms of the GNU Free Documentation License, Version 1.3, + or any later version published by the Free Software Foundation; + with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. + A copy of the license is included in the section entitled "GNU + Free Documentation License". + +# Installation for production + +An installation uses these services, which are deployed using docker containers: + +* a php-fpm image, which runs the Php and Symfony code for Chill; +* a nginx image, which serves the assets and usually proxies the php requests to the fpm image; +* a redis server, which stores the cache, sessions (this is currently hardcoded in the php image), and some useful keys (like wopi locks); +* a postgresql database. The use of postgresql is mandatory; +* a relater service, which transforms odt templates to full documents (replacing the placeholders); + +Some external services: + +* (required) an openstack object store, configured with `temporary url ` configured (no openstack users are required). This is currently the only way to store documents from chill; +* a mailer service (SMTP) +* (optional) a service for verifying phone number. Currently, only Twilio is possible; +* (optional) a service for sending Short Messages (SMS). Currently, only Ovh is possible; + +The `docker-compose.yaml` file of chill app is a basis for a production install. The environment variable in the ```.env``` and ```.env.prod``` should be overridden by environment variables, or ```.env.local``` files. + +This should be adapted to your needs: + +* The images for php and nginx apps are pre-compiled images, with the default configuration and bundle. If they do not fullfill your needs, you should compile your own images. + + .. TODO: + + As the time of writing (2022-07-03) those images are not published yet. + +* Think about how you will back up your database. Some adminsys find it easier to store a database outside of docker, which might be easier to administrate or replicate. + +## Run migrations on each update + +Every time you start a new version, you should apply update the sql schema: + +- running ``bin/console doctrine:migration:migrate`` to run SQL migration; +- synchronizing SQL views to the last state: ``bin/console chill:db:sync-views`` + +## Cron jobs + +The command `chill:cron-job:execute` should be executed every 15 minutes (more or less). + +This command should never be executed concurrently. It should be not having more than one process for a single instance. + +## Post-install tasks + +- import addresses. See [addresses](addresses.md). + +## Tweak symfony messenger + +Calendar sync is processed using symfony messenger. + +You can tweak the configuration + +Going further: + +* Configure the saml login and synchronization with Outlook api diff --git a/docs/source/installation/prod.rst b/docs/source/installation/prod.rst deleted file mode 100644 index 21da15267..000000000 --- a/docs/source/installation/prod.rst +++ /dev/null @@ -1,71 +0,0 @@ -.. Copyright (C) 2014-2019 Champs Libres Cooperative SCRLFS - Permission is granted to copy, distribute and/or modify this document - under the terms of the GNU Free Documentation License, Version 1.3 - or any later version published by the Free Software Foundation; - with no Invariant Sections, no Front-Cover Texts, and no Back-Cover Texts. - A copy of the license is included in the section entitled "GNU - Free Documentation License". - -.. _prod: - -Installation for production -########################### - -An installation use these services, which are deployed using docker containers: - -* a php-fpm image, which run the Php and Symfony code for Chill; -* a nginx image, which serves the assets, and usually proxy the php requests to the fpm image; -* a redis server, which stores the cache, sessions (this is currently hardcoded in the php image), and some useful keys (like wopi locks); -* a postgresql database. The use of postgresql is mandatory; -* a relatorio service, which transform odt templates to full documents (replacing the placeholders); - -Some external services: - -* (required) an openstack object store, configured with `temporary url ` configured (no openstack users is required). This is currently the only way to store documents from chill; -* a mailer service (SMTP) -* (optional) a service for verifying phone number. Currently, only Twilio is possible; -* (optional) a service for sending Short Messages (SMS). Currently, only Ovh is possible; - -The `docker-compose.yaml` file of chill app is a basis for a production install. The environment variable in the ```.env``` and ```.env.prod``` should be overriden by environment variables, or ```.env.local``` files. - -This should be adapted to your needs: - -* The image for php and nginx apps are pre-compiled images, with the default configuration and bundle. If they do not fullfill your needs, you should compile your own images. - - .. TODO: - - As the time of writing (2022-07-03) those images are not published yet. - -* Think about how you will backup your database. Some adminsys find easier to store database outside of docker, which might be easier to administrate or replicate. - -Run migrations on each update -============================= - -Every time you start a new version, you should apply update the sql schema: - -- running ``bin/console doctrine:migration:migrate`` to run sql migration; -- synchonizing sql views to the last state: ``bin/console chill:db:sync-views`` - -Cron jobs -========= - -The command :code:`chill:cron-job:execute` should be executed every 15 minutes (more or less). - -This command should never be executed concurrently. It should be not have more than one process for a single instance. - -Post-install tasks -================== - -- import addresses. See :ref:`addresses`. - - -Tweak symfony messenger -======================= - -Calendar sync is processed using symfony messenger. - -You can tweak the configuration - -Going further: - -* Configure the saml login and synchronisation with Outlook api diff --git a/package.json b/package.json index 1b4c7df13..cd4f23a07 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "typescript": "^5.6.3", "typescript-eslint": "^8.13.0", "vue-loader": "^17.0.0", + "vue-tsc": "^3.1.3", "webpack": "^5.75.0", "webpack-cli": "^5.0.1" }, @@ -81,12 +82,12 @@ "dev": "encore dev", "watch": "encore dev --watch", "build": "encore production --progress", - "specs-build": "yaml-merge src/Bundle/ChillMainBundle/chill.api.specs.yaml src/Bundle/ChillPersonBundle/chill.api.specs.yaml src/Bundle/ChillCalendarBundle/chill.api.specs.yaml src/Bundle/ChillThirdPartyBundle/chill.api.specs.yaml src/Bundle/ChillDocStoreBundle/chill.api.specs.yaml> templates/api/specs.yaml", + "specs-build": "yaml-merge src/Bundle/ChillMainBundle/chill.api.specs.yaml src/Bundle/ChillPersonBundle/chill.api.specs.yaml src/Bundle/ChillCalendarBundle/chill.api.specs.yaml src/Bundle/ChillThirdPartyBundle/chill.api.specs.yaml src/Bundle/ChillDocStoreBundle/chill.api.specs.yaml src/Bundle/ChillTicketBundle/chill.api.specs.yaml> templates/api/specs.yaml", "specs-validate": "swagger-cli validate templates/api/specs.yaml", "specs-create-dir": "mkdir -p templates/api", "specs": "yarn run specs-create-dir && yarn run specs-build && yarn run specs-validate", "version": "node --version", - "eslint": "npx eslint-baseline --fix \"src/**/*.{js,ts,vue}\"" + "eslint": "eslint-baseline --fix \"src/**/*.{js,ts,vue}\"" }, "private": true } diff --git a/packages/ChillZimbraBundle/.changes/1.0.0.md b/packages/ChillZimbraBundle/.changes/1.0.0.md new file mode 100644 index 000000000..c01d2b008 --- /dev/null +++ b/packages/ChillZimbraBundle/.changes/1.0.0.md @@ -0,0 +1,3 @@ +## 1.0.0 - 2025-12-05 +### Added +* First version of the zimbra connector diff --git a/packages/ChillZimbraBundle/.changes/header.tpl.md b/packages/ChillZimbraBundle/.changes/header.tpl.md new file mode 100644 index 000000000..df8faa7b2 --- /dev/null +++ b/packages/ChillZimbraBundle/.changes/header.tpl.md @@ -0,0 +1,6 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), +and is generated by [Changie](https://github.com/miniscruff/changie). diff --git a/packages/ChillZimbraBundle/.changes/unreleased/.gitkeep b/packages/ChillZimbraBundle/.changes/unreleased/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/ChillZimbraBundle/.changie.yaml b/packages/ChillZimbraBundle/.changie.yaml new file mode 100644 index 000000000..78ab6e36f --- /dev/null +++ b/packages/ChillZimbraBundle/.changie.yaml @@ -0,0 +1,26 @@ +changesDir: .changes +unreleasedDir: unreleased +headerPath: header.tpl.md +changelogPath: CHANGELOG.md +versionExt: md +versionFormat: '## {{.Version}} - {{.Time.Format "2006-01-02"}}' +kindFormat: '### {{.Kind}}' +changeFormat: '* {{.Body}}' +kinds: + - label: Added + auto: minor + - label: Changed + auto: major + - label: Deprecated + auto: minor + - label: Removed + auto: major + - label: Fixed + auto: patch + - label: Security + auto: patch +newlines: + afterChangelogHeader: 1 + beforeChangelogVersion: 1 + endOfVersion: 1 +envPrefix: CHANGIE_ diff --git a/packages/ChillZimbraBundle/CHANGELOG.md b/packages/ChillZimbraBundle/CHANGELOG.md new file mode 100644 index 000000000..3ff246416 --- /dev/null +++ b/packages/ChillZimbraBundle/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), +and is generated by [Changie](https://github.com/miniscruff/changie). + + +## 1.0.0 - 2025-12-05 +### Added +* First version of the zimbra connector diff --git a/packages/ChillZimbraBundle/LICENSE b/packages/ChillZimbraBundle/LICENSE new file mode 100644 index 000000000..be3f7b28e --- /dev/null +++ b/packages/ChillZimbraBundle/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/packages/ChillZimbraBundle/README.md b/packages/ChillZimbraBundle/README.md new file mode 100644 index 000000000..7b51e52db --- /dev/null +++ b/packages/ChillZimbraBundle/README.md @@ -0,0 +1,40 @@ +# Chill Zimbra Bundle + +This bundle provides integration with Zimbra email server for Chill application. + +## Source code + +This bundle should be modified within [the chill-bundles repository](https://gitlab.com/Chill-Projet/chill-bundles). +The code at https://gitlab.com/Chill-Projet/chill-zimbra-connector is a mirror of this main +repository and is intended to serve packagist packages. + +## Configuration + +This bundle should be configured using a dsn scheme with a `zimbra+http` or `zimbra+https` +scheme. + +```yaml +chill_calendar: + # remember to url-encode username and password + remote_calendar_dsn: zimbra+https://chill%40zimbra.example.com:password@zimbra.example.com +``` + +## Development + +This bundles should be developed from within the chill-bundles repository. + +During development, you must use an inline alias, or a branch alias to +be able to load the root package's master branch as a replacement for a version. + +Example of composer.json for chill-project/chill-zimbra-bundle: + +```json +{ + "require": { + "chill-project/chill-bundles": "dev-master as v4.6.1", + "zimbra-api/soap-api": "^3.2.2", + "psr/http-client": "^1.0", + "nyholm/psr7": "^1.0" + } +} +``` diff --git a/packages/ChillZimbraBundle/composer.json b/packages/ChillZimbraBundle/composer.json new file mode 100644 index 000000000..4a53b3ed5 --- /dev/null +++ b/packages/ChillZimbraBundle/composer.json @@ -0,0 +1,22 @@ +{ + "name": "chill-project/chill-zimbra-bundle", + "description": "Provide connection between Zimbra agenda and Chill", + "minimum-stability": "stable", + "license": "AGPL-3.0", + "type": "library", + "keywords": [ + "chill", + "social worker" + ], + "require": { + "chill-project/chill-bundles": "^4.9.0", + "zimbra-api/soap-api": "^3.2.2", + "psr/http-client": "^1.0", + "nyholm/psr7": "^1.0" + }, + "autoload": { + "psr-4": { + "Chill\\ZimbraBundle\\": "src/" + } + } +} diff --git a/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector.php b/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector.php new file mode 100644 index 000000000..acb6bdd84 --- /dev/null +++ b/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector.php @@ -0,0 +1,147 @@ +deleteEvent)($user, $remoteId); + + if (null !== $associatedCalendarRange) { + $this->logger->info(self::LOG_PREFIX.'Ask to re-create the previous calendar range', ['previous_calendar_range_id' => $associatedCalendarRange->getId()]); + $this->createCalendarRange($associatedCalendarRange); + } + } + + public function removeCalendarRange(string $remoteId, array $remoteAttributes, User $user): void + { + if ('' === $remoteId) { + return; + } + + ($this->deleteEvent)($user, $remoteId); + } + + public function syncCalendar(Calendar $calendar, string $action, ?CalendarRange $previousCalendarRange, ?User $previousMainUser, ?array $oldInvites, ?array $newInvites): void + { + if (null !== $previousMainUser && $previousMainUser !== $calendar->getMainUser()) { + $this->removeCalendar($calendar->getRemoteId(), [], $previousMainUser); + $calendar->setRemoteId(''); + } + + if (!$calendar->hasRemoteId()) { + $calItemId = ($this->createEvent)($calendar); + $this->logger->info(self::LOG_PREFIX.'Calendar synced with Zimbra', ['calendar_id' => $calendar->getId(), 'action' => $action, 'calItemId' => $calItemId]); + + $calendar->setRemoteId($calItemId); + } else { + ($this->updateEvent)($calendar); + $this->logger->info(self::LOG_PREFIX.'Calendar updated against zimbra', ['old_cal_remote_id' => $calendar->getRemoteId(), 'calendar_id' => $calendar->getId()]); + } + + if (null !== $calendar->getCalendarRange()) { + $range = $calendar->getCalendarRange(); + $this->removeCalendarRange($range->getRemoteId(), [], $range->getUser()); + $range->setRemoteId(''); + } + + if (null !== $previousCalendarRange) { + $this->syncCalendarRange($previousCalendarRange); + } + + foreach ($calendar->getInvites() as $invite) { + $this->syncInvite($invite); + } + } + + public function syncCalendarRange(CalendarRange $calendarRange): void + { + if (!$calendarRange->hasRemoteId()) { + $this->createCalendarRange($calendarRange); + } else { + ($this->updateEvent)($calendarRange); + $this->logger->info(self::LOG_PREFIX.'Calendar range updated against zimbra', ['old_cal_remote_id' => $calendarRange->getRemoteId(), 'calendar_range_id' => $calendarRange->getId()]); + } + } + + public function syncInvite(Invite $invite): void + { + if (Invite::ACCEPTED === $invite->getStatus()) { + if ($invite->hasRemoteId()) { + ($this->updateEvent)($invite); + $this->logger->info(self::LOG_PREFIX.'Invite range updated against zimbra', ['invite_id' => $invite->getId(), 'invite_remote_id', $invite->getRemoteId()]); + } else { + $remoteId = ($this->createEvent)($invite); + $invite->setRemoteId($remoteId); + $this->logger->info(self::LOG_PREFIX.'Invite range updated against zimbra', ['invite_id' => $invite->getId(), 'invite_remote_id', $invite->getRemoteId()]); + } + } elseif ($invite->hasRemoteId()) { + // case when the invite has been accepted in the past, and synchronized + ($this->deleteEvent)($invite->getUser(), $invite->getRemoteId()); + $this->logger->info(self::LOG_PREFIX.'Invite range removed in zimbra', ['invite_id' => $invite->getId(), 'invite_remote_id', $invite->getRemoteId()]); + $invite->setRemoteId(''); + } + } + + private function createCalendarRange(CalendarRange $calendarRange): void + { + $calItemId = ($this->createEvent)($calendarRange); + $this->logger->info(self::LOG_PREFIX.'Calendar range created with Zimbra', ['calendar_range_id' => $calendarRange->getId(), 'calItemId' => $calItemId]); + + $calendarRange->setRemoteId($calItemId); + } +} diff --git a/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/CreateEvent.php b/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/CreateEvent.php new file mode 100644 index 000000000..0ada55760 --- /dev/null +++ b/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/CreateEvent.php @@ -0,0 +1,96 @@ +getMainUser()->getEmail(); + $organizerLang = $calendar->getMainUser()->getLocale(); + } elseif ($calendar instanceof Invite) { + $organizerEmail = $calendar->getUser()->getEmail(); + $organizerLang = $calendar->getUser()->getLocale(); + } else { + $organizerEmail = $calendar->getUser()->getEmail(); + $organizerLang = $calendar->getUser()->getLocale(); + } + + if (null === $organizerEmail) { + throw new CalendarWithoutMainUserException(); + } + + $api = $this->soapClientBuilder->getApiForAccount($organizerEmail); + + $comp = $this->createEvent->createZimbraInviteComponentFromCalendar($calendar); + + $inv = new InvitationInfo(); + $inv->setInviteComponent($comp); + + $mp = new MimePartInfo(); + $mp->addMimePart(new MimePartInfo('text/plain', $this->translator->trans('zimbra.event_created_by_chill', locale: $organizerLang))); + + $msg = new Msg(); + $msg->setSubject($this->translator->trans('zimbra.event_created_trough_soap', locale: $organizerLang)) + ->setFolderId('10') + ->setInvite($inv) + ->setMimePart($mp); + + $response = $api->createAppointment($msg, echo: true); + + $echo = $response->getEcho(); + $invite = $echo->getInvite(); + $MPInviteInfo = $invite->getInvite(); + /** @var InviteComponent $firstInvite */ + $firstInvite = $MPInviteInfo->getInviteComponents()[0]; + + return $this->zimbraIdSerializer->serializeId( + $response->getCalItemId(), + $response->getCalInvId(), + $firstInvite->getUid(), + ); + } +} diff --git a/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/CreateZimbraComponent.php b/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/CreateZimbraComponent.php new file mode 100644 index 000000000..9fa14d75c --- /dev/null +++ b/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/CreateZimbraComponent.php @@ -0,0 +1,129 @@ +getPersons()->map(fn (Person $p) => $this->personRender->renderString($p, ['addAge' => false]))->toArray() + ); + $content = $this->twig->render('@ChillZimbra/ZimbraComponent/calendar_content.txt.twig', ['calendar' => $calendar]); + } elseif ($calendar instanceof Invite) { + $subject = '[Chill] '. + '('.$this->translator->trans('remote_calendar.calendar_invite_statement_in_calendar').') '. + implode( + ', ', + $calendar->getCalendar()->getPersons()->map(fn (Person $p) => $this->personRender->renderString($p, ['addAge' => false]))->toArray() + ); + $content = $this->twig->render('@ChillZimbra/ZimbraComponent/invitation_content.txt.twig', ['calendar' => $calendar->getCalendar()]); + } else { + // $calendar is an instanceof CalendarRange + $subject = $this->translator->trans('remote_calendar.calendar_range_title'); + $content = ''; + } + + if ($calendar instanceof Invite) { + $startDate = $calendar->getCalendar()->getStartDate(); + $endDate = $calendar->getCalendar()->getEndDate(); + $location = $calendar->getCalendar()->getLocation(); + $hasLocation = $calendar->getCalendar()->hasLocation(); + $isPrivate = $calendar->getCalendar()->getAccompanyingPeriod()?->isConfidential() ?? false; + } else { + $startDate = $calendar->getStartDate(); + $endDate = $calendar->getEndDate(); + $location = $calendar->getLocation(); + $hasLocation = $calendar->hasLocation(); + $isPrivate = $calendar->getAccompanyingPeriod()?->isConfidential() ?? false; + } + + $comp = new InviteComponent(); + $comp->setName($subject); + $comp->setDescription($content); + $comp->setFreeBusy(FreeBusyStatus::BUSY); + $comp->setStatus(InviteStatus::CONFIRMED); + $comp->setCalClass($isPrivate ? InviteClass::PRI : InviteClass::PUB); + $comp->setTransparency(Transparency::OPAQUE); + $comp->setIsAllDay(false); + $comp->setIsDraft(false); + $comp->setDtStart($this->dateConverter->phpToZimbraDateTime($startDate)); + $comp->setDtEnd($this->dateConverter->phpToZimbraDateTime($endDate)); + + if ($hasLocation) { + $comp + ->setLocation($this->createLocationString($location)); + } + + return $comp; + } + + private function createLocationString(Location $location): string + { + $str = ''; + + if ('' !== ($loc = (string) $location->getName())) { + $str .= $loc; + } + + if ($location->hasAddress()) { + if ('' !== $str) { + $str .= ', '; + } + + $str .= $this->addressRender->renderString($location->getAddress(), ['separator' => ', ']); + } + + return $str; + } +} diff --git a/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/DateConverter.php b/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/DateConverter.php new file mode 100644 index 000000000..1fdea5040 --- /dev/null +++ b/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/DateConverter.php @@ -0,0 +1,37 @@ +format(self::FORMAT_DATE_TIME), $date->getTimezone()->getName()); + } +} diff --git a/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/DeleteEvent.php b/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/DeleteEvent.php new file mode 100644 index 000000000..2e3ae5314 --- /dev/null +++ b/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/DeleteEvent.php @@ -0,0 +1,51 @@ +getEmail(); + + if (null === $organizerEmail) { + throw new CalendarWithoutMainUserException(); + } + + $api = $this->soapClientBuilder->getApiForAccount($organizerEmail); + ['calItemId' => $calItemId, 'calInvId' => $calInvId, 'inviteComponentCommonUid' => $inviteComponentCommonUid] + = $this->zimbraIdSerializer->deSerializeId($remoteId); + + $api->cancelAppointment( + id: $calInvId, + componentNum: 0, + ); + } +} diff --git a/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/SoapClientBuilder.php b/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/SoapClientBuilder.php new file mode 100644 index 000000000..e66c45e85 --- /dev/null +++ b/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/SoapClientBuilder.php @@ -0,0 +1,78 @@ +parameterBag->get('chill_calendar.remote_calendar_dsn'); + $url = parse_url($dsn); + + $this->username = urldecode($url['user']); + $this->password = urldecode($url['pass']); + if ('zimbra+http' === $url['scheme']) { + $scheme = 'http://'; + $port = $url['port'] ?? 80; + } elseif ('zimbra+https' === $url['scheme']) { + $scheme = 'https://'; + $port = $url['port'] ?? 443; + } else { + throw new \Symfony\Component\DependencyInjection\Exception\InvalidArgumentException('Unsupported remote calendar scheme: '.$url['scheme']); + } + + $this->url = $scheme.$url['host'].':'.$port; + } + + private function buildApi(): MailApi + { + $baseClient = $this->client->withOptions([ + 'base_uri' => $location = $this->url.'/service/soap', + 'verify_host' => false, + 'verify_peer' => false, + ]); + $psr18Client = new Psr18Client($baseClient); + $api = new MailApi(); + $client = ClientFactory::create($location, $psr18Client); + $api->setClient($client); + + return $api; + } + + public function getApiForAccount(string $accountName): MailApi + { + $api = $this->buildApi(); + $response = $api->authByAccountName($this->username, $this->password); + + $token = $response->getAuthToken(); + + $apiBy = $this->buildApi(); + $apiBy->setAuthToken($token); + $apiBy->setTargetAccount(new AccountInfo(AccountBy::NAME, $accountName)); + + return $apiBy; + } +} diff --git a/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/UpdateEvent.php b/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/UpdateEvent.php new file mode 100644 index 000000000..9bd42b016 --- /dev/null +++ b/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/UpdateEvent.php @@ -0,0 +1,97 @@ +getMainUser()->getEmail(); + $organizerLang = $calendar->getMainUser()->getLocale(); + } elseif ($calendar instanceof Invite) { + $organizerEmail = $calendar->getCalendar()->getMainUser()->getEmail(); + $organizerLang = $calendar->getCalendar()->getMainUser()->getLocale(); + } else { + $organizerEmail = $calendar->getUser()->getEmail(); + $organizerLang = $calendar->getUser()->getLocale(); + } + + if (null === $organizerEmail) { + throw new CalendarWithoutMainUserException(); + } + + $api = $this->soapClientBuilder->getApiForAccount($organizerEmail); + + ['calItemId' => $calItemId, 'calInvId' => $calInvId, 'inviteComponentCommonUid' => $inviteComponentCommonUid] + = $this->zimbraIdSerializer->deSerializeId($calendar->getRemoteId()); + + $existing = $api->getAppointment(sync: true, includeContent: true, includeInvites: true, id: $calItemId); + $appt = $existing->getApptItem(); + + $comp = $this->createZimbraComponent->createZimbraInviteComponentFromCalendar($calendar); + $comp->setUid($inviteComponentCommonUid); + + $inv = new InvitationInfo(); + $inv->setInviteComponent($comp) + ->setUid($calInvId); + + $mp = new MimePartInfo(); + $mp->addMimePart(new MimePartInfo('text/plain', $this->translator->trans('zimbra.event_created_by_chill', locale: $organizerLang))); + + $msg = new Msg(); + $msg->setSubject($this->translator->trans('zimbra.event_created_trough_soap', locale: $organizerLang)) + ->setFolderId('10') + ->setInvite($inv) + ->setMimePart($mp) + ; + + $response = $api->modifyAppointment( + id: $calInvId, + componentNum: 0, + modifiedSequence: $appt->getModifiedSequence(), + revision: $appt->getRevision(), + msg: $msg, + echo: true + ); + } +} diff --git a/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/ZimbraIdSerializer.php b/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/ZimbraIdSerializer.php new file mode 100644 index 000000000..ba9d9ab63 --- /dev/null +++ b/packages/ChillZimbraBundle/src/Calendar/Connector/ZimbraConnector/ZimbraIdSerializer.php @@ -0,0 +1,67 @@ + $exploded[0], + 'calInvId' => $exploded[1], + 'inviteComponentCommonUid' => $exploded[2], + ]; + } +} diff --git a/packages/ChillZimbraBundle/src/ChillZimbraBundle.php b/packages/ChillZimbraBundle/src/ChillZimbraBundle.php new file mode 100644 index 000000000..b49e7e8af --- /dev/null +++ b/packages/ChillZimbraBundle/src/ChillZimbraBundle.php @@ -0,0 +1,16 @@ +load('services.yaml'); + } +} diff --git a/packages/ChillZimbraBundle/src/Exception/CalendarWithoutMainUserException.php b/packages/ChillZimbraBundle/src/Exception/CalendarWithoutMainUserException.php new file mode 100644 index 000000000..01314a62b --- /dev/null +++ b/packages/ChillZimbraBundle/src/Exception/CalendarWithoutMainUserException.php @@ -0,0 +1,14 @@ + - - - - - - - - - - tests + + + + + + + + + + + + + + src/Bundle/ChillAsideActivityBundle/src/Tests/ + + + src/Bundle/ChillBudgetBundle/Tests/ + + + src/Bundle/ChillCalendarBundle/Tests/ + + + + src/Bundle/ChillDocGeneratorBundle/tests/ + + + src/Bundle/ChillDocStoreBundle/Tests/ + + + + src/Bundle/ChillMainBundle/Tests/ + + + src/Bundle/ChillPersonBundle/Tests/ + + src/Bundle/ChillPersonBundle/Tests/Controller/AccompanyingPeriodControllerTest.php + + src/Bundle/ChillPersonBundle/Tests/Controller/PersonAddressControllerTest.php + + src/Bundle/ChillPersonBundle/Tests/Controller/PersonControllerUpdateWithHiddenFieldsTest.php + + src/Bundle/ChillPersonBundle/Tests/Controller/PersonDuplicateControllerViewTest.php + + + src/Bundle/ChillTicketBundle/tests/ + + + + + src/Bundle/ChillThirdPartyBundle/Tests + + + src/Bundle/ChillWopiBundle/tests/ + + + + diff --git a/resources/ticket_motives_import/README.md b/resources/ticket_motives_import/README.md new file mode 100644 index 000000000..a24a80e5a --- /dev/null +++ b/resources/ticket_motives_import/README.md @@ -0,0 +1,8 @@ +In this directory, you find an example of file for the command `chill:main:ticket_motives_import`. + +This file contains a list of ticket motives to import into the system. Each entry is a dictionary with two keys: `code` and `label`. The `code` key contains the unique code for the ticket motive, and the `label` key contains the human-readable label for the ticket motive. + +The `stored_objects` key contains the documents that will be associated with the tickets. They must be found in the same directory. + +The command `chill:main:ticket_motives_import` uses this file to import the specified ticket motives into the system. + diff --git a/resources/ticket_motives_import/motives.yaml b/resources/ticket_motives_import/motives.yaml new file mode 100644 index 000000000..fb6bcb159 --- /dev/null +++ b/resources/ticket_motives_import/motives.yaml @@ -0,0 +1,136 @@ +- label: + fr: Appel famille pour annonce de décès + urgent: false + supplementary_informations: + - label: + fr: Date du décès + - label: + fr: lieu du décès (domicile ou hôpital) + - label: + fr: nom de l’hôpital + - label: + fr: service concerné + stored_objects: + - label: + fr: ☀️ De 07h à 21h + filename: 2_doc_20250402_Pelotons flux externes consolidés.pdf + - label: + fr: 🌙 De 21h à 07h du matin + filename: 3_doc_20250402_Pelotons flux externes consolidés.pdf + - label: + fr: 🗓️ Dimanches et jours fériés + filename: 4_doc_20250402_Pelotons flux externes consolidés.pdf +- label: + fr: 'Appel famille pour annonce absence : hospitalisation ou consultation' + urgent: false + supplementary_informations: + - label: + fr: Quel hôpital + - label: + fr: quel service + - label: + fr: pour quelles raisons + - label: + fr: 'consultation : date et heure' + - label: + fr: hospitalisation complète ou HDJ + stored_objects: + - label: + fr: ☀️ De 07h à 21h + filename: 5_doc_20250402_Pelotons flux externes consolidés.pdf + - label: + fr: 🌙 De 21h à 07h du matin + filename: 6_doc_20250402_Pelotons flux externes consolidés.pdf + - label: + fr: 🗓️ Dimanches et jours fériés + filename: 7_doc_20250402_Pelotons flux externes consolidés.pdf +- label: + fr: 'Appel famille pour annonce absence : interruption de prise en charge' + urgent: false + supplementary_informations: + - label: + fr: Pour quelles raisons ? Date + - label: + fr: durée + - label: + fr: accord médical ? + stored_objects: + - label: + fr: ☀️ De 07h à 21h + filename: 8_doc_20250402_Pelotons flux externes consolidés.pdf + - label: + fr: 🌙 De 21h à 07h du matin + filename: 9_doc_20250402_Pelotons flux externes consolidés.pdf + - label: + fr: 🗓️ Dimanches et jours fériés + filename: 10_doc_20250402_Pelotons flux externes consolidés.pdf +- label: + fr: 'Appel famille pour annonce absence : changement d’adresse' + urgent: false + supplementary_informations: + - label: + fr: Où + - label: + fr: Pourquoi ? Pour combien de temps ? Besoin d’un relais des soins ? Nouvelle adresse ? + stored_objects: + - label: + fr: ☀️ De 07h à 21h + filename: 11_doc_20250402_Pelotons flux externes consolidés.pdf + - label: + fr: 🌙 De 21h à 07h du matin + filename: 12_doc_20250402_Pelotons flux externes consolidés.pdf + - label: + fr: 🗓️ Dimanches et jours fériés + filename: 13_doc_20250402_Pelotons flux externes consolidés.pdf +- label: + fr: Appel famille pour altération de l’état général du patient + urgent: true + supplementary_informations: + - label: + fr: Recherche des symptômes + - label: + fr: Attentes par rapport à la demande + stored_objects: + - label: + fr: ☀️ De 07h à 21h + filename: 14_doc_20250402_Pelotons flux externes consolidés.pdf + - label: + fr: 🌙 De 21h à 07h du matin + filename: 15_doc_20250402_Pelotons flux externes consolidés.pdf + - label: + fr: 🗓️ Dimanches et jours fériés + filename: 16_doc_20250402_Pelotons flux externes consolidés.pdf +- label: + fr: Appel famille pour prise en charge de la douleur + urgent: true + supplementary_informations: + - label: + fr: Localisation douleur + - label: + fr: Horaire dernier passage + - label: + fr: Traitements en cours + stored_objects: + - label: + fr: ☀️ De 07h à 21h + filename: 17_doc_20250402_Pelotons flux externes consolidés.pdf + - label: + fr: 🌙 De 21h à 07h du matin + filename: 18_doc_20250402_Pelotons flux externes consolidés.pdf + - label: + fr: 🗓️ Dimanches et jours fériés + filename: 19_doc_20250402_Pelotons flux externes consolidés.pdf +- label: + fr: Appel famille pour information sur la date de prise en charge + urgent: false + supplementary_informations: [] + stored_objects: + - label: + fr: ☀️ De 07h à 21h + filename: 20_doc_20250402_Pelotons flux externes consolidés.pdf + - label: + fr: 🌙 De 21h à 07h du matin + filename: 21_doc_20250402_Pelotons flux externes consolidés.pdf + - label: + fr: 🗓️ Dimanches et jours fériés + filename: 22_doc_20250402_Pelotons flux externes consolidés.pdf diff --git a/resources/translation_override/README.md b/resources/translation_override/README.md new file mode 100644 index 000000000..837f071fd --- /dev/null +++ b/resources/translation_override/README.md @@ -0,0 +1,6 @@ +In this directory, you find an example of file for the command `chill:main:override_translation`. + +This file contains a list of translations to override in the translation catalogue. Each entry is a dictionary with two keys: `from` and `to`. The `from` key contains the original translation string, and the `to` key contains the replacement string. + +The command `chill:main:override_translation` uses this file to generate a new translation catalogue with the specified overrides applied. + diff --git a/resources/translation_override/overrides.yaml b/resources/translation_override/overrides.yaml new file mode 100644 index 000000000..c9f896df0 --- /dev/null +++ b/resources/translation_override/overrides.yaml @@ -0,0 +1,8 @@ +- {from: "de l'usager", to: "du patient"} +- {from: "l'usager", to: "le patient"} +- {from: "L'usager", to: "Le patient"} +- {from: "d'usagers", to: "de patients"} +- {from: "usagers", to: "patients"} +- {from: "Usagers", to: "Patients"} +- {from: "usager", to: "patient"} +- {from: "Usager", to: "Patient"} diff --git a/src/Bundle/ChillActivityBundle/Controller/ActivityController.php b/src/Bundle/ChillActivityBundle/Controller/ActivityController.php index 9be48e61e..a33757a46 100644 --- a/src/Bundle/ChillActivityBundle/Controller/ActivityController.php +++ b/src/Bundle/ChillActivityBundle/Controller/ActivityController.php @@ -382,6 +382,7 @@ final class ActivityController extends AbstractController $entity = new Activity(); $entity->setUser($this->security->getUser()); + $entity->addUser($this->security->getUser()); if ($person instanceof Person) { $entity->setPerson($person); diff --git a/src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/ByActivityNumberAggregator.php b/src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/ByActivityNumberAggregator.php index e304a9d7f..b901b10f5 100644 --- a/src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/ByActivityNumberAggregator.php +++ b/src/Bundle/ChillActivityBundle/Export/Aggregator/ACPAggregators/ByActivityNumberAggregator.php @@ -27,7 +27,8 @@ class ByActivityNumberAggregator implements AggregatorInterface public function alterQuery(QueryBuilder $qb, $data, \Chill\MainBundle\Export\ExportGenerationContext $exportGenerationContext): void { $qb - ->addSelect('(SELECT COUNT(activity.id) FROM '.Activity::class.' activity WHERE activity.accompanyingPeriod = acp) AS activity_by_number_aggregator') + // Use a distinct alias inside the subquery to avoid colliding with the root alias "activity" + ->addSelect('(SELECT COUNT(agg_activity.id) FROM '.Activity::class.' agg_activity WHERE agg_activity.accompanyingPeriod = acp) AS activity_by_number_aggregator') ->addGroupBy('activity_by_number_aggregator'); } @@ -65,7 +66,7 @@ class ByActivityNumberAggregator implements AggregatorInterface { return static function ($value) { if ('_header' === $value) { - return ''; + return 'Count activities linked to an accompanying period'; } if (null === $value) { diff --git a/src/Bundle/ChillActivityBundle/Export/Export/ListActivityHelper.php b/src/Bundle/ChillActivityBundle/Export/Export/ListActivityHelper.php index 2db6515b0..fb56def2f 100644 --- a/src/Bundle/ChillActivityBundle/Export/Export/ListActivityHelper.php +++ b/src/Bundle/ChillActivityBundle/Export/Export/ListActivityHelper.php @@ -66,6 +66,9 @@ class ListActivityHelper ->leftJoin('activity.location', 'location') ->addSelect('location.name AS locationName') ->addSelect('activity.sentReceived') + ->addSelect('activity.comment.comment AS commentText') + ->addSelect('activity.comment.date AS commentDate') + ->addSelect('JSON_BUILD_OBJECT(\'uid\', activity.comment.userId, \'d\', activity.comment.date) AS commentUser') ->addSelect('JSON_BUILD_OBJECT(\'uid\', IDENTITY(activity.createdBy), \'d\', activity.createdAt) AS createdBy') ->addSelect('activity.createdAt') ->addSelect('JSON_BUILD_OBJECT(\'uid\', IDENTITY(activity.updatedBy), \'d\', activity.updatedAt) AS updatedBy') @@ -87,6 +90,8 @@ class ListActivityHelper 'createdAt', 'updatedAt' => $this->dateTimeHelper->getLabel($key), 'createdBy', 'updatedBy' => $this->userHelper->getLabel($key, $values, $key), 'date' => $this->dateTimeHelper->getLabel(self::MSG_KEY.$key), + 'commentDate' => $this->dateTimeHelper->getLabel(self::MSG_KEY.'comment_date'), + 'commentUser' => $this->userHelper->getLabel($key, $values, self::MSG_KEY.'comment_user'), 'attendeeName' => function ($value) { if ('_header' === $value) { return 'Attendee'; @@ -176,6 +181,9 @@ class ListActivityHelper 'usersNames', 'thirdPartiesIds', 'thirdPartiesNames', + 'commentText', + 'commentDate', + 'commentUser', 'createdBy', 'createdAt', 'updatedBy', diff --git a/src/Bundle/ChillActivityBundle/Export/Filter/PersonFilters/ActivityReasonFilter.php b/src/Bundle/ChillActivityBundle/Export/Filter/PersonFilters/ActivityReasonFilter.php index 4802fc7ac..9909eab26 100644 --- a/src/Bundle/ChillActivityBundle/Export/Filter/PersonFilters/ActivityReasonFilter.php +++ b/src/Bundle/ChillActivityBundle/Export/Filter/PersonFilters/ActivityReasonFilter.php @@ -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 diff --git a/src/Bundle/ChillActivityBundle/Export/Filter/PersonFilters/PersonHavingActivityBetweenDateFilter.php b/src/Bundle/ChillActivityBundle/Export/Filter/PersonFilters/PersonHavingActivityBetweenDateFilter.php index 673aac25e..f3e8b347a 100644 --- a/src/Bundle/ChillActivityBundle/Export/Filter/PersonFilters/PersonHavingActivityBetweenDateFilter.php +++ b/src/Bundle/ChillActivityBundle/Export/Filter/PersonFilters/PersonHavingActivityBetweenDateFilter.php @@ -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') diff --git a/src/Bundle/ChillActivityBundle/Form/ActivityType.php b/src/Bundle/ChillActivityBundle/Form/ActivityType.php index c493c3499..176aaa78c 100644 --- a/src/Bundle/ChillActivityBundle/Form/ActivityType.php +++ b/src/Bundle/ChillActivityBundle/Form/ActivityType.php @@ -88,8 +88,8 @@ class ActivityType extends AbstractType if (null !== $options['data']->getPerson()) { $builder->add('scope', ScopePickerType::class, [ - 'center' => $options['center'], 'role' => ActivityVoter::CREATE === (string) $options['role'] ? ActivityVoter::CREATE_PERSON : (string) $options['role'], + 'center' => $options['center'], 'required' => true, ]); } diff --git a/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/App.vue b/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/App.vue index f17e42842..6f33a8deb 100644 --- a/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/App.vue +++ b/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/App.vue @@ -1,7 +1,7 @@ diff --git a/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/ConcernedGroups.vue b/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/ConcernedGroups.vue index b99de220d..6b275b4b9 100644 --- a/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/ConcernedGroups.vue +++ b/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/ConcernedGroups.vue @@ -1,46 +1,43 @@ diff --git a/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/ConcernedGroups/PersonBadge.vue b/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/ConcernedGroups/PersonBadge.vue index 7e839168e..3dffb5aac 100644 --- a/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/ConcernedGroups/PersonBadge.vue +++ b/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/ConcernedGroups/PersonBadge.vue @@ -1,29 +1,29 @@ diff --git a/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/ConcernedGroups/PersonsBloc.vue b/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/ConcernedGroups/PersonsBloc.vue index c09e122a9..3bb6a84aa 100644 --- a/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/ConcernedGroups/PersonsBloc.vue +++ b/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/ConcernedGroups/PersonsBloc.vue @@ -1,38 +1,38 @@ diff --git a/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/Location.vue b/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/Location.vue index 2496067b8..2c16ed472 100644 --- a/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/Location.vue +++ b/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/Location.vue @@ -1,32 +1,32 @@ diff --git a/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/Location/NewLocation.vue b/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/Location/NewLocation.vue index 1cc16ac95..dff32b35d 100644 --- a/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/Location/NewLocation.vue +++ b/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/Location/NewLocation.vue @@ -136,6 +136,8 @@ import { ACTIVITY_LOCATION_FIELDS_TYPE, ACTIVITY_CHOOSE_LOCATION_TYPE, ACTIVITY_CREATE_NEW_LOCATION, + ACTIVITY_EDIT_ADDRESS, + ACTIVITY_CREATE_ADDRESS, trans, } from "translator"; @@ -156,6 +158,8 @@ export default { ACTIVITY_LOCATION_FIELDS_TYPE, ACTIVITY_CHOOSE_LOCATION_TYPE, ACTIVITY_CREATE_NEW_LOCATION, + ACTIVITY_EDIT_ADDRESS, + ACTIVITY_CREATE_ADDRESS, }; }, props: ["availableLocations"], @@ -179,14 +183,14 @@ export default { options: { button: { text: { - create: "activity.create_address", - edit: "activity.edit_address", + create: ACTIVITY_CREATE_ADDRESS, + edit: ACTIVITY_EDIT_ADDRESS, }, size: "btn-sm", }, title: { - create: "activity.create_address", - edit: "activity.edit_address", + create: ACTIVITY_CREATE_ADDRESS, + edit: ACTIVITY_EDIT_ADDRESS, }, }, context: { diff --git a/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/SocialIssuesAcc.vue b/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/SocialIssuesAcc.vue index a45d93b2a..42df15f8b 100644 --- a/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/SocialIssuesAcc.vue +++ b/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/SocialIssuesAcc.vue @@ -1,103 +1,98 @@ @@ -263,18 +278,18 @@ export default { @import "ChillMainAssets/chill/scss/chill_variables"; span.multiselect__single { - display: none !important; + display: none !important; } #actionsList { - border-radius: 0.5rem; - padding: 1rem; - margin: 0.5rem; - background-color: whitesmoke; + border-radius: 0.5rem; + padding: 1rem; + margin: 0.5rem; + background-color: whitesmoke; } span.badge { - margin-bottom: 0.5rem; - @include badge_social($social-issue-color); + margin-bottom: 0.5rem; + @include badge_social($social-issue-color); } diff --git a/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/SocialIssuesAcc/CheckSocialAction.vue b/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/SocialIssuesAcc/CheckSocialAction.vue index 8172b2b6f..895e3e55b 100644 --- a/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/SocialIssuesAcc/CheckSocialAction.vue +++ b/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/SocialIssuesAcc/CheckSocialAction.vue @@ -1,38 +1,38 @@ @@ -41,13 +41,24 @@ export default { @import "ChillPersonAssets/chill/scss/mixins"; @import "ChillMainAssets/chill/scss/chill_variables"; span.badge { - @include badge_social($social-action-color); - font-size: 95%; - margin-bottom: 5px; - margin-right: 1em; - max-width: 100%; /* Adjust as needed */ - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + @include badge_social($social-action-color); + font-size: 95%; + white-space: normal; + word-wrap: break-word; + word-break: break-word; + display: inline-block; + max-width: 100%; + margin-bottom: 5px; + margin-right: 1em; + text-align: left; + line-height: 1.2em; + &::before { + position: absolute; + left: 11px; + top: 0; + margin: 0 0.3em 0 -0.75em; + } + position: relative; + padding-left: 1.5em; } diff --git a/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/SocialIssuesAcc/CheckSocialIssue.vue b/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/SocialIssuesAcc/CheckSocialIssue.vue index 9dbedf2ea..63fad09fc 100644 --- a/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/SocialIssuesAcc/CheckSocialIssue.vue +++ b/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/components/SocialIssuesAcc/CheckSocialIssue.vue @@ -1,38 +1,36 @@ @@ -41,9 +39,24 @@ export default { @import "ChillPersonAssets/chill/scss/mixins"; @import "ChillMainAssets/chill/scss/chill_variables"; span.badge { - @include badge_social($social-issue-color); - font-size: 95%; - margin-bottom: 5px; - margin-right: 1em; + @include badge_social($social-issue-color); + font-size: 95%; + white-space: normal; + word-wrap: break-word; + word-break: break-word; + display: inline-block; + max-width: 100%; + margin-bottom: 5px; + margin-right: 1em; + text-align: left; + + &::before { + position: absolute; + left: 11px; + top: 0; + margin: 0 0.3em 0 -0.75em; + } + position: relative; + padding-left: 1.5em; } diff --git a/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/store.js b/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/store.js index acd616dc9..2e240b4ad 100644 --- a/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/store.js +++ b/src/Bundle/ChillActivityBundle/Resources/public/vuejs/Activity/store.js @@ -103,7 +103,7 @@ const store = createStore({ } // console.log("suggested users", suggestedUsers); - return suggestedUsers; + return suggestedUsers.filter((u) => u.enabled === true); }, suggestedResources(state) { // const resources = state.activity.accompanyingPeriod.resources; diff --git a/src/Bundle/ChillActivityBundle/Resources/views/Activity/_list_item.html.twig b/src/Bundle/ChillActivityBundle/Resources/views/Activity/_list_item.html.twig index 13dfa7461..5a322d630 100644 --- a/src/Bundle/ChillActivityBundle/Resources/views/Activity/_list_item.html.twig +++ b/src/Bundle/ChillActivityBundle/Resources/views/Activity/_list_item.html.twig @@ -136,7 +136,6 @@
    {{ activity.comment|chill_entity_render_box({ 'disable_markdown': false, - 'limit_lines': 3, 'metadata': false, }) }}
    diff --git a/src/Bundle/ChillActivityBundle/migrations/Version20251118124241.php b/src/Bundle/ChillActivityBundle/migrations/Version20251118124241.php new file mode 100644 index 000000000..c06f3e674 --- /dev/null +++ b/src/Bundle/ChillActivityBundle/migrations/Version20251118124241.php @@ -0,0 +1,50 @@ +addSql('ALTER TABLE activity_user ADD COLUMN by_migration BOOL DEFAULT FALSE'); + $this->addSql("COMMENT ON COLUMN activity_user.by_migration IS 'For backup purpose - can be safely deleted after a while. See migration \\Chill\\Migrations\\Activity\\Version20251118124241'"); + + $this->addSql('INSERT INTO activity_user (activity_id, user_id, by_migration) + SELECT id, user_id, true FROM activity + ON CONFLICT DO NOTHING'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE activity_user DROP COLUMN by_migration'); + } +} diff --git a/src/Bundle/ChillActivityBundle/translations/messages+intl-icu.nl.yml b/src/Bundle/ChillActivityBundle/translations/messages+intl-icu.nl.yml new file mode 100644 index 000000000..dc122576f --- /dev/null +++ b/src/Bundle/ChillActivityBundle/translations/messages+intl-icu.nl.yml @@ -0,0 +1,18 @@ +export: + filter: + activity: + course_having_activity_between_date: + Only course having an activity between from and to: Alleen trajecten met een activiteit tussen {from, date, short} en {to, date, short} + + acp_by_activity_type: + 'acp_containing_at_least_one_activitytypes': >- + Gefilterde trajecten: alleen die welke ten minste één activiteit bevatten van een van de volgende types: {activitytypes} + {has_date_after, select, 1 {, na {date_after, date}} other {}} + {has_date_before, select, 1 {, voor {date_before, date}} other {}} + describe_action_with_no_subject: >- + Gefilterd op persoon die een activiteit had tussen {date_from, date} en {date_to, date} + describe_action_with_subject: >- + Gefilterd op persoon die een activiteit had tussen {date_from, date} en {date_to, date}, en een van deze gekozen onderwerpen: {reasons} + +activity: + title: Activiteit van {date, date, long} - {type} diff --git a/src/Bundle/ChillActivityBundle/translations/messages.fr.yml b/src/Bundle/ChillActivityBundle/translations/messages.fr.yml index 883310df9..63c61d8e4 100644 --- a/src/Bundle/ChillActivityBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillActivityBundle/translations/messages.fr.yml @@ -10,7 +10,7 @@ Attendee: Présence de l'usager attendee: présence de l'usager list_reasons: liste des sujets user_username: nom de l'utilisateur -circle_name: nom du cercle +circle_name: nom du service Remark: Commentaire No comments: Aucun commentaire Add a new activity: Ajouter une nouvel échange @@ -20,7 +20,7 @@ not present: absent Delete: Supprimer Update: Mettre à jour Update activity: Modifier l'échange -Scope: Cercle +Scope: Service Activity data: Données de l'échange Activity location: Localisation de l'échange No reason associated: Aucun sujet @@ -398,13 +398,15 @@ export: sent received: Envoyé ou reçu emergency: Urgence accompanying course id: Identifiant du parcours - course circles: Cercles du parcours + course circles: Services du parcours travelTime: Durée de déplacement durationTime: Durée id: Identifiant List activities linked to an accompanying course: Liste les échanges liés à un parcours en fonction de différents filtres. List activity linked to a course: Liste des échanges liés à un parcours - + commentText: Commentaire + comment_date: Date de la dernière édition du commentaire + comment_user: Dernière édition par filter: activity: diff --git a/src/Bundle/ChillActivityBundle/translations/messages.nl.yaml b/src/Bundle/ChillActivityBundle/translations/messages.nl.yaml index c7dc05f2b..9da8ffbd6 100644 --- a/src/Bundle/ChillActivityBundle/translations/messages.nl.yaml +++ b/src/Bundle/ChillActivityBundle/translations/messages.nl.yaml @@ -1,234 +1,500 @@ #general -Show the activity: Toon activiteit -Edit the activity: Wijzig activiteit -Activity: Activiteit +Show the activity: Uitwisseling bekijken +Edit the activity: Uitwisseling bewerken +Activity: Uitwisseling Duration time: Duur Duration Time: Duur -durationTime: duur -Travel time: Duur van verplaatsing -Attendee: Aanwezigheden -attendee: aanwezigheden -list_reasons: Onderwerpen -user_username: gebruikersnaam -circle_name: naam kring +durationTime: duur +Travel time: Reisduur +Attendee: Aanwezigheid van de gebruiker +attendee: aanwezigheid van de gebruiker +list_reasons: lijst van onderwerpen +user_username: naam van de gebruiker +circle_name: naam van de dienst Remark: Opmerking -No comments: Geen opmerkingen -Add a new activity: Voeg een nieuwe activiteit toe -Activity list: Lijst van activiteiten +No comments: Geen opmerking +Add a new activity: Nieuwe uitwisseling toevoegen +Activity list: Lijst van uitwisselingen present: aanwezig not present: afwezig Delete: Verwijderen Update: Bijwerken -Update activity: Activieit bijwerken -Scope: Werkingsgebied -Activity data: Gegevens activiteit -Activity location: Locatie activiteit +Update activity: Uitwisseling bewerken +Scope: Dienst +Activity data: Gegevens van de uitwisseling +Activity location: Locatie van de uitwisseling No reason associated: Geen onderwerp -No social issues associated: Geen sociaal vraagstuk -No social actions associated: Geen maatschappelijke actie -There isn't any activities.: Er zijn geen activiteiten -type_name: Soort activiteit +No social issues associated: Geen sociale problematiek +No social actions associated: Geen begeleidingsactie +There isn't any activities.: Geen uitwisseling geregistreerd. +type_name: type van de uitwisseling person_firstname: voornaam -person_lastname: familienaam -person_id: Identificatienummer persoon -Type: Soort +person_lastname: achternaam +person_id: identificatie van de gebruiker +Type: Type Invisible: Onzichtbaar Optional: Optioneel Required: Verplicht -Persons: Personen +Persons: Gebruikers Users: Gebruikers -Emergency: Dringend +Emergency: Urgent Sent received: Inkomend / Uitgaand Sent: Verzenden Received: Ontvangen by: 'Door ' location: Plaats Reasons: Onderwerpen +Private comment: Privé opmerking +sent: Verzonden +received: Ontvangen #forms -Activity creation: Nouvel échange -Create: Créer -Back to the list: Retour à la liste -Save activity: Sauver l'échange -Reset form: Remise à zéro du formulaire -Choose the duration: Choisir la durée -Choose a type: Choisir un type -5 minutes: 5 minutes -10 minutes: 10 minutes -15 minutes: 15 minutes -20 minutes: 20 minutes -25 minutes: 25 minutes -30 minutes: 30 minutes -45 minutes: 45 minutes -1 hour: 1 heure -1 hour 15: 1 heure 15 -1 hour 30: 1 heure 30 -1 hour 45: 1 heure 45 -2 hours: 2 heures -Concerned groups: Parties concernées -Persons in accompanying course: Usagers du parcours -Third persons: Tiers non-pro. -Others persons: Usagers -Third parties: Tiers professionnels +Activity creation: Nieuwe uitwisseling +Create: Aanmaken +Back to the list: Terug naar de lijst +Save activity: Uitwisseling opslaan +Reset form: Formulier resetten +Choose the duration: Duur kiezen +Choose a type: Type kiezen +5 minutes: 5 minuten +10 minutes: 10 minuten +15 minutes: 15 minuten +20 minutes: 20 minuten +25 minutes: 25 minuten +30 minutes: 30 minuten +45 minutes: 45 minuten +1 hour: 1 uur +1 hour 15: 1 uur 15 +1 hour 30: 1 uur 30 +1 hour 45: 1 uur 45 +2 hours: 2 uur +2 hours 15: 2 uur 15 +2 hours 30: 2 uur 30 +2 hours 45: 2 uur 45 +3 hours: 3 uur +3 hours 30: 3 uur 30 +4 hours: 4 uur +4 hours 30: 4 uur 30 +5 hours: 5 uur +5 hours 30: 5 uur 30 +6 hours: 6 uur +6 hours 30: 6 uur 30 +7 hours: 7 uur +7 hours 30: 7 uur 30 +8 hours: 8 uur +8 hours 30: 8 uur 30 +9 hours: 9 uur +9 hours 30: 9 uur 30 +10 hours: 10 uur +11 hours: 11 uur +12 hours: 12 uur +Concerned groups: Betrokken partijen bij de uitwisseling +Persons in accompanying course: Gebruikers van het traject +Third persons: Niet-prof. derden +Others persons: Gebruikers +Third parties: Professionele derden Users concerned: T(M)S + activity: - Insert a document: Insérer un document - Remove a document: Supprimer le document - comment: Commentaire -No documents: Aucun document + date: Datum van de uitwisseling + Insert a document: Document invoegen + Remove a document: Document verwijderen + comment: Opmerking + deleted: Uitwisseling verwijderd + + errors: Het formulier bevat fouten + social_issues: Sociale problematieken + choose_other_social_issue: Andere sociale problematiek toevoegen... + social_actions: Begeleidingsacties + select_first_a_social_issue: Selecteer eerst een sociale problematiek + social_action_list_empty: Geen sociale actie beschikbaar + add_persons: Betrokken personen toevoegen + bloc_persons: Gebruikers + bloc_persons_associated: Gebruikers van het traject + bloc_persons_not_associated: Niet-prof. derden + bloc_thirdparty: Professionele derden + bloc_users: T(M)S + location: Locatie + choose_location: Kies een locatie + choose_location_type: Kies een type locatie + create_new_location: Nieuwe locatie aanmaken + location_fields: + name: Naam + type: Type + phonenumber1: Telefoon + phonenumber2: Andere telefoon + email: E-mailadres + create_address: Adres aanmaken + edit_address: Adres bewerken + +No documents: Geen document + +# activity filter in list page +activity_filter: + My activities: Mijn uitwisselingen (waar ik aan deelneem) + Types: Op type uitwisseling + Jobs: Op betrokken beroep #timeline -'%user% has done an %activity_type%': '%user% a effectué un échange de type "%activity_type%"' +'%user% has done an %activity_type%': '%user% heeft een uitwisseling van type "%activity_type%" uitgevoerd' #controller -'Success : activity created!': L'échange a été créé. -'The form is not valid. The activity has not been created !': Le formulaire est invalide. L'échange n'a pas été créé. -'Success : activity updated!': L'échange a été mis à jour. -'The form is not valid. The activity has not been updated !': Le formulaire est invalide. L'échange n'a pas été mis à jour. +'Success : activity created!': De uitwisseling is aangemaakt. +'The form is not valid. The activity has not been created !': Het formulier is ongeldig. De uitwisseling is niet aangemaakt. +'Success : activity updated!': De uitwisseling is bijgewerkt. +'The form is not valid. The activity has not been updated !': Het formulier is ongeldig. De uitwisseling is niet bijgewerkt. # ROLES -CHILL_ACTIVITY_CREATE: Créer un échange -CHILL_ACTIVITY_UPDATE: Modifier un échange -CHILL_ACTIVITY_SEE: Voir un échange -CHILL_ACTIVITY_SEE_DETAILS: Voir le détail des échanges -CHILL_ACTIVITY_DELETE: Supprimer un échange -CHILL_ACTIVITY_STATS: Statistique des échanges -CHILL_ACTIVITY_LIST: Liste des échanges +CHILL_ACTIVITY_CREATE: Uitwisseling aanmaken +CHILL_ACTIVITY_UPDATE: Uitwisseling bewerken +CHILL_ACTIVITY_SEE: Uitwisseling bekijken +CHILL_ACTIVITY_SEE_DETAILS: Detail van uitwisselingen bekijken +CHILL_ACTIVITY_DELETE: Uitwisseling verwijderen +CHILL_ACTIVITY_STATS: Statistieken van uitwisselingen +CHILL_ACTIVITY_LIST: Lijst van uitwisselingen +CHILL_ACTIVITY_CREATE_PERSON: Uitwisseling aanmaken gekoppeld aan een gebruiker +CHILL_ACTIVITY_CREATE_ACCOMPANYING_COURSE: Uitwisseling aanmaken gekoppeld aan een traject +CHILL_ACTIVITY_FULL: Details bekijken, aanmaken, verwijderen en bijwerken van een uitwisseling # admin -Activities: Échanges -Activity configuration: Configuration des échanges -Activity configuration menu: Configuration des échanges -Activity types: Types d'échange -Activity type configuration: Configuration des categories d'échanges -Activity Reasons: Sujets d'un échange -Activity Reasons Category: Catégories de sujet d'échanges -Activity Types Categories: Catégories des types d'échanges -Activity Presences: Presences des échanges +Activities: Uitwisselingen +Activity configuration: Configuratie van uitwisselingen +Activity configuration menu: Configuratie van uitwisselingen +Activity types: Types uitwisseling +Activity type configuration: Configuratie van categorieën van uitwisselingen +Activity Reasons: Onderwerpen van een uitwisseling +Activity Reasons Category: Categorieën van onderwerpen van uitwisselingen +Activity Types Categories: Categorieën van types uitwisseling +Activity Presences: Aanwezigheden bij uitwisselingen +Associated activity reason category is inactive: De gekoppelde onderwerpscategorie is inactief # Crud crud: - activity_type: - title_new: Nouveau type d'échange - title_edit: Edition d'un type d'échange - activity_type_category: - title_new: Nouvelle catégorie de type d'échange - title_edit: Edition d'une catégorie de type d'échange + activity_type: + title_new: Nieuw type uitwisseling + title_edit: Type uitwisseling bewerken + activity_type_category: + title_new: Nieuwe categorie van type uitwisseling + title_edit: Categorie van type uitwisseling bewerken + activity_presence: + title_new: Nieuwe aanwezigheid bij uitwisselingen + title_edit: Aanwezigheid bij uitwisselingen bewerken # activity reason admin -ActivityReason list: Liste des sujets -Create a new activity reason: Créer un nouveau sujet -Active: Actif -Category: Catégorie -ActivityReason creation: Nouveau sujet -ActivityReason edit: Modification d'un sujet -ActivityReason: Sujet d'échange -The entity is inactive and won't be proposed: Le sujet est inactif et ne sera pas proposé -The entity is active and will be proposed: Le sujet est actif et sera proposé +ActivityReason list: Lijst van onderwerpen +Create a new activity reason: Nieuw onderwerp aanmaken +Active: Actief +Category: Categorie +ActivityReason creation: Nieuw onderwerp +ActivityReason edit: Onderwerp bewerken +ActivityReason: Onderwerp van uitwisseling +The entity is inactive and won't be proposed: Het onderwerp is inactief en zal niet worden voorgesteld +The entity is active and will be proposed: Het onderwerp is actief en zal worden voorgesteld #activity reason category admin -ActivityReasonCategory list: Catégories de sujets -Create a new activity category reason: Créer une nouvelle catégorie -ActivityReasonCategory creation: Nouvelle catégorie de sujet -ActivityReasonCategory edit: Modification d'une catégorie de sujet -ActivityReasonCategory: Catégorie de sujet d'échange -ActivityReasonCategory is active and will be proposed: La catégorie est active et sera proposée -ActivityReasonCategory is inactive and won't be proposed: La catégorie est inactive et ne sera pas proposée +ActivityReasonCategory list: Categorieën van onderwerpen +Create a new activity category reason: Nieuwe categorie aanmaken +ActivityReasonCategory creation: Nieuwe categorie van onderwerp +ActivityReasonCategory edit: Categorie van onderwerp bewerken +ActivityReasonCategory: Categorie van onderwerp van uitwisseling +ActivityReasonCategory is active and will be proposed: De categorie is actief en zal worden voorgesteld +ActivityReasonCategory is inactive and won't be proposed: De categorie is inactief en zal niet worden voorgesteld + +#activity presence admin +ActivityPresence list: Lijst van aanwezigheden bij uitwisselingen +Create a new activity presence: Nieuwe "Aanwezigheid bij uitwisselingen" aanmaken # activity type type admin -ActivityType list: Types d'échanges -Create a new activity type: Créer un nouveau type d'échange -Persons visible: Visibilité du champ Personnes -Persons label: Libellé du champ Personnes -User visible: Visibilité du champ Utilisateur -User label: Libellé du champ Utilisateur -Date visible: Visibilité du champ Date -Date label: Libellé du champ Date -Location visible: Visibilité du champ Lieu -Location label: Libellé du champ Lieu -Third parties visible: Visibilité du champ Tiers -Third parties label: Libellé du champ Tiers -Duration time visible: Visibilité du champ Durée -Duration time label: Libellé du champ Durée -Travel time visible: Visibilité du champ Durée de déplacement -Travel time label: Libellé du champ Durée de déplacement -Attendee visible: Visibilité du champ Présence de l'usager -Attendee label: Libellé du champ Présence de l'usager -Reasons visible: Visibilité du champ Sujet -Reasons label: Libellé du champ Sujet -Comment visible: Visibilité du champ Commentaire -Comment label: Libellé du champ Commentaire -Emergency visible: Visibilité du champ Urgent -Emergency label: Libellé du champ Urgent -Accompanying period visible: Visibilité du champ Période d'accompagnement -Accompanying period label: Libellé du champ Période d'accompagnement -Social issues visible: Visibilité du champ Problématiques sociales -Social issues label: Libellé du champ Problématiques sociales -Social actions visible: Visibilité du champ Action sociale -Social actions label: Libellé du champ Action sociale -Users visible: Visibilité du champ Utilisateurs -Users label: Libellé du champ Utilisateurs -Sent received visible: Visibilité du champ Entrant / Sortant -Sent received label: Libellé du champ Entrant / Sortant -Documents visible: Visibilité du champ Documents -Documents label: Libellé du champ Documents +ActivityType list: Types uitwisselingen +Create a new activity type: Nieuw type uitwisseling aanmaken +Persons visible: Zichtbaarheid van het veld Gebruikers +Persons label: Label van het veld Gebruikers +User visible: Zichtbaarheid van het veld Gebruiker +User label: Label van het veld Gebruiker +Date visible: Zichtbaarheid van het veld Datum +Date label: Label van het veld Datum +Location visible: Zichtbaarheid van het veld Plaats +Location label: Label van het veld Plaats +Third parties visible: Zichtbaarheid van het veld Derden +Third parties label: Label van het veld Derden +Duration time visible: Zichtbaarheid van het veld Duur +Duration time label: Label van het veld Duur +Travel time visible: Zichtbaarheid van het veld Reisduur +Travel time label: Label van het veld Reisduur +Attendee visible: Zichtbaarheid van het veld Aanwezigheid van de gebruiker +Attendee label: Label van het veld Aanwezigheid van de gebruiker +Reasons visible: Zichtbaarheid van het veld Onderwerp +Reasons label: Label van het veld Onderwerp +Comment visible: Zichtbaarheid van het veld Opmerking +Comment label: Label van het veld Opmerking +Private comment visible: Zichtbaarheid van het veld Privé Opmerking +Private comment label: Label van het veld Privé Opmerking +Emergency visible: Zichtbaarheid van het veld Urgent +Emergency label: Label van het veld Urgent +Accompanying period visible: Zichtbaarheid van het veld begeleidingstraject +Accompanying period label: Label van het veld begeleidingstraject +Social issues visible: Zichtbaarheid van het veld Sociale problematieken +Social issues label: Label van het veld Sociale problematieken +Social actions visible: Zichtbaarheid van het veld Sociale actie +Social actions label: Label van het veld Sociale actie +Users visible: Zichtbaarheid van het veld Gebruikers +Users label: Label van het veld Gebruikers +Sent received visible: Zichtbaarheid van het veld Inkomend / Uitgaand +Sent received label: Label van het veld Inkomend / Uitgaand +Documents visible: Zichtbaarheid van het veld Documenten +Documents label: Label van het veld Documenten # activity type category admin -ActivityTypeCategory list: Liste des catégories des types d'activité -Create a new activity type category: Créer une nouvelle catégorie de type d'échange +ActivityTypeCategory list: Lijst van categorieën van types uitwisseling +Create a new activity type category: Nieuwe categorie van type uitwisseling aanmaken +Create a new activity in accompanying course: Uitwisseling aanmaken in het traject # activity delete -Remove activity: Supprimer un échange -Are you sure you want to remove the activity about "%name%" ?: Êtes-vous sûr de vouloir supprimer un échange qui concerne "%name%" ? -The activity has been successfully removed.: L'échange a été supprimée. +Remove activity: Uitwisseling verwijderen +Are you sure you want to remove the activity about "%name%" ?: Weet u zeker dat u een uitwisseling wilt verwijderen die betrekking heeft op "%name%"? +The activity has been successfully removed.: De uitwisseling is verwijderd. # exports -Count activities: Nombre d'échanges -Count activities by various parameters.: Compte le nombre d'échanges enregistrées en fonction de différents paramètres. -Sum activity duration: Total de la durée des échanges -Sum activities duration by various parameters.: Additionne la durée des échanges en fonction de différents paramètres. -List activities: Liste les échanges -Number of activities: Nombre d'échanges +Exports of activities linked to a person: Exports van uitwisselingen gekoppeld aan een gebruiker +Number of activities linked to a person: Aantal uitwisselingen gekoppeld aan een gebruiker +Count activities linked to a person: Aantal uitwisselingen +Count activities linked to a person by various parameters.: Telt het aantal geregistreerde uitwisselingen gekoppeld aan een gebruiker op basis van verschillende parameters. +Sum activity linked to a person duration: Duur van uitwisselingen +Sum activities linked to a person duration: Duur van uitwisselingen gekoppeld aan een gebruiker +Sum activities linked to a person duration by various parameters.: Telt de duur van uitwisselingen op basis van verschillende parameters. +List activity linked to a person: Uitwisselingen opsommen +List activities linked to a person: Lijst van uitwisselingen gekoppeld aan een gebruiker +List activities linked to a person description: Maakt de lijst van uitwisselingen op basis van verschillende parameters. + +Exports of activities linked to an accompanying period: Exports van uitwisselingen gekoppeld aan een traject +Number of activities linked to an accompanying period: Aantal uitwisselingen gekoppeld aan een traject +Count activities linked to an accompanying period: Aantal uitwisselingen +Count activities linked to an accompanying period by various parameters.: Telt het aantal geregistreerde uitwisselingen gekoppeld aan een traject op basis van verschillende parameters. +Sum activity linked to an accompanying period duration: Som van de duur van uitwisselingen +Sum activities linked to an accompanying period duration: Som van de duur van uitwisselingen gekoppeld aan een traject +Sum activities linked to an accompanying period duration by various parameters.: Telt de duur van uitwisselingen op basis van verschillende parameters. +Sum activity linked to an accompanying period visit duration: Som van de reisduur van uitwisselingen +Sum activities linked to an accompanying period visit duration: Som van de reisduur van uitwisselingen gekoppeld aan een traject +Sum activities linked to an accompanying period visit duration by various parameters.: Telt de reisduur van uitwisselingen op basis van verschillende parameters. +Average activity linked to an accompanying period duration: Gemiddelde van de duur van uitwisselingen +Average activities linked to an accompanying period duration: Gemiddelde van de duur van uitwisselingen gekoppeld aan een traject +Average activities linked to an accompanying period duration by various parameters.: Gemiddelde van de duur van uitwisselingen op basis van verschillende parameters. +Average activity linked to an accompanying period visit duration: Gemiddelde van de reisduur van uitwisselingen +Average activities linked to an accompanying period visit duration: Gemiddelde van de reisduur van uitwisselingen gekoppeld aan een traject +Average activities linked to an accompanying period visit duration by various parameters.: Gemiddelde van de reisduur van uitwisselingen op basis van verschillende parameters. #filters -Filter by reason: Filtrer par sujet d'activité -'Filtered by reasons: only %list%': 'Filtré par sujet: seulement %list%' -'Filtered by activity type: only %list%': "Filtré par type d'activity: uniquement %list%" -Filtered by date activity: Filtrer par date d'activité -Activities after this date: Activités après cette date -Activities before this date: Activités avant cette date -"Filtered by date of activity: only between %date_from% and %date_to%": "Filtré par date de l'activité: uniquement entre %date_from% et %date_to%" -This date should be after the date given in "Implied in an activity after this date" field: Cette date devrait être postérieure à la date donnée dans le champ "activités après cette date" +Filter by reason: Uitwisselingen filteren op onderwerp +'Filtered by reasons: only %list%': 'Gefilterd op onderwerp: alleen %list%' +'Filtered by activity type: only %list%': "Gefilterd op type uitwisseling: alleen %list%" +Filtered by date activity: Uitwisselingen filteren op datum +Activities after this date: Uitwisselingen na deze datum +Activities before this date: Uitwisselingen vóór deze datum +"Filtered by date of activity: only between %date_from% and %date_to%": "Gefilterd op datum van de uitwisseling: alleen tussen %date_from% en %date_to%" +This date should be after the date given in "Implied in an activity after this date" field: Deze datum moet later zijn dan de datum in het veld "uitwisselingen na deze datum" -Filtered by person having an activity in a period: Uniquement les personnes ayant eu une activité dans la période donnée -Implied in an activity after this date: Impliqué dans une activité après cette date -Implied in an activity before this date: Impliqué dans une activité avant cette date -Filtered by person having an activity between %date_from% and %date_to% with reasons %reasons_name%: Filtré par personnes associées à une activité entre %date_from% et %date_to% avec les sujets %reasons_name% -Activity reasons for those activities: Sujets de ces activités -Filter by activity type: Filtrer par type d'activité +Filter by activity type: Uitwisselingen filteren op type + +Filter activity by location: Uitwisselingen filteren op locatie +'Filtered activity by location: only %locations%': "Gefilterd op locatie: alleen %locations%" +Filter activity by locationtype: Uitwisselingen filteren op type locatie +'Filtered activity by locationtype: only %types%': "Gefilterd op type locatie: alleen %types%" +Accepted locationtype: Types locatie +Accepted users: TMS(en) +Filter activity by emergency: Uitwisselingen filteren op urgentie +'Filtered activity by emergency: only %emergency%': "Gefilterd op urgentie: alleen als %emergency%" +activity is emergency: de uitwisseling is urgent +activity is not emergency: de uitwisseling is niet urgent +Filter activity by sentreceived: Uitwisselingen filteren op verzonden/ontvangen +'Filtered activity by sentreceived: only %sentreceived%': "Gefilterd op verzonden/ontvangen: alleen %sentreceived%" +Accepted sentreceived: '' +Filter activity by linked socialaction: Uitwisselingen filteren op gekoppelde actie +'Filtered activity by linked socialaction: only %actions%': "Gefilterd op gekoppelde actie: alleen %actions%" +Filter activity by linked socialissue: Uitwisselingen filteren op gekoppelde problematiek +'Filtered activity by linked socialissue: only %issues%': "Gefilterd op gekoppelde problematiek: alleen %issues%" +Filter activity by user: Uitwisselingen filteren op hoofdgebruiker +Filter activity by users: Uitwisselingen filteren op deelnemende gebruiker +Filter activity by creator: Uitwisselingen filteren op aanmaker van de uitwisseling +'Filtered activity by user: only %users%': "Gefilterd op referent: alleen %users%" +'Filtered activity by users: only %users%': "Gefilterd op deelnemende gebruikers: alleen %users%" +'Filtered activity by creator: only %users%': "Gefilterd op aanmaker: alleen %users%" +Creators: Aanmakers +Accepted userscope: Diensten + +Filter acp which has no activity: Trajecten filteren die geen uitwisseling hebben +Filtered acp which has no activities: Trajecten zonder gekoppelde uitwisseling filteren +Group acp by activity number: Trajecten groeperen op aantal uitwisselingen #aggregators -Activity type: Type d'activité -Activity user: Utilisateur lié à l'activity -By reason: Par sujet -By category of reason: Par catégorie de sujet -Reason's level: Niveau du sujet -Group by reasons: Sujet d'activité -Aggregate by activity user: Grouper par utilisateur lié à l'activité -Aggregate by activity type: Grouper par type d'activité -Aggregate by activity reason: Grouper par sujet de l'activité +Activity type: Type uitwisseling +Activity user: Gebruiker gekoppeld aan de uitwisseling +By reason: Op onderwerp +By category of reason: Op categorie van onderwerp +Reason's level: Niveau van het onderwerp +Group by reasons: Onderwerp van uitwisseling +Aggregate by activity user: Uitwisselingen groeperen op referent +Aggregate by activity users: Uitwisselingen groeperen op deelnemende gebruikers +Aggregate by activity type: Uitwisselingen groeperen op type +Aggregate by activity reason: Uitwisselingen groeperen op onderwerp -Last activities: Les dernières activités +Group activity by locationtype: Uitwisselingen groeperen op type locatie +Group activity by date: Uitwisselingen groeperen op datum +Frequency: Frequentie +by month: Per maand +by week: Per week +for week: Week +by year: Per jaar +in year: In +Group activity by creator: Uitwisselingen groeperen op aanmaker van de uitwisseling +Group activity by linked thirdparties: Uitwisselingen groeperen op betrokken derde +Accepted thirdparty: Betrokken derde +Group activity by linked socialaction: Uitwisselingen groeperen op gekoppelde actie +Group activity by linked socialissue: Uitwisselingen groeperen op gekoppelde problematiek +Group activity by userscope: Uitwisselingen groeperen op dienst van de aanmaker -See activity in accompanying course context: Voir l'activité dans le contexte du parcours d'accompagnement +Last activities: De laatste uitwisselingen -You get notified of an activity which does not exists any more: Cette notification ne correspond pas à une activité valide. -you are not allowed to see it details: La notification fait référence à une activité à laquelle vous n'avez pas accès. -This is the minimal activity data: Activité n° +See activity in accompanying course context: Uitwisseling bekijken in de context van het begeleidingstraject + +You get notified of an activity which does not exists any more: Deze melding komt niet overeen met een geldige uitwisseling. +you are not allowed to see it details: De melding verwijst naar een uitwisseling waartoe u geen toegang hebt. +This is the minimal activity data: Uitwisseling nr. docgen: - Activity basic: Echange - A basic context for activity: Contexte pour les activités + Activity basic: Uitwisseling + A basic context for activity: Context voor uitwisselingen + Accompanying period with a list of activities: Begeleidingstraject met lijst van uitwisselingen + Accompanying period with a list of activities description: Deze context neemt de informatie van het traject over, en alle uitwisselingen voor een traject. De uitwisselingen worden niet gefilterd. + myActivitiesOnly: Alleen rekening houden met uitwisselingen waarin ik heb deelgenomen + myWorksOnly: Alleen rekening houden met begeleidingsacties waarvan ik referent ben + +export: + export: + count_person_on_activity: + title: Aantal betrokken gebruikers bij uitwisselingen + description: Telt het aantal betrokken gebruikers bij uitwisselingen. Als een gebruiker aanwezig is in meerdere uitwisselingen, wordt hij slechts één keer geteld. + header: Aantal betrokken gebruikers bij uitwisselingen + count_household_on_activity: + title: Aantal betrokken huishoudens bij uitwisselingen + description: Telt het aantal betrokken huishoudens bij uitwisselingen. Als een huishouden aanwezig is in meerdere uitwisselingen, wordt het slechts één keer geteld. Gebruikers zonder huishouden worden niet geteld. + header: Aantal betrokken huishoudens bij uitwisselingen + count_household_on_activity_person: + title: Aantal betrokken huishoudens bij uitwisselingen + description: Telt het aantal betrokken huishoudens bij uitwisselingen. Als een huishouden aanwezig is in meerdere uitwisselingen, wordt het slechts één keer geteld. Gebruikers zonder huishouden worden niet geteld. Wanneer een gebruiker van huishouden verandert, wordt elk huishouden één keer geteld. + header: Aantal betrokken huishoudens bij uitwisselingen + list: + activity: + users name: Naam van de gebruikers + users ids: Identificatie van de gebruikers + third parties ids: Identificatie van de derden + persons ids: Identificatie van de gebruikers + persons name: Naam van de gebruikers + thirds parties: Derden + date: Datum van de uitwisseling + locationName: Locatie + sent received: Verzonden of ontvangen + emergency: Urgentie + accompanying course id: Identificatie van het traject + course circles: Diensten van het traject + travelTime: Reisduur + durationTime: Duur + id: Identificatie + List activities linked to an accompanying course: Somt uitwisselingen op gekoppeld aan een traject op basis van verschillende filters. + List activity linked to a course: Lijst van uitwisselingen gekoppeld aan een traject + commentText: Opmerking + comment_date: Datum van de laatste bewerking van de opmerking + comment_user: Laatste bewerking door + + filter: + activity: + by_users_job: + Filter by users job: Uitwisselingen filteren op beroep van ten minste één deelnemende gebruiker + 'Filtered activity by users job: only %jobs%': 'Gefilterd op beroep van ten minste één deelnemende gebruiker: alleen %jobs%' + by_users_scope: + Filter by users scope: Uitwisselingen filteren op dienst van ten minste één deelnemende gebruiker + 'Filtered activity by users scope: only %scopes%': 'Gefilterd op dienst van ten minste één deelnemende gebruiker: alleen %scopes%' + course_having_activity_between_date: + Title: Trajecten filteren die een uitwisseling hebben ontvangen tussen twee data + Receiving an activity after: Die een uitwisseling hebben ontvangen na + Receiving an activity before: Die een uitwisseling hebben ontvangen vóór + acp_by_activity_type: + 'activity after': Uitwisselingen na + activity after help: Indien leeg gelaten, wordt er geen rekening mee gehouden + activity before: Uitwisselingen vóór + activity before help: Indien leeg gelaten, wordt er geen rekening mee gehouden + person_between_dates: + Implied in an activity after this date: Betrokken bij een uitwisseling na deze datum + Implied in an activity before this date: Betrokken bij een uitwisseling vóór deze datum + Activity reasons for those activities: Onderwerpen van deze uitwisselingen + if no reasons: Als geen enkel onderwerp is aangevinkt, worden alle onderwerpen in aanmerking genomen + title: Gebruikers filteren die gekoppeld zijn geweest aan een uitwisseling tijdens de periode + date mismatch: De einddatum van de periode moet later zijn dan de startdatum + by_creator_scope: + Filter activity by user scope: Uitwisselingen filteren op dienst van de aanmaker van de uitwisseling + 'Filtered activity by user scope: only %scopes%': "Gefilterd op dienst van de aanmaker van de uitwisseling: alleen %scopes%" + by_creator_job: + job_form_label: Beroepen + Filter activity by user job: Uitwisselingen filteren op beroep van de aanmaker van de uitwisseling + 'Filtered activity by user job: only %jobs%': "Gefilterd op beroep van de aanmaker van de uitwisseling: alleen %jobs%" + by_persons: + Filter activity by persons: Uitwisselingen filteren op deelnemende gebruiker + 'Filtered activity by persons: only %persons%': 'Uitwisselingen gefilterd op deelnemende gebruikers: alleen %persons%' + persons taking part on the activity: Gebruikers deelnemend aan de uitwisseling + by_sent_received: + Sent or received: Verzonden of ontvangen + is sent: verzonden + is received: ontvangen + by_presence: + Filter activity by activity presence: Uitwisselingen filteren op aanwezigheid van de gebruiker + presences: Aanwezigheden + 'Filtered by activity presence: only %presences%': 'Gefilterd op aanwezigheid van de gebruiker: alleen %presences%' + + aggregator: + person: + by_person: + title: Uitwisselingen groeperen op gebruiker (gebruikersdossier waarin de uitwisseling is geregistreerd) + person: Gebruiker + by_household: + title: Uitwisselingen groeperen op huishouden + household: Identificatie huishouden + acp: + by_activity_type: + title: Trajecten groeperen op type uitwisseling + after_date: Alleen uitwisselingen na deze datum + before_date: Alleen uitwisselingen vóór deze datum + activity_type: Types uitwisseling + activity: + by_sent_received: + Sent or received: Verzonden of ontvangen + is sent: verzonden + is received: ontvangen + Group activity by sentreceived: Uitwisselingen groeperen op verzonden / ontvangen + by_location: + Activity Location: Locatie van de uitwisseling + Title: Uitwisselingen groeperen op locatie van de uitwisseling + by_user_job: + Users 's job: Beroep van de gebruikers deelnemend aan de uitwisseling + Aggregate by users job: Uitwisselingen groeperen op beroep van de deelnemende gebruikers + by_user_scope: + Users 's scope: Hoofddienst van de gebruikers deelnemend aan de uitwisseling + Aggregate by users scope: Uitwisselingen groeperen op hoofddienst van de gebruiker + by_creator_scope: + Group activity by creator scope: Uitwisselingen groeperen op dienst van de aanmaker van de uitwisseling + Calc date: Berekeningsdatum van de dienst van de aanmaker van de uitwisseling + by_creator_job: + Group activity by creator job: Uitwisselingen groeperen op beroep van de aanmaker van de uitwisseling + Calc date: Berekeningsdatum van het beroep van de aanmaker van de uitwisseling + by_persons: + Group activity by persons: Uitwisselingen groeperen op deelnemende gebruiker + Persons: Deelnemende gebruikers + by_activity_presence: + Group activity by presence: Uitwisselingen groeperen op aanwezigheid van de gebruiker + header: Aanwezigheid van gebruiker(s) + +generic_doc: + filter: + keys: + accompanying_period_activity_document: Document van uitwisselingen van trajecten diff --git a/src/Bundle/ChillAsideActivityBundle/src/DependencyInjection/ChillAsideActivityExtension.php b/src/Bundle/ChillAsideActivityBundle/src/DependencyInjection/ChillAsideActivityExtension.php index b212c06ee..0ce369f6c 100644 --- a/src/Bundle/ChillAsideActivityBundle/src/DependencyInjection/ChillAsideActivityExtension.php +++ b/src/Bundle/ChillAsideActivityBundle/src/DependencyInjection/ChillAsideActivityExtension.php @@ -25,6 +25,7 @@ final class ChillAsideActivityExtension extends Extension implements PrependExte $config = $this->processConfiguration($configuration, $configs); $container->setParameter('chill_aside_activity.form.time_duration', $config['form']['time_duration']); + $container->setParameter('chill_aside_activity.show_concerned_persons_count', 'visible' === $config['show_concerned_persons_count']); $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../config')); $loader->load('services.yaml'); @@ -38,6 +39,24 @@ final class ChillAsideActivityExtension extends Extension implements PrependExte { $this->prependRoute($container); $this->prependCruds($container); + $this->prependTwigConfig($container); + } + + protected function prependTwigConfig(ContainerBuilder $container) + { + // Get the configuration for this bundle + $chillAsideActivityConfig = $container->getExtensionConfig($this->getAlias()); + $config = $this->processConfiguration($this->getConfiguration($chillAsideActivityConfig, $container), $chillAsideActivityConfig); + + // Add configuration to twig globals + $twigConfig = [ + 'globals' => [ + 'chill_aside_activity_config' => [ + 'show_concerned_persons_count' => 'visible' === $config['show_concerned_persons_count'], + ], + ], + ]; + $container->prependExtensionConfig('twig', $twigConfig); } protected function prependCruds(ContainerBuilder $container): void diff --git a/src/Bundle/ChillAsideActivityBundle/src/DependencyInjection/Configuration.php b/src/Bundle/ChillAsideActivityBundle/src/DependencyInjection/Configuration.php index da2e9ec8e..f5c8006cb 100644 --- a/src/Bundle/ChillAsideActivityBundle/src/DependencyInjection/Configuration.php +++ b/src/Bundle/ChillAsideActivityBundle/src/DependencyInjection/Configuration.php @@ -141,6 +141,12 @@ class Configuration implements ConfigurationInterface ->end() ->end() ->end() + ->end() + ->enumNode('show_concerned_persons_count') + ->values(['hidden', 'visible']) + ->defaultValue('hidden') + ->info('Show the concerned persons count field in aside activity forms and views') + ->end() ->end(); return $treeBuilder; diff --git a/src/Bundle/ChillAsideActivityBundle/src/Entity/AsideActivity.php b/src/Bundle/ChillAsideActivityBundle/src/Entity/AsideActivity.php index a8ca26365..e3b5448eb 100644 --- a/src/Bundle/ChillAsideActivityBundle/src/Entity/AsideActivity.php +++ b/src/Bundle/ChillAsideActivityBundle/src/Entity/AsideActivity.php @@ -54,6 +54,16 @@ class AsideActivity implements TrackCreationInterface, TrackUpdateInterface #[ORM\JoinColumn(nullable: false)] private ?AsideActivityCategory $type = null; + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_MUTABLE, nullable: true)] + private ?\DateTimeInterface $updatedAt = null; + + #[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; @@ -130,4 +140,30 @@ class AsideActivity implements TrackCreationInterface, TrackUpdateInterface return $this; } + + public function setUpdatedAt(\DateTimeInterface $updatedAt): self + { + $this->updatedAt = $updatedAt; + + return $this; + } + + public function setUpdatedBy(?User $updatedBy): self + { + $this->updatedBy = $updatedBy; + + return $this; + } + + public function getConcernedPersonsCount(): ?int + { + return $this->concernedPersonsCount; + } + + public function setConcernedPersonsCount(?int $concernedPersonsCount): self + { + $this->concernedPersonsCount = $concernedPersonsCount; + + return $this; + } } diff --git a/src/Bundle/ChillAsideActivityBundle/src/Export/Aggregator/ByConcernedPersonsCountAggregator.php b/src/Bundle/ChillAsideActivityBundle/src/Export/Aggregator/ByConcernedPersonsCountAggregator.php new file mode 100644 index 000000000..444c49269 --- /dev/null +++ b/src/Bundle/ChillAsideActivityBundle/src/Export/Aggregator/ByConcernedPersonsCountAggregator.php @@ -0,0 +1,86 @@ +addSelect('aside.concernedPersonsCount AS by_concerned_persons_count_aggregator') + ->addGroupBy('by_concerned_persons_count_aggregator'); + } + + public function applyOn(): string + { + return Declarations::ASIDE_ACTIVITY_TYPE; + } + + public function buildForm(FormBuilderInterface $builder): void + { + // No form needed + } + + public function getNormalizationVersion(): int + { + return 1; + } + + public function normalizeFormData(array $formData): array + { + return []; + } + + public function denormalizeFormData(array $formData, int $fromVersion): array + { + return []; + } + + public function getFormDefaultData(): array + { + return []; + } + + public function getLabels($key, array $values, $data): callable + { + return function ($value): string { + if ('_header' === $value) { + return 'export.aggregator.Concerned persons count'; + } + + if (null === $value) { + return 'export.aggregator.No concerned persons count specified'; + } + + return (string) $value; + }; + } + + public function getQueryKeys($data): array + { + return ['by_concerned_persons_count_aggregator']; + } + + public function getTitle(): string + { + return 'export.aggregator.Group by concerned persons count'; + } +} diff --git a/src/Bundle/ChillAsideActivityBundle/src/Export/Export/SumConcernedPersonsCountAsideActivity.php b/src/Bundle/ChillAsideActivityBundle/src/Export/Export/SumConcernedPersonsCountAsideActivity.php new file mode 100644 index 000000000..ab22302c2 --- /dev/null +++ b/src/Bundle/ChillAsideActivityBundle/src/Export/Export/SumConcernedPersonsCountAsideActivity.php @@ -0,0 +1,116 @@ +getTitle(); + + return static fn ($value) => $labels[$value]; + } + + public function getQueryKeys($data): array + { + return ['export_sum_concerned_persons_count']; + } + + public function getResult($query, $data, \Chill\MainBundle\Export\ExportGenerationContext $context): array + { + return $query->getQuery()->getResult(Query::HYDRATE_SCALAR); + } + + public function getTitle(): string + { + return 'export.Sum concerned persons count for aside activities'; + } + + public function getType(): string + { + return Declarations::ASIDE_ACTIVITY_TYPE; + } + + public function initiateQuery(array $requiredModifiers, array $acl, array $data, \Chill\MainBundle\Export\ExportGenerationContext $context): \Doctrine\ORM\QueryBuilder + { + $qb = $this->repository->createQueryBuilder('aside'); + + $qb->select('SUM(COALESCE(aside.concernedPersonsCount, 0)) as export_sum_concerned_persons_count'); + + return $qb; + } + + public function requiredRole(): string + { + return AsideActivityVoter::STATS; + } + + public function supportsModifiers(): array + { + return [ + Declarations::ASIDE_ACTIVITY_TYPE, + ]; + } +} diff --git a/src/Bundle/ChillAsideActivityBundle/src/Form/AsideActivityFormType.php b/src/Bundle/ChillAsideActivityBundle/src/Form/AsideActivityFormType.php index 2b2e85a42..c6f0510b5 100644 --- a/src/Bundle/ChillAsideActivityBundle/src/Form/AsideActivityFormType.php +++ b/src/Bundle/ChillAsideActivityBundle/src/Form/AsideActivityFormType.php @@ -21,6 +21,7 @@ use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\DataTransformer\DateTimeToTimestampTransformer; use Symfony\Component\Form\Extension\Core\Type\ChoiceType; +use Symfony\Component\Form\Extension\Core\Type\IntegerType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Form\FormEvent; use Symfony\Component\Form\FormEvents; @@ -29,11 +30,13 @@ use Symfony\Component\OptionsResolver\OptionsResolver; final class AsideActivityFormType extends AbstractType { private readonly array $timeChoices; + private readonly bool $showConcernedPersonsCount; public function __construct( ParameterBagInterface $parameterBag, ) { $this->timeChoices = $parameterBag->get('chill_aside_activity.form.time_duration'); + $this->showConcernedPersonsCount = $parameterBag->get('chill_aside_activity.show_concerned_persons_count'); } public function buildForm(FormBuilderInterface $builder, array $options): void @@ -76,6 +79,16 @@ final class AsideActivityFormType extends AbstractType ->add('location', PickUserLocationType::class) ; + if ($this->showConcernedPersonsCount) { + $builder->add('concernedPersonsCount', IntegerType::class, [ + 'label' => 'Concerned persons count', + 'required' => false, + 'attr' => [ + 'min' => 0, + ], + ]); + } + foreach (['duration'] as $fieldName) { $builder->get($fieldName) ->addModelTransformer($durationTimeTransformer); diff --git a/src/Bundle/ChillAsideActivityBundle/src/Resources/views/asideActivity/index.html.twig b/src/Bundle/ChillAsideActivityBundle/src/Resources/views/asideActivity/index.html.twig index 0a8648749..0d06e5ba9 100644 --- a/src/Bundle/ChillAsideActivityBundle/src/Resources/views/asideActivity/index.html.twig +++ b/src/Bundle/ChillAsideActivityBundle/src/Resources/views/asideActivity/index.html.twig @@ -42,6 +42,11 @@ {%- if entity.location.name is defined -%}
    {{ entity.location.name }}
    {%- endif -%} + + {%- if entity.concernedPersonsCount > 0 -%} +
    {{ entity.concernedPersonsCount }}
    + {%- endif -%} +
    diff --git a/src/Bundle/ChillAsideActivityBundle/src/Resources/views/asideActivity/view.html.twig b/src/Bundle/ChillAsideActivityBundle/src/Resources/views/asideActivity/view.html.twig index ef6faa9c6..330a2ab13 100644 --- a/src/Bundle/ChillAsideActivityBundle/src/Resources/views/asideActivity/view.html.twig +++ b/src/Bundle/ChillAsideActivityBundle/src/Resources/views/asideActivity/view.html.twig @@ -38,6 +38,11 @@
    {{ 'Duration'|trans }}
    {{ entity.duration|date('H:i') }}
    + {% if chill_aside_activity_config.show_concerned_persons_count == 'visible' %} +
    {{ 'Concerned persons count'|trans }}
    +
    {{ entity.concernedPersonsCount }}
    + {% endif %} +
    {{ 'Remark'|trans }}
    {%- if entity.note is empty -%}
    diff --git a/src/Bundle/ChillAsideActivityBundle/src/Tests/Export/Aggregator/ByConcernedPersonsCountAggregatorTest.php b/src/Bundle/ChillAsideActivityBundle/src/Tests/Export/Aggregator/ByConcernedPersonsCountAggregatorTest.php new file mode 100644 index 000000000..a1d133d8c --- /dev/null +++ b/src/Bundle/ChillAsideActivityBundle/src/Tests/Export/Aggregator/ByConcernedPersonsCountAggregatorTest.php @@ -0,0 +1,49 @@ +get(EntityManagerInterface::class); + + return [ + $em->createQueryBuilder() + ->select('count(aside.id)') + ->from(AsideActivity::class, 'aside'), + ]; + } +} diff --git a/src/Bundle/ChillAsideActivityBundle/src/Tests/Export/Export/SumConcernedPersonsCountAsideActivityTest.php b/src/Bundle/ChillAsideActivityBundle/src/Tests/Export/Export/SumConcernedPersonsCountAsideActivityTest.php new file mode 100644 index 000000000..499986ac1 --- /dev/null +++ b/src/Bundle/ChillAsideActivityBundle/src/Tests/Export/Export/SumConcernedPersonsCountAsideActivityTest.php @@ -0,0 +1,50 @@ +get(AsideActivityRepository::class); + + yield new SumConcernedPersonsCountAsideActivity($repository); + } + + public static function getFormData(): array + { + return [ + [], + ]; + } + + public static function getModifiersCombination(): array + { + return [ + ['aside_activity'], + ]; + } +} diff --git a/src/Bundle/ChillAsideActivityBundle/src/config/services/export.yaml b/src/Bundle/ChillAsideActivityBundle/src/config/services/export.yaml index 40cb120da..efba7a8af 100644 --- a/src/Bundle/ChillAsideActivityBundle/src/config/services/export.yaml +++ b/src/Bundle/ChillAsideActivityBundle/src/config/services/export.yaml @@ -20,6 +20,10 @@ services: tags: - { name: chill.export, alias: 'avg_aside_activity_duration' } + Chill\AsideActivityBundle\Export\Export\SumConcernedPersonsCountAsideActivity: + tags: + - { name: chill.export, alias: 'sum_aside_activity_concerned_persons_count' } + ## Filters chill.aside_activity.export.date_filter: class: Chill\AsideActivityBundle\Export\Filter\ByDateFilter @@ -70,3 +74,7 @@ services: Chill\AsideActivityBundle\Export\Aggregator\ByLocationAggregator: tags: - { name: chill.export_aggregator, alias: 'aside_activity_location_aggregator' } + + Chill\AsideActivityBundle\Export\Aggregator\ByConcernedPersonsCountAggregator: + tags: + - { name: chill.export_aggregator, alias: 'aside_activity_concerned_persons_count_aggregator' } diff --git a/src/Bundle/ChillAsideActivityBundle/src/migrations/Version20251006113048.php b/src/Bundle/ChillAsideActivityBundle/src/migrations/Version20251006113048.php new file mode 100644 index 000000000..8ea1dbf4c --- /dev/null +++ b/src/Bundle/ChillAsideActivityBundle/src/migrations/Version20251006113048.php @@ -0,0 +1,33 @@ +addSql('ALTER TABLE chill_asideactivity.asideactivity ADD concernedPersonsCount INT DEFAULT 0'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_asideactivity.AsideActivity DROP concernedPersonsCount'); + } +} diff --git a/src/Bundle/ChillAsideActivityBundle/src/translations/messages.fr.yml b/src/Bundle/ChillAsideActivityBundle/src/translations/messages.fr.yml index 7d3c7a20e..1b0e39e1b 100644 --- a/src/Bundle/ChillAsideActivityBundle/src/translations/messages.fr.yml +++ b/src/Bundle/ChillAsideActivityBundle/src/translations/messages.fr.yml @@ -27,6 +27,7 @@ Emergency: Urgent by: "Par " location: Lieu Asideactivity location: Localisation de l'activité +Concerned persons count: Nombre d'usager concernés # Crud crud: @@ -177,7 +178,7 @@ export: agent_id: Utilisateur creator_id: Créateur main_scope: Service principal de l'utilisateur - main_center: Centre principal de l'utilisateur + main_center: Territoire principal de l'utilisateur aside_activity_type: Catégorie d'activité annexe date: Date duration: Durée @@ -190,6 +191,7 @@ export: Count aside activities by various parameters.: Compte le nombre d'activités annexes selon divers critères Average aside activities duration: Durée moyenne des activités annexes Sum aside activities duration: Durée des activités annexes + Sum concerned persons count for aside activities: Nombre d'usager concernés par les activités annexes filter: Filter by aside activity date: Filtrer les activités annexes par date Filter by aside activity type: Filtrer les activités annexes par type d'activité @@ -210,6 +212,8 @@ export: 'Filtered by aside activity location: only %location%': "Filtré par localisation: uniquement %location%" aggregator: Group by aside activity type: Grouper les activités annexes par type d'activité + Group by concerned persons count: Grouper les activités annexes par nombre d'usagers conernés + Concerned persons count: Nombre d'usagers concernés Aside activity type: Type d'activité annexe by_user_job: Aggregate by user job: Grouper les activités annexes par métier des utilisateurs diff --git a/src/Bundle/ChillAsideActivityBundle/src/translations/messages.nl.yaml b/src/Bundle/ChillAsideActivityBundle/src/translations/messages.nl.yaml index cae3cd059..14d1182be 100644 --- a/src/Bundle/ChillAsideActivityBundle/src/translations/messages.nl.yaml +++ b/src/Bundle/ChillAsideActivityBundle/src/translations/messages.nl.yaml @@ -165,3 +165,60 @@ Phonecall: "Telefoon oproep" Aside activities: Nevenactiviteiten Aside activity types: Types nevenactiviteiten Aside activity type configuration: Configuratie categorieën nevenactiviteiten + +# exports +export: + aside_activity: + List of aside activities: Lijst van nevenactiviteiten + createdAt: Aanmaak + updatedAt: Laatste update + agent_id: Gebruiker + creator_id: Aanmaker + main_scope: Hoofddienst van de gebruiker + main_center: Hoofdterritorium van de gebruiker + aside_activity_type: Categorie nevenactiviteit + date: Datum + duration: Duur + note: Notitie + id: Identificatie + location: Locatie + + Exports of aside activities: Exports van nevenactiviteiten + Count aside activities: Aantal nevenactiviteiten + Count aside activities by various parameters.: Telt het aantal nevenactiviteiten volgens diverse criteria + Average aside activities duration: Gemiddelde duur van nevenactiviteiten + Sum aside activities duration: Duur van nevenactiviteiten + Sum concerned persons count for aside activities: Aantal betrokken gebruikers bij nevenactiviteiten + filter: + Filter by aside activity date: Nevenactiviteiten filteren op datum + Filter by aside activity type: Nevenactiviteiten filteren op type activiteit + 'Filtered by aside activity type: only %type%': "Gefilterd op type nevenactiviteit: alleen %type%" + Filtered by aside activities between %dateFrom% and %dateTo%: Gefilterd op datum van nevenactiviteit, tussen %dateFrom% en %dateTo% + This date should be after the date given in "Implied in an aside activity after this date" field: Deze datum moet later zijn dan de datum in het veld "nevenactiviteiten na deze datum" + Aside activities after this date: Nevenactiviteiten na deze datum + Aside activities before this date: Nevenactiviteiten vóór deze datum + 'Filtered aside activity by user: only %users%': "Gefilterd op gebruiker: alleen %users%" + Filter aside activity by user: Filteren op gebruiker + by_user_job: + 'Filtered aside activities by user jobs: only %jobs%': "Gefilterd op beroep van gebruikers: alleen %jobs%" + Filter by user jobs: Nevenactiviteiten filteren op beroep van gebruikers + by_user_scope: + 'Filtered aside activities by user scope: only %scopes%': "Gefilterd op dienst van gebruikers: alleen %scopes%" + Filter by user scope: Nevenactiviteiten filteren op dienst van gebruiker + Filter by aside activity location: Nevenactiviteiten filteren op locatie + 'Filtered by aside activity location: only %location%': "Gefilterd op locatie: alleen %location%" + aggregator: + Group by aside activity type: Nevenactiviteiten groeperen op type activiteit + Group by concerned persons count: Nevenactiviteiten groeperen op aantal betrokken gebruikers + Concerned persons count: Aantal betrokken gebruikers + Aside activity type: Type nevenactiviteit + by_user_job: + Aggregate by user job: Nevenactiviteiten groeperen op beroep van gebruikers + by_user_scope: + Aggregate by user scope: Nevenactiviteiten groeperen op dienst van gebruikers + Aside activity location: Locatie van nevenactiviteiten + Group by aside activity location: Nevenactiviteiten groeperen op locatie + Aside activity localisation: Locatie + +# ROLES +CHILL_ASIDE_ACTIVITY_STATS: Statistieken voor nevenactiviteiten diff --git a/src/Bundle/ChillBudgetBundle/translations/messages.nl.yml b/src/Bundle/ChillBudgetBundle/translations/messages.nl.yml index de334f79f..113492864 100644 --- a/src/Bundle/ChillBudgetBundle/translations/messages.nl.yml +++ b/src/Bundle/ChillBudgetBundle/translations/messages.nl.yml @@ -74,3 +74,42 @@ The balance: Verschil tussen inkomsten en onkosten Valid since %startDate% until %endDate%: Geldig sinds %startDate% tot %endDate% Valid since %startDate%: Geldig sinds %startDate% + +budget: + admin: + form: + Charge_kind_key: Identificatiesleutel + Resource_kind_key: Identificatiesleutel + This kind must contains only alphabeticals characters, and dashes. This string is in use during document generation. Changes may have side effect on document: Deze sleutel dient om het type last of inkomen te identificeren bij het genereren van documenten. Alleen alfanumerieke tekens zijn toegestaan. Het wijzigen van deze sleutel kan een effect hebben bij het genereren van nieuwe documenten. + +# ROLES +Budget elements: Budget +CHILL_BUDGET_ELEMENT_CREATE: Inkomsten/last aanmaken +CHILL_BUDGET_ELEMENT_DELETE: Inkomsten/last verwijderen +CHILL_BUDGET_ELEMENT_SEE: Inkomstenen/lasten bekijken +CHILL_BUDGET_ELEMENT_UPDATE: Inkomsten/last bewerken + +## admin + +crud: + resource_kind: + title_new: Nieuw type inkomsten + title_edit: Type inkomsten bewerken + charge_kind: + title_new: Nieuw type last + title_edit: Type last bewerken + +admin: + menu: + Resource types: Types inkomsten + Charge types: Types last + title: + Charge Type List: Lijst van types last + Resource Type List: Lijst van types inkomsten + Budget configuration: Configuratie van budgetelementen + new: + Create a new charge type: Nieuw type last aanmaken + Create a new resource type: Nieuw type inkomsten aanmaken + form: + Choose the type of resource: Kies een type inkomsten + Choose the type of charge: Kies een type last diff --git a/src/Bundle/ChillBudgetBundle/translations/validators.nl.yml b/src/Bundle/ChillBudgetBundle/translations/validators.nl.yml index 7281b6380..fa308eb67 100644 --- a/src/Bundle/ChillBudgetBundle/translations/validators.nl.yml +++ b/src/Bundle/ChillBudgetBundle/translations/validators.nl.yml @@ -1,2 +1,8 @@ -The amount cannot be empty: Le montant ne peut pas être vide ou égal à zéro -The budget element's end date must be after the start date: La date de fin doit être après la date de début \ No newline at end of file +The amount cannot be empty: Het bedrag mag niet nul of leeg zijn +The budget element's end date must be after the start date: De einddatum moet later vallen dan de begindatum + +budget: + admin: + form: + kind: + enkel_alphanumeriek diff --git a/src/Bundle/ChillCalendarBundle/Controller/CalendarAPIController.php b/src/Bundle/ChillCalendarBundle/Controller/CalendarAPIController.php index 880891d55..2569e7b35 100644 --- a/src/Bundle/ChillCalendarBundle/Controller/CalendarAPIController.php +++ b/src/Bundle/ChillCalendarBundle/Controller/CalendarAPIController.php @@ -12,6 +12,7 @@ declare(strict_types=1); namespace Chill\CalendarBundle\Controller; use Chill\CalendarBundle\Repository\CalendarRepository; +use Chill\CalendarBundle\Repository\InviteRepository; use Chill\MainBundle\CRUD\Controller\ApiController; use Chill\MainBundle\Entity\User; use Chill\MainBundle\Serializer\Model\Collection; @@ -22,7 +23,10 @@ use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; class CalendarAPIController extends ApiController { - public function __construct(private readonly CalendarRepository $calendarRepository) {} + public function __construct( + private readonly CalendarRepository $calendarRepository, + private readonly InviteRepository $inviteRepository, + ) {} #[\Symfony\Component\Routing\Attribute\Route(path: '/api/1.0/calendar/calendar/by-user/{id}.{_format}', name: 'chill_api_single_calendar_list_by-user', requirements: ['_format' => 'json'])] public function listByUser(User $user, Request $request, string $_format): JsonResponse @@ -51,16 +55,37 @@ class CalendarAPIController extends ApiController throw new BadRequestHttpException('dateTo not parsable'); } - $total = $this->calendarRepository->countByUser($user, $dateFrom, $dateTo); - $paginator = $this->getPaginatorFactory()->create($total); - $ranges = $this->calendarRepository->findByUser( + // Get calendar items where user is the main user + $ownCalendars = $this->calendarRepository->findByUser( $user, $dateFrom, - $dateTo, - $paginator->getItemsPerPage(), - $paginator->getCurrentPageFirstItemNumber() + $dateTo ); + // Get calendar items from accepted invites + $acceptedInvites = $this->inviteRepository->findAcceptedInvitesByUserAndDateRange($user, $dateFrom, $dateTo); + $inviteCalendars = array_map(fn ($invite) => $invite->getCalendar(), $acceptedInvites); + + // Merge + $allCalendars = array_merge($ownCalendars, $inviteCalendars); + $uniqueCalendars = []; + $seenIds = []; + + foreach ($allCalendars as $calendar) { + $id = $calendar->getId(); + if (!in_array($id, $seenIds, true)) { + $seenIds[] = $id; + $uniqueCalendars[] = $calendar; + } + } + + $total = count($uniqueCalendars); + $paginator = $this->getPaginatorFactory()->create($total); + + $offset = $paginator->getCurrentPageFirstItemNumber(); + $limit = $paginator->getItemsPerPage(); + $ranges = array_slice($uniqueCalendars, $offset, $limit); + $collection = new Collection($ranges, $paginator); return $this->json($collection, Response::HTTP_OK, [], ['groups' => ['calendar:light']]); diff --git a/src/Bundle/ChillCalendarBundle/Controller/CalendarController.php b/src/Bundle/ChillCalendarBundle/Controller/CalendarController.php index abc0c0df2..e9ce1bd97 100644 --- a/src/Bundle/ChillCalendarBundle/Controller/CalendarController.php +++ b/src/Bundle/ChillCalendarBundle/Controller/CalendarController.php @@ -13,6 +13,7 @@ namespace Chill\CalendarBundle\Controller; use Chill\CalendarBundle\Entity\Calendar; use Chill\CalendarBundle\Form\CalendarType; +use Chill\CalendarBundle\Form\CancelType; use Chill\CalendarBundle\RemoteCalendar\Connector\RemoteCalendarConnectorInterface; use Chill\CalendarBundle\Repository\CalendarACLAwareRepositoryInterface; use Chill\CalendarBundle\Security\Voter\CalendarVoter; @@ -30,6 +31,7 @@ use Chill\PersonBundle\Repository\PersonRepository; use Chill\PersonBundle\Security\Authorization\AccompanyingPeriodVoter; use Chill\PersonBundle\Security\Authorization\PersonVoter; use Chill\ThirdPartyBundle\Entity\ThirdParty; +use Doctrine\ORM\EntityManagerInterface; use http\Exception\UnexpectedValueException; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -59,6 +61,7 @@ class CalendarController extends AbstractController private readonly UserRepositoryInterface $userRepository, private readonly TranslatorInterface $translator, private readonly \Doctrine\Persistence\ManagerRegistry $managerRegistry, + private readonly EntityManagerInterface $em, ) {} /** @@ -110,6 +113,55 @@ class CalendarController extends AbstractController ]); } + #[Route(path: '/{_locale}/calendar/calendar/{id}/cancel', name: 'chill_calendar_calendar_cancel')] + public function cancelAction(Calendar $calendar, Request $request): Response + { + // Deal with sms being sent or not + // Communicate cancellation with the remote calendar. + + $this->denyAccessUnlessGranted(CalendarVoter::EDIT, $calendar); + + [$person, $accompanyingPeriod] = [$calendar->getPerson(), $calendar->getAccompanyingPeriod()]; + + $form = $this->createForm(CancelType::class, $calendar); + $form->add('submit', SubmitType::class); + + if ($accompanyingPeriod instanceof AccompanyingPeriod) { + $view = '@ChillCalendar/Calendar/cancelCalendarByAccompanyingCourse.html.twig'; + $redirectRoute = $this->generateUrl('chill_calendar_calendar_list_by_period', ['id' => $accompanyingPeriod->getId()]); + } elseif ($person instanceof Person) { + $view = '@ChillCalendar/Calendar/cancelCalendarByPerson.html.twig'; + $redirectRoute = $this->generateUrl('chill_calendar_calendar_list_by_person', ['id' => $person->getId()]); + } else { + throw new \RuntimeException('nor person or accompanying period'); + } + + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + + $this->logger->notice('A calendar event has been cancelled', [ + 'by_user' => $this->getUser()->getUsername(), + 'calendar_id' => $calendar->getId(), + ]); + + $calendar->setStatus($calendar::STATUS_CANCELED); + $calendar->setSmsStatus($calendar::SMS_CANCEL_PENDING); + $this->em->flush(); + + $this->addFlash('success', $this->translator->trans('chill_calendar.calendar_canceled')); + + return new RedirectResponse($redirectRoute); + } + + return $this->render($view, [ + 'calendar' => $calendar, + 'form' => $form->createView(), + 'accompanyingCourse' => $accompanyingPeriod, + 'person' => $person, + ]); + } + /** * Edit a calendar item. */ @@ -265,7 +317,7 @@ class CalendarController extends AbstractController } if (!$this->getUser() instanceof User) { - throw new UnauthorizedHttpException('you are not an user'); + throw new UnauthorizedHttpException('you are not a user'); } $view = '@ChillCalendar/Calendar/listByUser.html.twig'; diff --git a/src/Bundle/ChillCalendarBundle/Controller/MyInvitationsController.php b/src/Bundle/ChillCalendarBundle/Controller/MyInvitationsController.php new file mode 100644 index 000000000..7af5ac18f --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Controller/MyInvitationsController.php @@ -0,0 +1,58 @@ +denyAccessUnlessGranted('ROLE_USER'); + + $user = $this->getUser(); + + if (!$user instanceof User) { + throw new UnauthorizedHttpException('you are not a user'); + } + + $total = count($this->inviteRepository->findBy(['user' => $user])); + $paginator = $this->paginator->create($total); + + $invitations = $this->inviteRepository->findBy( + ['user' => $user], + ['createdAt' => 'DESC'], + $paginator->getItemsPerPage(), + $paginator->getCurrentPageFirstItemNumber() + ); + + $view = '@ChillCalendar/Invitations/listByUser.html.twig'; + + return $this->render($view, [ + 'invitations' => $invitations, + 'paginator' => $paginator, + 'templates' => $this->docGeneratorTemplateRepository->findByEntity(Calendar::class), + ]); + } +} diff --git a/src/Bundle/ChillCalendarBundle/DataFixtures/ORM/LoadCancelReason.php b/src/Bundle/ChillCalendarBundle/DataFixtures/ORM/LoadCancelReason.php index ec67e584b..f9a7ec48c 100644 --- a/src/Bundle/ChillCalendarBundle/DataFixtures/ORM/LoadCancelReason.php +++ b/src/Bundle/ChillCalendarBundle/DataFixtures/ORM/LoadCancelReason.php @@ -35,7 +35,7 @@ class LoadCancelReason extends Fixture implements FixtureGroupInterface $arr = [ ['name' => CancelReason::CANCELEDBY_USER], ['name' => CancelReason::CANCELEDBY_PERSON], - ['name' => CancelReason::CANCELEDBY_DONOTCOUNT], + ['name' => CancelReason::CANCELEDBY_OTHER], ]; foreach ($arr as $a) { diff --git a/src/Bundle/ChillCalendarBundle/DependencyInjection/ChillCalendarExtension.php b/src/Bundle/ChillCalendarBundle/DependencyInjection/ChillCalendarExtension.php index 8eed040fc..b8836ab15 100644 --- a/src/Bundle/ChillCalendarBundle/DependencyInjection/ChillCalendarExtension.php +++ b/src/Bundle/ChillCalendarBundle/DependencyInjection/ChillCalendarExtension.php @@ -47,6 +47,8 @@ class ChillCalendarExtension extends Extension implements PrependExtensionInterf } else { $container->setParameter('chill_calendar.short_messages', null); } + + $container->setParameter('chill_calendar.remote_calendar_dsn', $config['remote_calendar_dsn']); } public function prepend(ContainerBuilder $container): void diff --git a/src/Bundle/ChillCalendarBundle/DependencyInjection/Configuration.php b/src/Bundle/ChillCalendarBundle/DependencyInjection/Configuration.php index f2617d841..bd7646742 100644 --- a/src/Bundle/ChillCalendarBundle/DependencyInjection/Configuration.php +++ b/src/Bundle/ChillCalendarBundle/DependencyInjection/Configuration.php @@ -32,9 +32,10 @@ class Configuration implements ConfigurationInterface ->canBeDisabled() ->children()->end() ->end() // end for short_messages + ->scalarNode('remote_calendar_dsn')->defaultValue('null://null')->cannotBeEmpty()->end() ->arrayNode('remote_calendars_sync')->canBeEnabled() ->children() - ->arrayNode('microsoft_graph')->canBeEnabled() + ->arrayNode('microsoft_graph')->canBeEnabled()->setDeprecated('chill-project/chill-bundles', '4.7.0', 'The child node %node% at path %path% is deprecated: use remote_calendar_dsn instead, with a "msgraph://default" value') ->children() ->end() // end of machine_access_token ->end() // end of microsoft_graph children diff --git a/src/Bundle/ChillCalendarBundle/Entity/Calendar.php b/src/Bundle/ChillCalendarBundle/Entity/Calendar.php index 2d7be7776..c7e663867 100644 --- a/src/Bundle/ChillCalendarBundle/Entity/Calendar.php +++ b/src/Bundle/ChillCalendarBundle/Entity/Calendar.php @@ -267,6 +267,11 @@ class Calendar implements TrackCreationInterface, TrackUpdateInterface, HasCente return $this->cancelReason; } + public function isCanceled(): bool + { + return null !== $this->cancelReason; + } + public function getCenters(): ?iterable { return match ($this->getContext()) { diff --git a/src/Bundle/ChillCalendarBundle/Entity/CalendarRange.php b/src/Bundle/ChillCalendarBundle/Entity/CalendarRange.php index eeca381cf..be034cac8 100644 --- a/src/Bundle/ChillCalendarBundle/Entity/CalendarRange.php +++ b/src/Bundle/ChillCalendarBundle/Entity/CalendarRange.php @@ -107,6 +107,11 @@ class CalendarRange implements TrackCreationInterface, TrackUpdateInterface return $this; } + public function hasLocation(): bool + { + return null !== $this->location; + } + public function setLocation(?Location $location): self { $this->location = $location; diff --git a/src/Bundle/ChillCalendarBundle/Entity/CancelReason.php b/src/Bundle/ChillCalendarBundle/Entity/CancelReason.php index 051f4bd79..338977912 100644 --- a/src/Bundle/ChillCalendarBundle/Entity/CancelReason.php +++ b/src/Bundle/ChillCalendarBundle/Entity/CancelReason.php @@ -18,14 +18,14 @@ use Doctrine\ORM\Mapping as ORM; #[ORM\Table(name: 'chill_calendar.cancel_reason')] class CancelReason { - final public const string CANCELEDBY_DONOTCOUNT = 'CANCELEDBY_DONOTCOUNT'; + final public const CANCELEDBY_OTHER = 'CANCELEDBY_OTHER'; final public const string CANCELEDBY_PERSON = 'CANCELEDBY_PERSON'; final public const string CANCELEDBY_USER = 'CANCELEDBY_USER'; - #[ORM\Column(type: \Doctrine\DBAL\Types\Types::BOOLEAN)] - private ?bool $active = null; + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::BOOLEAN, options: ['default' => true])] + private bool $active = true; #[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, length: 255)] private ?string $canceledBy = null; diff --git a/src/Bundle/ChillCalendarBundle/Form/CancelReasonType.php b/src/Bundle/ChillCalendarBundle/Form/CancelReasonType.php index 7d48c14e9..c5b476351 100644 --- a/src/Bundle/ChillCalendarBundle/Form/CancelReasonType.php +++ b/src/Bundle/ChillCalendarBundle/Form/CancelReasonType.php @@ -15,7 +15,7 @@ use Chill\CalendarBundle\Entity\CancelReason; use Chill\MainBundle\Form\Type\TranslatableStringFormType; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CheckboxType; -use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\Extension\Core\Type\ChoiceType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; @@ -28,7 +28,14 @@ class CancelReasonType extends AbstractType ->add('active', CheckboxType::class, [ 'required' => false, ]) - ->add('canceledBy', TextType::class); + ->add('canceledBy', ChoiceType::class, [ + 'choices' => [ + 'chill_calendar.canceled_by.user' => CancelReason::CANCELEDBY_USER, + 'chill_calendar.canceled_by.person' => CancelReason::CANCELEDBY_PERSON, + 'chill_calendar.canceled_by.other' => CancelReason::CANCELEDBY_OTHER, + ], + 'required' => true, + ]); } public function configureOptions(OptionsResolver $resolver): void diff --git a/src/Bundle/ChillCalendarBundle/Form/CancelType.php b/src/Bundle/ChillCalendarBundle/Form/CancelType.php new file mode 100644 index 000000000..ad41a6105 --- /dev/null +++ b/src/Bundle/ChillCalendarBundle/Form/CancelType.php @@ -0,0 +1,42 @@ +add('cancelReason', EntityType::class, [ + 'class' => CancelReason::class, + 'required' => true, + 'choice_label' => fn (CancelReason $cancelReason) => $this->translatableStringHelper->localize($cancelReason->getName()), + ]); + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => Calendar::class, + + ]); + } +} diff --git a/src/Bundle/ChillCalendarBundle/Menu/UserMenuBuilder.php b/src/Bundle/ChillCalendarBundle/Menu/UserMenuBuilder.php index 0313549ac..efaf02e45 100644 --- a/src/Bundle/ChillCalendarBundle/Menu/UserMenuBuilder.php +++ b/src/Bundle/ChillCalendarBundle/Menu/UserMenuBuilder.php @@ -11,25 +11,46 @@ declare(strict_types=1); namespace Chill\CalendarBundle\Menu; +use Chill\CalendarBundle\Repository\InviteRepository; +use Chill\MainBundle\Entity\User; use Chill\MainBundle\Routing\LocalMenuBuilderInterface; use Knp\Menu\MenuItem; use Symfony\Bundle\SecurityBundle\Security; use Symfony\Contracts\Translation\TranslatorInterface; -class UserMenuBuilder implements LocalMenuBuilderInterface +final readonly class UserMenuBuilder implements LocalMenuBuilderInterface { - public function __construct(private readonly Security $security, public TranslatorInterface $translator) {} + public function __construct( + private Security $security, + private TranslatorInterface $translator, + private InviteRepository $inviteRepository, + ) {} public function buildMenu($menuId, MenuItem $menu, array $parameters): void { - if ($this->security->isGranted('ROLE_USER')) { - $menu->addChild('My calendar list', [ - 'route' => 'chill_calendar_calendar_list_my', - ]) - ->setExtras([ - 'order' => 9, - 'icon' => 'tasks', - ]); + $user = $this->security->getUser(); + + if ($user instanceof User) { + $invitationsPending = $this->inviteRepository->countPendingInvitesByUser($user); + + if ($this->security->isGranted('ROLE_USER')) { + $menu->addChild('My calendar list', [ + 'route' => 'chill_calendar_calendar_list_my', + ]) + ->setExtras([ + 'order' => 8, + 'icon' => 'tasks', + ]); + $menu->addChild( + $this->translator->trans('invite.menu with counter', ['nb' => $invitationsPending]), + ['route' => 'chill_calendar_invitations_list_my'] + ) + ->setExtras([ + 'order' => 9, + 'icon' => 'tasks', + 'counter' => 0 < $invitationsPending ? $invitationsPending : null, + ]); + } } } diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Doctrine/CalendarEntityListener.php b/src/Bundle/ChillCalendarBundle/Messenger/Doctrine/CalendarEntityListener.php index 50c1cadbb..bef44e5f7 100644 --- a/src/Bundle/ChillCalendarBundle/Messenger/Doctrine/CalendarEntityListener.php +++ b/src/Bundle/ChillCalendarBundle/Messenger/Doctrine/CalendarEntityListener.php @@ -21,6 +21,7 @@ namespace Chill\CalendarBundle\Messenger\Doctrine; use Chill\CalendarBundle\Entity\Calendar; use Chill\CalendarBundle\Messenger\Message\CalendarMessage; use Chill\CalendarBundle\Messenger\Message\CalendarRemovedMessage; +use Chill\MainBundle\Entity\User; use Doctrine\ORM\Event\PostPersistEventArgs; use Doctrine\ORM\Event\PostRemoveEventArgs; use Doctrine\ORM\Event\PostUpdateEventArgs; @@ -31,6 +32,17 @@ class CalendarEntityListener { public function __construct(private readonly MessageBusInterface $messageBus, private readonly Security $security) {} + private function getAuthenticatedUser(): User + { + $user = $this->security->getUser(); + + if (!$user instanceof User) { + throw new \LogicException('Expected an instance of User.'); + } + + return $user; + } + public function postPersist(Calendar $calendar, PostPersistEventArgs $args): void { if (!$calendar->preventEnqueueChanges) { @@ -38,7 +50,7 @@ class CalendarEntityListener new CalendarMessage( $calendar, CalendarMessage::CALENDAR_PERSIST, - $this->security->getUser() + $this->getAuthenticatedUser() ) ); } @@ -50,7 +62,7 @@ class CalendarEntityListener $this->messageBus->dispatch( new CalendarRemovedMessage( $calendar, - $this->security->getUser() + $this->getAuthenticatedUser() ) ); } @@ -58,12 +70,19 @@ class CalendarEntityListener public function postUpdate(Calendar $calendar, PostUpdateEventArgs $args): void { - if (!$calendar->preventEnqueueChanges) { + if ($calendar->getStatus() === $calendar::STATUS_CANCELED) { + $this->messageBus->dispatch( + new CalendarRemovedMessage( + $calendar, + $this->getAuthenticatedUser() + ) + ); + } elseif (!$calendar->preventEnqueueChanges) { $this->messageBus->dispatch( new CalendarMessage( $calendar, CalendarMessage::CALENDAR_UPDATE, - $this->security->getUser() + $this->getAuthenticatedUser() ) ); } diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Handler/CalendarRemoveHandler.php b/src/Bundle/ChillCalendarBundle/Messenger/Handler/CalendarRemoveHandler.php index d48ac8645..db6ce0a22 100644 --- a/src/Bundle/ChillCalendarBundle/Messenger/Handler/CalendarRemoveHandler.php +++ b/src/Bundle/ChillCalendarBundle/Messenger/Handler/CalendarRemoveHandler.php @@ -22,16 +22,20 @@ use Chill\CalendarBundle\Messenger\Message\CalendarRemovedMessage; use Chill\CalendarBundle\RemoteCalendar\Connector\RemoteCalendarConnectorInterface; use Chill\CalendarBundle\Repository\CalendarRangeRepository; use Chill\MainBundle\Repository\UserRepositoryInterface; +use Doctrine\ORM\EntityManagerInterface; /** * Handle the deletion of calendar. - * - * @AsMessageHandler */ #[\Symfony\Component\Messenger\Attribute\AsMessageHandler] class CalendarRemoveHandler { - public function __construct(private readonly RemoteCalendarConnectorInterface $remoteCalendarConnector, private readonly CalendarRangeRepository $calendarRangeRepository, private readonly UserRepositoryInterface $userRepository) {} + public function __construct( + private readonly RemoteCalendarConnectorInterface $remoteCalendarConnector, + private readonly CalendarRangeRepository $calendarRangeRepository, + private readonly UserRepositoryInterface $userRepository, + private readonly EntityManagerInterface $entityManager, + ) {} public function __invoke(CalendarRemovedMessage $message): void { @@ -47,5 +51,7 @@ class CalendarRemoveHandler $this->userRepository->find($message->getCalendarUserId()), $associatedRange ); + + $this->entityManager->flush(); } } diff --git a/src/Bundle/ChillCalendarBundle/Messenger/Message/CalendarRemovedMessage.php b/src/Bundle/ChillCalendarBundle/Messenger/Message/CalendarRemovedMessage.php index 53dcea28c..7d2e88fef 100644 --- a/src/Bundle/ChillCalendarBundle/Messenger/Message/CalendarRemovedMessage.php +++ b/src/Bundle/ChillCalendarBundle/Messenger/Message/CalendarRemovedMessage.php @@ -70,6 +70,8 @@ class CalendarRemovedMessage public function getRemoteId(): string { + dump($this->remoteId); + return $this->remoteId; } } diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/RemoteCalendarConnectorInterface.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/RemoteCalendarConnectorInterface.php index 1e3c16845..644d22ae4 100644 --- a/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/RemoteCalendarConnectorInterface.php +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/Connector/RemoteCalendarConnectorInterface.php @@ -21,42 +21,137 @@ namespace Chill\CalendarBundle\RemoteCalendar\Connector; use Chill\CalendarBundle\Entity\Calendar; use Chill\CalendarBundle\Entity\CalendarRange; use Chill\CalendarBundle\Entity\Invite; +use Chill\CalendarBundle\Messenger\Message\CalendarMessage; use Chill\CalendarBundle\RemoteCalendar\Model\RemoteEvent; use Chill\MainBundle\Entity\User; use Symfony\Component\HttpFoundation\Response; +/** + * Contract for connectors that synchronize Chill calendars with a remote + * calendar provider (for example Microsoft 365/Graph, Zimbra, ...). + * + * Implementations act as an adapter between Chill domain objects + * (Calendar, CalendarRange, Invite) and the remote provider API. They must: + * - expose a readiness flow for per-user authorization when applicable + * (see {@see getMakeReadyResponse()} and {@see isReady()}); + * - list and count remote events in a time range for a given user; + * - mirror local lifecycle changes to the remote provider for calendars, + * calendar ranges (availability/busy blocks) and invites/attendees. + * + * Use {@see MSGraphRemoteCalendarConnector} as a reference implementation for + * expected behaviours, error handling and parameter semantics. + */ interface RemoteCalendarConnectorInterface { public function countEventsForUser(User $user, \DateTimeImmutable $startDate, \DateTimeImmutable $endDate): int; /** - * Return a response, more probably a RedirectResponse, where the user - * will be able to fullfill requirements to prepare this connector and - * make it ready. + * Returns a Response (typically a RedirectResponse) that lets the current + * user perform the steps required to make the connector usable (for + * example, OAuth consent or account linking). After completion, the user + * should be redirected back to the given path. */ public function getMakeReadyResponse(string $returnPath): Response; /** - * Return true if the connector is ready to act as a proxy for reading - * remote calendars. + * Returns true when the connector is ready to access the remote provider + * on behalf of the current user (e.g. required tokens/consent exist). */ public function isReady(): bool; /** + * Lists events from the remote provider for the given user and time range. + * + * Implementations should map provider-specific payloads to instances of + * {@see RemoteEvent}. + * * @return array|RemoteEvent[] */ public function listEventsForUser(User $user, \DateTimeImmutable $startDate, \DateTimeImmutable $endDate, ?int $offset = 0, ?int $limit = 50): array; + /** + * Removes a calendar (single event) from the remote provider. + * + * **Note**: calendar (single event) which are canceled will appears in this + * method, and not in syncCalendar method. + * + * Parameters: + * - remoteId: the provider identifier of the remote event to delete. If + * empty, implementations should no-op. + * - remoteAttributes: provider-specific metadata previously stored with the + * local entity (e.g. change keys, etags) that can help perform safe + * concurrency checks when deleting. Implementations may ignore unknown + * keys. + * - user: the user in whose remote calendar the event lives and on whose + * behalf the deletion must be performed. + * - associatedCalendarRange: when provided, the implementation should + * update/synchronize the corresponding remote busy-time block after the + * event removal so that availability stays consistent. + */ public function removeCalendar(string $remoteId, array $remoteAttributes, User $user, ?CalendarRange $associatedCalendarRange = null): void; + /** + * Removes a remote busy-time block (calendar range) identified by + * provider-specific id and attributes for the given user. + * + * Implementations should no-op if the id is empty. + */ public function removeCalendarRange(string $remoteId, array $remoteAttributes, User $user): void; /** + * Synchronizes a Calendar entity to the remote provider. + * + * Typical cases to support (see MSGraph implementation): + * - Creating the event on the remote calendar when it has no remote id. + * - Updating the existing remote event when details or attendees change. + * - Handling main user changes: cancel on the previous user's calendar, + * (re)create associated ranges where needed, then create on the new + * main user's calendar. + * - If the Calendar uses a CalendarRange that already exists remotely, + * implementations should remove/update that remote range when the event + * becomes the source of truth for busy times. + * + * The implementation should not expects to receive calendar which are canceled + * here. + * + * Parameters: + * - calendar: the domain Calendar to mirror remotely. + * - action: a hint about what triggered the sync; implementations should not rely + * solely on this value and must base decisions on the Calendar state. + * - previousCalendarRange: if the Calendar was previously attached to a + * different range, this contains the former range so it can be recreated + * remotely to preserve availability history when applicable. + * - previousMainUser: the former main user, when the main user changed; + * used to cancel the event in the previous user's calendar. + * - oldInvites: the attendee snapshot before the change. Each item is an + * array with keys: inviteId, userId, userEmail, userLabel. + * - newInvites: the attendee snapshot after the change, same shape as + * oldInvites. Implementations can compute diffs to add/remove attendees. + * + * The $action argument is a string tag indicating what happened to the + * calendar. It MUST be one of the constants defined on + * {@see CalendarMessage}: + * - {@see CalendarMessage::CALENDAR_PERSIST} + * - {@see CalendarMessage::CALENDAR_UPDATE} + * * @param array $oldInvites + * + * @phpstan-param (CalendarMessage::CALENDAR_PERSIST|CalendarMessage::CALENDAR_UPDATE) $action */ public function syncCalendar(Calendar $calendar, string $action, ?CalendarRange $previousCalendarRange, ?User $previousMainUser, ?array $oldInvites, ?array $newInvites): void; + /** + * Creates or updates a remote busy-time block representing the provided + * CalendarRange. If the range has a remote id, it should be updated; + * otherwise it should be created remotely, and the range enriched with + * the new id/attributes by the caller. + */ public function syncCalendarRange(CalendarRange $calendarRange): void; + /** + * Synchronizes a single Invite (attendee) change to the remote provider. + * Implementations may need to lookup the attendee's personal calendar to + * find provider-specific identifiers before patching the main event. + */ public function syncInvite(Invite $invite): void; } diff --git a/src/Bundle/ChillCalendarBundle/RemoteCalendar/DependencyInjection/RemoteCalendarCompilerPass.php b/src/Bundle/ChillCalendarBundle/RemoteCalendar/DependencyInjection/RemoteCalendarCompilerPass.php index 8c5a709ff..2ad5f4666 100644 --- a/src/Bundle/ChillCalendarBundle/RemoteCalendar/DependencyInjection/RemoteCalendarCompilerPass.php +++ b/src/Bundle/ChillCalendarBundle/RemoteCalendar/DependencyInjection/RemoteCalendarCompilerPass.php @@ -35,25 +35,46 @@ use TheNetworg\OAuth2\Client\Provider\Azure; class RemoteCalendarCompilerPass implements CompilerPassInterface { + private const ZIMBRA_CONNECTOR = 'Chill\ZimbraBundle\Calendar\Connector\ZimbraConnector'; + + private const MS_GRAPH_SERVICES_TO_REMOVE = [ + MapAndSubscribeUserCalendarCommand::class, + AzureGrantAdminConsentAndAcquireToken::class, + RemoteCalendarConnectAzureController::class, + MachineTokenStorage::class, + MachineHttpClient::class, + MSGraphRemoteCalendarConnector::class, + MSUserAbsenceReaderInterface::class, + MSUserAbsenceSync::class, + ]; + public function process(ContainerBuilder $container): void { - $config = $container->getParameter('chill_calendar'); + $config = $container->getParameter('chill_calendar.remote_calendar_dsn'); + if (true === $container->getParameter('chill_calendar')['remote_calendars_sync']['microsoft_graph']['enabled']) { + $dsn = 'msgraph://default'; + } else { + $dsn = $config; + } - if (true === $config['remote_calendars_sync']['microsoft_graph']['enabled']) { + $scheme = parse_url($dsn, PHP_URL_SCHEME); + + if ('msgraph' === $scheme) { $connector = MSGraphRemoteCalendarConnector::class; $container->setAlias(HttpClientInterface::class.' $machineHttpClient', MachineHttpClient::class); - } else { + } elseif ('zimbra+http' === $scheme || 'zimbra+https' === $scheme) { + $connector = self::ZIMBRA_CONNECTOR; + foreach (self::MS_GRAPH_SERVICES_TO_REMOVE as $serviceId) { + $container->removeDefinition($serviceId); + } + } elseif ('null' === $scheme) { $connector = NullRemoteCalendarConnector::class; - // remove services which cannot be loaded - $container->removeDefinition(MapAndSubscribeUserCalendarCommand::class); - $container->removeDefinition(AzureGrantAdminConsentAndAcquireToken::class); - $container->removeDefinition(RemoteCalendarConnectAzureController::class); - $container->removeDefinition(MachineTokenStorage::class); - $container->removeDefinition(MachineHttpClient::class); - $container->removeDefinition(MSGraphRemoteCalendarConnector::class); - $container->removeDefinition(MSUserAbsenceReaderInterface::class); - $container->removeDefinition(MSUserAbsenceSync::class); + foreach (self::MS_GRAPH_SERVICES_TO_REMOVE as $serviceId) { + $container->removeDefinition($serviceId); + } + } else { + throw new \Symfony\Component\DependencyInjection\Exception\InvalidArgumentException('Unsupported remote calendar scheme: '.$scheme); } if (!$container->hasAlias(Azure::class) && $container->hasDefinition('knpu.oauth2.client.azure')) { @@ -62,7 +83,9 @@ class RemoteCalendarCompilerPass implements CompilerPassInterface foreach ([ NullRemoteCalendarConnector::class, - MSGraphRemoteCalendarConnector::class, ] as $serviceId) { + MSGraphRemoteCalendarConnector::class, + self::ZIMBRA_CONNECTOR, + ] as $serviceId) { if ($connector === $serviceId) { $container->getDefinition($serviceId) ->setDecoratedService(RemoteCalendarConnectorInterface::class); diff --git a/src/Bundle/ChillCalendarBundle/Repository/CalendarRepository.php b/src/Bundle/ChillCalendarBundle/Repository/CalendarRepository.php index c37906552..383eb92f4 100644 --- a/src/Bundle/ChillCalendarBundle/Repository/CalendarRepository.php +++ b/src/Bundle/ChillCalendarBundle/Repository/CalendarRepository.php @@ -191,6 +191,7 @@ class CalendarRepository implements ObjectRepository $qb->expr()->eq('c.mainUser', ':user'), $qb->expr()->gte('c.startDate', ':startDate'), $qb->expr()->lte('c.endDate', ':endDate'), + $qb->expr()->isNull('c.cancelReason'), ) ) ->setParameters(new \Doctrine\Common\Collections\ArrayCollection([new \Doctrine\ORM\Query\Parameter('user', $user), new \Doctrine\ORM\Query\Parameter('startDate', $from), new \Doctrine\ORM\Query\Parameter('endDate', $to)])); diff --git a/src/Bundle/ChillCalendarBundle/Repository/InviteRepository.php b/src/Bundle/ChillCalendarBundle/Repository/InviteRepository.php index 8778330f8..6ba967871 100644 --- a/src/Bundle/ChillCalendarBundle/Repository/InviteRepository.php +++ b/src/Bundle/ChillCalendarBundle/Repository/InviteRepository.php @@ -12,6 +12,7 @@ declare(strict_types=1); namespace Chill\CalendarBundle\Repository; use Chill\CalendarBundle\Entity\Invite; +use Chill\MainBundle\Entity\User; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; use Doctrine\Persistence\ObjectRepository; @@ -41,7 +42,7 @@ class InviteRepository implements ObjectRepository /** * @return array|Invite[] */ - public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null) + public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array { return $this->entityRepository->findBy($criteria, $orderBy, $limit, $offset); } @@ -51,6 +52,71 @@ class InviteRepository implements ObjectRepository return $this->entityRepository->findOneBy($criteria); } + /** + * Find accepted invites for a user within a date range. + * + * @return array|Invite[] + */ + public function findAcceptedInvitesByUserAndDateRange(User $user, \DateTimeImmutable $from, \DateTimeImmutable $to): array + { + return $this->buildAcceptedInviteByUserAndDateRangeQuery($user, $from, $to) + ->getQuery() + ->getResult(); + } + + /** + * Count accepted invites for a user within a date range. + */ + public function countAcceptedInvitesByUserAndDateRange(User $user, \DateTimeImmutable $from, \DateTimeImmutable $to): int + { + return $this->buildAcceptedInviteByUserAndDateRangeQuery($user, $from, $to) + ->select('COUNT(c)') + ->getQuery() + ->getSingleScalarResult(); + } + + public function countPendingInvitesByUser(User $user): int + { + $qb = $this->entityRepository->createQueryBuilder('i'); + + $qb->select('COUNT(i)') + ->where( + $qb->expr()->andX( + $qb->expr()->eq('i.user', ':user'), + $qb->expr()->eq('i.status', ':status') + ) + ) + ->setParameters([ + 'user' => $user, + 'status' => Invite::PENDING, + ]); + + return $qb->getQuery()->getSingleScalarResult(); + } + + public function buildAcceptedInviteByUserAndDateRangeQuery(User $user, \DateTimeImmutable $from, \DateTimeImmutable $to) + { + $qb = $this->entityRepository->createQueryBuilder('i'); + + return $qb + ->join('i.calendar', 'c') + ->where( + $qb->expr()->andX( + $qb->expr()->eq('i.user', ':user'), + $qb->expr()->eq('i.status', ':status'), + $qb->expr()->gte('c.startDate', ':startDate'), + $qb->expr()->lte('c.endDate', ':endDate'), + $qb->expr()->isNull('c.cancelReason') + ) + ) + ->setParameters([ + 'user' => $user, + 'status' => Invite::ACCEPTED, + 'startDate' => $from, + 'endDate' => $to, + ]); + } + public function getClassName(): string { return Invite::class; diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/types.ts b/src/Bundle/ChillCalendarBundle/Resources/public/types.ts index f0f16429a..542067901 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/public/types.ts +++ b/src/Bundle/ChillCalendarBundle/Resources/public/types.ts @@ -1,76 +1,74 @@ import { EventInput } from "@fullcalendar/core"; import { - DateTime, - Location, - User, - UserAssociatedInterface, + DateTime, + Location, + User, + UserAssociatedInterface, } from "../../../ChillMainBundle/Resources/public/types"; import { Person } from "../../../ChillPersonBundle/Resources/public/types"; export interface CalendarRange { - id: number; - endDate: DateTime; - startDate: DateTime; - user: User; - location: Location; - createdAt: DateTime; - createdBy: User; - updatedAt: DateTime; - updatedBy: User; + id: number; + endDate: DateTime; + startDate: DateTime; + user: User; + location: Location; + createdAt: DateTime; + createdBy: User; + updatedAt: DateTime; + updatedBy: User; } export interface CalendarRangeCreate { - user: UserAssociatedInterface; - startDate: DateTime; - endDate: DateTime; - location: Location; + user: UserAssociatedInterface; + startDate: DateTime; + endDate: DateTime; + location: Location; } export interface CalendarRangeEdit { - startDate?: DateTime; - endDate?: DateTime; - location?: Location; + startDate?: DateTime; + endDate?: DateTime; + location?: Location; } export interface Calendar { - id: number; + id: number; } export interface CalendarLight { - id: number; - endDate: DateTime; - startDate: DateTime; - mainUser: User; - persons: Person[]; - status: "valid" | "moved" | "canceled"; + id: number; + endDate: DateTime; + startDate: DateTime; + mainUser: User; + persons: Person[]; + status: "valid" | "moved" | "canceled"; } export interface CalendarRemote { - id: number; - endDate: DateTime; - startDate: DateTime; - title: string; - isAllDay: boolean; + id: number; + endDate: DateTime; + startDate: DateTime; + title: string; + isAllDay: boolean; } export type EventInputCalendarRange = EventInput & { - id: string; - userId: number; - userLabel: string; - calendarRangeId: number; - locationId: number; - locationName: string; - start: string; - end: string; - is: "range"; + id: string; + userId: number; + userLabel: string; + calendarRangeId: number; + locationId: number; + locationName: string; + start: string; + end: string; + is: "range"; }; export function isEventInputCalendarRange( - toBeDetermined: EventInputCalendarRange | EventInput, + toBeDetermined: EventInputCalendarRange | EventInput, ): toBeDetermined is EventInputCalendarRange { - return ( - typeof toBeDetermined.is === "string" && toBeDetermined.is === "range" - ); + return typeof toBeDetermined.is === "string" && toBeDetermined.is === "range"; } export {}; diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/App.vue b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/App.vue index 27acb901a..d1374714b 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/App.vue +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/App.vue @@ -1,166 +1,148 @@ diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/Components/CalendarActive.vue b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/Components/CalendarActive.vue index a51e7dd7c..b90349025 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/Components/CalendarActive.vue +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/Components/CalendarActive.vue @@ -1,119 +1,105 @@ diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/api.ts b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/api.ts index 135b3ef1a..d267d2606 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/api.ts +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/api.ts @@ -14,37 +14,37 @@ export { whoami } from "../../../../../ChillMainBundle/Resources/public/lib/api/ * @return Promise */ export const fetchCalendarRangeForUser = ( - user: User, - start: Date, - end: Date, + user: User, + start: Date, + end: Date, ): Promise => { - const uri = `/api/1.0/calendar/calendar-range-available/${user.id}.json`; - const dateFrom = datetimeToISO(start); - const dateTo = datetimeToISO(end); + const uri = `/api/1.0/calendar/calendar-range-available/${user.id}.json`; + const dateFrom = datetimeToISO(start); + const dateTo = datetimeToISO(end); - return fetchResults(uri, { dateFrom, dateTo }); + return fetchResults(uri, { dateFrom, dateTo }); }; export const fetchCalendarRemoteForUser = ( - user: User, - start: Date, - end: Date, + user: User, + start: Date, + end: Date, ): Promise => { - const uri = `/api/1.0/calendar/proxy/calendar/by-user/${user.id}/events`; - const dateFrom = datetimeToISO(start); - const dateTo = datetimeToISO(end); + const uri = `/api/1.0/calendar/proxy/calendar/by-user/${user.id}/events`; + const dateFrom = datetimeToISO(start); + const dateTo = datetimeToISO(end); - return fetchResults(uri, { dateFrom, dateTo }); + return fetchResults(uri, { dateFrom, dateTo }); }; export const fetchCalendarLocalForUser = ( - user: User, - start: Date, - end: Date, + user: User, + start: Date, + end: Date, ): Promise => { - const uri = `/api/1.0/calendar/calendar/by-user/${user.id}.json`; - const dateFrom = datetimeToISO(start); - const dateTo = datetimeToISO(end); + const uri = `/api/1.0/calendar/calendar/by-user/${user.id}.json`; + const dateFrom = datetimeToISO(start); + const dateTo = datetimeToISO(end); - return fetchResults(uri, { dateFrom, dateTo }); + return fetchResults(uri, { dateFrom, dateTo }); }; diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/const.ts b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/const.ts index 7d7cfaf74..62be00685 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/const.ts +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/const.ts @@ -1,17 +1,17 @@ const COLORS = [ - /* from https://colorbrewer2.org/#type=qualitative&scheme=Set3&n=12 */ - "#8dd3c7", - "#ffffb3", - "#bebada", - "#fb8072", - "#80b1d3", - "#fdb462", - "#b3de69", - "#fccde5", - "#d9d9d9", - "#bc80bd", - "#ccebc5", - "#ffed6f", + /* from https://colorbrewer2.org/#type=qualitative&scheme=Set3&n=12 */ + "#8dd3c7", + "#ffffb3", + "#bebada", + "#fb8072", + "#80b1d3", + "#fdb462", + "#b3de69", + "#fccde5", + "#d9d9d9", + "#bc80bd", + "#ccebc5", + "#ffed6f", ]; export { COLORS }; diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/utils.ts b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/utils.ts index 4e8f97e33..ec312e6de 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/utils.ts +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Calendar/store/utils.ts @@ -1,117 +1,117 @@ import { COLORS } from "../const"; import { ISOToDatetime } from "../../../../../../ChillMainBundle/Resources/public/chill/js/date"; import { - DateTime, - User, + DateTime, + User, } from "../../../../../../ChillMainBundle/Resources/public/types"; import { CalendarLight, CalendarRange, CalendarRemote } from "../../../types"; import type { EventInputCalendarRange } from "../../../types"; import { EventInput } from "@fullcalendar/core"; export interface UserData { - user: User; - calendarRanges: CalendarRange[]; - calendarRangesLoaded: {}[]; - remotes: CalendarRemote[]; - remotesLoaded: {}[]; - locals: CalendarRemote[]; - localsLoaded: {}[]; - mainColor: string; + user: User; + calendarRanges: CalendarRange[]; + calendarRangesLoaded: {}[]; + remotes: CalendarRemote[]; + remotesLoaded: {}[]; + locals: CalendarRemote[]; + localsLoaded: {}[]; + mainColor: string; } export const addIdToValue = (string: string, id: number): string => { - const array = string ? string.split(",") : []; - array.push(id.toString()); - const str = array.join(); - return str; + const array = string ? string.split(",") : []; + array.push(id.toString()); + const str = array.join(); + return str; }; export const removeIdFromValue = (string: string, id: number) => { - let array = string.split(","); - array = array.filter((el) => el !== id.toString()); - const str = array.join(); - return str; + let array = string.split(","); + array = array.filter((el) => el !== id.toString()); + const str = array.join(); + return str; }; /* * Assign missing keys for the ConcernedGroups component */ export const mapEntity = (entity: EventInput): EventInput => { - const calendar = { ...entity }; - Object.assign(calendar, { thirdParties: entity.professionals }); + const calendar = { ...entity }; + Object.assign(calendar, { thirdParties: entity.professionals }); - if (entity.startDate !== null) { - calendar.startDate = ISOToDatetime(entity.startDate.datetime); - } - if (entity.endDate !== null) { - calendar.endDate = ISOToDatetime(entity.endDate.datetime); - } + if (entity.startDate !== null) { + calendar.startDate = ISOToDatetime(entity.startDate.datetime); + } + if (entity.endDate !== null) { + calendar.endDate = ISOToDatetime(entity.endDate.datetime); + } - if (entity.calendarRange !== null) { - calendar.calendarRange.calendarRangeId = entity.calendarRange.id; - calendar.calendarRange.id = `range_${entity.calendarRange.id}`; - } + if (entity.calendarRange !== null) { + calendar.calendarRange.calendarRangeId = entity.calendarRange.id; + calendar.calendarRange.id = `range_${entity.calendarRange.id}`; + } - return calendar; + return calendar; }; export const createUserData = (user: User, colorIndex: number): UserData => { - const colorId = colorIndex % COLORS.length; + const colorId = colorIndex % COLORS.length; - return { - user: user, - calendarRanges: [], - calendarRangesLoaded: [], - remotes: [], - remotesLoaded: [], - locals: [], - localsLoaded: [], - mainColor: COLORS[colorId], - }; + return { + user: user, + calendarRanges: [], + calendarRangesLoaded: [], + remotes: [], + remotesLoaded: [], + locals: [], + localsLoaded: [], + mainColor: COLORS[colorId], + }; }; // TODO move this function to a more global namespace, as it is also in use in MyCalendarRange app export const calendarRangeToFullCalendarEvent = ( - entity: CalendarRange, + entity: CalendarRange, ): EventInputCalendarRange => { - return { - id: `range_${entity.id}`, - title: "(" + entity.user.text + ")", - start: entity.startDate.datetime8601, - end: entity.endDate.datetime8601, - allDay: false, - userId: entity.user.id, - userLabel: entity.user.label, - calendarRangeId: entity.id, - locationId: entity.location.id, - locationName: entity.location.name, - is: "range", - }; + return { + id: `range_${entity.id}`, + title: "(" + entity.user.text + ")", + start: entity.startDate.datetime8601, + end: entity.endDate.datetime8601, + allDay: false, + userId: entity.user.id, + userLabel: entity.user.label, + calendarRangeId: entity.id, + locationId: entity.location.id, + locationName: entity.location.name, + is: "range", + }; }; export const remoteToFullCalendarEvent = ( - entity: CalendarRemote, + entity: CalendarRemote, ): EventInput & { id: string } => { - return { - id: `range_${entity.id}`, - title: entity.title, - start: entity.startDate.datetime8601, - end: entity.endDate.datetime8601, - allDay: entity.isAllDay, - is: "remote", - }; + return { + id: `range_${entity.id}`, + title: entity.title, + start: entity.startDate.datetime8601, + end: entity.endDate.datetime8601, + allDay: entity.isAllDay, + is: "remote", + }; }; export const localsToFullCalendarEvent = ( - entity: CalendarLight, + entity: CalendarLight, ): EventInput & { id: string; originId: number } => { - return { - id: `local_${entity.id}`, - title: entity.persons.map((p) => p.text).join(", "), - originId: entity.id, - start: entity.startDate.datetime8601, - end: entity.endDate.datetime8601, - allDay: false, - is: "local", - }; + return { + id: `local_${entity.id}`, + title: entity.persons.map((p) => p.text).join(", "), + originId: entity.id, + start: entity.startDate.datetime8601, + end: entity.endDate.datetime8601, + allDay: false, + is: "local", + }; }; diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Invite/Answer.vue b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Invite/Answer.vue index 34b5e3d77..34252ea1b 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Invite/Answer.vue +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/Invite/Answer.vue @@ -1,58 +1,50 @@ diff --git a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/App2.vue b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/App2.vue index 6e3d5b61c..e35db74ad 100644 --- a/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/App2.vue +++ b/src/Bundle/ChillCalendarBundle/Resources/public/vuejs/MyCalendarRange/App2.vue @@ -1,228 +1,185 @@ diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/BadgeEntity.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/BadgeEntity.vue index f8cd21c55..69df000dd 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/BadgeEntity.vue +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/BadgeEntity.vue @@ -1,67 +1,64 @@ diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/CommentEditor/CommentEditor.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/CommentEditor/CommentEditor.vue index d6019fabe..97242ffa2 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/CommentEditor/CommentEditor.vue +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/CommentEditor/CommentEditor.vue @@ -4,9 +4,9 @@ import { Ckeditor } from "@ckeditor/ckeditor5-vue"; import { ClassicEditor } from "ckeditor5"; import classicEditorConfig from "ChillMainAssets/module/ckeditor5/editor_config"; import { - trans, - EDITOR_SWITCH_TO_SIMPLE, - EDITOR_SWITCH_TO_COMPLEX, + trans, + EDITOR_SWITCH_TO_SIMPLE, + EDITOR_SWITCH_TO_COMPLEX, } from "translator"; const EDITOR_MODE_KEY = "editorMode"; @@ -16,107 +16,101 @@ const value = defineModel({ required: true }); const isSimple = computed(() => kind.value === "simple"); const toggleButtonClass = computed(() => { - return { - ["toggle-button"]: true, - onEditor: !isSimple.value, - onSimple: isSimple.value, - }; + return { + ["toggle-button"]: true, + onEditor: !isSimple.value, + onSimple: isSimple.value, + }; }); const toggleEditor = () => { - let newValue; + let newValue; - newValue = kind.value === "simple" ? "rich" : "simple"; - kind.value = "rich"; - window.localStorage.setItem(EDITOR_MODE_KEY, newValue); + newValue = kind.value === "simple" ? "rich" : "simple"; + kind.value = "rich"; + window.localStorage.setItem(EDITOR_MODE_KEY, newValue); - window.dispatchEvent(new Event("toggleEditorKind")); + window.dispatchEvent(new Event("toggleEditorKind")); }; const onKindChange = function (/* event: StorageEvent | Event */) { - const newValue = window.localStorage.getItem(EDITOR_MODE_KEY); + const newValue = window.localStorage.getItem(EDITOR_MODE_KEY); - if (null === newValue || !(newValue === "rich" || newValue === "simple")) { - throw "invalid new value: " + newValue; - } + if (null === newValue || !(newValue === "rich" || newValue === "simple")) { + throw "invalid new value: " + newValue; + } - if (kind.value !== newValue) { - kind.value = newValue; - } + if (kind.value !== newValue) { + kind.value = newValue; + } }; onMounted(function () { - const storage = window.localStorage; - const savedKind = storage.getItem(EDITOR_MODE_KEY); + const storage = window.localStorage; + const savedKind = storage.getItem(EDITOR_MODE_KEY); - if ( - null !== kind.value && - (savedKind === "simple" || savedKind === "rich") - ) { - kind.value = savedKind; - } + if (null !== kind.value && (savedKind === "simple" || savedKind === "rich")) { + kind.value = savedKind; + } - window.addEventListener("storage", onKindChange); - window.addEventListener("toggleEditorKind", onKindChange); + window.addEventListener("storage", onKindChange); + window.addEventListener("toggleEditorKind", onKindChange); }); onUnmounted(function () { - window.removeEventListener("storage", onKindChange); - window.removeEventListener("toggleEditorKind", onKindChange); + window.removeEventListener("storage", onKindChange); + window.removeEventListener("toggleEditorKind", onKindChange); }); diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Confidential.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Confidential.vue index 13459b390..9c010e8d1 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Confidential.vue +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Confidential.vue @@ -1,40 +1,40 @@ diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Entity/AddressRenderBox.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Entity/AddressRenderBox.vue index 86f4155b6..7085b3b18 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Entity/AddressRenderBox.vue +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Entity/AddressRenderBox.vue @@ -1,83 +1,77 @@ diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Entity/GenderIconRenderBox.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Entity/GenderIconRenderBox.vue index d47b9b777..318d4faaa 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Entity/GenderIconRenderBox.vue +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Entity/GenderIconRenderBox.vue @@ -1,9 +1,28 @@ - diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Entity/UserGroupRenderBox.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Entity/UserGroupRenderBox.vue index 7c37e98a9..81f836c62 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Entity/UserGroupRenderBox.vue +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Entity/UserGroupRenderBox.vue @@ -4,23 +4,23 @@ import { computed } from "vue"; import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper"; interface UserGroupRenderBoxProps { - userGroup: UserGroup; + userGroup: UserGroup; } const props = defineProps(); const styles = computed<{ color: string; "background-color": string }>(() => { - return { - color: props.userGroup.foregroundColor, - "background-color": props.userGroup.backgroundColor, - }; + return { + color: props.userGroup.foregroundColor, + "background-color": props.userGroup.backgroundColor, + }; }); diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Entity/UserRenderBoxBadge.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Entity/UserRenderBoxBadge.vue index 63c43c37f..4bae3b541 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Entity/UserRenderBoxBadge.vue +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Entity/UserRenderBoxBadge.vue @@ -1,31 +1,31 @@ diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/EntityWorkflow/EntityWorkflowVueSubscriber.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/EntityWorkflow/EntityWorkflowVueSubscriber.vue index 332849ef5..63f762302 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/EntityWorkflow/EntityWorkflowVueSubscriber.vue +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/EntityWorkflow/EntityWorkflowVueSubscriber.vue @@ -1,83 +1,71 @@ diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/EntityWorkflow/ListWorkflowModal.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/EntityWorkflow/ListWorkflowModal.vue index 59dcd80f4..658c0f9e9 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/EntityWorkflow/ListWorkflowModal.vue +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/EntityWorkflow/ListWorkflowModal.vue @@ -1,49 +1,45 @@ diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Modal.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Modal.vue index 7ec7f47e2..4ea96bbe9 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Modal.vue +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Modal.vue @@ -1,42 +1,39 @@ @@ -73,19 +78,19 @@ const emits = defineEmits<{ * This is a mask behind the modal. */ .modal-mask { - position: fixed; - z-index: 9998; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: rgba(0, 0, 0, 0.75); - transition: opacity 0.3s ease; + position: fixed; + z-index: 9998; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.75); + transition: opacity 0.3s ease; } .modal-header .close { - border-top-right-radius: 0.3rem; - margin-right: 0; - margin-left: auto; + border-top-right-radius: 0.3rem; + margin-right: 0; + margin-left: auto; } /* * The following styles are auto-applied to elements with @@ -96,23 +101,23 @@ const emits = defineEmits<{ * these styles. */ .modal-enter { - opacity: 0; + opacity: 0; } .modal-leave-active { - opacity: 0; + opacity: 0; } .modal-enter .modal-container, .modal-leave-active .modal-container { - -webkit-transform: scale(1.1); - transform: scale(1.1); + -webkit-transform: scale(1.1); + transform: scale(1.1); } h3.modal-title { - font-size: 1.5rem; - font-weight: bold; + font-size: 1.5rem; + font-weight: bold; } div.modal-footer { - button:first-child { - margin-right: auto; - } + button:first-child { + margin-right: auto; + } } diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Notification/NotificationReadAllToggle.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Notification/NotificationReadAllToggle.vue index 57f7fd7f8..bb7a43c92 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Notification/NotificationReadAllToggle.vue +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Notification/NotificationReadAllToggle.vue @@ -1,17 +1,17 @@ diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Notification/NotificationReadToggle.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Notification/NotificationReadToggle.vue index cc64d4835..fb94af80f 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Notification/NotificationReadToggle.vue +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/Notification/NotificationReadToggle.vue @@ -1,96 +1,96 @@ diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/WaitingScreen.vue b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/WaitingScreen.vue index 963db5e81..ea12b1577 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/WaitingScreen.vue +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_components/WaitingScreen.vue @@ -2,61 +2,61 @@ import { WaitingScreenState } from "ChillMainAssets/types"; interface Props { - state: WaitingScreenState; + state: WaitingScreenState; } const props = defineProps(); diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_composables/violationList.ts b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_composables/violationList.ts new file mode 100644 index 000000000..b8af0b519 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_composables/violationList.ts @@ -0,0 +1,78 @@ +import { ref } from "vue"; +import { ValidationExceptionInterface } from "ChillMainAssets/types"; + +export function useViolationList< + T extends Record>, +>() { + type ViolationKey = Extract; + const violationsList = ref | null>(null); + + function violationTitles

    (property: P): string[] { + if (null === violationsList.value) { + return []; + } + const r = violationsList.value + .violationsByNormalizedProperty(property) + .map((v) => v.title); + + return r; + } + function violationTitlesWithParameter< + P extends ViolationKey, + Param extends Extract, + >( + property: P, + with_parameter: Param, + with_parameter_value: T[P][Param], + ): string[] { + if (violationsList.value === null) { + return []; + } + return violationsList.value + .violationsByNormalizedPropertyAndParams( + property, + with_parameter, + with_parameter_value, + ) + .map((v) => v.title); + } + + function hasViolation

    (property: P): boolean { + return violationTitles(property).length > 0; + } + function hasViolationWithParameter< + P extends ViolationKey, + Param extends Extract, + >( + property: P, + with_parameter: Param, + with_parameter_value: T[P][Param], + ): boolean { + return ( + violationTitlesWithParameter( + property, + with_parameter, + with_parameter_value, + ).length > 0 + ); + } + + function setValidationException>( + validationException: V, + ): void { + violationsList.value = validationException; + } + + function cleanException(): void { + violationsList.value = null; + } + + return { + violationTitles, + violationTitlesWithParameter, + setValidationException, + cleanException, + hasViolationWithParameter, + hasViolation, + }; +} diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_js/i18n.ts b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_js/i18n.ts index db9a37104..cbee6bf25 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/vuejs/_js/i18n.ts +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/_js/i18n.ts @@ -2,87 +2,86 @@ import { createI18n } from "vue-i18n"; import datetimeFormats from "../i18n/datetimeFormats"; const messages = { - fr: { - action: { - actions: "Actions", - show: "Voir", - edit: "Modifier", - create: "Créer", - remove: "Enlever", - delete: "Supprimer", - save: "Enregistrer", - valid: "Valider", - valid_and_see: "Valider et voir", - add: "Ajouter", - show_modal: "Ouvrir une modale", - ok: "OK", - cancel: "Annuler", - close: "Fermer", - back: "Retour", - check_all: "cocher tout", - reset: "réinitialiser", - redirect: { - person: "Quitter la page et ouvrir la fiche de l'usager", - thirdparty: "Quitter la page et voir le tiers", - }, - refresh: "Rafraîchir", - addContact: "Ajouter un contact", - }, - nav: { - next: "Suivant", - previous: "Précédent", - top: "Haut", - bottom: "Bas", - }, - renderbox: { - person: "Usager", - birthday: { - man: "Né le", - woman: "Née le", - neutral: "Né·e le", - unknown: "Né·e le", - }, - deathdate: "Date de décès", - household_without_address: "Le ménage de l'usager est sans adresse", - no_data: "Aucune information renseignée", - type: { - thirdparty: "Tiers", - person: "Usager", - }, - holder: "Titulaire", - years_old: "1 an | {n} an | {n} ans", - residential_address: "Adresse de résidence", - located_at: "réside chez", - }, + fr: { + action: { + actions: "Actions", + show: "Voir", + edit: "Modifier", + create: "Créer", + remove: "Enlever", + delete: "Supprimer", + save: "Enregistrer", + valid: "Valider", + valid_and_see: "Valider et voir", + add: "Ajouter", + show_modal: "Ouvrir une modale", + ok: "OK", + cancel: "Annuler", + close: "Fermer", + back: "Retour", + check_all: "cocher tout", + reset: "réinitialiser", + redirect: { + person: "Quitter la page et ouvrir la fiche de l'usager", + thirdparty: "Quitter la page et voir le tiers", + }, + refresh: "Rafraîchir", + addContact: "Ajouter un contact", }, + nav: { + next: "Suivant", + previous: "Précédent", + top: "Haut", + bottom: "Bas", + }, + renderbox: { + person: "Usager", + birthday: { + man: "Né le", + woman: "Née le", + neutral: "Né·e le", + unknown: "Né·e le", + }, + deathdate: "Date de décès", + household_without_address: "Le ménage de l'usager est sans adresse", + no_data: "Aucune information renseignée", + type: { + thirdparty: "Tiers", + person: "Usager", + }, + holder: "Titulaire", + years_old: "1 an | {n} an | {n} ans", + residential_address: "Adresse de résidence", + located_at: "réside chez", + }, + }, }; const _createI18n = (appMessages: any, legacy?: boolean) => { - Object.assign(messages.fr, appMessages.fr); - return createI18n({ - legacy: typeof legacy === undefined ? true : legacy, - locale: "fr", - fallbackLocale: "fr", - // @ts-ignore - datetimeFormats, - messages, - }); + Object.assign(messages.fr, appMessages.fr); + return createI18n({ + legacy: typeof legacy === undefined ? true : legacy, + locale: "fr", + fallbackLocale: "fr", + // @ts-ignore + datetimeFormats, + messages, + }); }; export { _createI18n }; export const multiSelectMessages = { - fr: { - multiselect: { - placeholder: "Choisir", - tag_placeholder: "Créer un nouvel élément", - select_label: '"Entrée" ou cliquez pour sélectionner', - deselect_label: '"Entrée" ou cliquez pour désélectionner', - select_group_label: - 'Appuyer sur "Entrée" pour sélectionner ce groupe', - deselect_group_label: - 'Appuyer sur "Entrée" pour désélectionner ce groupe', - selected_label: "Sélectionné", - }, + fr: { + multiselect: { + placeholder: "Choisir", + tag_placeholder: "Créer un nouvel élément", + select_label: '"Entrée" ou cliquez pour sélectionner', + deselect_label: '"Entrée" ou cliquez pour désélectionner', + select_group_label: 'Appuyer sur "Entrée" pour sélectionner ce groupe', + deselect_group_label: + 'Appuyer sur "Entrée" pour désélectionner ce groupe', + selected_label: "Sélectionné", }, + }, }; diff --git a/src/Bundle/ChillMainBundle/Resources/public/vuejs/i18n/datetimeFormats.ts b/src/Bundle/ChillMainBundle/Resources/public/vuejs/i18n/datetimeFormats.ts index fe20cb217..aca1328ac 100644 --- a/src/Bundle/ChillMainBundle/Resources/public/vuejs/i18n/datetimeFormats.ts +++ b/src/Bundle/ChillMainBundle/Resources/public/vuejs/i18n/datetimeFormats.ts @@ -1,27 +1,27 @@ export default { - fr: { - short: { - year: "numeric", - month: "numeric", - day: "numeric", - }, - text: { - year: "numeric", - month: "long", - day: "numeric", - }, - long: { - year: "numeric", - month: "numeric", - day: "numeric", - hour: "numeric", - minute: "numeric", - hour12: false, - }, - hoursOnly: { - hour: "numeric", - minute: "numeric", - hour12: false, - }, + fr: { + short: { + year: "numeric", + month: "numeric", + day: "numeric", }, + text: { + year: "numeric", + month: "long", + day: "numeric", + }, + long: { + year: "numeric", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + hour12: false, + }, + hoursOnly: { + hour: "numeric", + minute: "numeric", + hour12: false, + }, + }, }; diff --git a/src/Bundle/ChillMainBundle/Resources/views/Form/fields.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Form/fields.html.twig index 5e3edca10..11fc00828 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Form/fields.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Form/fields.html.twig @@ -311,3 +311,32 @@

    {% endblock %} + +{% block chill_datetime_label %} + +{% endblock %} + +{% block chill_datetime_widget %} +
    + {#date#} + {{ form_widget(form.date, { + attr: { class: 'form-control', style: 'flex: 1 1 auto;' } + }) }} + {#time#} + {{ form_widget(form.time, { + attr: { + class: 'form-select', + style: 'flex: 0 0 200px; max-width: 200px; white-space: nowrap; padding:0;' + } + }) }} +
    +{% endblock %} + +{% block chill_datetime_row %} +
    + {{ block('chill_datetime_label') }} + {{ block('chill_datetime_widget') }} +
    +{% endblock %} diff --git a/src/Bundle/ChillMainBundle/Resources/views/Homepage/index.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Homepage/index.html.twig index 98af21171..290fd91ef 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Homepage/index.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Homepage/index.html.twig @@ -11,5 +11,13 @@ {% endblock %} {% block js %} + + {{ encore_entry_script_tags('page_homepage_widget') }} + {% endblock %} diff --git a/src/Bundle/ChillMainBundle/Resources/views/Layout/_top_banner.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Layout/_top_banner.html.twig new file mode 100644 index 000000000..0ad81cce1 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Resources/views/Layout/_top_banner.html.twig @@ -0,0 +1,17 @@ +{% if chill_main_config.top_banner is defined and chill_main_config.top_banner.text is defined %} + {% set banner_text = '' %} + {% set current_locale = app.request.locale %} + + {% if chill_main_config.top_banner.text[current_locale] is defined %} + {% set banner_text = chill_main_config.top_banner.text[current_locale] %} + {% else %} + {% set banner_text = chill_main_config.top_banner.text|first %} + {% endif %} + + {% if banner_text %} +
    + {{ banner_text }} +
    + {% endif %} +{% endif %} diff --git a/src/Bundle/ChillMainBundle/Resources/views/Login/_login-logo.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Login/_login-logo.html.twig index 751165357..6c5d06de7 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Login/_login-logo.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Login/_login-logo.html.twig @@ -1 +1 @@ - \ No newline at end of file + diff --git a/src/Bundle/ChillMainBundle/Resources/views/Login/login.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Login/login.html.twig index 8a636a8cd..4edbc19f5 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Login/login.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Login/login.html.twig @@ -16,7 +16,7 @@ * along with this program. If not, see . #} - + @@ -35,10 +35,10 @@ <form method="POST" action="{{ path('login_check') }}"> <label for="_username">{{ 'Username'|trans }}</label> - <input type="text" name="_username" value="{{ last_username }}" /> + <input type="text" name="_username" value="{{ last_username }}" id="_username" /> <br/> <label for="_password">{{ 'Password'|trans }}</label> - <input type="password" name="_password" /> + <input type="password" name="_password" id="_password" /> <input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}" /> <br/> <button type="submit" name="login">{{ 'Login'|trans }}</button> diff --git a/src/Bundle/ChillMainBundle/Resources/views/Menu/section.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Menu/section.html.twig index 67683bf50..56bb9a2a9 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Menu/section.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Menu/section.html.twig @@ -1,34 +1,16 @@ -{# - * Copyright (C) 2014-2015, Champs Libres Cooperative SCRLFS, - <info@champs-libres.coop> / <http://www.champs-libres.coop> - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. -#} - <li class="nav-item dropdown btn btn-primary nav-section"> - <a id="menu-section" - class="nav-link dropdown-toggle" - type="button" - data-bs-toggle="dropdown" - aria-haspopup="true" + <a id="menu-section" + class="nav-link dropdown-toggle" + type="button" + data-bs-toggle="dropdown" + aria-haspopup="true" aria-expanded="false"> - + {{ 'Sections'|trans }} </a> <div class="dropdown-menu dropdown-menu-end dropdown-menu-dark" aria-labelledby="menu-section"> {% for menu in menus %} - <a class="dropdown-item list-group-item bg-dark text-white" + <a class="dropdown-item list-group-item bg-dark text-white" href="{{ menu.uri }}"> {{ menu.label }} {% apply spaceless %} diff --git a/src/Bundle/ChillMainBundle/Resources/views/Notification/edit.html.twig b/src/Bundle/ChillMainBundle/Resources/views/Notification/edit.html.twig index fd3b3b6bf..b51cc4dab 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Notification/edit.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Notification/edit.html.twig @@ -21,8 +21,6 @@ {{ form_row(form.title, { 'label': 'notification.subject'|trans }) }} {{ form_row(form.addressees, { 'label': 'notification.sent_to'|trans }) }} - {{ form_row(form.addressesEmails) }} - {% include handler.template(notification) with handler.templateData(notification) %} <div class="mb-3 row"> diff --git a/src/Bundle/ChillMainBundle/Resources/views/Notification/email_daily_digest.fr.md.twig b/src/Bundle/ChillMainBundle/Resources/views/Notification/email_daily_digest.md.twig similarity index 100% rename from src/Bundle/ChillMainBundle/Resources/views/Notification/email_daily_digest.fr.md.twig rename to src/Bundle/ChillMainBundle/Resources/views/Notification/email_daily_digest.md.twig diff --git a/src/Bundle/ChillMainBundle/Resources/views/Notification/email_non_system_notification_content.fr.md.twig b/src/Bundle/ChillMainBundle/Resources/views/Notification/email_non_system_notification_content.md.twig similarity index 88% rename from src/Bundle/ChillMainBundle/Resources/views/Notification/email_non_system_notification_content.fr.md.twig rename to src/Bundle/ChillMainBundle/Resources/views/Notification/email_non_system_notification_content.md.twig index 62e41860b..023f2901a 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Notification/email_non_system_notification_content.fr.md.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Notification/email_non_system_notification_content.md.twig @@ -14,7 +14,7 @@ Vous pouvez visualiser la notification et y répondre ici: -{{ absolute_url(path('chill_main_notification_show', {'_locale': 'fr', 'id': notification.id }, false)) }} +{{ absolute_url(path('chill_main_notification_show', {'_locale': dest.locale, 'id': notification.id }, false)) }} -- Le logiciel Chill diff --git a/src/Bundle/ChillMainBundle/Resources/views/Notification/email_non_system_notification_content_to_email.fr.md.twig b/src/Bundle/ChillMainBundle/Resources/views/Notification/email_non_system_notification_content_to_email.md.twig similarity index 100% rename from src/Bundle/ChillMainBundle/Resources/views/Notification/email_non_system_notification_content_to_email.fr.md.twig rename to src/Bundle/ChillMainBundle/Resources/views/Notification/email_non_system_notification_content_to_email.md.twig diff --git a/src/Bundle/ChillMainBundle/Resources/views/Notification/email_notification_comment_persist.fr.md.twig b/src/Bundle/ChillMainBundle/Resources/views/Notification/email_notification_comment_persist.md.twig similarity index 87% rename from src/Bundle/ChillMainBundle/Resources/views/Notification/email_notification_comment_persist.fr.md.twig rename to src/Bundle/ChillMainBundle/Resources/views/Notification/email_notification_comment_persist.md.twig index b1244da39..e7e212492 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Notification/email_notification_comment_persist.fr.md.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Notification/email_notification_comment_persist.md.twig @@ -13,7 +13,7 @@ Commentaire: Vous pouvez visualiser la notification et y répondre ici: -{{ absolute_url(path('chill_main_notification_show', {'_locale': 'fr', 'id': comment.notification.id }, false)) }} +{{ absolute_url(path('chill_main_notification_show', {'_locale': dest.locale, 'id': comment.notification.id }, false)) }} -- Le logiciel Chill diff --git a/src/Bundle/ChillMainBundle/Resources/views/User/index.html.twig b/src/Bundle/ChillMainBundle/Resources/views/User/index.html.twig index 5393f09c8..66ffc030b 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/User/index.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/User/index.html.twig @@ -61,7 +61,7 @@ {% endif %} </li> <li> - <span class="dt">cercle/centre:</span> + <span class="dt">{{ 'Scope'|trans }}/{{ 'center'|trans }}:</span> {% if entity.mainScope %} {{ entity.mainScope.name|localize_translatable_string }} {% endif %} diff --git a/src/Bundle/ChillMainBundle/Resources/views/User/profile.html.twig b/src/Bundle/ChillMainBundle/Resources/views/User/profile.html.twig index 0b268af03..266f75115 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/User/profile.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/User/profile.html.twig @@ -44,6 +44,7 @@ <div> {{ form_start(form) }} {{ form_row(form.phonenumber) }} + {{ form_row(form.locale) }} <h2 class="mb-4">{{ 'user.profile.notification_preferences'|trans }}</h2> <table class="table table-striped align-middle"> diff --git a/src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_notification_on_transition_completed_content.fr.txt.twig b/src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_notification_on_transition_completed_content.fr.txt.twig index 03ddad84f..2b4beb42b 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_notification_on_transition_completed_content.fr.txt.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_notification_on_transition_completed_content.fr.txt.twig @@ -1,16 +1,16 @@ {{ dest.label }}, -Un suivi "{{ workflow.text }}" a atteint une nouvelle étape: {{ workflow.text }} +{{ 'workflow.notification.content.new_step_reached'|trans({'%workflow%': workflow.text}) }} -Titre du workflow: "{{ title }}". +{{ 'workflow.notification.content.workflow_title'|trans({'%title%': title}) }} {% if is_dest %} -Vous êtes invités à valider cette étape au plus tôt. +{{ 'workflow.notification.content.validation_needed'|trans }} {% endif %} -Vous pouvez visualiser le workflow sur cette page: +{{ 'workflow.notification.content.view_workflow'|trans }} -{{ absolute_url(path('chill_main_workflow_show', {'id': entity_workflow.id, '_locale': 'fr'})) }} +{{ absolute_url(path('chill_main_workflow_show', {'id': entity_workflow.id, '_locale': dest.locale|default('fr')})) }} -Cordialement, +{{ 'workflow.notification.content.regards'|trans }} diff --git a/src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_notification_on_transition_completed_title.fr.txt.twig b/src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_notification_on_transition_completed_title.fr.txt.twig index 9a6bc70a0..0d266cbac 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_notification_on_transition_completed_title.fr.txt.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/Workflow/workflow_notification_on_transition_completed_title.fr.txt.twig @@ -1,5 +1,5 @@ {%- if is_dest -%} -Un suivi {{ workflow.text }} demande votre attention: {{ title }} +{{ 'workflow.notification.title.attention_needed'|trans({'%workflow%': workflow.text, '%title%': title}) }} {%- else -%} -Un suivi {{ workflow.text }} a atteint une nouvelle étape: {{ place.text }}: {{ title }} +{{ 'workflow.notification.title.new_step'|trans({'%workflow%': workflow.text, '%place%': place.text, '%title%': title}) }} {%- endif -%} diff --git a/src/Bundle/ChillMainBundle/Resources/views/layout.html.twig b/src/Bundle/ChillMainBundle/Resources/views/layout.html.twig index 343c12eea..0ff39c8d9 100644 --- a/src/Bundle/ChillMainBundle/Resources/views/layout.html.twig +++ b/src/Bundle/ChillMainBundle/Resources/views/layout.html.twig @@ -26,6 +26,10 @@ </head> <body> + {% if chill_main_config.top_banner is defined and chill_main_config.top_banner.visible is true %} + {{ include('@ChillMain/Layout/_top_banner.html.twig') }} + {% endif %} + {% if responsive_debug is defined and responsive_debug == 1 %} {{ include('@ChillMain/Layout/_debug.html.twig') }} {% endif %} diff --git a/src/Bundle/ChillMainBundle/Routing/MenuBuilder/AdminUserMenuBuilder.php b/src/Bundle/ChillMainBundle/Routing/MenuBuilder/AdminUserMenuBuilder.php index 613e511f4..8c36fe35e 100644 --- a/src/Bundle/ChillMainBundle/Routing/MenuBuilder/AdminUserMenuBuilder.php +++ b/src/Bundle/ChillMainBundle/Routing/MenuBuilder/AdminUserMenuBuilder.php @@ -49,7 +49,7 @@ class AdminUserMenuBuilder implements LocalMenuBuilderInterface 'route' => 'chill_crud_center_index', ])->setExtras(['order' => 1010]); - $menu->addChild('Regroupements des centres', [ + $menu->addChild('Regroupements des territoires', [ 'route' => 'chill_crud_regroupment_index', ])->setExtras(['order' => 1015]); diff --git a/src/Bundle/ChillMainBundle/Routing/MenuComposer.php b/src/Bundle/ChillMainBundle/Routing/MenuComposer.php index 54d00d990..2337788f6 100644 --- a/src/Bundle/ChillMainBundle/Routing/MenuComposer.php +++ b/src/Bundle/ChillMainBundle/Routing/MenuComposer.php @@ -13,39 +13,37 @@ namespace Chill\MainBundle\Routing; use Knp\Menu\FactoryInterface; use Knp\Menu\ItemInterface; -use Symfony\Component\Routing\RouteCollection; +use Knp\Menu\MenuItem; use Symfony\Component\Routing\RouterInterface; use Symfony\Contracts\Translation\TranslatorInterface; /** * This class permit to build menu from the routing information * stored in each bundle. - * - * how to must come here FIXME */ -class MenuComposer +final readonly class MenuComposer { - private array $localMenuBuilders = []; + public function __construct( + private RouterInterface $router, + private FactoryInterface $menuFactory, + private TranslatorInterface $translator, + /** + * @var iterable<LocalMenuBuilderInterface> + */ + private iterable $localMenuBuilders, + ) {} - private RouteCollection $routeCollection; - - public function __construct(private readonly RouterInterface $router, private readonly FactoryInterface $menuFactory, private readonly TranslatorInterface $translator) {} - - public function addLocalMenuBuilder(LocalMenuBuilderInterface $menuBuilder, $menuId): void + public function getMenuFor($menuId, array $parameters = []): ItemInterface { - $this->localMenuBuilders[$menuId][] = $menuBuilder; - } - - public function getMenuFor($menuId, array $parameters = []) - { - $routes = $this->getRoutesFor($menuId, $parameters); + $routes = $this->getRoutesForInternal($menuId, $parameters); + /** @var MenuItem $menu */ $menu = $this->menuFactory->createItem($menuId); // build menu from routes foreach ($routes as $order => $route) { $menu->addChild($this->translator->trans($route['label']), [ 'route' => $route['key'], - 'routeParameters' => $parameters['args'], + 'routeParameters' => $parameters, 'order' => $order, ]) ->setExtras([ @@ -55,10 +53,9 @@ class MenuComposer ]); } - if ($this->hasLocalMenuBuilder($menuId)) { - foreach ($this->localMenuBuilders[$menuId] as $builder) { - /* @var $builder LocalMenuBuilderInterface */ - $builder->buildMenu($menuId, $menu, $parameters['args']); + foreach ($this->localMenuBuilders as $builder) { + if (in_array($menuId, $builder::getMenuIds(), true)) { + $builder->buildMenu($menuId, $menu, $parameters); } } @@ -71,12 +68,16 @@ class MenuComposer * Return an array of routes added to $menuId, * The array is aimed to build route with MenuTwig. * - * @param string $menuId - * @param array $parameters see https://redmine.champs-libres.coop/issues/179 + * @deprecated * - * @return array + * @param array $parameters see https://redmine.champs-libres.coop/issues/179 */ - public function getRoutesFor($menuId, array $parameters = []) + public function getRoutesFor(string $menuId, array $parameters = []): array + { + return $this->getRoutesForInternal($menuId, $parameters); + } + + private function getRoutesForInternal(string $menuId, array $parameters = []): array { $routes = []; $routeCollection = $this->router->getRouteCollection(); @@ -108,22 +109,17 @@ class MenuComposer * should be used, or `getRouteFor`. The method `getMenuFor` should be used * if the result is true (it **does** exists at least one menu builder. * - * @param string $menuId + * @deprecated */ - public function hasLocalMenuBuilder($menuId): bool + public function hasLocalMenuBuilder(string $menuId): bool { - return \array_key_exists($menuId, $this->localMenuBuilders); - } + foreach ($this->localMenuBuilders as $localMenuBuilder) { + if (in_array($menuId, $localMenuBuilder::getMenuIds(), true)) { + return true; + } + } - /** - * Set the route Collection - * This function is needed for testing purpose: routeCollection is not - * available as a service (RouterInterface is provided as a service and - * added to this class as paramater in __construct). - */ - public function setRouteCollection(RouteCollection $routeCollection): void - { - $this->routeCollection = $routeCollection; + return false; } private function reorderMenu(ItemInterface $menu): void diff --git a/src/Bundle/ChillMainBundle/Routing/MenuTwig.php b/src/Bundle/ChillMainBundle/Routing/MenuTwig.php index 12e38937f..f257b9132 100644 --- a/src/Bundle/ChillMainBundle/Routing/MenuTwig.php +++ b/src/Bundle/ChillMainBundle/Routing/MenuTwig.php @@ -50,13 +50,8 @@ class MenuTwig $layout = $resolvedParams['layout']; unset($resolvedParams['layout']); - - if (false === $this->menuComposer->hasLocalMenuBuilder($menuId)) { - $resolvedParams['routes'] = $this->menuComposer->getRoutesFor($menuId, $resolvedParams); - - return $env->render($layout, $resolvedParams); - } - $resolvedParams['menus'] = $this->menuComposer->getMenuFor($menuId, $resolvedParams); + $resolvedParams['routes'] = $this->menuComposer->getMenuFor($menuId, $resolvedParams['args']); + $resolvedParams['menus'] = $resolvedParams['routes']; return $env->render($layout, $resolvedParams); } diff --git a/src/Bundle/ChillMainBundle/Search/Entity/SearchUserGroupApiProvider.php b/src/Bundle/ChillMainBundle/Search/Entity/SearchUserGroupApiProvider.php index acdd99976..40f1b792d 100644 --- a/src/Bundle/ChillMainBundle/Search/Entity/SearchUserGroupApiProvider.php +++ b/src/Bundle/ChillMainBundle/Search/Entity/SearchUserGroupApiProvider.php @@ -44,16 +44,16 @@ class SearchUserGroupApiProvider implements SearchApiInterface, LocaleAwareInter public function provideQuery(string $pattern, array $parameters): SearchApiQuery { - return $this->userGroupRepository->provideSearchApiQuery($pattern, $this->getLocale(), 'user-group'); + return $this->userGroupRepository->provideSearchApiQuery($pattern, $this->getLocale(), 'user_group'); } public function supportsResult(string $key, array $metadatas): bool { - return 'user-group' === $key; + return 'user_group' === $key; } public function supportsTypes(string $pattern, array $types, array $parameters): bool { - return in_array('user-group', $types, true); + return in_array('user_group', $types, true); } } diff --git a/src/Bundle/ChillMainBundle/Search/Utils/ExtractPhonenumberFromPattern.php b/src/Bundle/ChillMainBundle/Search/Utils/ExtractPhonenumberFromPattern.php index f1e0a29d7..2a7dba128 100644 --- a/src/Bundle/ChillMainBundle/Search/Utils/ExtractPhonenumberFromPattern.php +++ b/src/Bundle/ChillMainBundle/Search/Utils/ExtractPhonenumberFromPattern.php @@ -80,9 +80,7 @@ class ExtractPhonenumberFromPattern } if (5 < $length) { - $filtered = \trim(\strtr($subject, [$matches[0] => ''])); - - return new SearchExtractionResult($filtered, [\implode('', $phonenumber)]); + return new SearchExtractionResult($subject, [\implode('', $phonenumber)]); } } diff --git a/src/Bundle/ChillMainBundle/Security/PasswordRecover/RecoverPasswordHelper.php b/src/Bundle/ChillMainBundle/Security/PasswordRecover/RecoverPasswordHelper.php index ddee723cd..e16a586b2 100644 --- a/src/Bundle/ChillMainBundle/Security/PasswordRecover/RecoverPasswordHelper.php +++ b/src/Bundle/ChillMainBundle/Security/PasswordRecover/RecoverPasswordHelper.php @@ -16,11 +16,13 @@ use Symfony\Bridge\Twig\Mime\TemplatedEmail; use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +// use Symfony\Component\Translation\LocaleSwitcher; + class RecoverPasswordHelper { final public const string RECOVER_PASSWORD_ROUTE = 'password_recover'; - public function __construct(private readonly TokenManager $tokenManager, private readonly UrlGeneratorInterface $urlGenerator, private readonly MailerInterface $mailer) {} + public function __construct(private readonly TokenManager $tokenManager, private readonly UrlGeneratorInterface $urlGenerator, private readonly MailerInterface $mailer/* , private readonly LocaleSwitcher $localeSwitcher */) {} /** * @param bool $absolute @@ -51,7 +53,25 @@ class RecoverPasswordHelper throw new \UnexpectedValueException('No emaail associated to the user'); } - $email = new TemplatedEmail() + // Implementation with LocaleSwitcher (commented out - to be activated after migration to sf7.2): + /* + $this->localeSwitcher->runWithLocale($user->getLocale(), function () use ($user, $expiration, $template, $templateParameters, $emailSubject, $additionalUrlParameters) { + $email = (new TemplatedEmail()) + ->subject($emailSubject) + ->to($user->getEmail()) + ->textTemplate($template) + ->context([ + 'user' => $user, + 'url' => $this->generateUrl($user, $expiration, true, $additionalUrlParameters), + ...$templateParameters, + ]); + + $this->mailer->send($email); + }); + */ + + // Current implementation: + $email = (new TemplatedEmail()) ->subject($emailSubject) ->to($user->getEmail()) ->textTemplate($template) diff --git a/src/Bundle/ChillMainBundle/Serializer/Normalizer/DateNormalizer.php b/src/Bundle/ChillMainBundle/Serializer/Normalizer/DateNormalizer.php index c363f9252..43617b794 100644 --- a/src/Bundle/ChillMainBundle/Serializer/Normalizer/DateNormalizer.php +++ b/src/Bundle/ChillMainBundle/Serializer/Normalizer/DateNormalizer.php @@ -15,14 +15,15 @@ use DateTimeInterface; use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\Serializer\Exception\UnexpectedValueException; +use Symfony\Component\Serializer\Normalizer\ContextAwareNormalizerInterface; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; -class DateNormalizer implements NormalizerInterface, DenormalizerInterface +class DateNormalizer implements ContextAwareNormalizerInterface, NormalizerInterface, DenormalizerInterface { public function __construct(private readonly RequestStack $requestStack, private readonly ParameterBagInterface $parameterBag) {} - public function denormalize($data, $type, $format = null, array $context = []): mixed + public function denormalize($data, $type, $format = null, array $context = []): ?\DateTimeInterface { if (null === $data) { return null; @@ -51,7 +52,7 @@ class DateNormalizer implements NormalizerInterface, DenormalizerInterface return $result; } - public function normalize($date, $format = null, array $context = []): string|int|float|bool|\ArrayObject|array|null + public function normalize($date, $format = null, array $context = []): array { /* @var DateTimeInterface $date */ switch ($format) { diff --git a/src/Bundle/ChillMainBundle/Serializer/Normalizer/PhonenumberNormalizer.php b/src/Bundle/ChillMainBundle/Serializer/Normalizer/PhonenumberNormalizer.php index 7880ebf64..571e8ec11 100644 --- a/src/Bundle/ChillMainBundle/Serializer/Normalizer/PhonenumberNormalizer.php +++ b/src/Bundle/ChillMainBundle/Serializer/Normalizer/PhonenumberNormalizer.php @@ -45,8 +45,11 @@ class PhonenumberNormalizer implements NormalizerInterface, DenormalizerInterfac try { return $this->phoneNumberUtil->parse($data, $this->defaultCarrierCode); - } catch (NumberParseException $e) { - throw new UnexpectedValueException($e->getMessage(), $e->getCode(), $e); + } catch (NumberParseException) { + $phonenumber = new PhoneNumber(); + $phonenumber->setRawInput($data); + + return $phonenumber; } } diff --git a/src/Bundle/ChillMainBundle/Serializer/Normalizer/UserNormalizer.php b/src/Bundle/ChillMainBundle/Serializer/Normalizer/UserNormalizer.php index 393da07c9..11ed95675 100644 --- a/src/Bundle/ChillMainBundle/Serializer/Normalizer/UserNormalizer.php +++ b/src/Bundle/ChillMainBundle/Serializer/Normalizer/UserNormalizer.php @@ -41,6 +41,7 @@ class UserNormalizer implements NormalizerInterface, NormalizerAwareInterface 'isAbsent' => false, 'absenceStart' => null, 'absenceEnd' => null, + 'enabled' => true, ]; public function __construct(private readonly UserRender $userRender, private readonly ClockInterface $clock) {} @@ -108,6 +109,7 @@ class UserNormalizer implements NormalizerInterface, NormalizerAwareInterface 'isAbsent' => $object->isAbsent(), 'absenceStart' => $this->normalizer->normalize($object->getAbsenceStart(), $format, $absenceDatesContext), 'absenceEnd' => $this->normalizer->normalize($object->getAbsenceEnd(), $format, $absenceDatesContext), + 'enabled' => $object->isEnabled(), ]; if ('docgen' === $format) { diff --git a/src/Bundle/ChillMainBundle/Service/Notifier/SentMessageEventSubscriber.php b/src/Bundle/ChillMainBundle/Service/Notifier/SentMessageEventSubscriber.php index dc9d3d475..2780833f8 100644 --- a/src/Bundle/ChillMainBundle/Service/Notifier/SentMessageEventSubscriber.php +++ b/src/Bundle/ChillMainBundle/Service/Notifier/SentMessageEventSubscriber.php @@ -18,7 +18,7 @@ use Symfony\Component\Notifier\Event\SentMessageEvent; final readonly class SentMessageEventSubscriber implements EventSubscriberInterface { public function __construct( - private LoggerInterface $logger, + private LoggerInterface $notifierLogger, // will be send to "notifierLogger" if it exists ) {} public static function getSubscribedEvents(): array @@ -33,9 +33,9 @@ final readonly class SentMessageEventSubscriber implements EventSubscriberInterf $message = $event->getMessage(); if (null === $message->getMessageId()) { - $this->logger->info('[sms] a sms message did not had any id after sending.', ['validReceiversI' => $message->getOriginalMessage()->getRecipientId()]); + $this->notifierLogger->info('[sms] a sms message did not had any id after sending.', ['validReceiversI' => $message->getOriginalMessage()->getRecipientId()]); } else { - $this->logger->warning('[sms] a sms was sent', ['validReceiversI' => $message->getOriginalMessage()->getRecipientId(), 'idsI' => $message->getMessageId()]); + $this->notifierLogger->warning('[sms] a sms was sent', ['validReceiversI' => $message->getOriginalMessage()->getRecipientId(), 'idsI' => $message->getMessageId()]); } } } diff --git a/src/Bundle/ChillMainBundle/Templating/Entity/AddressRender.php b/src/Bundle/ChillMainBundle/Templating/Entity/AddressRender.php index 07dc40422..7fe267e69 100644 --- a/src/Bundle/ChillMainBundle/Templating/Entity/AddressRender.php +++ b/src/Bundle/ChillMainBundle/Templating/Entity/AddressRender.php @@ -26,6 +26,7 @@ class AddressRender implements ChillEntityRenderInterface 'with_delimiter' => false, 'has_no_address' => false, 'multiline' => true, + 'separator' => ' — ', /* deprecated */ 'extended_infos' => false, ]; @@ -114,7 +115,9 @@ class AddressRender implements ChillEntityRenderInterface public function renderString($addr, array $options): string { - return implode(' — ', $this->renderLines($addr)); + $opts = [...self::DEFAULT_OPTIONS, ...$options]; + + return implode($opts['separator'], $this->renderLines($addr)); } public function supports($entity, array $options): bool diff --git a/src/Bundle/ChillMainBundle/Templating/Entity/CommentRender.php b/src/Bundle/ChillMainBundle/Templating/Entity/CommentRender.php index a4d0f48e6..9a808da77 100644 --- a/src/Bundle/ChillMainBundle/Templating/Entity/CommentRender.php +++ b/src/Bundle/ChillMainBundle/Templating/Entity/CommentRender.php @@ -52,7 +52,7 @@ class CommentRender implements ChillEntityRenderInterface public function renderString($entity, array $options): string { - return $entity->getComment(); + return (string) $entity->getComment(); } public function supports($entity, array $options): bool diff --git a/src/Bundle/ChillMainBundle/Tests/Controller/ScopeControllerTest.php b/src/Bundle/ChillMainBundle/Tests/Controller/ScopeControllerTest.php index 00d1281e1..0cc71ca5d 100644 --- a/src/Bundle/ChillMainBundle/Tests/Controller/ScopeControllerTest.php +++ b/src/Bundle/ChillMainBundle/Tests/Controller/ScopeControllerTest.php @@ -35,7 +35,7 @@ final class ScopeControllerTest extends WebTestCase $client->getResponse()->getStatusCode(), 'Unexpected HTTP status code for GET /fr/admin/scope/' ); - $crawler = $client->click($crawler->selectLink('Créer un nouveau cercle')->link()); + $crawler = $client->click($crawler->selectLink('Créer un nouveau service')->link()); // Fill in the form and submit it $form = $crawler->selectButton('Créer')->form([ 'chill_mainbundle_scope[name][fr]' => 'Test en fr', diff --git a/src/Bundle/ChillMainBundle/Tests/DependencyInjection/ConfigurationTest.php b/src/Bundle/ChillMainBundle/Tests/DependencyInjection/ConfigurationTest.php new file mode 100644 index 000000000..27f4db512 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/DependencyInjection/ConfigurationTest.php @@ -0,0 +1,98 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\MainBundle\Tests\DependencyInjection; + +use Chill\MainBundle\DependencyInjection\Configuration; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Config\Definition\Processor; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +/** + * @internal + * + * @coversNothing + */ +class ConfigurationTest extends TestCase +{ + public function testTopBannerConfiguration(): void + { + $containerBuilder = new ContainerBuilder(); + $configuration = new Configuration([], $containerBuilder); + $processor = new Processor(); + + // Test with top_banner configuration + $config = [ + 'chill_main' => [ + 'top_banner' => [ + 'text' => [ + 'fr' => 'Vous travaillez actuellement avec la version de pré-production de Chill.', + 'nl' => 'Je werkte momenteel in de pré-productie versie van Chill.', + ], + 'color' => 'white', + 'background-color' => 'red', + ], + ], + ]; + + $processedConfig = $processor->processConfiguration($configuration, $config); + + self::assertArrayHasKey('top_banner', $processedConfig); + self::assertArrayHasKey('text', $processedConfig['top_banner']); + self::assertArrayHasKey('fr', $processedConfig['top_banner']['text']); + self::assertArrayHasKey('nl', $processedConfig['top_banner']['text']); + self::assertSame('white', $processedConfig['top_banner']['color']); + self::assertSame('red', $processedConfig['top_banner']['background_color']); + } + + public function testTopBannerConfigurationOptional(): void + { + $containerBuilder = new ContainerBuilder(); + $configuration = new Configuration([], $containerBuilder); + $processor = new Processor(); + + // Test without top_banner configuration + $config = [ + 'chill_main' => [], + ]; + + $processedConfig = $processor->processConfiguration($configuration, $config); + + // top_banner should not be present when not configured + self::assertArrayNotHasKey('top_banner', $processedConfig); + } + + public function testTopBannerWithMinimalConfiguration(): void + { + $containerBuilder = new ContainerBuilder(); + $configuration = new Configuration([], $containerBuilder); + $processor = new Processor(); + + // Test with minimal top_banner configuration (only text) + $config = [ + 'chill_main' => [ + 'top_banner' => [ + 'text' => [ + 'fr' => 'Test message', + ], + ], + ], + ]; + + $processedConfig = $processor->processConfiguration($configuration, $config); + + self::assertArrayHasKey('top_banner', $processedConfig); + self::assertArrayHasKey('text', $processedConfig['top_banner']); + self::assertSame('Test message', $processedConfig['top_banner']['text']['fr']); + self::assertNull($processedConfig['top_banner']['color']); + self::assertNull($processedConfig['top_banner']['background_color']); + } +} diff --git a/src/Bundle/ChillMainBundle/Tests/Form/Type/ScopePickerTypeTest.php b/src/Bundle/ChillMainBundle/Tests/Form/Type/ScopePickerTypeTest.php index 3b4510ea3..a9e292781 100644 --- a/src/Bundle/ChillMainBundle/Tests/Form/Type/ScopePickerTypeTest.php +++ b/src/Bundle/ChillMainBundle/Tests/Form/Type/ScopePickerTypeTest.php @@ -11,11 +11,11 @@ declare(strict_types=1); namespace Form\Type; -use Chill\MainBundle\Entity\Center; use Chill\MainBundle\Entity\Scope; use Chill\MainBundle\Entity\User; use Chill\MainBundle\Form\Type\ScopePickerType; use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface; +use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface; use Chill\MainBundle\Templating\TranslatableStringHelperInterface; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder; @@ -39,11 +39,11 @@ final class ScopePickerTypeTest extends TypeTestCase { use ProphecyTrait; - public function estBuildOneScopeIsSuccessful(): void + public function testBuildOneScopeIsSuccessful() { $form = $this->factory->create(ScopePickerType::class, null, [ - 'center' => new Center(), 'role' => 'ONE_SCOPE', + 'center' => [], ]); $view = $form->createView(); @@ -54,8 +54,8 @@ final class ScopePickerTypeTest extends TypeTestCase public function testBuildThreeScopesIsSuccessful(): void { $form = $this->factory->create(ScopePickerType::class, null, [ - 'center' => new Center(), 'role' => 'THREE_SCOPE', + 'center' => [], ]); $view = $form->createView(); @@ -66,8 +66,8 @@ final class ScopePickerTypeTest extends TypeTestCase public function testBuildTwoScopesIsSuccessful(): void { $form = $this->factory->create(ScopePickerType::class, null, [ - 'center' => new Center(), 'role' => 'TWO_SCOPE', + 'center' => [], ]); $view = $form->createView(); @@ -82,9 +82,9 @@ final class ScopePickerTypeTest extends TypeTestCase $role1Scope = 'ONE_SCOPE'; $role2Scope = 'TWO_SCOPE'; $role3Scope = 'THREE_SCOPE'; - $scopeA = new Scope()->setName(['fr' => 'scope a']); - $scopeB = new Scope()->setName(['fr' => 'scope b']); - $scopeC = new Scope()->setName(['fr' => 'scope b'])->setActive(false); + $scopeA = (new Scope())->setName(['fr' => 'scope a']); + $scopeB = (new Scope())->setName(['fr' => 'scope b']); + $scopeC = (new Scope())->setName(['fr' => 'scope b'])->setActive(false); $authorizationHelper = $this->prophesize(AuthorizationHelperInterface::class); $authorizationHelper->getReachableScopes($user, $role1Scope, Argument::any()) @@ -102,10 +102,13 @@ final class ScopePickerTypeTest extends TypeTestCase static fn ($args) => $args[0]['fr'] ); + $centerResolverManager = $this->prophesize(CenterResolverManagerInterface::class); + $type = new ScopePickerType( + $translatableStringHelper->reveal(), $authorizationHelper->reveal(), $security->reveal(), - $translatableStringHelper->reveal() + $centerResolverManager->reveal() ); // add the mocks for creating EntityType diff --git a/src/Bundle/ChillMainBundle/Tests/Notification/Email/DailyNotificationDigestCronJobFunctionalTest.php b/src/Bundle/ChillMainBundle/Tests/Notification/Email/DailyNotificationDigestCronJobFunctionalTest.php index 0caeebc36..a1f749399 100644 --- a/src/Bundle/ChillMainBundle/Tests/Notification/Email/DailyNotificationDigestCronJobFunctionalTest.php +++ b/src/Bundle/ChillMainBundle/Tests/Notification/Email/DailyNotificationDigestCronJobFunctionalTest.php @@ -37,10 +37,5 @@ class DailyNotificationDigestCronJobFunctionalTest extends KernelTestCase $actual = $this->dailyNotificationDigestCronjob->run([]); self::assertArrayHasKey('last_execution', $actual); - self::assertInstanceOf( - \DateTimeImmutable::class, - \DateTimeImmutable::createFromFormat('Y-m-d-H:i:s.u e', $actual['last_execution']), - 'test that the string can be converted to a date' - ); } } diff --git a/src/Bundle/ChillMainBundle/Tests/Notification/Email/DailyNotificationDigestCronJobTest.php b/src/Bundle/ChillMainBundle/Tests/Notification/Email/DailyNotificationDigestCronJobTest.php index 5894385c6..075929838 100644 --- a/src/Bundle/ChillMainBundle/Tests/Notification/Email/DailyNotificationDigestCronJobTest.php +++ b/src/Bundle/ChillMainBundle/Tests/Notification/Email/DailyNotificationDigestCronJobTest.php @@ -12,16 +12,21 @@ declare(strict_types=1); namespace Chill\MainBundle\Tests\Notification\Email; use Chill\MainBundle\Notification\Email\DailyNotificationDigestCronjob; +use Chill\MainBundle\Notification\Email\NotificationEmailMessages\ScheduleDailyNotificationDigestMessage; use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Result; +use Doctrine\DBAL\Statement; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; use Symfony\Component\Clock\ClockInterface; +use Symfony\Component\Clock\MockClock; +use Symfony\Component\Messenger\Envelope; use Symfony\Component\Messenger\MessageBusInterface; /** * @internal * - * @coversNothing + * @covers \DailyNotificationDigestCronjob */ class DailyNotificationDigestCronJobTest extends TestCase { @@ -30,6 +35,7 @@ class DailyNotificationDigestCronJobTest extends TestCase private MessageBusInterface $messageBus; private LoggerInterface $logger; private DailyNotificationDigestCronjob $cronjob; + private \DateTimeImmutable $firstNow; protected function setUp(): void { @@ -38,6 +44,8 @@ class DailyNotificationDigestCronJobTest extends TestCase $this->messageBus = $this->createMock(MessageBusInterface::class); $this->logger = $this->createMock(LoggerInterface::class); + $this->firstNow = new \DateTimeImmutable('2024-01-02T07:15:00+00:00'); + $this->cronjob = new DailyNotificationDigestCronjob( $this->clock, $this->connection, @@ -78,4 +86,129 @@ class DailyNotificationDigestCronJobTest extends TestCase 'hour 23 - should not run' => [23, false], ]; } + + public function testRunFirstExecutionReturnsStateAndDispatches(): array + { + // Use MockClock for deterministic time + $firstNow = $this->firstNow; + $clock = new MockClock($firstNow); + + // Mock DBAL statement/result + $statement = $this->createMock(Statement::class); + $result = $this->createMock(Result::class); + + $this->connection->method('prepare')->willReturn($statement); + $statement->method('bindValue')->willReturnSelf(); + $statement->method('executeQuery')->willReturn($result); + + $rows = [ + ['user_id' => 10], + ['user_id' => 42], + ]; + $result->method('fetchAllAssociative')->willReturn($rows); + + $dispatched = []; + $this->messageBus->method('dispatch')->willReturnCallback(function ($message) use (&$dispatched) { + $dispatched[] = $message; + + return new Envelope($message); + }); + + $cron = new DailyNotificationDigestCronjob($clock, $this->connection, $this->messageBus, $this->logger); + $state = $cron->run([]); + + // Assert dispatch count and message contents + self::assertCount(2, $dispatched); + $expectedLast = $firstNow->sub(new \DateInterval('P1D')); + foreach ($dispatched as $i => $msg) { + self::assertInstanceOf(ScheduleDailyNotificationDigestMessage::class, $msg); + self::assertTrue(in_array($msg->getUserId(), [10, 42], true)); + self::assertEquals($firstNow, $msg->getCurrentDateTime(), 'compare the current date'); + self::assertEquals($expectedLast, $msg->getLastExecutionDateTime(), 'compare the last execution date'); + } + + // Assert returned state + self::assertIsArray($state); + self::assertArrayHasKey('last_execution', $state); + self::assertSame($firstNow->format(\DateTimeInterface::ATOM), $state['last_execution']); + + return $state; + } + + /** + * @depends testRunFirstExecutionReturnsStateAndDispatches + */ + public function testRunSecondExecutionUsesPreviousState(array $previousState): void + { + $firstNow = $this->firstNow; + $secondNow = $firstNow->add(new \DateInterval('P1D')); + $clock = new MockClock($secondNow); + + // Mock DBAL for a single user this time + $statement = $this->createMock(Statement::class); + $result = $this->createMock(Result::class); + + $this->connection->method('prepare')->willReturn($statement); + $statement->method('bindValue')->willReturnSelf(); + $statement->method('executeQuery')->willReturn($result); + + $rows = [ + ['user_id' => 7], + ]; + $result->method('fetchAllAssociative')->willReturn($rows); + + $captured = []; + $this->messageBus->method('dispatch')->willReturnCallback(function ($message) use (&$captured) { + $captured[] = $message; + + return new Envelope($message); + }); + + $cron = new DailyNotificationDigestCronjob($clock, $this->connection, $this->messageBus, $this->logger); + $cron->run($previousState); + + self::assertCount(1, $captured); + $msg = $captured[0]; + self::assertInstanceOf(ScheduleDailyNotificationDigestMessage::class, $msg); + self::assertEquals(7, $msg->getUserId()); + self::assertEquals($secondNow, $msg->getCurrentDateTime(), 'compare the current date'); + self::assertEquals($firstNow, $msg->getLastExecutionDateTime(), 'compare the last execution date'); + } + + public function testRunWithInvalidExecutionState(): void + { + $firstNow = new \DateTimeImmutable('2025-10-14T10:30:00 Europe/Brussels'); + $previousExpected = $firstNow->sub(new \DateInterval('P1D')); + $clock = new MockClock($firstNow); + + // Mock DBAL for a single user this time + $statement = $this->createMock(Statement::class); + $result = $this->createMock(Result::class); + + $this->connection->method('prepare')->willReturn($statement); + $statement->method('bindValue')->willReturnSelf(); + $statement->method('executeQuery')->willReturn($result); + + $rows = [ + ['user_id' => 7], + ]; + $result->method('fetchAllAssociative')->willReturn($rows); + + $captured = []; + $this->messageBus->method('dispatch')->willReturnCallback(function ($message) use (&$captured) { + $captured[] = $message; + + return new Envelope($message); + }); + + $cron = new DailyNotificationDigestCronjob($clock, $this->connection, $this->messageBus, $this->logger); + $cron->run(['last_execution' => 'invalid data']); + + self::assertCount(1, $captured); + $msg = $captured[0]; + self::assertInstanceOf(ScheduleDailyNotificationDigestMessage::class, $msg); + self::assertEquals(7, $msg->getUserId()); + self::assertEquals($firstNow, $msg->getCurrentDateTime(), 'compare the current date'); + self::assertEquals($previousExpected, $msg->getLastExecutionDateTime(), 'compare the last execution date'); + } } diff --git a/src/Bundle/ChillMainBundle/Tests/Phonenumber/PhonenumberHelperTest.php b/src/Bundle/ChillMainBundle/Tests/Phonenumber/PhonenumberHelperTest.php index a74243c64..6bb5739e3 100644 --- a/src/Bundle/ChillMainBundle/Tests/Phonenumber/PhonenumberHelperTest.php +++ b/src/Bundle/ChillMainBundle/Tests/Phonenumber/PhonenumberHelperTest.php @@ -12,6 +12,7 @@ declare(strict_types=1); namespace Chill\MainBundle\Tests\Phonenumber; use Chill\MainBundle\Phonenumber\PhonenumberHelper; +use libphonenumber\PhoneNumber; use libphonenumber\PhoneNumberUtil; use Psr\Log\NullLogger; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; @@ -31,6 +32,7 @@ final class PhonenumberHelperTest extends KernelTestCase public function testFormatPhonenumbers(string $defaultCarrierCode, string $phoneNumber, string $expected): void { $util = PhoneNumberUtil::getInstance(); + $subject = new PhonenumberHelper( new ArrayAdapter(), new ParameterBag([ @@ -70,4 +72,47 @@ final class PhonenumberHelperTest extends KernelTestCase '00 33 6 23 12 45 54', ]; } + + /** + * @dataProvider providePhoneNumbersToParse + */ + public function testParsePhonenumbers(string $defaultCarrierCode, string $phoneNumber, PhoneNumber $expected): void + { + $subject = new PhonenumberHelper( + new ArrayAdapter(), + new ParameterBag([ + 'chill_main.phone_helper' => [ + 'default_carrier_code' => $defaultCarrierCode, + ], + ]), + new NullLogger() + ); + + $actual = $subject->parse($phoneNumber); + + self::assertTrue($expected->equals($actual)); + } + + public static function providePhoneNumbersToParse(): iterable + { + $util = PhoneNumberUtil::getInstance(); + + yield [ + 'FR', + '+32486544999', + $util->parse('+32486544999', 'FR'), + ]; + + yield [ + 'FR', + '32486544999', + $util->parse('+32486544999', 'FR'), + ]; + + yield [ + 'FR', + '0228858040', + $util->parse('+33228858040', 'FR'), + ]; + } } diff --git a/src/Bundle/ChillMainBundle/Tests/Search/Utils/ExtractPhonenumberFromPatternTest.php b/src/Bundle/ChillMainBundle/Tests/Search/Utils/ExtractPhonenumberFromPatternTest.php index 78e08504e..24e251adf 100644 --- a/src/Bundle/ChillMainBundle/Tests/Search/Utils/ExtractPhonenumberFromPatternTest.php +++ b/src/Bundle/ChillMainBundle/Tests/Search/Utils/ExtractPhonenumberFromPatternTest.php @@ -43,20 +43,20 @@ final class ExtractPhonenumberFromPatternTest extends KernelTestCase yield ['BE', 'Diallo 15/06/2021', 0, [], 'Diallo 15/06/2021', 'no phonenumber and a date']; - yield ['BE', 'Diallo 0486 123 456', 1, ['+32486123456'], 'Diallo', 'a phonenumber and a name']; + yield ['BE', 'Diallo 0486 123 456', 1, ['+32486123456'], 'Diallo 0486 123 456', 'a phonenumber and a name']; - yield ['BE', 'Diallo 123 456', 1, ['123456'], 'Diallo', 'a number and a name, without leadiing 0']; + yield ['BE', 'Diallo 123 456', 1, ['123456'], 'Diallo 123 456', 'a number and a name, without leadiing 0']; - yield ['BE', '123 456', 1, ['123456'], '', 'only phonenumber']; + yield ['BE', '123 456', 1, ['123456'], '123 456', 'only phonenumber']; - yield ['BE', '0123 456', 1, ['+32123456'], '', 'only phonenumber with a leading 0']; + yield ['BE', '0123 456', 1, ['+32123456'], '0123 456', 'only phonenumber with a leading 0']; - yield ['FR', '123 456', 1, ['123456'], '', 'only phonenumber']; + yield ['FR', '123 456', 1, ['123456'], '123 456', 'only phonenumber']; - yield ['FR', '0123 456', 1, ['+33123456'], '', 'only phonenumber with a leading 0']; + yield ['FR', '0123 456', 1, ['+33123456'], '0123 456', 'only phonenumber with a leading 0']; - yield ['FR', 'Diallo 0486 123 456', 1, ['+33486123456'], 'Diallo', 'a phonenumber and a name']; + yield ['FR', 'Diallo 0486 123 456', 1, ['+33486123456'], 'Diallo 0486 123 456', 'a phonenumber and a name']; - yield ['FR', 'Diallo +32486 123 456', 1, ['+32486123456'], 'Diallo', 'a phonenumber and a name']; + yield ['FR', 'Diallo +32486 123 456', 1, ['+32486123456'], 'Diallo +32486 123 456', 'a phonenumber and a name']; } } diff --git a/src/Bundle/ChillMainBundle/Tests/Serializer/Normalizer/UserNormalizerTest.php b/src/Bundle/ChillMainBundle/Tests/Serializer/Normalizer/UserNormalizerTest.php index 6cbfd5a3b..a5dadf83e 100644 --- a/src/Bundle/ChillMainBundle/Tests/Serializer/Normalizer/UserNormalizerTest.php +++ b/src/Bundle/ChillMainBundle/Tests/Serializer/Normalizer/UserNormalizerTest.php @@ -103,6 +103,7 @@ final class UserNormalizerTest extends TestCase 'main_center' => ['context' => Center::class], 'absenceStart' => ['context' => \DateTimeImmutable::class], 'absenceEnd' => ['context' => \DateTimeImmutable::class], + 'enabled' => true, ]]; yield [$userNoPhone, 'docgen', ['docgen:expects' => User::class], @@ -124,6 +125,7 @@ final class UserNormalizerTest extends TestCase 'main_center' => ['context' => Center::class], 'absenceStart' => ['context' => \DateTimeImmutable::class], 'absenceEnd' => ['context' => \DateTimeImmutable::class], + 'enabled' => true, ]]; yield [null, 'docgen', ['docgen:expects' => User::class], [ @@ -144,6 +146,7 @@ final class UserNormalizerTest extends TestCase 'main_center' => ['context' => Center::class], 'absenceStart' => null, 'absenceEnd' => null, + 'enabled' => true, ]]; } } diff --git a/src/Bundle/ChillMainBundle/Tests/Services/MenuComposerTest.php b/src/Bundle/ChillMainBundle/Tests/Services/MenuComposerTest.php index 9f75da76e..7be02c7e4 100644 --- a/src/Bundle/ChillMainBundle/Tests/Services/MenuComposerTest.php +++ b/src/Bundle/ChillMainBundle/Tests/Services/MenuComposerTest.php @@ -11,44 +11,107 @@ declare(strict_types=1); namespace Chill\MainBundle\Tests\Services; -use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Chill\MainBundle\Routing\LocalMenuBuilderInterface; +use Chill\MainBundle\Routing\MenuComposer; +use Knp\Menu\MenuFactory; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\Routing\RouterInterface; use Symfony\Component\Routing\RouteCollection; +use Symfony\Contracts\Translation\TranslatorInterface; /** - * This class provide functional test for MenuComposer. + * Tests for MenuComposer methods. + * + * We only verify that items provided by local menu builders are present + * when getRoutesFor() yields no routes, and that hasLocalMenuBuilder behaves + * as expected with the configured builders. * * @internal * * @coversNothing */ -final class MenuComposerTest extends KernelTestCase +final class MenuComposerTest extends TestCase { - /** - * @var \Symfony\Bundle\FrameworkBundle\Routing\DelegatingLoader; - */ - private $loader; + use ProphecyTrait; - /** - * @var \Chill\MainBundle\DependencyInjection\Services\MenuComposer; - */ - private $menuComposer; - - protected function setUp(): void + private function buildMenuComposerWithDefaultBuilder(): array { - self::bootKernel(['environment' => 'test']); - $this->menuComposer = self::getContainer() - ->get(\Chill\MainBundle\Routing\MenuComposer::class); + // Router: returns an empty RouteCollection so getRoutesFor() yields [] + $routerProphecy = $this->prophesize(RouterInterface::class); + $routerProphecy->getRouteCollection()->willReturn(new RouteCollection()); + $router = $routerProphecy->reveal(); + + // Menu factory from Knp\Menu + $menuFactory = new MenuFactory(); + + // Translator: identity translator + $translator = new class () implements TranslatorInterface { + public function trans(string $id, array $parameters = [], ?string $domain = null, ?string $locale = null): string + { + return $id; + } + + public function getLocale(): string + { + return 'en'; + } + }; + + // Local builder that adds two items to the requested menu + $builder = new class () implements LocalMenuBuilderInterface { + public static function getMenuIds(): array + { + return ['main']; + } + + public function buildMenu($menuId, \Knp\Menu\MenuItem $menu, array $parameters) + { + // Ensure we can use parameters passed to getMenuFor + $suffix = $parameters['suffix'] ?? ''; + $menu->addChild('local_item_one', [ + 'label' => 'Local Item One'.$suffix, + ])->setExtras(['order' => 1]); + $menu->addChild('local_item_two', [ + 'label' => 'Local Item Two'.$suffix, + ])->setExtras(['order' => 2]); + } + }; + + $composer = new MenuComposer( + $router, + $menuFactory, + $translator, + [$builder] + ); + + return [$composer, $builder]; } - /** - * @covers \Chill\MainBundle\Routing\MenuComposer - */ - public function testMenuComposer(): void + public function testGetMenuForReturnsItemsFromLocalBuildersOnly(): void { - $collection = new RouteCollection(); + [$composer] = $this->buildMenuComposerWithDefaultBuilder(); - $routes = $this->menuComposer->getRoutesFor('dummy0'); + $menu = $composer->getMenuFor('main', []); - $this->assertIsArray($routes); + // No routes were added, only local builder items should be present + $children = $menu->getChildren(); + self::assertCount(2, $children, 'Menu should contain exactly the items provided by local builders'); + + // Assert the two expected items exist with their names + self::assertNotNull($menu->getChild('local_item_one')); + self::assertNotNull($menu->getChild('local_item_two')); + + // And that their labels include the parameter suffix + self::assertSame('Local Item One', $menu->getChild('local_item_one')->getLabel()); + self::assertSame('Local Item Two', $menu->getChild('local_item_two')->getLabel()); + } + + public function testHasLocalMenuBuilder(): void + { + [$composer] = $this->buildMenuComposerWithDefaultBuilder(); + + self::assertTrue($composer->hasLocalMenuBuilder('main')); + self::assertFalse($composer->hasLocalMenuBuilder('secondary')); } } diff --git a/src/Bundle/ChillMainBundle/Tests/Validation/Validator/UserGroupDoNotExcludeTest.php b/src/Bundle/ChillMainBundle/Tests/Validation/Validator/UserGroupDoNotExcludeTest.php new file mode 100644 index 000000000..d6d1c9887 --- /dev/null +++ b/src/Bundle/ChillMainBundle/Tests/Validation/Validator/UserGroupDoNotExcludeTest.php @@ -0,0 +1,91 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\MainBundle\Tests\Validation\Validator; + +use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Entity\UserGroup; +use Chill\MainBundle\Templating\TranslatableStringHelperInterface; +use Chill\MainBundle\Validation\Validator\UserGroupDoNotExclude; +use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; + +/** + * @internal + * + * @coversNothing + */ +class UserGroupDoNotExcludeTest extends ConstraintValidatorTestCase +{ + protected function createValidator() + { + return new UserGroupDoNotExclude( + new class () implements TranslatableStringHelperInterface { + public function localize(array $translatableStrings): ?string + { + return $translatableStrings['fr']; + } + } + ); + } + + public function testEmptyArrayIsValid(): void + { + $this->validator->validate([], new \Chill\MainBundle\Validation\Constraint\UserGroupDoNotExclude()); + + $this->assertNoViolation(); + } + + public function testMixedUserGroupAndUsersIsValid(): void + { + $this->validator->validate( + [new User(), new UserGroup()], + new \Chill\MainBundle\Validation\Constraint\UserGroupDoNotExclude() + ); + + $this->assertNoViolation(); + } + + public function testDifferentExcludeKeysIsValid(): void + { + $this->validator->validate( + [(new UserGroup())->setExcludeKey('A'), (new UserGroup())->setExcludeKey('B')], + new \Chill\MainBundle\Validation\Constraint\UserGroupDoNotExclude() + ); + + $this->assertNoViolation(); + } + + public function testMultipleGroupsWithEmptyExcludeKeyIsValid(): void + { + $this->validator->validate( + [(new UserGroup())->setExcludeKey(''), (new UserGroup())->setExcludeKey('')], + new \Chill\MainBundle\Validation\Constraint\UserGroupDoNotExclude() + ); + + $this->assertNoViolation(); + } + + public function testSameExclusionKeyWillRaiseError(): void + { + $this->validator->validate( + [ + (new UserGroup())->setExcludeKey('A')->setLabel(['fr' => 'Group 1']), + (new UserGroup())->setExcludeKey('A')->setLabel(['fr' => 'Group 2']), + ], + new \Chill\MainBundle\Validation\Constraint\UserGroupDoNotExclude() + ); + + $this->buildViolation('The groups {{ excluded_groups }} do exclude themselves. Please choose one between them') + ->setParameter('excluded_groups', 'Group 1, Group 2') + ->setCode('e16c8226-0090-11ef-8560-f7239594db09') + ->assertRaised(); + } +} diff --git a/src/Bundle/ChillMainBundle/Validation/Constraint/PhonenumberConstraint.php b/src/Bundle/ChillMainBundle/Validation/Constraint/PhonenumberConstraint.php index 2290fd055..6e6c66895 100644 --- a/src/Bundle/ChillMainBundle/Validation/Constraint/PhonenumberConstraint.php +++ b/src/Bundle/ChillMainBundle/Validation/Constraint/PhonenumberConstraint.php @@ -13,6 +13,9 @@ namespace Chill\MainBundle\Validation\Constraint; use Symfony\Component\Validator\Constraint; +/** + * @deprecated use odolbeau/phonenumber validator instead + */ #[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD)] class PhonenumberConstraint extends Constraint { diff --git a/src/Bundle/ChillMainBundle/Validation/Validator/ValidPhonenumber.php b/src/Bundle/ChillMainBundle/Validation/Validator/ValidPhonenumber.php index c010fed8a..6984a6a8a 100644 --- a/src/Bundle/ChillMainBundle/Validation/Validator/ValidPhonenumber.php +++ b/src/Bundle/ChillMainBundle/Validation/Validator/ValidPhonenumber.php @@ -16,6 +16,9 @@ use Psr\Log\LoggerInterface; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\ConstraintValidator; +/** + * @deprecated use https://github.com/odolbeau/phone-number-bundle/blob/master/src/Validator/Constraints/PhoneNumberValidator.php instead + */ final class ValidPhonenumber extends ConstraintValidator { public function __construct(private readonly LoggerInterface $logger, private readonly PhoneNumberHelperInterface $phonenumberHelper) {} diff --git a/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/NotificationToUserGroupsOnTransition.php b/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/NotificationToUserGroupsOnTransition.php index 1eebb03a6..dad18a30c 100644 --- a/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/NotificationToUserGroupsOnTransition.php +++ b/src/Bundle/ChillMainBundle/Workflow/EventSubscriber/NotificationToUserGroupsOnTransition.php @@ -22,6 +22,8 @@ use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\Workflow\Event\Event; use Symfony\Component\Workflow\Registry; +// use Symfony\Component\Translation\LocaleSwitcher; + final readonly class NotificationToUserGroupsOnTransition implements EventSubscriberInterface { public function __construct( @@ -31,6 +33,7 @@ final readonly class NotificationToUserGroupsOnTransition implements EventSubscr private MailerInterface $mailer, private EntityManagerInterface $entityManager, private EntityWorkflowManager $entityWorkflowManager, + // private LocaleSwitcher $localeSwitcher, ) {} public static function getSubscribedEvents(): array @@ -87,6 +90,24 @@ final readonly class NotificationToUserGroupsOnTransition implements EventSubscr 'title' => $title, ]; + // Implementation with LocaleSwitcher (commented out - to be activated after migration to sf7.2): + // Note: This sends emails to user groups, not individual users, so locale switching may use default locale + /* + $this->localeSwitcher->runWithLocale('fr', function () use ($context, $userGroup) { + $email = new TemplatedEmail(); + $email + ->htmlTemplate('@ChillMain/Workflow/workflow_notification_on_transition_completed_content_to_user_group.fr.txt.twig') + ->context($context) + ->subject( + $this->engine->render('@ChillMain/Workflow/workflow_notification_on_transition_completed_title.fr.txt.twig', $context) + ) + ->to($userGroup->getEmail()); + + $this->mailer->send($email); + }); + */ + + // Current implementation: $email = new TemplatedEmail(); $email ->htmlTemplate('@ChillMain/Workflow/workflow_notification_on_transition_completed_content_to_user_group.fr.txt.twig') diff --git a/src/Bundle/ChillMainBundle/chill.api.specs.yaml b/src/Bundle/ChillMainBundle/chill.api.specs.yaml index 62df1a44c..a69815050 100644 --- a/src/Bundle/ChillMainBundle/chill.api.specs.yaml +++ b/src/Bundle/ChillMainBundle/chill.api.specs.yaml @@ -10,6 +10,31 @@ servers: components: schemas: + Collection: + type: object + properties: + count: + type: number + format: u64 + pagination: + type: object + properties: + first: + type: number + format: u64 + items_per_page: + type: number + format: u64 + next: + type: string + format: uri + nullable: true + previous: + type: string + format: uri + nullable: true + more: + type: boolean EntityWorkflowAttachment: type: object properties: @@ -280,7 +305,7 @@ paths: - thirdparty - user - household - - user-group + - user_group responses: 200: description: "OK" diff --git a/src/Bundle/ChillMainBundle/chill.webpack.config.js b/src/Bundle/ChillMainBundle/chill.webpack.config.js index 596fab77f..cc230a9e8 100644 --- a/src/Bundle/ChillMainBundle/chill.webpack.config.js +++ b/src/Bundle/ChillMainBundle/chill.webpack.config.js @@ -26,7 +26,7 @@ module.exports = function (encore, entries) { ); encore.addEntry( "page_homepage_widget", - __dirname + "/Resources/public/page/homepage_widget/index.js", + __dirname + "/Resources/public/page/homepage_widget/index.ts", ); encore.addEntry( "page_export", diff --git a/src/Bundle/ChillMainBundle/config/services/command.yaml b/src/Bundle/ChillMainBundle/config/services/command.yaml index 313ca28c5..2b765a1c1 100644 --- a/src/Bundle/ChillMainBundle/config/services/command.yaml +++ b/src/Bundle/ChillMainBundle/config/services/command.yaml @@ -42,38 +42,26 @@ services: - { name: console.command } Chill\MainBundle\Command\LoadAddressesFRFromBANOCommand: - autoconfigure: true - autowire: true tags: - { name: console.command } Chill\MainBundle\Command\LoadAddressesFRFromBANCommand: - autoconfigure: true - autowire: true tags: - { name: console.command } Chill\MainBundle\Command\LoadAddressesBEFromBestAddressCommand: - autoconfigure: true - autowire: true tags: - { name: console.command } Chill\MainBundle\Command\LoadPostalCodeFR: - autoconfigure: true - autowire: true tags: - { name: console.command } Chill\MainBundle\Command\LoadAddressesLUFromBDAddressCommand: - autoconfigure: true - autowire: true tags: - { name: console.command } Chill\MainBundle\Command\ExecuteCronJobCommand: - autoconfigure: true - autowire: true tags: - {name: console.command } @@ -81,6 +69,6 @@ services: tags: - {name: console.command} - Chill\MainBundle\Command\DumpListPermissionsCommand: - autoconfigure: true - autowire: true + Chill\MainBundle\Command\DumpListPermissionsCommand: ~ + + Chill\MainBundle\Command\OverrideTranslationCommand: ~ diff --git a/src/Bundle/ChillMainBundle/config/services/form.yaml b/src/Bundle/ChillMainBundle/config/services/form.yaml index e917b37c9..ab7a49c68 100644 --- a/src/Bundle/ChillMainBundle/config/services/form.yaml +++ b/src/Bundle/ChillMainBundle/config/services/form.yaml @@ -12,6 +12,12 @@ services: tags: - { name: form.type, alias: translatable_string } + Chill\MainBundle\Form\Type\UserLocaleType: + arguments: + - "%chill_main.available_languages%" + tags: + - { name: form.type } + chill.main.form.type.select2choice: class: Chill\MainBundle\Form\Type\Select2ChoiceType tags: diff --git a/src/Bundle/ChillMainBundle/config/services/routing.yaml b/src/Bundle/ChillMainBundle/config/services/routing.yaml index 80cb07f90..54ff27c3e 100644 --- a/src/Bundle/ChillMainBundle/config/services/routing.yaml +++ b/src/Bundle/ChillMainBundle/config/services/routing.yaml @@ -6,9 +6,8 @@ services: chill.main.menu_composer: class: Chill\MainBundle\Routing\MenuComposer arguments: - - '@Symfony\Component\Routing\RouterInterface' - - '@Knp\Menu\FactoryInterface' - - '@Symfony\Contracts\Translation\TranslatorInterface' + $localMenuBuilders: !tagged_iterator 'chill.menu_builder' + Chill\MainBundle\Routing\MenuComposer: '@chill.main.menu_composer' chill.main.routes_loader: diff --git a/src/Bundle/ChillMainBundle/migrations/Version20251022140718.php b/src/Bundle/ChillMainBundle/migrations/Version20251022140718.php new file mode 100644 index 000000000..733bf5da1 --- /dev/null +++ b/src/Bundle/ChillMainBundle/migrations/Version20251022140718.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\Migrations\Main; + +use Doctrine\DBAL\Schema\Schema; +use Doctrine\Migrations\AbstractMigration; + +final class Version20251022140718 extends AbstractMigration +{ + public function getDescription(): string + { + return 'Add locale field to users table for user language preferences'; + } + + public function up(Schema $schema): void + { + $this->addSql('ALTER TABLE users ADD locale VARCHAR(5) DEFAULT \'fr\' NOT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE users DROP locale'); + } +} diff --git a/src/Bundle/ChillMainBundle/migrations/Version20251104124123.php b/src/Bundle/ChillMainBundle/migrations/Version20251104124123.php new file mode 100644 index 000000000..9ba086f36 --- /dev/null +++ b/src/Bundle/ChillMainBundle/migrations/Version20251104124123.php @@ -0,0 +1,46 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\Migrations\Main; + +use Doctrine\DBAL\Schema\Schema; +use Doctrine\Migrations\AbstractMigration; + +final class Version20251104124123 extends AbstractMigration +{ + public function getDescription(): string + { + return 'Delete on cascade EntityWorkflowStepHold'; + } + + public function up(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_main_workflow_entity_step_hold + DROP CONSTRAINT fk_1be2e7c73b21e9c'); + + $this->addSql('ALTER TABLE chill_main_workflow_entity_step_hold + ADD CONSTRAINT fk_1be2e7c73b21e9c + FOREIGN KEY (step_id) + REFERENCES chill_main_workflow_entity_step (id) + ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_main_workflow_entity_step_hold + DROP CONSTRAINT fk_1be2e7c73b21e9c'); + + $this->addSql('ALTER TABLE chill_main_workflow_entity_step_hold + ADD CONSTRAINT fk_1be2e7c73b21e9c + FOREIGN KEY (step_id) + REFERENCES chill_main_workflow_entity_step (id)'); + } +} diff --git a/src/Bundle/ChillMainBundle/translations/date.nl.yml b/src/Bundle/ChillMainBundle/translations/date.nl.yml new file mode 100644 index 000000000..ad134564c --- /dev/null +++ b/src/Bundle/ChillMainBundle/translations/date.nl.yml @@ -0,0 +1,7 @@ +#diff ago. See doc here : http://twig.sensiolabs.org/doc/extensions/date.html +diff.ago.second: '{0} Nu | {1} Een seconde geleden | ]1,Inf] %count% seconden geleden' +diff.ago.minute: '{0} Nu | {1} Een minuut geleden | ]1,Inf] %count% minuten geleden' +diff.ago.hour: '{1} Een uur geleden | ]1,Inf] %count% uur geleden' +diff.ago.day: '{1} Gisteren | ]1,Inf] %count% dagen geleden' +diff.ago.month: '{1} Vorige maand | ]1,Inf] %count% maanden geleden' +diff.ago.year: '{1} Een jaar geleden | ]1,Inf] %count% jaar geleden' diff --git a/src/Bundle/ChillMainBundle/translations/messages+intl-icu.fr.yaml b/src/Bundle/ChillMainBundle/translations/messages+intl-icu.fr.yaml index 580910b2c..fc8c7a48e 100644 --- a/src/Bundle/ChillMainBundle/translations/messages+intl-icu.fr.yaml +++ b/src/Bundle/ChillMainBundle/translations/messages+intl-icu.fr.yaml @@ -127,6 +127,20 @@ duration: few {# minutes} other {# minutes} } + hour: >- + {h, plural, + =0 {Aucune durée} + one {# heure} + few {# heures} + other {# heures} + } + day: >- + {d, plural, + =0 {Aucune durée} + one {# jour} + few {# jours} + other {# jours} + } filter_order: by_date: @@ -136,6 +150,91 @@ filter_order: Search: Chercher dans la liste By date: Filtrer par date search_box: Filtrer par contenu +pick_entity: + add: "Ajouter" + modal_title: >- + {count, plural, + one {Indiquer un} + other {Ajouter des} + } + user: >- + {count, plural, + one {Utilisateur} + other {Utilisateurs} + } + user_group: >- + {count, plural, + one {Groupe d'utilisateur} + other {Groupes d'utilisateurs} + } + person: >- + {count, plural, + one {Usager} + other {Usagers} + } + thirdparty: >- + {count, plural, + one {Tiers} + other {Tiers} + } + +loading: "Chargement..." +main_title: "Vue d'ensemble" +my_tickets.tab: "Mes Tickets" +my_works.tab: "Mes actions" +my_works.description: "Liste des actions d'accompagnement dont je suis référent et qui arrivent à échéance." +my_evaluations.tab: "Mes évaluations" +my_evaluations.description: "Liste des évaluations dont je suis référent et qui arrivent à échéance." +my_tasks.tab: "Mes tâches" +my_tasks.description_alert: "Liste des tâches auxquelles je suis assigné et dont la date de rappel est dépassée." +my_tasks.description_warning: "Liste des tâches auxquelles je suis assigné et dont la date d'échéance est dépassée." +my_accompanying_courses.tab: "Mes nouveaux parcours" +my_accompanying_courses.description: "Liste des parcours d'accompagnement que l'on vient de m'attribuer depuis moins de 15 jours." +my_notifications.tab: "Mes nouvelles notifications" +my_notifications.description: "Liste des notifications reçues et non lues." +my_workflows.tab: "Mes workflows" +my_workflows.description: "Liste des workflows en attente d'une action." +my_workflows.description_cc: "Liste des workflows dont je suis en copie." +opening_date: "Date d'ouverture" +social_issues: "Problématiques sociales" +concerned_persons: "Usagers concernés" +max_date: "Date d'échéance" +warning_date: "Date de rappel" +evaluation: "Évaluation" +task: "Tâche" +Date: "Date" +From: "Expéditeur" +Subject: "Objet" +Entity: "Associé à" +Step: "Étape" +concerned_users: "Usagers concernés" +Object_workflow: "Objet du workflow" +on_hold: "En attente" +show_entity: "Voir {entity}" +the_activity: "l'échange" +the_course: "le parcours" +the_action: "l'action" +the_evaluation: "l'évaluation" +the_evaluation_document: "le document" +the_task: "la tâche" +the_workflow: "le workflow" +StartDate: "Date d'ouverture" +SocialAction: "Action d'accompagnement" +no_data: "Aucun résultats" +no_dashboard: "Pas de tableaux de bord" +counter.unread_notifications: "{n, plural, one {# notification non lue} other {# notifications non lues}}" +counter.assignated_courses: "{n, plural, one {# parcours récent assigné} other {# parcours récents assignés}}" +counter.assignated_actions: "{n, plural, one {# action assignée} other {# actions assignées}}" +counter.assignated_evaluations: "{n, plural, one {# évaluation assignée} other {# évaluations assignées}}" +counter.alert_tasks: "{n, plural, one {# tâche en rappel} other {# tâches en rappel}}" +counter.warning_tasks: "{n, plural, one {# tâche à échéance} other {# tâches à échéance}}" +emergency: "Urgent" +confidential: "Confidentiel" +automatic_notification: "Notification automatique" +widget.news.title: "Actualités" +widget.news.readMore: "Lire la suite" +widget.news.date: "Date" +widget.news.none: "Aucune actualité" absence: You are listed as absent, as of {date, date, short}: Votre absence est indiquée à partir du {date, date, short} diff --git a/src/Bundle/ChillMainBundle/translations/messages+intl-icu.nl.yaml b/src/Bundle/ChillMainBundle/translations/messages+intl-icu.nl.yaml new file mode 100644 index 000000000..d95a19aab --- /dev/null +++ b/src/Bundle/ChillMainBundle/translations/messages+intl-icu.nl.yaml @@ -0,0 +1,155 @@ +years_old: >- + {age, plural, + one {# jaar} + many {# jaar} + other {# jaar} + } + +user_group: + with_count_users: >- + {count, plural, + =0 {Geen lid} + one {1 gebruiker} + many {# gebruikers} + other {# gebruikers} + } + user_removed: Gebruiker {user} is succesvol verwijderd uit groep {user_group} + user_added: Gebruiker {user} is succesvol toegevoegd aan groep {user_group} + label_related_to_user_job: Groep {job} (Beroepsgroep) + +notification: + My notifications with counter: >- + {nb, plural, + =0 {Mijn meldingen} + one {# melding} + few {# meldingen} + other {# meldingen} + } + + counter total notifications: >- + {total, plural, + =0 {Geen melding} + one {# melding} + few {# meldingen} + other {# meldingen} + } + + counter unread notifications: >- + {unread, plural, + =0 {Geen ongelezen} + one {# ongelezen} + few {# ongelezen} + other {# ongelezen} + } + counter comments: >- + {nb, plural, + =0 {Geen opmerking} + one {# opmerking} + few {# opmerkingen} + other {# opmerkingen} + } + +daily_notifications: >- + {notification_count, plural, + =1 {Hier is uw melding van de dag:} + other {Hier zijn uw # meldingen van de dag:} + } + +workflow: + My workflows with counter: >- + {wc, plural, + =0 {Mijn workflows} + one {# workflow} + few {# workflows} + other {# workflows} + } + signature: + signed_statement: 'Handtekening toegepast op {datetime, date, short} om {datetime, time, short}' + rejected_statement: 'Handtekening geweigerd op {datetime, date, short} om {datetime, time, short}' + canceled_statement: 'Handtekening geannuleerd op {datetime, date, short} om {datetime, time, short}' + On hold by: In afwachting door {by} + signature_required_title: >- + {nb_signatures, plural, + =0 {Geen handtekening gevraagd} + one {Handtekening gevraagd} + other {Handtekeningen gevraagd} + } + signatures_title: >- + {nb_signatures, plural, + =0 {Geen handtekening} + one {Handtekening} + other {Handtekeningen} + } + pending_signatures: >- + {nb_signatures, plural, + =0 {Geen handtekening gevraagd} + one {Eén handtekening gevraagd} + other {# handtekeningen gevraagd} + } + send_external_message: + document_available_until: De link is geldig tot {expiration, date, long} om {expiration, time, short}. + explanation: '{sender} stuurt u documenten.' + button_content: 'Documenten bekijken die zijn verzonden door {sender}' + confidentiality: Wij vestigen uw aandacht op het feit dat deze documenten vertrouwelijk zijn. + see_doc_action_description: 'Vertrouwelijke documenten bekijken die zijn verzonden door {sender}' + + external_views: + title: >- + {numberOfSends, plural, + =0 {In afwachting van raadpleging} + =1 {In afwachting van raadpleging} + other {In afwachting van raadplegingen} + } + last_view_at: Laatst bekeken op {at, date, long} om {at, time, short} + number_of_views: >- + {numberOfViews, plural, + =0 {De deling is nooit bekeken} + =1 {De deling is één keer bekeken} + other {De deling is # keer bekeken} + } + public_link: + shared_explanation_until_remaining: >- + Deze deling is actief tot {expireAt, date, long} om {expireAt, time, short}. {viewsCount, plural, + =0 {Deze deling is nog niet bekeken} + one {Deze deling is één keer bekeken} + other {Deze deling is # keer bekeken} + }, {viewsRemaining, plural, + =0 {er zijn geen weergaven meer mogelijk.} + one {er is nog één weergave mogelijk.} + other {er zijn nog # weergaven mogelijk.} + } + +duration: + minute: >- + {m, plural, + =0 {Geen duur} + one {# minuut} + few {# minuten} + other {# minuten} + } + hour: >- + {h, plural, + =0 {Geen duur} + one {# uur} + few {# uur} + other {# uur} + } + day: >- + {d, plural, + =0 {Geen duur} + one {# dag} + few {# dagen} + other {# dagen} + } + +filter_order: + by_date: + From: Vanaf {from_date, date, long} + To: Tot {to_date, date, long} + By: Filteren op + Search: Zoeken in de lijst + By date: Filteren op datum + search_box: Filteren op inhoud + +absence: + You are listed as absent, as of {date, date, short}: Uw afwezigheid is aangegeven vanaf {date, date, short} diff --git a/src/Bundle/ChillMainBundle/translations/messages.fr.yml b/src/Bundle/ChillMainBundle/translations/messages.fr.yml index 0430b72c6..f79b93500 100644 --- a/src/Bundle/ChillMainBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillMainBundle/translations/messages.fr.yml @@ -54,8 +54,12 @@ user: title: Mon profil Profile successfully updated!: Votre profil a été mis à jour! no job: Pas de métier assigné - no scope: Pas de cercle assigné + no scope: Pas de service assigné notification_preferences: Préférences pour mes notifications + locale: + label: Langue de communication + help: Langue utilisée pour les notifications par email et autres communications. + placeholder: Choisissez une langue user_group: inactive: Inactif @@ -102,9 +106,9 @@ createdAt: Créé le createdBy: Créé par #elements used in software -centers: centres -Centers: Centres -center: centre +centers: territoires +Centers: Territoires +center: territoire comment: commentaire Comment: Commentaire Comments: Commentaires @@ -227,12 +231,12 @@ Location Menu: Localisations et types de localisation Management of location: Gestion des localisations et types de localisation #admin section for center's administration -Create a new center: Créer un nouveau centre -Center list: Liste des centres -Center edit: Édition d'un centre -Center creation: Création d'un centre -New center: Nouveau centre -Center: Centre +Create a new center: Créer une nouveau territoire +Center list: Liste des territoires +Center edit: Édition d'un territoire +Center creation: Création d'un territoire +New center: Nouveau territoire +Center: Territoire #admin section for permissions group Permissions group list: Groupes de permissions @@ -246,15 +250,15 @@ New permission group: Nouveau groupe de permissions PermissionsGroup "%name%" edit: Modification du groupe de permission '%name%' Role: Rôle Choose amongst roles: Choisir un rôle -Choose amongst scopes: Choisir un cercle +Choose amongst scopes: Choisir un service Add permission: Ajouter les permissions This group does not provide any permission: Ce groupe n'attribue aucune permission The role '%role%' has been removed: Le rôle "%role%" a été enlevé de ce groupe de permission -The role '%role%' on circle '%scope%' has been removed: Le rôle "%role%" sur le cercle "%scope%" a été enlevé de ce groupe de permission +The role '%role%' on circle '%scope%' has been removed: Le rôle "%role%" sur le service "%scope%" a été enlevé de ce groupe de permission Unclassified: Non classifié -Help to pick role and scope: Certains rôles ne nécessitent pas de cercle. -The role need scope: Ce rôle nécessite un cercle. -The role does not need scope: Ce rôle ne nécessite pas de cercle ! +Help to pick role and scope: Certains rôles ne nécessitent pas de service. +The role need scope: Ce rôle nécessite un service. +The role does not need scope: Ce rôle ne nécessite pas de service ! #admin section for users User configuration: Gestion des utilisateurs @@ -270,7 +274,7 @@ Grant new permissions: Ajout de permissions Add a new groupCenter: Ajout de permissions The permissions have been successfully added to the user: Les permissions ont été accordées à l'utilisateur The permissions where removed.: Les permissions ont été enlevées. -Center & groups: Centre et groupes +Center & groups: Territoire et groupes User %username%: Utilisateur %username% Add a new user: Ajouter un nouvel utilisateur The permissions have been added: Les permissions ont été ajoutées @@ -280,13 +284,13 @@ Back to the user edition: Retour au formulaire d'édition Password successfully updated!: Mot de passe mis à jour Flags: Drapeaux Main location: Localisation principale -Main scope: Cercle -Main center: Centre +Main scope: Service +Main center: Territoire user job: Métier de l'utilisateur Job: Métier Jobs: Métiers -Choose a main center: Choisir un centre -Choose a main scope: Choisir un cercle +Choose a main center: Choisir un territoire +Choose a main scope: Choisir un service choose a job: Choisir un métier choose a location: Choisir une localisation @@ -302,12 +306,12 @@ Current location successfully updated: Localisation actuelle mise à jour Pick a location: Choisir un lieu #admin section for circles (old: scopes) -List circles: Cercles -New circle: Nouveau cercle -Circle: Cercle -Circle edit: Modification du cercle -Circle creation: Création d'un cercle -Create a new circle: Créer un nouveau cercle +List circles: Services +New circle: Nouveau service +Circle: Service +Circle edit: Modification du service +Circle creation: Création d'un service +Create a new circle: Créer un nouveau service #admin section for location Location: Localisation @@ -347,9 +351,9 @@ Country list: Liste des pays Country code: Code du pays # circles / scopes -Choose the circle: Choisir le cercle -Scope: Cercle -Scopes: Cercles +Choose the circle: Choisir le service +Scope: Service +Scopes: Services #export @@ -357,14 +361,14 @@ Scopes: Cercles Exports list: Liste des exports Create an export: Créer un export #export creation step 'center' : pick a center -Pick centers: Choisir les centres -Pick a center: Choisir un centre -The export will contains only data from the picked centers.: L'export ne contiendra que les données des centres choisis. -This will eventually restrict your possibilities in filtering the data.: Les possibilités de filtrages seront adaptées aux droits de consultation pour les centres choisis. +Pick centers: Choisir les territoires +Pick a center: Choisir un territoire +The export will contains only data from the picked centers.: L'export ne contiendra que les données des territoires choisis. +This will eventually restrict your possibilities in filtering the data.: Les possibilités de filtrages seront adaptées aux droits de consultation pour les territoires choisis. Go to export options: Vers la préparation de l'export -Pick aggregated centers: Regroupement de centres -uncheck all centers: Désélectionner tous les centres -check all centers: Sélectionner tous les centres +Pick aggregated centers: Regroupement de territoires +uncheck all centers: Désélectionner tous les territoires +check all centers: Sélectionner tous les territoires # export creation step 'export' : choose aggregators, filtering and formatter Formatter: Mise en forme Choose the formatter: Choisissez le format d'export voulu. @@ -510,10 +514,10 @@ crud: title_edit: Modifier un regroupement center: index: - title: Liste des centres - add_new: Ajouter un centre - title_new: Nouveau centre - title_edit: Modifier un centre + title: Liste des territoires + add_new: Ajouter un territoire + title_new: Nouveau territoire + title_edit: Modifier un territoire news_item: index: title: Liste des actualités @@ -668,8 +672,21 @@ workflow: reject_are_you_sure: Êtes-vous sûr de vouloir rejeter la signature de %signer% waiting_for: En attente de modification de l'état de la signature + notification: + title: + attention_needed: "Attention requise dans le workflow %workflow% pour %title%" + new_step: "Nouvelle étape dans le workflow %workflow% (%place%) pour %title%" + content: + new_step_reached: "Une nouvelle étape a été atteinte dans le workflow %workflow%." + workflow_title: "Titre du workflow : %title%" + validation_needed: "Votre validation est nécessaire pour cette étape." + view_workflow: "Vous pouvez consulter le workflow ici :" + regards: "Cordialement," + attachments: title: Pièces jointes + no_attachment: Aucune pièce jointe + Add_an_attachment: Ajouter une pièce jointe wait: title: En attente de traitement @@ -745,7 +762,22 @@ notification: greeting: "Bonjour %user%" intro: "Vous avez reçu %notification_count% nouvelle(s) notification(s)." view_notification: "Vous pouvez visualiser la notification et y répondre ici:" - signature: "Le logiciel Chill" + signature: "L'équipe Chill" + +daily_notifications: "{1}Vous avez 1 nouvelle notification.|]1,Inf[Vous avez %notification_count% nouvelles notifications." + +docgen: + failure_email: + "The generation of a document failed": "La génération d'un document a échoué" + "The generation of the document %template_name% failed": "La génération du document %template_name% a échoué" + "Forward this email to your administrator for solving": "Transmettez cet email à votre administrateur pour résolution" + "References": "Références" + "The following errors were encoutered": "Les erreurs suivantes ont été rencontrées" + data_dump_email: + subject: "Export de données disponible" + "Dear": "Cher utilisateur," + "data_dump_ready_and_attached": "Votre export de données est prêt et joint à cet email." + "filename": "Nom du fichier : %filename%" CHILL_MAIN_COMPOSE_EXPORT: Exécuter des exports et les sauvegarder CHILL_MAIN_GENERATE_SAVED_EXPORT: Exécuter et modifier des exports préalablement sauvegardés @@ -860,7 +892,7 @@ absence: admin: users: export_list_csv: Liste des utilisateurs (format CSV) - export_permissions_csv: Association utilisateurs - groupes de permissions - centre (format CSV) + export_permissions_csv: Association utilisateurs - groupes de permissions - territoire (format CSV) export: id: Identifiant username: Nom d'utilisateur @@ -870,8 +902,8 @@ admin: civility_abbreviation: Abbréviation civilité civility_name: Civilité label: Label - mainCenter_id: Identifiant centre principal - mainCenter_name: Centre principal + mainCenter_id: Identifiant territoire principal + mainCenter_name: Territoire principal mainScope_id: Identifiant service principal mainScope_name: Service principal userJob_id: Identifiant métier @@ -881,8 +913,8 @@ admin: mainLocation_id: Identifiant localisation principale mainLocation_name: Localisation principale absenceStart: Absent à partir du - center_id: Identifiant du centre - center_name: Centre + center_id: Identifiant du territoire + center_name: Territoire permissionsGroup_id: Identifiant du groupe de permissions permissionsGroup_name: Groupe de permissions job_scope_histories: @@ -942,11 +974,12 @@ onthefly: thirdparty: Détails du tiers file_person: Ouvrir la fiche de l'usager file_thirdparty: Voir le Tiers + file_default: Voir edit: person: Modifier un usager thirdparty: Modifier un tiers create: - button: Créer {q} + button: Créer "q" title: default: Création d'un nouvel usager ou d'un tiers professionnel person: Création d'un nouvel usager @@ -973,3 +1006,37 @@ multiselect: editor: switch_to_simple: Éditeur simple switch_to_complex: Éditeur riche +action: + actions: Actions + show: Voir + edit: Modifier + create: Créer + remove: Enlever + delete: Supprimer + save: Enregistrer + valid: Valider + valid_and_see: Valider et voir + add: Ajouter + show_modal: Ouvrir une modale + ok: OK + cancel: Annuler + close: Fermer + back: Retour + check_all: cocher tout + reset: réinitialiser + redirect: + person: Quitter la page et ouvrir la fiche de l'usager + thirdparty: Quitter la page et voir le tiers + refresh: Rafraîchir + addContact: Ajouter un contact + +nav: + next: "Suivant" + previous: "Précédent" + top: "Haut" + bottom: "Bas" + + + +login_page: + logo_alt: "Logo de Chill" diff --git a/src/Bundle/ChillMainBundle/translations/messages.nl.yml b/src/Bundle/ChillMainBundle/translations/messages.nl.yml index 22bb6a50e..4cdf4f8a2 100644 --- a/src/Bundle/ChillMainBundle/translations/messages.nl.yml +++ b/src/Bundle/ChillMainBundle/translations/messages.nl.yml @@ -1,55 +1,93 @@ -"This program is free software: you can redistribute it and/or modify it under the terms of the <strong>GNU Affero General Public License</strong>": "Ce programme est un logiciel libre: vous pouvez le redistribuer et/ou le modifier selon les termes de la licence <strong>GNU Affero GPL</strong>" +"This program is free software: you can redistribute it and/or modify it under the terms of the <strong>GNU Affero General Public License</strong>": "Dit programma is vrije software: u kunt het herdistribueren en/of wijzigen onder de voorwaarden van de <strong>GNU Affero GPL</strong>-licentie" User manual: Gebruikershandleiding Search: Zoeken -"Search persons, ...": "Zoek personen, ..." -Person name: Naam / voornaam persoon +"Search persons, ...": "Gebruikers zoeken, ..." +Person name: Naam / Voornaam van de gebruiker Login: Inloggen Logout: Uitloggen -Bad credentials.: Een verkeerd wachtwoord of gebruikersnaam werd opgegeven. -Invalid CSRF token.: Uw sessie is verlopen. +Bad credentials.: Het wachtwoord en de gebruikersnaam komen niet overeen. +Invalid CSRF token.: Uw sessie is verlopen of ongeldig geworden. Username: Gebruikersnaam username: gebruikersnaam Password: Wachtwoord Welcome to %installation_name%: Welkom bij %installation_name% Login to %installation_name%: Inloggen op %installation_name% -Enabled: Ingeschakeld -enabled: ingeschakeld -disabled: uitgeschakeld -Disabled: Uitgeschakeld -Id: Id -Homepage: Hoofdpagina +Enabled: Geactiveerd +enabled: geactiveerd +disabled: gedeactiveerd +Disabled: Gedeactiveerd +Id: identificatie +Homepage: Startpagina Welcome: Welkom Export Menu: Export -Admin Menu: Admin menu +Admin Menu: Beheerdersmenu Details: Details yes: ja no: nee valid: geldig Valid: Geldig -Not valid: Ongeldig -not valid: ongeldig +Not valid: Niet geldig +not valid: niet geldig Confirm: Bevestigen Cancel: Annuleren Save: Opslaan This form contains errors: Dit formulier bevat fouten -Choose an user: Kies een gebruiker -"You are going to leave a page with unsubmitted data. Are you sure you want to leave ?": "U verlaat een pagina waarvan de gegevens niet werden opgeslagen. Bent u zeker deze pagina te willen verlaten?" +Choose an user: Gebruiker kiezen +"You are going to leave a page with unsubmitted data. Are you sure you want to leave ?": "U gaat de pagina verlaten terwijl gegevens niet zijn opgeslagen. Weet u zeker dat u wilt vertrekken?" No value: Geen informatie Last updated by: Laatste update door on: "op " Last updated on: Laatste update op by_user: "door " -lifecycleUpdate: Updates en creatie gebeurtenissen -address_fields: Gegevens gelinked aan het adres +lifecycleUpdate: Gebeurtenissen van aanmaak en update +address_fields: Gegevens gerelateerd aan het adres Datas: Gegevens No title: Geen titel -User profile: Mijn gebruikersprofiel -Phonenumber successfully updated!: Telefoonnummer bijgewerkt! +icon: pictogram +See: Bekijken +Name: Naam +Label: Naam +user: + current_user: Huidige gebruiker + profile: + title: Mijn profiel + Profile successfully updated!: Uw profiel is bijgewerkt! + no job: Geen toegewezen beroep + no scope: Geen toegewezen dienst + notification_preferences: Voorkeuren voor mijn meldingen + locale: + label: Communicatietaal + help: Taal gebruikt voor e-mailmeldingen en andere communicatie. + placeholder: Kies een taal + +user_group: + inactive: Inactief + with_users: Leden + no_users: Geen gekoppelde gebruiker + no_user_groups: Geen gebruikersgroep + no_admin_users: Geen beheerder + Label: Naam van de groep + BackgroundColor: Achtergrondkleur van de badge + ForegroundColor: Letterkleur van de badge + ExcludeKey: Uitsluitingssleutel + ExcludeKeyHelp: Indien relevant, sluiten groepen met dezelfde uitsluitingssleutel elkaar wederzijds uit. + Users: Leden van de groep + adminUsers: Beheerders van de groep + adminUsersHelp: Groepsbeheerders kunnen leden toevoegen of verwijderen uit de groep. + my_groups: Mijn groepen + me_and: Ik en + me_only: Alleen ik + me: Ik + append_users: Gebruikers toevoegen + Email: E-mailadres van de groep + EmailHelp: Indien ingevuld, worden aanvullende meldingen verzonden naar het e-mailadres van de groep (workflow in afwachting, enz.) + +inactive: inactief Edit: Bewerken -Update: Updaten -Back to the list: Terug naar overzicht +Update: Bijwerken +Back to the list: Terug naar de lijst #interval Years: Jaren @@ -61,17 +99,26 @@ Until %date%: Tot %date% until %date%: tot %date% Since: Sinds Until: Tot + +updatedAt: Bijgewerkt op +updatedBy: Bijgewerkt door +createdAt: Aangemaakt op +createdBy: Aangemaakt door + #elements used in software -centers: centra -Centers: Centra -comment: opmerkingen -Comment: Opmerkingen -Pinned comment: Gepinde opmerking -Any comment: Geen opmerkingen -Read more: Meer lezen +centers: territoria +Centers: Territoria +center: territorium +comment: opmerking +Comment: Opmerking +Comments: Opmerkingen +Pinned comment: Vastgezette opmerking +Any comment: Geen opmerking +(more...): (vervolg...) # comment embeddable -No comment associated: Geen opmerkingen +No comment associated: Geen opmerking +private comment: Privé notities #pagination Previous: Vorige @@ -82,136 +129,195 @@ Street address1: Adres regel 1 Street address2: Adres regel 2 Postal code: Postcode Valid from: Geldig vanaf -Choose a postal code: Kies een postcode +Choose a postal code: Postcode kiezen address: - address_homeless: Betreft dit een domicilie adres ? - real address: Domicilie adres - consider homeless: Dit adres is onvolledig + address_homeless: Is het adres een vaste woonplaats? + real address: Adres van een woonplaats + consider homeless: Dit adres is onvolledig + add_an_address_title: Adres aanmaken + edit_an_address_title: Adres bewerken + create_a_new_address: Nieuw adres aanmaken + edit_address: Adres bewerken + select_an_address_title: Adres selecteren + fill_an_address: Adres aanvullen + select_country: Land kiezen + country: Land + select_city: Plaats kiezen + city: Plaats + other_city: Andere plaats + select_address: Adres kiezen + address: Adres + other_address: Ander adres + create_address: Adres onbekend. Klik hier om een nieuw adres aan te maken + isNoAddress: Geen volledig adres + isConfidential: Vertrouwelijk adres + street: Straatnaam + streetNumber: Nummer + floor: Verdieping + corridor: Gang + steps: Trap + flat: Appartement + buildingName: Wooncomplex + extra: Adrestoevoeging + distribution: Cedex + create_postal_code: Plaats onbekend. Klik hier om een nieuwe plaats aan te maken + postalCode_name: Naam + postalCode_code: Postcode + date: Datum van het nieuwe adres + valid_from: Het adres is geldig vanaf + valid_to: Het adres is geldig tot + back_to_the_list: Terug naar de lijst + loading: laden... + address_suggestions: Adressuggestie + address_new_success: Het nieuwe adres is opgeslagen. + address_edit_success: Het adres is bijgewerkt. + wait_redirection: De pagina wordt doorverwezen. + not_yet_address: Er is nog geen adres. Klik op '+ Adres aanmaken' + use_this_address: Dit adres gebruiken + household: + move_date: Verhuisdatum + address more: - floor: verd. - corridor: gang - steps: trap - flat: appart. - buildingName: residentie - extra: "" - distribution: cedex -Create a new address: Maak een nieuw adres aan -Create an address: Maak een adres aan -Update address: Bewerk het adres + floor: verd + corridor: gang + steps: trap + flat: app + buildingName: complex + extra: "" + distribution: cedex +Create a new address: Nieuw adres aanmaken +Create an address: Adres aanmaken +Update address: Adres bewerken City or postal code: Stad of postcode # contact Part of the phonenumber: Deel van het telefoonnummer #serach -Your search is empty. Please provide search terms.: De zoekopdracht is leeg. Gelieve een zoekterm op te geven. -The domain %domain% is unknow. Please check your search.: Het zoekgebied "%domain%" is ongekend. Gelieve uw zoekopdracht te verifiëren. +Your search is empty. Please provide search terms.: De zoekopdracht is leeg. Geef zoektermen op. +The domain %domain% is unknow. Please check your search.: Het zoekdomein "%domain%" is onbekend. Controleer uw zoekopdracht. Invalid terms: Ongeldige zoekopdracht -You should not have more than one domain.: Gelieve slechts één zoekgebied op te geven. +You should not have more than one domain.: U mag niet meer dan één zoekdomein hebben. #used for page title -Search %pattern%: Zoek "%pattern%" +Search %pattern%: Zoeken naar "%pattern%" Results %start%-%end% of %total%: Resultaten %start%-%end% van %total% -See all results: Alle resultaten zien -Advanced search: Geavanceerde zoekopdracht +See all results: Alle resultaten bekijken +Advanced search: Geavanceerd zoeken results: resultaten # timeline -Global timeline: Globale tijdslijn +Global timeline: Globale geschiedenis #admin Create: Aanmaken show: bekijken Show: Bekijken edit: bewerken -Main admin menu: Hoofdmenu admin +Main admin menu: Hoofdmenu beheer Actions: Acties Users and permissions: Gebruikers en rechten -Location and location type: Vestigingen en vestiging types +Location and location type: Locaties en locatietypes +Back to the admin: Beheermenu +"Administration interface": Beheerinterface +Welcome to the admin section !: > + Welkom in de beheerinterface! #permissions -Permissions Menu: Beheer rechten -Permissions management of your chill installation: Beheer rechten voor deze Chill installatie. +Permissions Menu: Rechtenbeheer +Permissions management of your chill installation: Rechtenbeheer van uw instantie #location -Location Menu: Vestigingen en vestiging types -Management of location: Beheer vestigingen en vestiging types - -#admin section -"Administration interface": Admin paneel -Welcome to the admin section !: > - Welkom op het admin paneel ! +Location Menu: Locaties en locatietypes +Management of location: Beheer van locaties en locatietypes #admin section for center's administration -Create a new center: Maak een nieuw centrum aan -Center list: Overzicht centra -Center edit: Bewerk een centrum -Center creation: Aanmaak centrum -New center: Nieuw centrum -Center: Centrum +Create a new center: Nieuw territorium aanmaken +Center list: Lijst van territoria +Center edit: Territorium bewerken +Center creation: Territorium aanmaken +New center: Nieuw territorium +Center: Territorium #admin section for permissions group -Permissions group list: Groepsrechten -Create a new permissions group: Maak een nieuw groepsrecht aan -Permission group "%name%": Groepsrecht "%name%" -Grant those permissions: Rechten toekennen -Which implies: Wat impliceert -Permission group: Groepsrecht -Permissionsgroup: Groepsrecht -New permission group: Nieuw groepsrecht -PermissionsGroup "%name%" edit: Bewerk groepsrecht '%name%' +Permissions group list: Rechtengroepen +Create a new permissions group: Nieuwe rechtengroep aanmaken +Permission group "%name%": Rechtengroep "%name%" +Grant those permissions: Kent deze rechten toe +Which implies: Wat inhoudt +Permission group: Rechtengroep +Permissionsgroup: Rechtengroep +New permission group: Nieuwe rechtengroep +PermissionsGroup "%name%" edit: Rechtengroep '%name%' bewerken Role: Rol -Choose amongst roles: Kies een rol +Choose amongst roles: Rol kiezen +Choose amongst scopes: Dienst kiezen Add permission: Rechten toevoegen -This group does not provide any permission: Deze groep kent geen rechten toe. -The role '%role%' has been removed: De rol "%role%" werd verwijdert uit dit groepsrecht. -The role '%role%' on circle '%scope%' has been removed: De rol "%role%" binnen de cirkel "%scope%" werd verwijdert uit dit groepsrecht. +This group does not provide any permission: Deze groep kent geen rechten toe +The role '%role%' has been removed: De rol "%role%" is verwijderd uit deze rechtengroep +The role '%role%' on circle '%scope%' has been removed: De rol "%role%" op dienst "%scope%" is verwijderd uit deze rechtengroep +Unclassified: Niet geclassificeerd +Help to pick role and scope: Sommige rollen vereisen geen dienst. +The role need scope: Deze rol vereist een dienst. +The role does not need scope: Deze rol vereist geen dienst! #admin section for users +User configuration: Gebruikersbeheer User edit: Gebruiker bewerken -User'status: Gebruikersstatuut -Disabled, the user is not allowed to login: Uitgeschakeld, de gebruiker krijgt geen toestemming om in te loggen. -Enabled, the user is active: Ingeschakeld, de gebruiker kan zich inloggen. -Edit password: Wachtwoord aanpassen +User'status: Status van de gebruiker +Disabled, the user is not allowed to login: Gedeactiveerd, de gebruiker mag niet inloggen +Enabled, the user is active: Actief, de gebruiker kan inloggen +Edit password: Wachtwoord bewerken Repeat the password: Wachtwoord herhalen -Permissions granted: Rechten toegekend -Any permissions granted to this user: Geen enkele rechten werden toegekend aan deze gebruiker +Permissions granted: Toegekende rechten +Any permissions granted to this user: Geen rechten toegekend aan deze gebruiker Grant new permissions: Rechten toevoegen Add a new groupCenter: Rechten toevoegen -The permissions have been successfully added to the user: De rechten werden toegekend aan de gebruiker -The permissions where removed.: De rechten werden verwijdert voor dze gebruiker -Center & groups: Centra en groepen +The permissions have been successfully added to the user: De rechten zijn toegekend aan de gebruiker +The permissions where removed.: De rechten zijn verwijderd. +Center & groups: Territorium en groepen User %username%: Gebruiker %username% Add a new user: Nieuwe gebruiker toevoegen -The permissions have been added: De rechten werden toegevoegd -Edit password for %username%: Wachtwoord voor %username% aanpassen -Change password: Wachtwoord aanpassen -Back to the user edition: Terugkeren naar bewerkingsformulier -Password successfully updated!: Wachtwoord opgeslagen +The permissions have been added: De rechten zijn toegevoegd +Edit password for %username%: Wachtwoord bewerken voor %username% +Change password: Wachtwoord wijzigen +Back to the user edition: Terug naar bewerkingsformulier +Password successfully updated!: Wachtwoord bijgewerkt Flags: Vlaggen +Main location: Hoofdlocatie +Main scope: Dienst +Main center: Territorium +user job: Beroep van de gebruiker +Job: Beroep +Jobs: Beroepen +Choose a main center: Territorium kiezen +Choose a main scope: Dienst kiezen +choose a job: Beroep kiezen +choose a location: Locatie kiezen # admin section for users jobs User jobs: Beroepen # user page for current location -Current location: Huidige vestiging -Edit my current location: Mijn huidige vestiging bewerken -Change current location: CMijn huidige vestiging vernanderen -Set a location: Een vestiging instellen -Current location successfully updated: Huidige vestiging werd opgeslagen -Pick a location: Kies een vestiging. +Current location: Huidige locatie +Edit my current location: Mijn huidige locatie bewerken +Change current location: Huidige locatie wijzigen +Set a location: Locatie aangeven +Current location successfully updated: Huidige locatie bijgewerkt +Pick a location: Locatie kiezen #admin section for circles (old: scopes) -List circles: Cirkels -New circle: Nieuwe cirkel -Circle: Cirkel -Circle edit: Cirkel bewerken -Circle creation: Cirkel aanmaken -Create a new circle: Nieuwe cirkel aanmaken +List circles: Diensten +New circle: Nieuwe dienst +Circle: Dienst +Circle edit: Dienst bewerken +Circle creation: Dienst aanmaken +Create a new circle: Nieuwe dienst aanmaken #admin section for location -Location: Vestigingen -Location type list: Vestiging types -Create a new location type: Nieuwe type vestiging aanmaken +Location: Locatie +pick location: Locatie +Location type list: Lijst van locatietypes +Create a new location type: Nieuw locatietype aanmaken Available for users: Beschikbaar voor gebruikers Editable by users: Bewerkbaar door gebruikers Address required: Adres vereist? @@ -219,251 +325,686 @@ Contact data: Contactgegevens? optional: optioneel required: vereist never: nooit -Create a new location: Nieuwe vestiging aanmaken -Location list: Overzicht vestigingen -Location type: Type vestiging -Phonenumber1: Telefoonnummer 1 -Phonenumber2: Telefoonnummer 2 -Configure location and location type: Configureer vestigingen en types vestiging -Default for: Standaard vestiging +Create a new location: Nieuwe locatie aanmaken +Location list: Lijst van locaties +Location type: Locatietype +Pick a location type: Locatietype kiezen +Phonenumber1: Telefoonnummer +Phonenumber2: Ander telefoonnummer +Location configuration: Configuratie van locaties +Default for: Standaard locatietype voor none: geen person: gebruiker -thirdparty: externe partner +thirdparty: derde +civility: aanspreekvorm + +#admin section for civility +abbreviation: afkorting + +#admin section for language and country +Language and countries menu: Menu Talen & Landen +Languages and countries: Talen & Landen +Management of languages and countries: Beheer van talen & landen +Language configuration: Configuratie van talen & landen +Language list: Lijst van talen +Country list: Lijst van landen +Country code: Landcode # circles / scopes -Choose the circle: Kies een cirkel +Choose the circle: Dienst kiezen +Scope: Dienst Scopes: Diensten #export # export creation step 0 : list of exports -Exports list: Overzicht rapporten -Create an export: Maak een rapport aan +Exports list: Lijst van exports +Create an export: Export aanmaken #export creation step 'center' : pick a center -Pick centers: Kies centra -Pick a center: Kies een centrum -The export will contains only data from the picked centers.: het rapport zal enkel data bevatten voor het geselecteerde centrum. -This will eventually restrict your possibilities in filtering the data.: De filterkeuzes zullen worden aangepast aan de rechten tot raadpleging van de geselecteerde centra. -Go to export options: Ga naar de rapport opties -Pick aggregated centers: Hergroepering centra +Pick centers: Territoria kiezen +Pick a center: Territorium kiezen +The export will contains only data from the picked centers.: De export bevat alleen gegevens van de gekozen territoria. +This will eventually restrict your possibilities in filtering the data.: De filtermogelijkheden worden aangepast aan de raadplegingsrechten voor de gekozen territoria. +Go to export options: Naar de voorbereiding van de export +Pick aggregated centers: Groepering van territoria +uncheck all centers: Alle territoria deselecteren +check all centers: Alle territoria selecteren # export creation step 'export' : choose aggregators, filtering and formatter -Formatter: Formateren -Choose the formatter: Kies het gewenste formaat voor dit rapport. -Export parameters: Rapport parameters +Formatter: Opmaak +Choose the formatter: Kies het gewenste exportformaat. +Export parameters: Exportparameters Filters: Filters -Aggregators: Aggregaten -Go to formatter options: Naar de formateeropties -Choose a format: Kies een formaat +Aggregators: Aggregators +Go to formatter options: Naar de opmaakmogelijkheden +Choose a format: Formaat kiezen #export creation step 'formatter' : choose formatter option Generate the report: Rapport genereren -No options availables. Your report is fully configured.: Geen beschikbare opties. Dit rapport werd volledig geconfigureerd. -Ungrouped exports: Overige expor +No options availables. Your report is fully configured.: Geen opties beschikbaar. Uw rapport is al geconfigureerd. +Ungrouped exports: Andere exports #export download -Download export: Downloaden van rapport -Waiting for your report: Wachten op je rapport -Download your report: Télécharger votre rapport -Problem during download: Problème durant le téléchargement +Download export: Rapport downloaden +Waiting for your report: Wachten op uw rapport +Download your report: Uw rapport downloaden +Problem during download: Probleem tijdens het downloaden # sans valeur -without data: sans valeur +without data: zonder waarde #CSV List Formatter -Add a number on first column: La première colonne est un numéro -Number: Numéro +Add a number on first column: De eerste kolom is een nummer +Number: Nummer # the label which appears in the UI -CSV vertical list: Liste verticale au format CSV -CSV horizontal list: Liste horizontale au format CSV -Spreadsheet list formatter (.xlsx, .ods): Liste au format tableur (.xlsx, .ods) -Order: Ordre -Position: Position -row: ligne -column: colonne -Comma separated values (CSV): Valeurs séparées par des virgules (CSV - tableur) +CSV vertical list: Verticale lijst in CSV-formaat +CSV horizontal list: Horizontale lijst in CSV-formaat +Spreadsheet list formatter (.xlsx, .ods): Lijst in spreadsheetformaat (.xlsx, .ods) +Order: Volgorde +Position: Positie +row: rij +column: kolom +Comma separated values (CSV): Kommagescheiden waarden (CSV - spreadsheet) # spreadsheet formatter -Choose the format: Choisir le format +Choose the format: Formaat kiezen # select2 -"select2.no_results": Aucun résultat -"select2.error_loading": Erreur de chargement des résultats -"select2.searching": Recherche en cours... +"select2.no_results": Geen resultaat +"select2.error_loading": Fout bij het laden van de resultaten +"select2.searching": Zoeken... # change password -Change my password: Modification du mot de passe -Your actual password: Mot de passe actuel +Change my password: Wachtwoord wijzigen +Your actual password: Huidig wachtwoord # recover password -Forgot your password ?: Mot de passe oublié ? -Recover password: Remplacement du mot de passe -Username or email: Nom d'utilisateur ou email -Request recover: Demande de remplacement -Check your email: Vérifiez votre courriel -An email has been sent to your address. Click on the link inside this email to confirm that you are the owner of this account.: Un courriel a été envoyé à votre adresse. Cliquez sur le lien de cet email pour confirmer que vous êtes bien le propriétaire de ce compte. -You requested to recover your password: Vous avez demandé à renouveler votre mot de passe. -Click on the link below to recover your password: Cliquez sur le lien ci-dessous pour re-générer votre mot de passe -Regards,: Cordialement, -Your administrator: Votre administrateur -Recover your password: Regénération du mot de passe -New password set: Le nouveau mot de passe est enregistré -Your password has been set.: Votre mot de passe a été changé. -Log in with your new password: Connectez-vous avec votre nouveau mot de passe +Forgot your password ?: Wachtwoord vergeten? +Recover password: Wachtwoord vervangen +Username or email: Gebruikersnaam of e-mail +Request recover: Verzoek tot vervanging +Check your email: Controleer uw e-mail +An email has been sent to your address. Click on the link inside this email to confirm that you are the owner of this account.: Er is een e-mail verzonden naar uw adres. Klik op de link in deze e-mail om te bevestigen dat u de eigenaar van dit account bent. +You requested to recover your password: U hebt gevraagd om uw wachtwoord te vernieuwen. +Click on the link below to recover your password: Klik op onderstaande link om uw wachtwoord te hergenereren +Regards,: Met vriendelijke groet, +Your administrator: Uw beheerder +Recover your password: Wachtwoord hergenereren +New password set: Het nieuwe wachtwoord is opgeslagen +Your password has been set.: Uw wachtwoord is gewijzigd. +Log in with your new password: Log in met uw nieuwe wachtwoord # impersonate -Exit impersonation: Retour Administrateur -Impersonate: Incarner l'utilisateur -Impersonate mode: Mode fantôme +Exit impersonation: Terug beheerder +Impersonate: Gebruiker incarneren +Impersonate mode: Fantoom-modus crud: - # general items - new: - button_action_form: Créer - link_edit: Modifier - save_and_close: Créer & fermer - save_and_show: Créer & voir - save_and_new: Créer & nouveau - success: Les données ont été créées - edit: - button_action_form: Enregistrer - back_to_view: Voir - save_and_close: Enregistrer & fermer - save_and_show: Enregistrer & voir - success: Les données ont été modifiées - delete: - success: Les données ont été supprimées - link_to_form: Supprimer - default: - success: Les données ont été enregistrées - view: - link_duplicate: Dupliquer - admin_user: - index: - title: Utilisateurs - add_new: Créer - admin_user_job: - index: - title: Métiers - add_new: Créer - title_new: Nouveau métier - title_edit: Modifier un métier - main_location_type: - title_new: Nouveau type de localisation - title_edit: Modifier un type de localisation - main_location: - title_new: Nouvelle localisation - title_edit: Modifier une localisation + # general items + new: + button_action_form: Aanmaken + link_edit: Bewerken + save_and_close: Aanmaken & sluiten + save_and_show: Aanmaken & bekijken + save_and_new: Aanmaken & nieuw + success: De gegevens zijn aangemaakt + edit: + button_action_form: Opslaan + back_to_view: Bekijken + save_and_close: Opslaan & sluiten + save_and_show: Opslaan & bekijken + success: De gegevens zijn gewijzigd + delete: + success: De gegevens zijn verwijderd + link_to_form: Verwijderen + default: + success: De gegevens zijn opgeslagen + view: + link_duplicate: Dupliceren + admin_user: + index: + title: Gebruikers + add_new: Aanmaken + title_edit: Gebruiker bewerken + title_new: Gebruiker aanmaken + admin_user_job: + index: + title: Beroepen + add_new: Aanmaken + title_new: Nieuw beroep + title_edit: Beroep bewerken + admin_user_group: + index: + title: Gebruikersgroepen + add_new: Aanmaken + title_edit: Gebruikersgroep bewerken + title_new: Nieuwe gebruikersgroep + main_location_type: + index: + title: Lijst van locatietypes + add_new: Locatietype toevoegen + title_new: Nieuw locatietype + title_edit: Locatietype bewerken + main_location: + index: + title: Lijst van locaties + add_new: Locatie toevoegen + title_new: Nieuwe locatie + title_edit: Locatie bewerken + main_language: + index: + title: Lijst van talen + add_new: Taal toevoegen + title_new: Nieuwe taal + title_edit: Taal bewerken + main_country: + index: + title: Lijst van landen + add_new: Land toevoegen + title_new: Nieuw land + title_edit: Land bewerken + main_civility: + index: + title: Lijst van aanspreekvormen + add_new: Aanspreekvorm toevoegen + title_new: Nieuwe aanspreekvorm + title_edit: Aanspreekvorm bewerken + regroupment: + index: + title: Lijst van groeperingen + add_new: Groepering toevoegen + title_new: Nieuwe groepering + title_edit: Groepering bewerken + center: + index: + title: Lijst van territoria + add_new: Territorium toevoegen + title_new: Nieuw territorium + title_edit: Territorium bewerken + news_item: + index: + title: Lijst van actualiteiten + add_new: Nieuwe actualiteit aanmaken + title_new: Nieuwe actualiteit + title_view: Actualiteit bekijken + title_edit: Actualiteit bewerken + title_delete: Actualiteit verwijderen + button_delete: Verwijderen + confirm_message_delete: Weet u zeker dat u de actualiteit "%as_string%" wilt verwijderen? + main_gender: + index: + title: Lijst van geslachten + add_new: Geslacht toevoegen + title_new: Nieuw geslacht + title_edit: Geslacht bewerken -No entities: Aucun élément +No entities: Geen element -CHILL_FOO_SEE: Voir un élément -CHILL_FOO_EDIT: Modifier un élément +CHILL_FOO_SEE: Element bekijken +CHILL_FOO_EDIT: Element bewerken +chill_export: Exports (statistieken) #Show templates -Date: Date -By: Par -For: Pour -Created for: Créé pour -Created by: Créé par -Created on: Créé le +Date: Datum +By: Door +For: Voor +Created for: Aangemaakt voor +Created by: Aangemaakt door +Created on: Aangemaakt op # Workflows 💊 -Workflow: Workflow — chemin de décision -Workflow n°%id%: 'Workflow (n°%id%)' +Workflow: Workflow — beslissingspad +Workflow n°%id%: 'Workflow (nr. %id%)' workflow_: Workflow -target: ' (cible)' -Decision: Décision -Join a comment: Laisser un commentaire -Follow workflow: Suivre la décision -Workflow history: Historique de la décision +target: ' (doel)' +Decision: Beslissing +Join a comment: Opmerking achterlaten +Follow workflow: Beslissing volgen +Workflow history: Beslissingsgeschiedenis workflow: - Created by: Créé par - My decision: Ma décision - Next step: Prochaine étape - cc for next steps: Utilisateurs en copie - dest for next steps: Utilisateurs qui valideront la prochaine étape - Freeze: Geler - Freezed: Gelé - freezed document: Le document est gelé - The associated element will be freezed: L'élément associé sera gelé et ne pourra plus être modifié après cette décision. - Finalize: Étape finale - The workflow will be finalized: Le suivi est clôturé lors de cette décision. - No transitions: Aucune transition - Comment added: Commentaire ajouté - This workflow is finalized: Ce suivi est finalisé. - You are not allowed to apply a transition on this workflow: Vous n'êtes pas autorisé à appliquer une décision pour ce suivi - Only those users are allowed: Seuls ces utilisateurs sont autorisés - My workflows: Mes workflows - No workflow: Aucun workflow - subscribed: Workflows suivis - dest: Workflows en attente d'action - cc: Workflows dont je suis en copie - you subscribed to all steps: Vous recevrez une notification à chaque étape - you subscribed to final step: Vous recevrez une notification à l'étape finale - Current step: Étape actuelle - Comment on last change: Commentaire à la transition précédente - Users allowed to apply transition: Utilisateurs pouvant valider cette étape - Users put in Cc: Utilisateurs mis en copie - Workflow deleted with success: Le workflow a été supprimé - Delete workflow ?: Supprimer le workflow ? - Are you sure you want to delete this workflow ?: Êtes-vous sûr·e de vouloir supprimer ce workflow ? - Delete workflow: Supprimer le workflow - Steps is not waiting for transition. Maybe someone apply the transition before you ?: L'étape que vous cherchez a déjà été modifiée par un autre utilisateur. Peut-être quelqu'un a-t-il modifié cette étape avant vous ? - You get access to this step: Vous avez acquis les droits pour appliquer une transition sur ce workflow. - Those users are also granted to apply a transition by using an access key: Ces utilisateurs peuvent également valider cette étape, grâce à un lien d'accès - dest by email: Liens d'autorisation par email - dest by email help: Les adresses email mentionnées ici recevront un lien d'accès. Ce lien d'accès permettra à l'utilisateur de valider cette étape. - Add an email: Ajouter une adresse email - Remove an email: Enlever cette adresse email - Any email: Aucune adresse email - Previous dest without reaction: Workflows clotûrés après action d'un autre utilisateur - Previous workflow without reaction help: Liste des workflows où vous avez été cité comme pouvant réagir à une étape, mais où un autre utilisateur a exécuté une action avant vous. - Previous transitionned: Anciens workflows - Previous workflow transitionned help: Workflows où vous avez exécuté une action. - For: Pour + list: Lijst van gekoppelde workflows + associated: gekoppelde workflow + deleted: Workflow verwijderd + Created by: Aangemaakt door + My decision: Mijn beslissing + Next step: Volgende stap + cc for next steps: Gebruikers in kopie + dest for next steps: Gebruikers die de volgende stap zullen valideren + Freeze: Bevriezen + Freezed: Bevroren + freezed document: Het document is bevroren + The associated element will be freezed: Het gekoppelde element wordt bevroren en kan na deze beslissing niet meer worden gewijzigd. + Finalize: Eindstap + The workflow will be finalized: De opvolging wordt afgesloten bij deze beslissing. + No transitions: Geen transitie + Comment added: Opmerking toegevoegd + This workflow is finalized: Deze opvolging is afgesloten. + You are not allowed to apply a transition on this workflow: U bent niet bevoegd om een beslissing toe te passen voor deze opvolging + Only those users are allowed: Alleen deze gebruikers zijn bevoegd + My workflows: Mijn workflows + No workflow: Geen workflow + subscribed: Gevolgde workflows + cc: Workflows waarvan ik in kopie ben + dest: Workflows in afwachting van actie + you subscribed to all steps: U ontvangt een melding bij elke stap + you subscribed to final step: U ontvangt een melding bij de eindstap + Current step: Huidige stap + Comment on last change: Opmerking bij de vorige transitie + Users allowed to apply transition: Gebruikers die deze stap kunnen valideren + Users put in Cc: Gebruikers in kopie gezet + Workflow deleted with success: De workflow is verwijderd + Delete workflow ?: Workflow verwijderen? + Are you sure you want to delete this workflow ?: Weet u zeker dat u deze workflow wilt verwijderen? + Delete workflow: Workflow verwijderen + Steps is not waiting for transition. Maybe someone apply the transition before you ?: De stap die u zoekt is al gewijzigd door een andere gebruiker. Misschien heeft iemand deze stap voor u gewijzigd? + You get access to this step: U hebt rechten verkregen om een transitie toe te passen op deze workflow. + dest by email: Autorisatielinks per e-mail + dest by email help: De hier vermelde e-mailadressen ontvangen een toegangslink. Deze toegangslink stelt de gebruiker in staat deze stap te valideren. + Add an email: E-mailadres toevoegen + Remove an email: Dit e-mailadres verwijderen + Any email: Geen e-mailadres + Previous dest without reaction: Workflows afgesloten na actie van een andere gebruiker + Previous workflow without reaction help: Lijst van workflows waar u werd genoemd als iemand die op een stap kon reageren, maar waar een andere gebruiker een actie heeft uitgevoerd voor u. + Previous transitionned: Oude workflows + Previous workflow transitionned help: Workflows waar u een actie hebt uitgevoerd. + For: Voor Cc: Cc + At: Op + You must select a next step, pick another decision if no next steps are available: Er is een volgende stap nodig. Kies indien nodig een andere beslissing. + An access key was also sent to those addresses: Een toegangslink is verzonden naar deze adressen + Those users are also granted to apply a transition by using an access key: Deze gebruikers hebben toegang verkregen dankzij de ontvangen link per e-mail + Access link copied: Toegangslink gekopieerd + This link grant any user to apply a transition: De volgende toegangslink maakt het mogelijk een transitie toe te passen + The workflow may be accssed through this link: Een transitie kan worden toegepast op deze workflow dankzij de volgende toegangslink + Put on hold: In wacht zetten + Remove hold: Wacht verwijderen + On hold: In afwachting + Automated transition: Automatische transitie + waiting_for_signature: In afwachting van handtekening + Permissions: Workflows (opvolging van beslissing) + transition_destinee_third_party: Geadresseerde uit externe derden + transition_destinee_third_party_help: Elke geadresseerde ontvangt een beveiligde link per e-mail. + transition_destinee_emails_label: Verzending per e-mail + transition_destinee_add_emails: E-mailadres toevoegen + transition_destinee_remove_emails: Verwijderen + transition_destinee_emails_help: De beveiligde link wordt naar elk aangegeven adres verzonden + sent_through_secured_link: Verzending via beveiligde link + public_views_by_ip: Weergave per IP-adres + May not associate a document: De workflow betreft geen document + + subscribe_final: Melding ontvangen bij de eindstap + unsubscribe_final: Geen melding meer ontvangen bij de eindstap + subscribe_all_steps: Melding ontvangen bij elke stap van de opvolging + unsubscribe_all_steps: Geen melding meer ontvangen bij elke stap van de opvolging -Subscribe final: Recevoir une notification à l'étape finale -Subscribe all steps: Recevoir une notification à chaque étape + public_link: + expired_link_title: Verlopen link + expired_link_explanation: De link is verlopen, u kunt dit document niet meer bekijken. + + send_external_message: + greeting: Goedendag + or_see_link: 'Als het klikken op de knop niet werkt, kunt u de documenten raadplegen door onderstaande link te kopiëren en in uw browser te openen' + use_button: Om toegang te krijgen tot deze documenten, kunt u onderstaande knop gebruiken + see_docs_action_name: Vertrouwelijke documenten bekijken + sender_system_user: de Chill-software + + signature_zone: + title: Elektronische handtekeningen + button_sign: Ondertekenen + button_cancel: Annuleren + button_reject: Weigeren + metadata: + sign_by: 'Handtekening voor %name%' + docType: Documenttype + docNumber: Documentnummer + docExpiration: Vervaldatum + type of signature: Type handtekening + person signatures: Gebruikers selecteren om te ondertekenen + user signature: Gebruiker selecteren om te ondertekenen + persons: Gebruikers + user: Gebruiker + already_signed_alert: De handtekening is al toegepast + + signature: + cancel_signature_of: Annulering van de handtekening van %signer% + cancel_are_you_sure: Weet u zeker dat u de handtekening van %signer% wilt annuleren + reject_signature_of: Weigering van de handtekening van %signer% + reject_are_you_sure: Weet u zeker dat u de handtekening van %signer% wilt weigeren + waiting_for: In afwachting van wijziging van de handtekeningstatus + + notification: + title: + attention_needed: "Aandacht vereist in workflow %workflow% voor %title%" + new_step: "Nieuwe stap in workflow %workflow% (%place%) voor %title%" + content: + new_step_reached: "Een nieuwe stap is bereikt in workflow %workflow%." + workflow_title: "Titel van de workflow: %title%" + validation_needed: "Uw validatie is nodig voor deze stap." + view_workflow: "U kunt de workflow hier raadplegen:" + regards: "Met vriendelijke groet," + + attachments: + title: Bijlagen + no_attachment: Geen bijlage + Add_an_attachment: Bijlage toevoegen + + wait: + title: In afwachting van verwerking + error_while_waiting: De verwerking is mislukt + success: Verwerking voltooid. Doorverwijzing bezig... + + +Subscribe final: Melding ontvangen bij de eindstap +Subscribe all steps: Melding ontvangen bij elke stap +CHILL_MAIN_WORKFLOW_APPLY_ALL_TRANSITION: Transities toepassen op alle workflows notification: - Notification: Notification - Notifications: Notifications - My own notifications: Mes notifications - Notify: Envoyer une notification - Send: Envoyer - Edit notification: Modifier une notification - Notification created: Notification envoyée - Notification updated: La notification a été mise à jour - Any notification received: Aucune notification reçue - Any notification sent: Aucune notification envoyée - Notifications received: Notifications reçues - Notifications sent: Notifications envoyées - comment_appended: Commentaire ajouté - append_comment: Ajouter un commentaire - comment_updated: Commentaire mis à jour - comments_list: Fil de commentaires - show notification from %sender%: Voir la notification de %sender% - is_unread: Non-lue - is_system: notification automatique - list: Notifications - Sent: Envoyé - to: À + Daily Notification Digest: Dagelijks overzicht van meldingen + Notification: Melding + Notifications: Meldingen + My own notifications: Mijn meldingen + Notify: Melding verzenden + Send: Verzenden + Edit notification: Melding bewerken + Notification created: Melding verzonden + Notification updated: De melding is bijgewerkt + Any notification received: Geen melding ontvangen + Any notification sent: Geen melding verzonden + Notifications received: Ontvangen meldingen + Notifications sent: Verzonden meldingen + comment_appended: Opmerking toegevoegd + append_comment: Opmerking toevoegen + comment_updated: Opmerking bijgewerkt + comments_list: Opmerkingendraad + show notification from %sender%: Melding bekijken van %sender% + is_unread: Ongelezen + is_system: automatische melding + list: Meldingen + Sent: Verzonden + to: Aan cc: Cc - sent_to: Destinataire(s) - sent_cc: En copie - from: De - received_from: Expéditeur - you were notified by %sender%: Vous avez été notifié par %sender% - you were notified by system: Vous avez été notifié automatiquement - subject: Objet - see_comments_thread: Voir le fil de commentaires associé - object_prefix: "[CHILL] notification - " - dest by email: Lien d'accès par email - Any email: Aucun email - Add an email: Ajouter un email - dest by email help: Les adresses email mentionnées ici recevront un lien d'accès. Un compte utilisateur sera toujours nécessaire. - Remove an email: Supprimer l'adresse email - Email with access link: Adresse email ayant reçu un lien d'accès + sent_to: Ontvanger(s) + sent_cc: In kopie + from: Van + received_from: Afzender + you were notified by %sender%: U werd verwittigd door %sender% + you were notified by system: U werd automatisch verwittigd + subject: Onderwerp + see_comments_thread: Bijbehorende opmerkingendraad bekijken + object_prefix: "[CHILL] melding - " + dest by email: Toegang via e-mail + Any email: Geen e-mail + Add an email: E-mailadres toevoegen + dest by email help: De hier vermelde e-mailadressen ontvangen een toegangslink. Een gebruikersaccount blijft altijd nodig. + Remove an email: E-mailadres verwijderen + Email with access link: E-mailadres dat een toegangslink ontving + Pick user or user group: Gebruiker of gebruikersgroep selecteren + mark_as_read: Markeren als gelezen + mark_as_unread: Markeren als ongelezen + + flags: + type: Type melding + by-user: Wanneer een gebruiker u een persoonlijke melding stuurt + referrer-acc-course: Wanneer een andere gebruiker u aanduidt als referent van een traject + person-address-move: Wanneer een andere gebruiker de verhuis registreert van een cliënt in een traject waarvoor u referent bent. + person: Melding over een cliënt + workflow-trans: Wanneer een andere gebruiker een transitie toepast op een workflow. + none selected message: Als u geen optie selecteert, ontvangt u geen e-mail over dit type melding. + preferences: + column_title: Voorkeuren + immediate_email: Onmiddellijke e-mail + daily_email: Dagelijks overzicht + no_email: Geen e-mail ontvangen + + daily_digest: + title: "Dagelijks overzicht van meldingen" + greeting: "Hallo %user%" + intro: "U hebt %notification_count% nieuwe melding(en) ontvangen." + view_notification: "U kunt de melding hier bekijken en erop antwoorden:" + signature: "Het Chill-team" + +daily_notifications: "{1}U hebt 1 nieuwe melding.|]1,Inf[U hebt %notification_count% nieuwe meldingen." + +docgen: + failure_email: + "The generation of a document failed": "Het genereren van een document is mislukt" + "The generation of the document %template_name% failed": "Het genereren van document %template_name% is mislukt" + "Forward this email to your administrator for solving": "Stuur deze e-mail door naar uw beheerder voor een oplossing" + "References": "Referenties" + "The following errors were encoutered": "De volgende fouten zijn opgetreden" + data_dump_email: + subject: "Gegevensexport beschikbaar" + "Dear": "Beste gebruiker," + "data_dump_ready_and_attached": "Uw gegevensexport is klaar en als bijlage bij deze e-mail toegevoegd." + "filename": "Bestandsnaam: %filename%" + +CHILL_MAIN_COMPOSE_EXPORT: Exports uitvoeren en opslaan +CHILL_MAIN_GENERATE_SAVED_EXPORT: Opgeslagen exports uitvoeren en wijzigen + + +export: + role: + export_role: Exports + generation: + Export generation is pending: Het aanmaken van de export is bezig + Export generation is pending_short: Bezig + Come back later: Terug naar het overzicht + Too many retries: Te veel pogingen om de beschikbaarheid van de export te controleren. Probeer de pagina te herladen. + Error while generating export: Interne fout tijdens het genereren van de export + Error_short: Fout + Export ready: De export is klaar om te downloaden + address_helper: + id: Adres-ID + street: Straat + streetNumber: Straatnummer + buildingName: Gebouwnaam + corridor: Gang + distribution: Distributie + extra: Extra + flat: Appartement + floor: Verdieping + postcode_code: Postcode + postcode_name: Postcodeomschrijving + country: Land + _as_string: Geformatteerd adres + confidential: Vertrouwelijk adres ? + isNoAddress: Onvolledig adres ? + steps: Trappen + _lat: Breedtegraad + _lon: Lengtegraad + social_action_list: + id: Actie-ID + social_issue_id: ID sociale problematiek + social_issue: Sociale problematiek + desactivation_date: Uitschakelingsdatum + social_issue_ordering: Volgorde van de sociale problematiek + action_label: Begeleidingsactie + action_ordering: Volgorde + goal_label: Doelstelling + goal_id: Doel-ID + goal_result_label: Resultaat + goal_result_id: Resultaat-ID + result_without_goal_label: Resultaat (zonder doelstelling) + result_without_goal_id: Resultaat-ID (zonder doelstelling) + evaluation_title: Evaluatie + evaluation_id: Evaluatie-ID + evaluation_url: Externe URL (evaluatie) + evaluation_delay_month: Meldingstermijn (maanden) + evaluation_delay_week: Meldingstermijn (weken) + evaluation_delay_day: Meldingstermijn (dagen) + +rolling_date: + year_previous_start: Begin van vorig jaar + quarter_previous_start: Begin van het vorige kwartaal + month_previous_start: Begin van vorige maand + week_previous_start: Begin van vorige week + year_current_start: Begin van het lopende jaar + quarter_current_start: Begin van het lopende kwartaal + month_current_start: Begin van deze maand + week_current_start: Begin van deze week + today: Vandaag (geen wijziging van de huidige datum) + year_next_start: Begin van volgend jaar + quarter_next_start: Begin van het volgende kwartaal + month_next_start: Begin van volgende maand + week_next_start: Begin van volgende week + fixed_date: Vaste datum + roll_movement: Wijziging ten opzichte van vandaag + fixed_date_date: Vaste datum + +saved_export: + Any saved export: Geen opgeslagen export + New: Nieuwe opgeslagen export + Edit: Opgeslagen export bewerken + Delete saved ?: Opgeslagen export verwijderen? + Are you sure you want to delete this saved ?: Weet u zeker dat u deze export wilt verwijderen? + Saved exports: Opgeslagen exports + Export is deleted: De export is verwijderd + Saved export is saved!: De export is opgeslagen + Created on %date%: Aangemaakt op %date% + update_title_and_description: Titel en beschrijving bijwerken + update_filters_aggregators_and_execute: Filters en groeperingen bijwerken en downloaden + execute: Genereren + Update existing: Bestaande opgeslagen export bijwerken + Owner: Eigenaar + Shared with others: Gedeeld + Save to new saved export: Opslaan als nieuwe opgeslagen export + Update current saved export: Configuratie van de bestaande export bijwerken + Duplicate: Dupliceren + Duplicated: Gekopieerd + Options updated successfully: De configuratie van de export is bijgewerkt + Share: Delen + Alert auto generated description: De onderstaande beschrijving werd automatisch gegenereerd alsof de export onmiddellijk werd uitgevoerd. Pas ze aan om rekening te houden met parameters die kunnen wijzigen (huidige gebruiker, rollende datums, enz.). + +absence: + # single letter for absence + A: A + My absence: Mijn afwezigheid + Unset absence: Mijn afwezigheidsdata verwijderen + Set absence date: Afwezigheidsdatum instellen + Absence start: Afwezig vanaf + Absence end: Tot + Absent: Afwezig + You are marked as being absent: U staat als afwezig genoteerd. + No absence listed: Geen afwezigheid opgegeven. + Is absent: Afwezig? + +admin: + users: + export_list_csv: Gebruikerslijst (CSV-formaat) + export_permissions_csv: Koppeling gebruikers - permissiegroepen - territorium (CSV-formaat) + export: + id: Identificatie + username: Gebruikersnaam + email: E-mail + enabled: Geactiveerd + civility_id: Aanhef-ID + civility_abbreviation: Afkorting aanhef + civility_name: Aanhef + label: Label + mainCenter_id: ID hoofdterritorium + mainCenter_name: Hoofdterritorium + mainScope_id: ID hoofddienst + mainScope_name: Hoofddienst + userJob_id: ID beroep + userJob_name: Beroep + currentLocation_id: ID huidige locatie + currentLocation_name: Huidige locatie + mainLocation_id: ID hoofdlocatie + mainLocation_name: Hoofdlocatie + absenceStart: Afwezig vanaf + center_id: Territorium-ID + center_name: Territorium + permissionsGroup_id: Permissiegroep-ID + permissionsGroup_name: Permissiegroep + job_scope_histories: + Show history: Historiek bekijken + index: + histories: Historiek van diensten en beroepen + Back to user job: Terug naar gebruiker + job_history: + title: Historiek van beroepen + start: Van + end: Tot + today: lopend + undefined: niet gedefinieerd + user: Gebruiker + job: Beroep + scope_history: + title: Historiek van diensten + start: Van + end: Tot + today: lopend + undefined: niet gedefinieerd + user: Gebruiker + scope: Dienst + dashboard: + title: Dashboard + news: Nieuws + description: Configuratie van het dashboard + + +news: + noDate: Geen einddatum + startDate: Startdatum publicatie + endDate: Einddatum publicatie op de startpagina + title: Overzicht van nieuwsberichten + menu: Nieuws + no_data: Geen nieuwsbericht + read_more: Lees meer + show_details: Nieuwsbericht bekijken + +gender: + genderTranslation: Grammaticale vertaling + not defined: Niet gedefinieerd + pick gender: Kies een gender + Select gender translation: Grammaticale vertaling kiezen + Select gender icon: Te gebruiken pictogram kiezen + +wopi: + online_edit_document: Online bewerken + save_and_quit: Opslaan en afsluiten + loading: Online editor wordt geladen + invalid_title: Onverenigbaar formaat + invalid_message: Sorry, dit documentformaat kan niet online worden bewerkt. + +onthefly: + show: + person: Details van de cliënt + thirdparty: Details van de derde + file_person: Dossier van de cliënt openen + file_thirdparty: Derde bekijken + edit: + person: Cliënt bewerken + thirdparty: Derde bewerken + create: + button: Aanmaken {q} + title: + default: Aanmaak van een nieuwe cliënt of professionele derde + person: Aanmaak van een nieuwe cliënt + thirdparty: Aanmaak van een nieuwe professionele derde + person: een nieuwe cliënt + thirdparty: een nieuwe professionele derde + addContact: + title: Een contact aanmaken voor {q} + resource_comment_title: Er is een opmerking gekoppeld aan deze gesprekspartner + +modal: + action: + close: Sluiten + +multiselect: + placeholder: Kiezen + tag_placeholder: Nieuw element aanmaken + select_label: Enter of klik om te selecteren + deselect_label: Enter of klik om de selectie op te heffen + select_group_label: Druk op "Enter" om deze groep te selecteren + deselect_group_label: Druk op "Enter" om deze groep te deselecteren + selected_label: Geselecteerd + +editor: + switch_to_simple: Eenvoudige editor + switch_to_complex: Rijke editor + +login_page: + logo_alt: "Chill-logo" diff --git a/src/Bundle/ChillMainBundle/translations/validators.fr.yml b/src/Bundle/ChillMainBundle/translations/validators.fr.yml index ce84efc9f..54083cf7c 100644 --- a/src/Bundle/ChillMainBundle/translations/validators.fr.yml +++ b/src/Bundle/ChillMainBundle/translations/validators.fr.yml @@ -1,15 +1,15 @@ # role_scope constraint # scope presence -The role "%role%" require to be associated with a scope.: Le rôle "%role%" doit être associé à un cercle. -The role "%role%" should not be associated with a scope.: Le rôle "%role%" ne doit pas être associé à un cercle. +The role "%role%" require to be associated with a scope.: Le rôle "%role%" doit être associé à un service. +The role "%role%" should not be associated with a scope.: Le rôle "%role%" ne doit pas être associé à un service. "The password must contains one letter, one capitalized letter, one number and one special character as *[@#$%!,;:+\"'-/{}~=µ()£]). Other characters are allowed.": "Le mot de passe doit contenir une majuscule, une minuscule, et au moins un caractère spécial parmi *[@#$%!,;:+\"'-/{}~=µ()£]). Les autres caractères sont autorisés." The password fields must match: Les mots de passe doivent correspondre The password must be greater than {{ limit }} characters: "[1,Inf] Le mot de passe doit contenir au moins {{ limit }} caractères" -A permission is already present for the same role and scope: Une permission est déjà présente pour le même rôle et cercle. +A permission is already present for the same role and scope: Une permission est déjà présente pour le même rôle et service. #UserCircleConsistency -"{{ username }} is not allowed to see entities published in this circle": "{{ username }} n'est pas autorisé à voir l'élément publié dans ce cercle." +"{{ username }} is not allowed to see entities published in this circle": "{{ username }} n'est pas autorisé à voir l'élément publié dans ce service." The user in cc cannot be a dest user in the same workflow step: Un utilisateur en Cc ne peut pas être un utilisateur qui valide. diff --git a/src/Bundle/ChillMainBundle/translations/validators.nl.yml b/src/Bundle/ChillMainBundle/translations/validators.nl.yml new file mode 100644 index 000000000..85d391ba1 --- /dev/null +++ b/src/Bundle/ChillMainBundle/translations/validators.nl.yml @@ -0,0 +1,45 @@ +# role_scope constraint +# scope presence +The role "%role%" require to be associated with a scope.: De rol "%role%" moet gekoppeld zijn aan een dienst. +The role "%role%" should not be associated with a scope.: De rol "%role%" mag niet gekoppeld zijn aan een dienst. +"The password must contains one letter, one capitalized letter, one number and one special character as *[@#$%!,;:+\"'-/{}~=µ()£]). Other characters are allowed.": "Het wachtwoord moet een hoofdletter, een kleine letter en ten minste één speciaal teken bevatten uit *[@#$%!,;:+\"'-/{}~=µ()£]). Andere tekens zijn toegestaan." +The password fields must match: De wachtwoorden moeten overeenkomen +The password must be greater than {{ limit }} characters: "[1,Inf] Het wachtwoord moet minstens {{ limit }} tekens bevatten" + +A permission is already present for the same role and scope: Er bestaat al een toestemming voor dezelfde rol en dienst. + +#UserCircleConsistency +"{{ username }} is not allowed to see entities published in this circle": "{{ username }} is niet bevoegd om het element te zien dat in deze dienst is gepubliceerd." + +The user in cc cannot be a dest user in the same workflow step: Een gebruiker in Cc kan geen gebruiker zijn die valideert. + +#password request +This username or email does not exists: Deze gebruikersnaam of e-mail bestaat niet in de database + +#phonenumber +This is not a landline phonenumber: Dit nummer is geen geldig vast telefoonnummer +This is not a mobile phonenumber: Dit nummer is geen geldig mobiel nummer +This is not a valid phonenumber: Dit telefoonnummer is niet geldig + +address: + street1-should-be-set: Er moet een adresregel aanwezig zijn + date-should-be-set: De begindatum van geldigheid moet aanwezig zijn + postcode-should-be-set: De postcode moet worden ingevuld + +notification: + At least one addressee: Geef ten minste één geadresseerde op + Title must be defined: Er moet een titel worden aangegeven + Comment content might not be blank: De opmerking kan niet leeg zijn + +workflow: + You must add at least one dest user or email: Geef ten minste één geadresseerde of een e-mailadres op + The user in cc cannot be a dest user in the same workflow step: De gebruiker in kopie kan niet aanwezig zijn in de gebruikers die de volgende stap zullen valideren + transition_has_destinee_if_sent_external: Geef een geadresseerde van de externe verzending op + transition_destinee_not_necessary: Voor deze transitie kunt u geen externe geadresseerden opgeven + You must add a destinee for signing: Geef een gebruiker of een gebruiker op voor ondertekening + +rolling_date: + When fixed date is selected, you must provide a date: Geef de gekozen vaste datum op + +user: + absence_end_requires_start: U kunt geen einddatum van afwezigheid invullen zonder begindatum. diff --git a/src/Bundle/ChillPersonBundle/Actions/PersonCreate/PersonCreateDTO.php b/src/Bundle/ChillPersonBundle/Actions/PersonCreate/PersonCreateDTO.php new file mode 100644 index 000000000..bb8d4f79c --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Actions/PersonCreate/PersonCreateDTO.php @@ -0,0 +1,70 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\PersonBundle\Actions\PersonCreate; + +use Chill\MainBundle\Entity\Address; +use Chill\MainBundle\Entity\Center; +use Chill\MainBundle\Entity\Civility; +use Chill\MainBundle\Entity\Gender; +use Chill\PersonBundle\Entity\Identifier\PersonIdentifier; +use Chill\PersonBundle\Entity\PersonAltName; +use libphonenumber\PhoneNumber; +use Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber as MisdPhoneNumberConstraint; +use Symfony\Component\Validator\Constraints as Assert; +use Chill\PersonBundle\Validator\Constraints\Person\Birthdate; + +class PersonCreateDTO +{ + #[Assert\NotBlank(message: 'The firstname cannot be empty')] + #[Assert\Length(max: 255)] + public string $firstName; + + #[Assert\NotBlank(message: 'The lastname cannot be empty')] + #[Assert\Length(max: 255)] + public string $lastName; + + #[Birthdate] + public ?\DateTime $birthdate = null; + + #[Assert\NotNull(message: 'The gender must be set')] + public ?Gender $gender = null; + + public ?Civility $civility = null; + + #[MisdPhoneNumberConstraint(type: [MisdPhoneNumberConstraint::FIXED_LINE, MisdPhoneNumberConstraint::VOIP, MisdPhoneNumberConstraint::PERSONAL_NUMBER])] + public ?PhoneNumber $phonenumber = null; + + #[MisdPhoneNumberConstraint(type: [MisdPhoneNumberConstraint::MOBILE])] + public ?PhoneNumber $mobilenumber = null; + + #[Assert\Email] + public ?string $email = ''; + + // Checkbox that indicates whether the address form was checked in creation form + public bool $addressForm = false; + + // Selected address value (unmapped in Person entity during creation) + public ?Address $address = null; + + public ?Center $center = null; + + /** + * @var array<string, PersonAltName> where the key is the altname's key + */ + public array $altNames = []; + + /** + * @var array<string, PersonIdentifier> + */ + #[Assert\Valid(traverse: true)] + public array $identifiers = []; +} diff --git a/src/Bundle/ChillPersonBundle/Actions/PersonCreate/Service/PersonCreateDTOFactory.php b/src/Bundle/ChillPersonBundle/Actions/PersonCreate/Service/PersonCreateDTOFactory.php new file mode 100644 index 000000000..2259e345a --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Actions/PersonCreate/Service/PersonCreateDTOFactory.php @@ -0,0 +1,96 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\PersonBundle\Actions\PersonCreate\Service; + +use Chill\PersonBundle\Actions\PersonCreate\PersonCreateDTO; +use Chill\PersonBundle\Config\ConfigPersonAltNamesHelper; +use Chill\PersonBundle\Entity\Identifier\PersonIdentifier; +use Chill\PersonBundle\Entity\Person; +use Chill\PersonBundle\Entity\PersonAltName; +use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface; + +class PersonCreateDTOFactory +{ + public function __construct( + private readonly ConfigPersonAltNamesHelper $configPersonAltNamesHelper, + private readonly PersonIdentifierManagerInterface $personIdentifierManager, + ) {} + + public function createPersonCreateDTO(Person $person): PersonCreateDTO + { + $dto = new PersonCreateDTO(); + $dto->firstName = $person->getFirstName(); + $dto->lastName = $person->getLastName(); + $dto->birthdate = $person->getBirthdate(); + $dto->gender = $person->getGender(); + $dto->civility = $person->getCivility(); + $dto->phonenumber = $person->getPhonenumber(); + $dto->mobilenumber = $person->getMobilenumber(); + $dto->email = $person->getEmail(); + $dto->center = $person->getCenter(); + // address/addressForm are not mapped on Person entity; left to defaults + + foreach ($this->configPersonAltNamesHelper->getChoices() as $key => $labels) { + $altName = $person->getAltNames()->findFirst(fn (int $k, PersonAltName $altName) => $altName->getKey() === $key); + if (null === $altName) { + $altName = new PersonAltName(); + $altName->setKey($key); + } + $dto->altNames[$key] = $altName; + } + + foreach ($this->personIdentifierManager->getWorkers() as $worker) { + $identifier = $person + ->getIdentifiers() + ->findFirst(fn (int $key, PersonIdentifier $identifier) => $worker->getDefinition() === $identifier->getDefinition()); + if (null === $identifier) { + $identifier = new PersonIdentifier($worker->getDefinition()); + $person->addIdentifier($identifier); + } + $dto->identifiers['identifier_'.$worker->getDefinition()->getId()] = $identifier; + } + + return $dto; + } + + public function mapPersonCreateDTOtoPerson(PersonCreateDTO $personCreateDTO, Person $person): void + { + $person + ->setFirstName($personCreateDTO->firstName) + ->setLastName($personCreateDTO->lastName) + ->setBirthdate($personCreateDTO->birthdate) + ->setGender($personCreateDTO->gender) + ->setCivility($personCreateDTO->civility) + ->setPhonenumber($personCreateDTO->phonenumber) + ->setMobilenumber($personCreateDTO->mobilenumber) + ->setEmail($personCreateDTO->email) + ->setCenter($personCreateDTO->center); + + foreach ($personCreateDTO->altNames as $altName) { + if ('' === $altName->getLabel()) { + $person->removeAltName($altName); + } else { + $person->addAltName($altName); + } + } + + foreach ($personCreateDTO->identifiers as $identifier) { + $worker = $this->personIdentifierManager->buildWorkerByPersonIdentifierDefinition($identifier->getDefinition()); + if ($worker->isEmpty($identifier)) { + $person->removeIdentifier($identifier); + } else { + $person->addIdentifier($identifier); + } + } + // Note: address and addressForm are handled by controller/form during creation, not mapped here + } +} diff --git a/src/Bundle/ChillPersonBundle/Actions/PersonEdit/PersonEditDTO.php b/src/Bundle/ChillPersonBundle/Actions/PersonEdit/PersonEditDTO.php new file mode 100644 index 000000000..ed19e581d --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Actions/PersonEdit/PersonEditDTO.php @@ -0,0 +1,108 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\PersonBundle\Actions\PersonEdit; + +use Chill\MainBundle\Entity\Civility; +use Chill\MainBundle\Entity\Country; +use Chill\MainBundle\Entity\Embeddable\CommentEmbeddable; +use Chill\MainBundle\Entity\Gender; +use Chill\MainBundle\Entity\Language; +use Chill\PersonBundle\Entity\AdministrativeStatus; +use Chill\PersonBundle\Entity\EmploymentStatus; +use Chill\PersonBundle\Entity\Identifier\PersonIdentifier; +use Chill\PersonBundle\Entity\MaritalStatus; +use Chill\PersonBundle\Entity\PersonAltName; +use Chill\PersonBundle\Validator\Constraints\Person\Birthdate; +use Doctrine\Common\Collections\Collection; +use libphonenumber\PhoneNumber; +use Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber as MisdPhoneNumberConstraint; +use Symfony\Component\Validator\Constraints as Assert; + +class PersonEditDTO +{ + #[Assert\NotBlank(message: 'The firstname cannot be empty')] + #[Assert\Length(max: 255)] + public string $firstName; + + #[Assert\NotBlank(message: 'The lastname cannot be empty')] + #[Assert\Length(max: 255)] + public string $lastName; + + #[Birthdate] + public ?\DateTime $birthdate = null; + + #[Assert\GreaterThanOrEqual(propertyPath: 'birthdate')] + #[Assert\LessThanOrEqual('today')] + public ?\DateTimeImmutable $deathdate = null; + + #[Assert\NotNull(message: 'The gender must be set')] + public ?Gender $gender = null; + + #[Assert\Valid] + public CommentEmbeddable $genderComment; + + public ?int $numberOfChildren = null; + + /** + * @var array<string, PersonAltName> where the key is the altname's key + */ + public array $altNames = []; + + public string $memo = ''; + + public ?EmploymentStatus $employmentStatus = null; + + public ?AdministrativeStatus $administrativeStatus = null; + + public string $placeOfBirth = ''; + + public ?string $contactInfo = ''; + + #[MisdPhoneNumberConstraint(type: [MisdPhoneNumberConstraint::FIXED_LINE, MisdPhoneNumberConstraint::VOIP, MisdPhoneNumberConstraint::PERSONAL_NUMBER])] + public ?PhoneNumber $phonenumber = null; + + #[MisdPhoneNumberConstraint(type: [MisdPhoneNumberConstraint::MOBILE])] + public ?PhoneNumber $mobilenumber = null; + + public ?bool $acceptSms = false; + + #[Assert\Valid(traverse: true)] + public Collection $otherPhonenumbers; // Collection<int, \Chill\PersonBundle\Entity\PersonPhone> + + #[Assert\Email] + public ?string $email = ''; + + public ?bool $acceptEmail = false; + + public ?Country $countryOfBirth = null; + + public ?Country $nationality = null; + + public Collection $spokenLanguages; // Collection<int, Language> + + public ?Civility $civility = null; + + public ?MaritalStatus $maritalStatus = null; + + public ?\DateTimeInterface $maritalStatusDate = null; + + #[Assert\Valid] + public CommentEmbeddable $maritalStatusComment; + + public ?array $cFData = null; + + /** + * @var array<string, PersonIdentifier> + */ + #[Assert\Valid(traverse: true)] + public array $identifiers = []; +} diff --git a/src/Bundle/ChillPersonBundle/Actions/PersonEdit/Service/PersonEditDTOFactory.php b/src/Bundle/ChillPersonBundle/Actions/PersonEdit/Service/PersonEditDTOFactory.php new file mode 100644 index 000000000..e25cd0ad2 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Actions/PersonEdit/Service/PersonEditDTOFactory.php @@ -0,0 +1,130 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\PersonBundle\Actions\PersonEdit\Service; + +use Chill\PersonBundle\Actions\PersonEdit\PersonEditDTO; +use Chill\PersonBundle\Config\ConfigPersonAltNamesHelper; +use Chill\PersonBundle\Entity\Identifier\PersonIdentifier; +use Chill\PersonBundle\Entity\Person; +use Chill\PersonBundle\Entity\PersonAltName; +use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface; + +class PersonEditDTOFactory +{ + public function __construct( + private readonly ConfigPersonAltNamesHelper $configPersonAltNamesHelper, + private readonly PersonIdentifierManagerInterface $personIdentifierManager, + ) {} + + public function createPersonEditDTO(Person $person): PersonEditDTO + { + $dto = new PersonEditDTO(); + $dto->firstName = $person->getFirstName(); + $dto->lastName = $person->getLastName(); + $dto->birthdate = $person->getBirthdate(); + $dto->deathdate = (null !== $deathDate = $person->getDeathdate()) ? \DateTimeImmutable::createFromInterface($deathDate) : null; + $dto->gender = $person->getGender(); + $dto->genderComment = $person->getGenderComment(); + $dto->numberOfChildren = $person->getNumberOfChildren(); + $dto->memo = $person->getMemo() ?? ''; + $dto->employmentStatus = $person->getEmploymentStatus(); + $dto->administrativeStatus = $person->getAdministrativeStatus(); + $dto->placeOfBirth = $person->getPlaceOfBirth() ?? ''; + $dto->contactInfo = $person->getcontactInfo(); + $dto->phonenumber = $person->getPhonenumber(); + $dto->mobilenumber = $person->getMobilenumber(); + $dto->acceptSms = $person->getAcceptSMS(); + $dto->otherPhonenumbers = $person->getOtherPhoneNumbers(); + $dto->email = $person->getEmail(); + $dto->acceptEmail = $person->getAcceptEmail(); + $dto->countryOfBirth = $person->getCountryOfBirth(); + $dto->nationality = $person->getNationality(); + $dto->spokenLanguages = $person->getSpokenLanguages(); + $dto->civility = $person->getCivility(); + $dto->maritalStatus = $person->getMaritalStatus(); + $dto->maritalStatusDate = $person->getMaritalStatusDate(); + $dto->maritalStatusComment = $person->getMaritalStatusComment(); + $dto->cFData = $person->getCFData(); + + + foreach ($this->configPersonAltNamesHelper->getChoices() as $key => $labels) { + $altName = $person->getAltNames()->findFirst(fn (int $k, PersonAltName $altName) => $altName->getKey() === $key); + if (null === $altName) { + $altName = new PersonAltName(); + $altName->setKey($key); + } + $dto->altNames[$key] = $altName; + } + + foreach ($this->personIdentifierManager->getWorkers() as $worker) { + $identifier = $person + ->getIdentifiers() + ->findFirst(fn (int $key, PersonIdentifier $identifier) => $worker->getDefinition() === $identifier->getDefinition()); + if (null === $identifier) { + $identifier = new PersonIdentifier($worker->getDefinition()); + $person->addIdentifier($identifier); + } + $dto->identifiers['identifier_'.$worker->getDefinition()->getId()] = $identifier; + } + + return $dto; + } + + public function mapPersonEditDTOtoPerson(PersonEditDTO $personEditDTO, Person $person): void + { + // Copy all editable fields from the DTO back to the Person entity + $person + ->setFirstName($personEditDTO->firstName) + ->setLastName($personEditDTO->lastName) + ->setBirthdate($personEditDTO->birthdate) + ->setDeathdate($personEditDTO->deathdate) + ->setGender($personEditDTO->gender) + ->setGenderComment($personEditDTO->genderComment) + ->setNumberOfChildren($personEditDTO->numberOfChildren) + ->setMemo($personEditDTO->memo) + ->setEmploymentStatus($personEditDTO->employmentStatus) + ->setAdministrativeStatus($personEditDTO->administrativeStatus) + ->setPlaceOfBirth($personEditDTO->placeOfBirth) + ->setcontactInfo($personEditDTO->contactInfo) + ->setPhonenumber($personEditDTO->phonenumber) + ->setMobilenumber($personEditDTO->mobilenumber) + ->setAcceptSMS($personEditDTO->acceptSms ?? false) + ->setOtherPhoneNumbers($personEditDTO->otherPhonenumbers) + ->setEmail($personEditDTO->email) + ->setAcceptEmail($personEditDTO->acceptEmail ?? false) + ->setCountryOfBirth($personEditDTO->countryOfBirth) + ->setNationality($personEditDTO->nationality) + ->setSpokenLanguages($personEditDTO->spokenLanguages) + ->setCivility($personEditDTO->civility) + ->setMaritalStatus($personEditDTO->maritalStatus) + ->setMaritalStatusDate($personEditDTO->maritalStatusDate) + ->setMaritalStatusComment($personEditDTO->maritalStatusComment) + ->setCFData($personEditDTO->cFData); + + foreach ($personEditDTO->altNames as $altName) { + if ('' === $altName->getLabel()) { + $person->removeAltName($altName); + } else { + $person->addAltName($altName); + } + } + + foreach ($personEditDTO->identifiers as $identifier) { + $worker = $this->personIdentifierManager->buildWorkerByPersonIdentifierDefinition($identifier->getDefinition()); + if ($worker->isEmpty($identifier)) { + $person->removeIdentifier($identifier); + } else { + $person->addIdentifier($identifier); + } + } + } +} diff --git a/src/Bundle/ChillPersonBundle/Config/ConfigPersonAltNamesHelper.php b/src/Bundle/ChillPersonBundle/Config/ConfigPersonAltNamesHelper.php index 4be480442..bac53aa23 100644 --- a/src/Bundle/ChillPersonBundle/Config/ConfigPersonAltNamesHelper.php +++ b/src/Bundle/ChillPersonBundle/Config/ConfigPersonAltNamesHelper.php @@ -28,6 +28,8 @@ class ConfigPersonAltNamesHelper /** * get the choices as key => values. + * + * @return array<string, array<string, string>> where the key is the altName's key, and the value is an array of TranslatableString */ public function getChoices(): array { diff --git a/src/Bundle/ChillPersonBundle/Controller/PersonController.php b/src/Bundle/ChillPersonBundle/Controller/PersonController.php index c22584505..3b5cabd95 100644 --- a/src/Bundle/ChillPersonBundle/Controller/PersonController.php +++ b/src/Bundle/ChillPersonBundle/Controller/PersonController.php @@ -11,93 +11,29 @@ declare(strict_types=1); namespace Chill\PersonBundle\Controller; -use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface; use Chill\PersonBundle\Config\ConfigPersonAltNamesHelper; -use Chill\PersonBundle\Entity\Household\Household; -use Chill\PersonBundle\Entity\Household\HouseholdMember; use Chill\PersonBundle\Entity\Person; -use Chill\PersonBundle\Form\CreationPersonType; use Chill\PersonBundle\Privacy\PrivacyEvent; use Chill\PersonBundle\Repository\PersonRepository; -use Chill\PersonBundle\Search\SimilarPersonMatcher; -use Chill\PersonBundle\Security\Authorization\PersonVoter; use Doctrine\ORM\EntityManagerInterface; use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\EventDispatcher\EventDispatcherInterface; -use Symfony\Component\Form\Extension\Core\Type\SubmitType; -use Symfony\Component\Form\Form; use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpFoundation\Session\SessionInterface; +use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Validator\Validator\ValidatorInterface; -use Symfony\Contracts\Translation\TranslatorInterface; -use function hash; final class PersonController extends AbstractController { public function __construct( - private readonly AuthorizationHelperInterface $authorizationHelper, - private readonly SimilarPersonMatcher $similarPersonMatcher, - private readonly TranslatorInterface $translator, private readonly EventDispatcherInterface $eventDispatcher, private readonly PersonRepository $personRepository, private readonly ConfigPersonAltNamesHelper $configPersonAltNameHelper, private readonly ValidatorInterface $validator, private readonly EntityManagerInterface $em, - private readonly RequestStack $requestStack, ) {} - #[\Symfony\Component\Routing\Attribute\Route(path: '/{_locale}/person/{person_id}/general/edit', name: 'chill_person_general_edit')] - public function editAction(int $person_id, Request $request): \Symfony\Component\HttpFoundation\RedirectResponse|Response - { - $person = $this->_getPerson($person_id); - - if (null === $person) { - throw $this->createNotFoundException(); - } - - $this->denyAccessUnlessGranted( - 'CHILL_PERSON_UPDATE', - $person, - 'You are not allowed to edit this person' - ); - - $form = $this->createForm( - PersonType::class, - $person, - [ - 'cFGroup' => $this->getCFGroup(), - ] - ); - - $form->handleRequest($request); - - if ($form->isSubmitted() && !$form->isValid()) { - - $this->addFlash('error', $this->translator - ->trans('This form contains errors')); - } elseif ($form->isSubmitted() && $form->isValid()) { - $this->em->flush(); - - $this->addFlash( - 'success', - $this->translator - ->trans('The person data has been updated') - ); - - return $this->redirectToRoute('chill_person_view', [ - 'person_id' => $person->getId(), - ]); - } - - return $this->render( - '@ChillPerson/Person/edit.html.twig', - ['person' => $person, 'form' => $form] - ); - } - public function getCFGroup() { $cFGroup = null; @@ -115,7 +51,7 @@ final class PersonController extends AbstractController /** * @ParamConverter("person", options={"id": "person_id"}) */ - #[\Symfony\Component\Routing\Attribute\Route(path: '/{_locale}/person/household/{person_id}/history', name: 'chill_person_household_person_history', methods: ['GET', 'POST'])] + #[Route(path: '/{_locale}/person/household/{person_id}/history', name: 'chill_person_household_person_history', methods: ['GET', 'POST'])] public function householdHistoryByPerson(Request $request, Person $person): Response { $this->denyAccessUnlessGranted( @@ -135,112 +71,8 @@ final class PersonController extends AbstractController ); } - /** - * Method for creating a new person. - * - * The controller register data from a previous post on the form, and - * register it in the session. - * - * The next post compare the data with previous one and, if yes, show a - * review page if there are "alternate persons". - */ - #[\Symfony\Component\Routing\Attribute\Route(path: '/{_locale}/person/new', name: 'chill_person_new')] - public function newAction(Request $request): Response - { - $person = new Person(); - - $authorizedCenters = $this->authorizationHelper->getReachableCenters($this->getUser(), PersonVoter::CREATE); - - if (1 === \count($authorizedCenters)) { - $person->setCenter($authorizedCenters[0]); - } - - $form = $this->createForm(CreationPersonType::class, $person) - ->add('editPerson', SubmitType::class, [ - 'label' => 'Add the person', - ])->add('createPeriod', SubmitType::class, [ - 'label' => 'Add the person and create an accompanying period', - ])->add('createHousehold', SubmitType::class, [ - 'label' => 'Add the person and create a household', - ]); - - $form->handleRequest($request); - - if (Request::METHOD_GET === $request->getMethod()) { - $this->lastPostDataReset(); - } elseif ( - Request::METHOD_POST === $request->getMethod() - && $form->isValid() - ) { - $alternatePersons = $this->similarPersonMatcher - ->matchPerson($person); - - if ( - false === $this->isLastPostDataChanges($form, $request, true) - || 0 === \count($alternatePersons) - ) { - $this->em->persist($person); - - $this->em->flush(); - $this->lastPostDataReset(); - - $address = $form->get('address')->getData(); - $addressForm = (bool) $form->get('addressForm')->getData(); - - if (null !== $address && $addressForm) { - $household = new Household(); - - $member = new HouseholdMember(); - $member->setPerson($person); - $member->setStartDate(new \DateTimeImmutable()); - - $household->addMember($member); - $household->setForceAddress($address); - - $this->em->persist($member); - $this->em->persist($household); - $this->em->flush(); - - if ($form->get('createHousehold')->isClicked()) { - return $this->redirectToRoute('chill_person_household_members_editor', [ - 'persons' => [$person->getId()], - 'household' => $household->getId(), - ]); - } - } - - if ($form->get('createPeriod')->isClicked()) { - return $this->redirectToRoute('chill_person_accompanying_course_new', [ - 'person_id' => [$person->getId()], - ]); - } - - if ($form->get('createHousehold')->isClicked()) { - return $this->redirectToRoute('chill_person_household_members_editor', [ - 'persons' => [$person->getId()], - ]); - } - - return $this->redirectToRoute( - 'chill_person_general_edit', - ['person_id' => $person->getId()] - ); - } - } elseif (Request::METHOD_POST === $request->getMethod() && !$form->isValid()) { - $this->addFlash('error', $this->translator->trans('This form contains errors')); - } - - return $this->render( - '@ChillPerson/Person/create.html.twig', - [ - 'form' => $form, - 'alternatePersons' => $alternatePersons ?? [], - ] - ); - } - - #[\Symfony\Component\Routing\Attribute\Route(path: '/{_locale}/person/{person_id}/general', name: 'chill_person_view')] - public function viewAction(int $person_id): Response + #[Route(path: '/{_locale}/person/{person_id}/general', name: 'chill_person_view')] + public function viewAction(int $person_id) { $person = $this->_getPerson($person_id); @@ -269,8 +101,10 @@ final class PersonController extends AbstractController /** * easy getting a person by his id. + * + * @return Person */ - private function _getPerson(int $id): ?Person + private function _getPerson(int $id) { return $this->personRepository->find($id); } @@ -298,51 +132,4 @@ final class PersonController extends AbstractController return $errors; } - - private function isLastPostDataChanges(Form $form, Request $request, bool $replace = false): bool - { - /** @var SessionInterface $session */ - $session = $this->requestStack->getSession(); - - if (!$session->has('last_person_data')) { - return true; - } - - $newPost = $this->lastPostDataBuildHash($form, $request); - - $isChanged = $session->get('last_person_data') !== $newPost; - - if ($replace) { - $session->set('last_person_data', $newPost); - } - - return $isChanged; - } - - /** - * build the hash for posted data. - * - * For privacy reasons, the data are hashed using sha512 - */ - private function lastPostDataBuildHash(Form $form, Request $request): string - { - $fields = []; - $ignoredFields = ['form_status', '_token']; - - foreach ($request->request->all()[$form->getName()] as $field => $value) { - if (\in_array($field, $ignoredFields, true)) { - continue; - } - $fields[$field] = \is_array($value) ? - \implode(',', $value) : $value; - } - ksort($fields); - - return \hash('sha512', \implode('&', $fields)); - } - - private function lastPostDataReset(): void - { - $this->requestStack->getSession()->set('last_person_data', ''); - } } diff --git a/src/Bundle/ChillPersonBundle/Controller/PersonCreateController.php b/src/Bundle/ChillPersonBundle/Controller/PersonCreateController.php new file mode 100644 index 000000000..7ddd883d7 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Controller/PersonCreateController.php @@ -0,0 +1,205 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\PersonBundle\Controller; + +use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface; +use Chill\PersonBundle\Actions\PersonCreate\Service\PersonCreateDTOFactory; +use Chill\PersonBundle\Entity\Household\Household; +use Chill\PersonBundle\Entity\Household\HouseholdMember; +use Chill\PersonBundle\Entity\Person; +use Chill\PersonBundle\Form\CreationPersonType; +use Chill\PersonBundle\Search\SimilarPersonMatcher; +use Chill\PersonBundle\Security\Authorization\PersonVoter; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\Form\ClickableInterface; +use Symfony\Component\Form\Extension\Core\Type\SubmitType; +use Symfony\Component\Form\Form; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\Session\SessionInterface; +use Symfony\Component\Routing\Annotation\Route; +use Symfony\Contracts\Translation\TranslatorInterface; + +final class PersonCreateController extends AbstractController +{ + public function __construct( + private readonly AuthorizationHelperInterface $authorizationHelper, + private readonly SimilarPersonMatcher $similarPersonMatcher, + private readonly TranslatorInterface $translator, + private readonly EntityManagerInterface $em, + private readonly PersonCreateDTOFactory $personCreateDTOFactory, + ) {} + + /** + * Method for creating a new person. + * + * The controller registers data from a previous post on the form and + * registers it in the session. + * + * The next post compares the data with the previous one and, if yes, shows a + * review page if there are "alternate persons". + */ + #[Route(path: '/{_locale}/person/new', name: 'chill_person_new')] + public function newAction(Request $request, SessionInterface $session): Response + { + $person = new Person(); + + $authorizedCenters = $this->authorizationHelper->getReachableCenters($this->getUser(), PersonVoter::CREATE); + + $dto = $this->personCreateDTOFactory->createPersonCreateDTO($person); + + if (1 === \count($authorizedCenters)) { + $dto->center = $authorizedCenters[0]; + } + + $form = $this->createForm(CreationPersonType::class, $dto) + ->add('editPerson', SubmitType::class, [ + 'label' => 'Add the person', + ])->add('createPeriod', SubmitType::class, [ + 'label' => 'Add the person and create an accompanying period', + ])->add('createHousehold', SubmitType::class, [ + 'label' => 'Add the person and create a household', + ]); + + $form->handleRequest($request); + + if (Request::METHOD_GET === $request->getMethod()) { + $this->lastPostDataReset($session); + } elseif ( + Request::METHOD_POST === $request->getMethod() + && $form->isValid() + ) { + $alternatePersons = $this->similarPersonMatcher + ->matchPerson($person); + + $createHouseholdButton = $form->get('createHousehold'); + $createPeriodButton = $form->get('createPeriod'); + $editPersonButton = $form->get('editPerson'); + + if (!$createHouseholdButton instanceof ClickableInterface) { + throw new \UnexpectedValueException(); + } + if (!$createPeriodButton instanceof ClickableInterface) { + throw new \UnexpectedValueException(); + } + if (!$editPersonButton instanceof ClickableInterface) { + throw new \UnexpectedValueException(); + } + + if ( + false === $this->isLastPostDataChanges($form, $request, $session) + || 0 === \count($alternatePersons) + ) { + $this->personCreateDTOFactory->mapPersonCreateDTOtoPerson($dto, $person); + $this->em->persist($person); + + $this->em->flush(); + $this->lastPostDataReset($session); + + $address = $dto->address; + $addressForm = $dto->addressForm; + + if (null !== $address && $addressForm) { + $household = new Household(); + + $member = new HouseholdMember(); + $member->setPerson($person); + $member->setStartDate(new \DateTimeImmutable()); + + $household->addMember($member); + $household->setForceAddress($address); + + $this->em->persist($member); + $this->em->persist($household); + $this->em->flush(); + + if ($createHouseholdButton->isClicked()) { + return $this->redirectToRoute('chill_person_household_members_editor', [ + 'persons' => [$person->getId()], + 'household' => $household->getId(), + ]); + } + } + + if ($createPeriodButton->isClicked()) { + return $this->redirectToRoute('chill_person_accompanying_course_new', [ + 'person_id' => [$person->getId()], + ]); + } + + if ($createHouseholdButton->isClicked()) { + return $this->redirectToRoute('chill_person_household_members_editor', [ + 'persons' => [$person->getId()], + ]); + } + + return $this->redirectToRoute( + 'chill_person_general_edit', + ['person_id' => $person->getId()] + ); + } + } elseif (Request::METHOD_POST === $request->getMethod() && !$form->isValid()) { + $this->addFlash('error', $this->translator->trans('This form contains errors')); + } + + return $this->render( + '@ChillPerson/Person/create.html.twig', + [ + 'form' => $form->createView(), + 'alternatePersons' => $alternatePersons ?? [], + ] + ); + } + + private function isLastPostDataChanges(FormInterface $form, Request $request, SessionInterface $session): bool + { + if (!$session->has('last_person_data')) { + return true; + } + + $newPost = $this->lastPostDataBuildHash($form, $request); + + $isChanged = $session->get('last_person_data') !== $newPost; + $session->set('last_person_data', $newPost); + + return $isChanged; + } + + /** + * build the hash for posted data. + * + * For privacy reasons, the data are hashed using sha512 + */ + private function lastPostDataBuildHash(FormInterface $form, Request $request): string + { + $fields = []; + $ignoredFields = ['form_status', '_token', 'identifiers']; + + foreach ($request->request->all()[$form->getName()] as $field => $value) { + if (\in_array($field, $ignoredFields, true)) { + continue; + } + $fields[$field] = \is_array($value) ? + \implode(',', $value) : $value; + } + ksort($fields); + + return \hash('sha512', \implode('&', $fields)); + } + + private function lastPostDataReset(SessionInterface $session): void + { + $session->set('last_person_data', ''); + } +} diff --git a/src/Bundle/ChillPersonBundle/Controller/PersonEditController.php b/src/Bundle/ChillPersonBundle/Controller/PersonEditController.php index 3db9e68e6..d77ea0ca0 100644 --- a/src/Bundle/ChillPersonBundle/Controller/PersonEditController.php +++ b/src/Bundle/ChillPersonBundle/Controller/PersonEditController.php @@ -12,6 +12,7 @@ declare(strict_types=1); namespace Chill\PersonBundle\Controller; use Chill\CustomFieldsBundle\EntityRepository\CustomFieldsDefaultGroupRepository; +use Chill\PersonBundle\Actions\PersonEdit\Service\PersonEditDTOFactory; use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Form\PersonType; use Chill\PersonBundle\Security\Authorization\PersonVoter; @@ -38,6 +39,7 @@ final readonly class PersonEditController private EntityManagerInterface $entityManager, private UrlGeneratorInterface $urlGenerator, private Environment $twig, + private PersonEditDTOFactory $personEditDTOFactory, ) {} /** @@ -50,9 +52,11 @@ final readonly class PersonEditController throw new AccessDeniedHttpException('You are not allowed to edit this person.'); } + $dto = $this->personEditDTOFactory->createPersonEditDTO($person); + $form = $this->formFactory->create( PersonType::class, - $person, + $dto, ['cFGroup' => $this->customFieldsDefaultGroupRepository->findOneByEntity(Person::class)?->getCustomFieldsGroup()] ); @@ -62,6 +66,7 @@ final readonly class PersonEditController $session ->getFlashBag()->add('error', new TranslatableMessage('This form contains errors')); } elseif ($form->isSubmitted() && $form->isValid()) { + $this->personEditDTOFactory->mapPersonEditDTOtoPerson($dto, $person); $this->entityManager->flush(); $session->getFlashBag()->add('success', new TranslatableMessage('The person data has been updated')); diff --git a/src/Bundle/ChillPersonBundle/Controller/PersonIdentifierApiController.php b/src/Bundle/ChillPersonBundle/Controller/PersonIdentifierApiController.php new file mode 100644 index 000000000..200d5c224 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Controller/PersonIdentifierApiController.php @@ -0,0 +1,14 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\PersonBundle\Controller; + +final readonly class PersonIdentifierApiController {} diff --git a/src/Bundle/ChillPersonBundle/Controller/PersonIdentifierListApiController.php b/src/Bundle/ChillPersonBundle/Controller/PersonIdentifierListApiController.php new file mode 100644 index 000000000..2f0b3af28 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Controller/PersonIdentifierListApiController.php @@ -0,0 +1,47 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\PersonBundle\Controller; + +use Chill\MainBundle\Pagination\PaginatorFactoryInterface; +use Chill\MainBundle\Serializer\Model\Collection; +use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\SerializerInterface; + +final readonly class PersonIdentifierListApiController +{ + public function __construct( + private Security $security, + private SerializerInterface $serializer, + private PersonIdentifierManagerInterface $personIdentifierManager, + private PaginatorFactoryInterface $paginatorFactory, + ) {} + + #[Route('/api/1.0/person/identifiers/workers', name: 'person_person_identifiers_worker_list')] + public function list(): Response + { + if (!$this->security->isGranted('ROLE_USER')) { + throw new AccessDeniedHttpException(); + } + + $workers = $this->personIdentifierManager->getWorkers(); + $paginator = $this->paginatorFactory->create(count($workers)); + $paginator->setItemsPerPage(count($workers)); + $collection = new Collection($workers, $paginator); + + return new JsonResponse($this->serializer->serialize($collection, 'json'), json: true); + } +} diff --git a/src/Bundle/ChillPersonBundle/DataFixtures/ORM/LoadPeople.php b/src/Bundle/ChillPersonBundle/DataFixtures/ORM/LoadPeople.php index 5807b02c3..ee45b6c0d 100644 --- a/src/Bundle/ChillPersonBundle/DataFixtures/ORM/LoadPeople.php +++ b/src/Bundle/ChillPersonBundle/DataFixtures/ORM/LoadPeople.php @@ -11,15 +11,19 @@ declare(strict_types=1); namespace Chill\PersonBundle\DataFixtures\ORM; +use Chill\MainBundle\DataFixtures\ORM\LoadAdministrativeLocation; use Chill\MainBundle\DataFixtures\ORM\LoadPostalCodes; +use Chill\MainBundle\DataFixtures\ORM\LoadUserJob; use Chill\MainBundle\Entity\Address; use Chill\MainBundle\Entity\Center; use Chill\MainBundle\Entity\Country; use Chill\MainBundle\Entity\Gender; use Chill\MainBundle\Entity\GenderEnum; +use Chill\MainBundle\Entity\Location; use Chill\MainBundle\Entity\PostalCode; use Chill\MainBundle\Entity\Scope; use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Entity\UserJob; use Chill\MainBundle\Repository\CenterRepository; use Chill\MainBundle\Repository\CountryRepository; use Chill\MainBundle\Repository\GenderRepository; @@ -362,6 +366,10 @@ class LoadPeople extends Fixture implements OrderedFixtureInterface $origin = $this->getReference(LoadAccompanyingPeriodOrigin::ACCOMPANYING_PERIOD_ORIGIN, AccompanyingPeriod\Origin::class); $accompanyingPeriod->setOrigin($origin); $accompanyingPeriod->setIntensity('regular'); + $userJob = $this->getReference(LoadUserJob::USER_JOB, UserJob::class); + $accompanyingPeriod->setJob($userJob); + $administrativeLocation = $this->getReference(LoadAdministrativeLocation::ADMINISTRATIVE_LOCATION, Location::class); + $accompanyingPeriod->setAdministrativeLocation($administrativeLocation); $accompanyingPeriod->setAddressLocation($this->createAddress()); $manager->persist($accompanyingPeriod->getAddressLocation()); $workflow = $this->workflowRegistry->get($accompanyingPeriod); diff --git a/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php b/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php index 0c8c70ce0..76c4fd352 100644 --- a/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php +++ b/src/Bundle/ChillPersonBundle/DependencyInjection/ChillPersonExtension.php @@ -96,7 +96,6 @@ class ChillPersonExtension extends Extension implements PrependExtensionInterfac // We can get rid of this file when the service 'chill.person.repository.person' is no more used. // We should use the PersonRepository service instead of a custom service name. $loader->load('services/repository.yaml'); - $loader->load('services/serializer.yaml'); $loader->load('services/security.yaml'); $loader->load('services/doctrineEventListener.yaml'); $loader->load('services/accompanyingPeriodConsistency.yaml'); diff --git a/src/Bundle/ChillPersonBundle/Entity/Identifier/IdentifierPresenceEnum.php b/src/Bundle/ChillPersonBundle/Entity/Identifier/IdentifierPresenceEnum.php new file mode 100644 index 000000000..120fed3f0 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Entity/Identifier/IdentifierPresenceEnum.php @@ -0,0 +1,42 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\PersonBundle\Entity\Identifier; + +enum IdentifierPresenceEnum: string +{ + /** + * The person identifier is not editable by any user. + * + * The identifier is intended to be added by an import script, for instance. + */ + case NOT_EDITABLE = 'NOT_EDITABLE'; + + /** + * The person identifier is present on the edit form only. + */ + case ON_EDIT = 'ON_EDIT'; + + /** + * The person identifier is present on both person's creation form, and edit form. + */ + case ON_CREATION = 'ON_CREATION'; + + /** + * The person identifier is required to create the person. It should not be empty. + */ + case REQUIRED = 'REQUIRED'; + + public function isEditableByUser(): bool + { + return IdentifierPresenceEnum::NOT_EDITABLE !== $this; + } +} diff --git a/src/Bundle/ChillPersonBundle/Entity/Identifier/PersonIdentifier.php b/src/Bundle/ChillPersonBundle/Entity/Identifier/PersonIdentifier.php index f0dea00b4..a6840f285 100644 --- a/src/Bundle/ChillPersonBundle/Entity/Identifier/PersonIdentifier.php +++ b/src/Bundle/ChillPersonBundle/Entity/Identifier/PersonIdentifier.php @@ -12,15 +12,24 @@ declare(strict_types=1); namespace Chill\PersonBundle\Entity\Identifier; use Chill\PersonBundle\Entity\Person; +use Chill\PersonBundle\PersonIdentifier\Validator\UniqueIdentifierConstraint; +use Chill\PersonBundle\PersonIdentifier\Validator\ValidIdentifierConstraint; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation as Serializer; #[ORM\Entity] #[ORM\Table(name: 'chill_person_identifier')] +#[ORM\UniqueConstraint(name: 'chill_person_identifier_unique', columns: ['definition_id', 'canonical'])] +#[ORM\UniqueConstraint(name: 'chill_person_identifier_unique_person_definition', columns: ['definition_id', 'person_id'])] +#[UniqueIdentifierConstraint] +#[ValidIdentifierConstraint] +#[Serializer\DiscriminatorMap('type', ['person_identifier' => PersonIdentifier::class])] class PersonIdentifier { #[ORM\Id] #[ORM\Column(name: 'id', type: \Doctrine\DBAL\Types\Types::INTEGER)] #[ORM\GeneratedValue] + #[Serializer\Groups(['read'])] private ?int $id = null; #[ORM\ManyToOne(targetEntity: Person::class)] @@ -28,14 +37,16 @@ class PersonIdentifier private ?Person $person = null; #[ORM\Column(name: 'value', type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]', 'jsonb' => true])] + #[Serializer\Groups(['read'])] private array $value = []; - #[ORM\Column(name: 'canonical', type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => '[]'])] + #[ORM\Column(name: 'canonical', type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])] private string $canonical = ''; public function __construct( #[ORM\ManyToOne(targetEntity: PersonIdentifierDefinition::class)] - #[ORM\JoinColumn(name: 'definition_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] + #[ORM\JoinColumn(name: 'definition_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')] + #[Serializer\Groups(['read'])] private PersonIdentifierDefinition $definition, ) {} diff --git a/src/Bundle/ChillPersonBundle/Entity/Identifier/PersonIdentifierDefinition.php b/src/Bundle/ChillPersonBundle/Entity/Identifier/PersonIdentifierDefinition.php index 6d6112569..8b60c7814 100644 --- a/src/Bundle/ChillPersonBundle/Entity/Identifier/PersonIdentifierDefinition.php +++ b/src/Bundle/ChillPersonBundle/Entity/Identifier/PersonIdentifierDefinition.php @@ -11,30 +11,35 @@ declare(strict_types=1); namespace Chill\PersonBundle\Entity\Identifier; +use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation as Serializer; #[ORM\Entity] #[ORM\Table(name: 'chill_person_identifier_definition')] +#[Serializer\DiscriminatorMap('type', ['person_identifier_definition' => PersonIdentifierDefinition::class])] class PersonIdentifierDefinition { #[ORM\Id] - #[ORM\Column(name: 'id', type: \Doctrine\DBAL\Types\Types::INTEGER)] + #[ORM\Column(name: 'id', type: Types::INTEGER)] #[ORM\GeneratedValue] + #[Serializer\Groups(['read'])] private ?int $id = null; - #[ORM\Column(name: 'active', type: \Doctrine\DBAL\Types\Types::BOOLEAN, nullable: false, options: ['default' => true])] + #[ORM\Column(name: 'active', type: Types::BOOLEAN, nullable: false, options: ['default' => true])] private bool $active = true; public function __construct( - #[ORM\Column(name: 'label', type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]'])] + #[ORM\Column(name: 'label', type: Types::JSON, nullable: false, options: ['default' => '[]'])] private array $label, - #[ORM\Column(name: 'engine', type: \Doctrine\DBAL\Types\Types::STRING, length: 100)] + #[ORM\Column(name: 'engine', type: Types::STRING, length: 100)] + #[Serializer\Groups(['read'])] private string $engine, - #[ORM\Column(name: 'is_searchable', type: \Doctrine\DBAL\Types\Types::BOOLEAN, options: ['default' => false])] + #[ORM\Column(name: 'is_searchable', type: Types::BOOLEAN, options: ['default' => false])] private bool $isSearchable = false, - #[ORM\Column(name: 'is_editable_by_users', type: \Doctrine\DBAL\Types\Types::BOOLEAN, nullable: false, options: ['default' => false])] - private bool $isEditableByUsers = false, - #[ORM\Column(name: 'data', type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]', 'jsonb' => true])] + #[ORM\Column(name: 'presence', type: Types::STRING, nullable: false, enumType: IdentifierPresenceEnum::class, options: ['default' => IdentifierPresenceEnum::ON_EDIT])] + private IdentifierPresenceEnum $presence = IdentifierPresenceEnum::ON_EDIT, + #[ORM\Column(name: 'data', type: Types::JSON, nullable: false, options: ['default' => '[]', 'jsonb' => true])] private array $data = [], ) {} @@ -58,11 +63,6 @@ class PersonIdentifierDefinition return $this->engine; } - public function setEngine(string $engine): void - { - $this->engine = $engine; - } - public function isSearchable(): bool { return $this->isSearchable; @@ -75,12 +75,7 @@ class PersonIdentifierDefinition public function isEditableByUsers(): bool { - return $this->isEditableByUsers; - } - - public function setIsEditableByUsers(bool $isEditableByUsers): void - { - $this->isEditableByUsers = $isEditableByUsers; + return $this->presence->isEditableByUser(); } public function isActive(): bool @@ -104,4 +99,16 @@ class PersonIdentifierDefinition { $this->data = $data; } + + public function getPresence(): IdentifierPresenceEnum + { + return $this->presence; + } + + public function setPresence(IdentifierPresenceEnum $presence): self + { + $this->presence = $presence; + + return $this; + } } diff --git a/src/Bundle/ChillPersonBundle/Entity/Person.php b/src/Bundle/ChillPersonBundle/Entity/Person.php index 6b064bcf6..43c54fbd0 100644 --- a/src/Bundle/ChillPersonBundle/Entity/Person.php +++ b/src/Bundle/ChillPersonBundle/Entity/Person.php @@ -27,7 +27,6 @@ use Chill\MainBundle\Entity\Language; use Chill\MainBundle\Entity\User; use Chill\MainBundle\Entity\Workflow\EntityWorkflowSignatureStateEnum; use Chill\MainBundle\Entity\Workflow\EntityWorkflowStepSignature; -use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint; use Chill\PersonBundle\Entity\Household\Household; use Chill\PersonBundle\Entity\Household\HouseholdMember; use Chill\PersonBundle\Entity\Household\PersonHouseholdAddress; @@ -36,6 +35,7 @@ use Chill\PersonBundle\Entity\Person\PersonCenterCurrent; use Chill\PersonBundle\Entity\Person\PersonCenterHistory; use Chill\PersonBundle\Entity\Person\PersonCurrentAddress; use Chill\PersonBundle\Entity\Person\PersonResource; +use Chill\PersonBundle\PersonIdentifier\Validator\RequiredIdentifierConstraint; use Chill\PersonBundle\Validator\Constraints\Household\HouseholdMembershipSequential; use Chill\PersonBundle\Validator\Constraints\Person\Birthdate; use Chill\PersonBundle\Validator\Constraints\Person\PersonHasCenter; @@ -43,10 +43,12 @@ use DateTime; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Criteria; +use Doctrine\Common\Collections\Order; use Doctrine\Common\Collections\ReadableCollection; use Doctrine\Common\Collections\Selectable; use Doctrine\ORM\Mapping as ORM; use libphonenumber\PhoneNumber; +use Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber as MisdPhoneNumberConstraint; use Symfony\Component\Serializer\Attribute\DiscriminatorMap; use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Context\ExecutionContextInterface; @@ -139,6 +141,12 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI #[ORM\ManyToMany(targetEntity: Calendar::class, mappedBy: 'persons')] private Collection $calendars; + /** + * @var Collection<int, Calendar>&Selectable<int, Calendar> + */ + #[ORM\OneToMany(mappedBy: 'person', targetEntity: Calendar::class)] + private Collection $directCalendars; + /** * The person's center. * @@ -273,6 +281,8 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI private ?int $id = null; #[ORM\OneToMany(mappedBy: 'person', targetEntity: PersonIdentifier::class, cascade: ['persist', 'remove'], orphanRemoval: true)] + #[RequiredIdentifierConstraint] + #[Assert\Valid] private Collection $identifiers; /** @@ -319,7 +329,7 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI * The person's mobile phone number. */ #[ORM\Column(type: 'phone_number', nullable: true)] - #[PhonenumberConstraint(type: 'mobile')] + #[MisdPhoneNumberConstraint(type: [MisdPhoneNumberConstraint::MOBILE])] private ?PhoneNumber $mobilenumber = null; /** @@ -359,7 +369,7 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI * The person's phonenumber. */ #[ORM\Column(type: 'phone_number', nullable: true)] - #[PhonenumberConstraint(type: 'landline')] + #[MisdPhoneNumberConstraint(type: [MisdPhoneNumberConstraint::FIXED_LINE, MisdPhoneNumberConstraint::VOIP, MisdPhoneNumberConstraint::PERSONAL_NUMBER])] private ?PhoneNumber $phonenumber = null; /** @@ -406,6 +416,7 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI public function __construct() { $this->calendars = new ArrayCollection(); + $this->directCalendars = new ArrayCollection(); $this->accompanyingPeriodParticipations = new ArrayCollection(); $this->spokenLanguages = new ArrayCollection(); $this->addresses = new ArrayCollection(); @@ -865,6 +876,30 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI return $this->calendars; } + /** + * Get next calendars for this person (calendars with start date after today). + * Only returns calendars that are directly linked to this person via the person property, + * not those linked through AccompanyingPeriods. + * + * @param int|null $limit Optional limit for the number of results + * + * @return array<Calendar> + */ + public function getNextCalendarsForPerson(?int $limit = null): array + { + $today = new \DateTimeImmutable('today'); + + $criteria = Criteria::create() + ->where(Criteria::expr()->gte('startDate', $today)) + ->orderBy(['startDate' => Order::Ascending]); + + if (null !== $limit) { + $criteria->setMaxResults($limit); + } + + return $this->directCalendars->matching($criteria)->toArray(); + } + public function getCenter(): ?Center { if (null !== $this->centerCurrent) { @@ -1117,7 +1152,7 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI ->where( $expr->eq('shareHousehold', false) ) - ->orderBy(['startDate' => \Doctrine\Common\Collections\Order::Descending]); + ->orderBy(['startDate' => Order::Descending]); return $this->getHouseholdParticipations() ->matching($criteria); @@ -1139,7 +1174,7 @@ class Person implements HasCenterInterface, TrackCreationInterface, TrackUpdateI ->where( $expr->eq('shareHousehold', true) ) - ->orderBy(['startDate' => \Doctrine\Common\Collections\Order::Descending, 'id' => \Doctrine\Common\Collections\Order::Descending]); + ->orderBy(['startDate' => Order::Descending, 'id' => Order::Descending]); return $this->getHouseholdParticipations() ->matching($criteria); diff --git a/src/Bundle/ChillPersonBundle/Entity/SocialWork/SocialIssue.php b/src/Bundle/ChillPersonBundle/Entity/SocialWork/SocialIssue.php index d7d78ac77..9563e6166 100644 --- a/src/Bundle/ChillPersonBundle/Entity/SocialWork/SocialIssue.php +++ b/src/Bundle/ChillPersonBundle/Entity/SocialWork/SocialIssue.php @@ -122,6 +122,8 @@ class SocialIssue * get all the ancestors of the social issue. * * @param bool $includeThis if the array in the result must include the present SocialIssue + * + * @return list<SocialIssue> */ public function getAncestors(bool $includeThis = true): array { @@ -176,7 +178,7 @@ class SocialIssue } /** - * @return Collection|SocialAction[] All the descendant social actions of the entity + * @return Collection<int, SocialAction> All the descendant social actions of the entity */ public function getDescendantsSocialActions(): Collection { @@ -239,18 +241,23 @@ class SocialIssue } /** - * @return Collection<SocialAction> All the descendant social actions of all - * the descendants of the entity + * @return Collection<int, SocialAction> All the social actions of the entity, it's + * the descendants and it's parents */ public function getRecursiveSocialActions(): Collection { $recursiveSocialActions = new ArrayCollection(); + // Get social actions from parent issues + foreach ($this->getAncestors(false) as $ancestor) { + foreach ($ancestor->getDescendantsSocialActions() as $descendant) { + $recursiveSocialActions->add($descendant); + } + } + foreach ($this->getDescendantsWithThis() as $socialIssue) { foreach ($socialIssue->getDescendantsSocialActions() as $descendant) { - if (!$recursiveSocialActions->contains($descendant)) { - $recursiveSocialActions->add($descendant); - } + $recursiveSocialActions->add($descendant); } } diff --git a/src/Bundle/ChillPersonBundle/Form/CreationPersonType.php b/src/Bundle/ChillPersonBundle/Form/CreationPersonType.php index cdf03128f..22579027a 100644 --- a/src/Bundle/ChillPersonBundle/Form/CreationPersonType.php +++ b/src/Bundle/ChillPersonBundle/Form/CreationPersonType.php @@ -11,15 +11,14 @@ declare(strict_types=1); namespace Chill\PersonBundle\Form; -use Chill\MainBundle\Entity\Address; use Chill\MainBundle\Form\Event\CustomizeFormEvent; use Chill\MainBundle\Form\Type\ChillDateType; use Chill\MainBundle\Form\Type\ChillPhoneNumberType; use Chill\MainBundle\Form\Type\PickAddressType; use Chill\MainBundle\Form\Type\PickCenterType; use Chill\MainBundle\Form\Type\PickCivilityType; +use Chill\PersonBundle\Actions\PersonCreate\PersonCreateDTO; use Chill\PersonBundle\Config\ConfigPersonAltNamesHelper; -use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Form\Type\PersonAltNameType; use Chill\PersonBundle\Form\Type\PickGenderType; use Chill\PersonBundle\Security\Authorization\PersonVoter; @@ -33,6 +32,7 @@ use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Validator\Constraints\Callback; use Symfony\Component\Validator\Context\ExecutionContextInterface; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; final class CreationPersonType extends AbstractType { @@ -80,15 +80,18 @@ final class CreationPersonType extends AbstractType ->add('addressForm', CheckboxType::class, [ 'label' => 'Create a household and add an address', 'required' => false, - 'mapped' => false, 'help' => 'A new household will be created. The person will be member of this household.', ]) ->add('address', PickAddressType::class, [ 'required' => false, - 'mapped' => false, 'label' => false, ]); + $builder->add('identifiers', PersonIdentifiersType::class, [ + 'by_reference' => false, + 'step' => 'on_create', + ]); + if ($this->askCenters) { $builder ->add('center', PickCenterType::class, [ @@ -112,7 +115,7 @@ final class CreationPersonType extends AbstractType public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ - 'data_class' => Person::class, + 'data_class' => PersonCreateDTO::class, 'constraints' => [ new Callback($this->validateCheckedAddress(...)), ], @@ -127,10 +130,12 @@ final class CreationPersonType extends AbstractType public function validateCheckedAddress($data, ExecutionContextInterface $context, $payload): void { - /** @var bool $addressFrom */ - $addressFrom = $context->getObject()->get('addressForm')->getData(); - /** @var ?Address $address */ - $address = $context->getObject()->get('address')->getData(); + if (!$data instanceof PersonCreateDTO) { + throw new UnexpectedTypeException($data, PersonCreateDTO::class); + } + + $addressFrom = $data->addressForm; + $address = $data->address; if ($addressFrom && null === $address) { $context->buildViolation('person_creation.If you want to create an household, an address is required') diff --git a/src/Bundle/ChillPersonBundle/Form/DataMapper/PersonAltNameDataMapper.php b/src/Bundle/ChillPersonBundle/Form/DataMapper/PersonAltNameDataMapper.php index 27503cff3..00986bddd 100644 --- a/src/Bundle/ChillPersonBundle/Form/DataMapper/PersonAltNameDataMapper.php +++ b/src/Bundle/ChillPersonBundle/Form/DataMapper/PersonAltNameDataMapper.php @@ -12,10 +12,7 @@ declare(strict_types=1); namespace Chill\PersonBundle\Form\DataMapper; use Chill\PersonBundle\Entity\PersonAltName; -use Doctrine\Common\Collections\ArrayCollection; -use Doctrine\Common\Collections\Collection; use Symfony\Component\Form\DataMapperInterface; -use Symfony\Component\Form\Exception\UnexpectedTypeException; class PersonAltNameDataMapper implements DataMapperInterface { @@ -25,62 +22,24 @@ class PersonAltNameDataMapper implements DataMapperInterface return; } - if (!$viewData instanceof Collection) { - throw new UnexpectedTypeException($viewData, Collection::class); - } - - $mapIndexToKey = []; - - foreach ($viewData->getIterator() as $key => $altName) { - /* @var PersonAltName $altName */ - $mapIndexToKey[$altName->getKey()] = $key; + if (!is_array($viewData)) { + throw new \InvalidArgumentException('View data must be an array'); } foreach ($forms as $key => $form) { - if (\array_key_exists($key, $mapIndexToKey)) { - $form->setData($viewData->get($mapIndexToKey[$key])->getLabel()); + $personAltName = $viewData[$key]; + if (!$personAltName instanceof PersonAltName) { + throw new \InvalidArgumentException('PersonAltName must be an instance of PersonAltName'); } + $form->setData($personAltName->getLabel()); } } public function mapFormsToData(\Traversable $forms, &$viewData): void { - $mapIndexToKey = []; - - if (\is_array($viewData)) { - $dataIterator = $viewData; - } else { - $dataIterator = $viewData instanceof ArrayCollection ? - $viewData->toArray() : $viewData->getIterator(); - } - - foreach ($dataIterator as $key => $altName) { - /* @var PersonAltName $altName */ - $mapIndexToKey[$altName->getKey()] = $key; - } - foreach ($forms as $key => $form) { - $isEmpty = empty($form->getData()); - - if (\array_key_exists($key, $mapIndexToKey)) { - if ($isEmpty) { - $viewData->remove($mapIndexToKey[$key]); - } else { - $viewData->get($mapIndexToKey[$key])->setLabel($form->getData()); - } - } else { - if (!$isEmpty) { - $altName = new PersonAltName() - ->setKey($key) - ->setLabel($form->getData()); - - if (\is_array($viewData)) { - $viewData[] = $altName; - } else { - $viewData->add($altName); - } - } - } + $personAltName = array_find($viewData, fn (PersonAltName $altName) => $altName->getKey() === $key); + $personAltName->setLabel($form->getData()); } } } diff --git a/src/Bundle/ChillPersonBundle/Form/DataMapper/PersonIdentifiersDataMapper.php b/src/Bundle/ChillPersonBundle/Form/DataMapper/PersonIdentifiersDataMapper.php index eea151865..bae359e34 100644 --- a/src/Bundle/ChillPersonBundle/Form/DataMapper/PersonIdentifiersDataMapper.php +++ b/src/Bundle/ChillPersonBundle/Form/DataMapper/PersonIdentifiersDataMapper.php @@ -14,60 +14,51 @@ namespace Chill\PersonBundle\Form\DataMapper; use Chill\PersonBundle\Entity\Identifier\PersonIdentifier; use Chill\PersonBundle\PersonIdentifier\Exception\UnexpectedTypeException; use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface; -use Chill\PersonBundle\Repository\Identifier\PersonIdentifierDefinitionRepository; -use Doctrine\Common\Collections\Collection; use Symfony\Component\Form\DataMapperInterface; -use Symfony\Component\Form\FormInterface; final readonly class PersonIdentifiersDataMapper implements DataMapperInterface { public function __construct( private PersonIdentifierManagerInterface $identifierManager, - private PersonIdentifierDefinitionRepository $identifierDefinitionRepository, ) {} + /** + * @pure + */ public function mapDataToForms($viewData, \Traversable $forms): void { - if (!$viewData instanceof Collection) { - throw new UnexpectedTypeException($viewData, Collection::class); + if (!$viewData instanceof PersonIdentifier) { + throw new UnexpectedTypeException($viewData, PersonIdentifier::class); } - /** @var array<string, FormInterface> $formsByKey */ - $formsByKey = iterator_to_array($forms); - foreach ($this->identifierManager->getWorkers() as $worker) { - if (!$worker->getDefinition()->isEditableByUsers()) { - continue; - } - $form = $formsByKey['identifier_'.$worker->getDefinition()->getId()]; - $identifier = $viewData->findFirst(fn (int $key, PersonIdentifier $identifier) => $worker->getDefinition()->getId() === $identifier->getId()); - if (null === $identifier) { - $identifier = new PersonIdentifier($worker->getDefinition()); - } - $form->setData($identifier->getValue()); + $worker = $this->identifierManager->buildWorkerByPersonIdentifierDefinition($viewData->getDefinition()); + if (!$worker->getDefinition()->isEditableByUsers()) { + return; + } + foreach ($forms as $key => $form) { + $form->setData($viewData->getValue()[$key] ?? $worker->getDefaultValue()[$key] ?? ''); } } + /** + * @pure + */ public function mapFormsToData(\Traversable $forms, &$viewData): void { - if (!$viewData instanceof Collection) { - throw new UnexpectedTypeException($viewData, Collection::class); + if (!$viewData instanceof PersonIdentifier) { + throw new UnexpectedTypeException($viewData, PersonIdentifier::class); + } + $worker = $this->identifierManager->buildWorkerByPersonIdentifierDefinition($viewData->getDefinition()); + if (!$worker->getDefinition()->isEditableByUsers()) { + return; } + $values = []; foreach ($forms as $name => $form) { - $identifierId = (int) substr((string) $name, 11); - $identifier = $viewData->findFirst(fn (int $key, PersonIdentifier $identifier) => $identifier->getId() === $identifierId); - $definition = $this->identifierDefinitionRepository->find($identifierId); - if (null === $identifier) { - $identifier = new PersonIdentifier($definition); - $viewData->add($identifier); - } - if (!$identifier->getDefinition()->isEditableByUsers()) { - continue; - } - - $worker = $this->identifierManager->buildWorkerByPersonIdentifierDefinition($definition); - $identifier->setValue($form->getData()); - $identifier->setCanonical($worker->canonicalizeValue($identifier->getValue())); + $values[$name] = $form->getData(); } + + $viewData->setValue($values); + $viewData->setCanonical($worker->canonicalizeValue($viewData->getValue())); } } diff --git a/src/Bundle/ChillPersonBundle/Form/PersonIdentifiersType.php b/src/Bundle/ChillPersonBundle/Form/PersonIdentifiersType.php index ea077f626..199b89bc8 100644 --- a/src/Bundle/ChillPersonBundle/Form/PersonIdentifiersType.php +++ b/src/Bundle/ChillPersonBundle/Form/PersonIdentifiersType.php @@ -12,10 +12,12 @@ declare(strict_types=1); namespace Chill\PersonBundle\Form; use Chill\MainBundle\Templating\TranslatableStringHelperInterface; +use Chill\PersonBundle\Entity\Identifier\IdentifierPresenceEnum; use Chill\PersonBundle\Form\DataMapper\PersonIdentifiersDataMapper; use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; final class PersonIdentifiersType extends AbstractType { @@ -27,22 +29,34 @@ final class PersonIdentifiersType extends AbstractType public function buildForm(FormBuilderInterface $builder, array $options): void { - foreach ($this->identifierManager->getWorkers() as $worker) { + foreach ($this->identifierManager->getWorkers() as $k => $worker) { if (!$worker->getDefinition()->isEditableByUsers()) { continue; } + // skip some on creation + if ('on_create' === $options['step'] + && IdentifierPresenceEnum::ON_EDIT === $worker->getDefinition()->getPresence()) { + continue; + } + $subBuilder = $builder->create( 'identifier_'.$worker->getDefinition()->getId(), options: [ 'compound' => true, 'label' => $this->translatableStringHelper->localize($worker->getDefinition()->getLabel()), + 'error_bubbling' => false, ] ); + $subBuilder->setDataMapper($this->identifiersDataMapper); $worker->buildForm($subBuilder); $builder->add($subBuilder); } + } - $builder->setDataMapper($this->identifiersDataMapper); + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefault('step', 'on_edit') + ->setAllowedValues('step', ['on_edit', 'on_create']); } } diff --git a/src/Bundle/ChillPersonBundle/Form/PersonType.php b/src/Bundle/ChillPersonBundle/Form/PersonType.php index f4562edb1..aa0e5c5c1 100644 --- a/src/Bundle/ChillPersonBundle/Form/PersonType.php +++ b/src/Bundle/ChillPersonBundle/Form/PersonType.php @@ -21,8 +21,8 @@ use Chill\MainBundle\Form\Type\PickCivilityType; use Chill\MainBundle\Form\Type\Select2CountryType; use Chill\MainBundle\Form\Type\Select2LanguageType; use Chill\MainBundle\Templating\TranslatableStringHelperInterface; +use Chill\PersonBundle\Actions\PersonEdit\PersonEditDTO; use Chill\PersonBundle\Config\ConfigPersonAltNamesHelper; -use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Entity\PersonPhone; use Chill\PersonBundle\Form\Type\PersonAltNameType; use Chill\PersonBundle\Form\Type\PersonPhoneType; @@ -242,7 +242,7 @@ class PersonType extends AbstractType public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ - 'data_class' => Person::class, + 'data_class' => PersonEditDTO::class, ]); $resolver->setRequired([ diff --git a/src/Bundle/ChillPersonBundle/Form/Type/PersonAltNameType.php b/src/Bundle/ChillPersonBundle/Form/Type/PersonAltNameType.php index 4d7026652..8220cebc1 100644 --- a/src/Bundle/ChillPersonBundle/Form/Type/PersonAltNameType.php +++ b/src/Bundle/ChillPersonBundle/Form/Type/PersonAltNameType.php @@ -32,6 +32,7 @@ class PersonAltNameType extends AbstractType [ 'label' => $label, 'required' => false, + 'empty_data' => '', ] ); } diff --git a/src/Bundle/ChillPersonBundle/PersonIdentifier/Identifier/StringIdentifier.php b/src/Bundle/ChillPersonBundle/PersonIdentifier/Identifier/StringIdentifier.php index 0801d4947..68620d869 100644 --- a/src/Bundle/ChillPersonBundle/PersonIdentifier/Identifier/StringIdentifier.php +++ b/src/Bundle/ChillPersonBundle/PersonIdentifier/Identifier/StringIdentifier.php @@ -13,20 +13,26 @@ namespace Chill\PersonBundle\PersonIdentifier\Identifier; use Chill\PersonBundle\Entity\Identifier\PersonIdentifier; use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition; +use Chill\PersonBundle\PersonIdentifier\IdentifierViolationDTO; use Chill\PersonBundle\PersonIdentifier\PersonIdentifierEngineInterface; use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; final readonly class StringIdentifier implements PersonIdentifierEngineInterface { + public const NAME = 'chill-person-bundle.string-identifier'; + + private const ONLY_NUMBERS = 'only_numbers'; + private const FIXED_LENGTH = 'fixed_length'; + public static function getName(): string { - return 'chill-person-bundle.string-identifier'; + return self::NAME; } public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string { - return $value['content'] ?? ''; + return trim($value['content'] ?? ''); } public function buildForm(FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void @@ -36,6 +42,37 @@ final readonly class StringIdentifier implements PersonIdentifierEngineInterface public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string { - return $identifier?->getValue()['content'] ?? ''; + return trim($identifier?->getValue()['content'] ?? ''); + } + + public function isEmpty(PersonIdentifier $identifier): bool + { + return '' === trim($identifier->getValue()['content'] ?? ''); + } + + public function validate(PersonIdentifier $identifier, PersonIdentifierDefinition $definition): array + { + $config = $definition->getData(); + $content = (string) ($identifier->getValue()['content'] ?? ''); + $violations = []; + + if (($config[self::ONLY_NUMBERS] ?? false) && !preg_match('/^[0-9]+$/', $content)) { + $violations[] = new IdentifierViolationDTO('person_identifier.only_number', '2a3352c0-a2b9-11f0-a767-b7a3f80e52f1'); + } + + if (null !== ($config[self::FIXED_LENGTH] ?? null) && strlen($content) !== $config[self::FIXED_LENGTH]) { + $violations[] = new IdentifierViolationDTO( + 'person_identifier.fixed_length', + '2b02a8fe-a2b9-11f0-bfe5-033300972783', + ['limit' => (string) $config[self::FIXED_LENGTH]] + ); + } + + return $violations; + } + + public function getDefaultValue(PersonIdentifierDefinition $definition): array + { + return ['content' => '']; } } diff --git a/src/Bundle/ChillPersonBundle/PersonIdentifier/IdentifierViolationDTO.php b/src/Bundle/ChillPersonBundle/PersonIdentifier/IdentifierViolationDTO.php new file mode 100644 index 000000000..3a74a4b34 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/PersonIdentifier/IdentifierViolationDTO.php @@ -0,0 +1,31 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\PersonBundle\PersonIdentifier; + +/** + * Data Transfer Object to create a ConstraintViolationListInterface. + */ +class IdentifierViolationDTO +{ + public function __construct( + public string $message, + /** + * @var string an UUID + */ + public string $code, + /** + * @var array<string, string> + */ + public array $parameters = [], + public string $messageDomain = 'validators', + ) {} +} diff --git a/src/Bundle/ChillPersonBundle/PersonIdentifier/Normalizer/PersonIdentifierWorkerNormalizer.php b/src/Bundle/ChillPersonBundle/PersonIdentifier/Normalizer/PersonIdentifierWorkerNormalizer.php new file mode 100644 index 000000000..432dea50c --- /dev/null +++ b/src/Bundle/ChillPersonBundle/PersonIdentifier/Normalizer/PersonIdentifierWorkerNormalizer.php @@ -0,0 +1,39 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\PersonBundle\PersonIdentifier\Normalizer; + +use Chill\PersonBundle\PersonIdentifier\PersonIdentifierWorker; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +final readonly class PersonIdentifierWorkerNormalizer implements NormalizerInterface +{ + public function normalize($object, ?string $format = null, array $context = []): array + { + if (!$object instanceof PersonIdentifierWorker) { + throw new \Symfony\Component\Serializer\Exception\UnexpectedValueException(); + } + + return [ + 'type' => 'person_identifier_worker', + 'definition_id' => $object->getDefinition()->getId(), + 'engine' => $object->getDefinition()->getEngine(), + 'label' => $object->getDefinition()->getLabel(), + 'isActive' => $object->getDefinition()->isActive(), + 'presence' => $object->getDefinition()->getPresence()->value, + ]; + } + + public function supportsNormalization($data, ?string $format = null): bool + { + return $data instanceof PersonIdentifierWorker; + } +} diff --git a/src/Bundle/ChillPersonBundle/PersonIdentifier/PersonIdentifierEngineInterface.php b/src/Bundle/ChillPersonBundle/PersonIdentifier/PersonIdentifierEngineInterface.php index 6c75f263e..a29172958 100644 --- a/src/Bundle/ChillPersonBundle/PersonIdentifier/PersonIdentifierEngineInterface.php +++ b/src/Bundle/ChillPersonBundle/PersonIdentifier/PersonIdentifierEngineInterface.php @@ -19,9 +19,29 @@ interface PersonIdentifierEngineInterface { public static function getName(): string; + /** + * @phpstan-pure + */ public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string; public function buildForm(FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void; public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string; + + /** + * Return true if the identifier must be considered as empty. + * + * This is in use when the identifier is validated and must be required. If the identifier is empty and is required + * by the definition, the validation will fails. + */ + public function isEmpty(PersonIdentifier $identifier): bool; + + /** + * Return a list of @see{IdentifierViolationDTO} to generatie violation errors. + * + * @return list<IdentifierViolationDTO> + */ + public function validate(PersonIdentifier $identifier, PersonIdentifierDefinition $definition): array; + + public function getDefaultValue(PersonIdentifierDefinition $definition): array; } diff --git a/src/Bundle/ChillPersonBundle/PersonIdentifier/PersonIdentifierManager.php b/src/Bundle/ChillPersonBundle/PersonIdentifier/PersonIdentifierManager.php index 3cbc2c621..83e883fe4 100644 --- a/src/Bundle/ChillPersonBundle/PersonIdentifier/PersonIdentifierManager.php +++ b/src/Bundle/ChillPersonBundle/PersonIdentifier/PersonIdentifierManager.php @@ -13,6 +13,7 @@ namespace Chill\PersonBundle\PersonIdentifier; use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition; use Chill\PersonBundle\PersonIdentifier\Exception\EngineNotFoundException; +use Chill\PersonBundle\PersonIdentifier\Exception\PersonIdentifierDefinitionNotFoundException; use Chill\PersonBundle\Repository\Identifier\PersonIdentifierDefinitionRepository; final readonly class PersonIdentifierManager implements PersonIdentifierManagerInterface @@ -44,8 +45,16 @@ final readonly class PersonIdentifierManager implements PersonIdentifierManagerI return $workers; } - public function buildWorkerByPersonIdentifierDefinition(PersonIdentifierDefinition $personIdentifierDefinition): PersonIdentifierWorker + public function buildWorkerByPersonIdentifierDefinition(int|PersonIdentifierDefinition $personIdentifierDefinition): PersonIdentifierWorker { + if (is_int($personIdentifierDefinition)) { + $id = $personIdentifierDefinition; + $personIdentifierDefinition = $this->personIdentifierDefinitionRepository->find($id); + if (null === $personIdentifierDefinition) { + throw new PersonIdentifierDefinitionNotFoundException($id); + } + } + return new PersonIdentifierWorker($this->getEngine($personIdentifierDefinition->getEngine()), $personIdentifierDefinition); } diff --git a/src/Bundle/ChillPersonBundle/PersonIdentifier/PersonIdentifierManagerInterface.php b/src/Bundle/ChillPersonBundle/PersonIdentifier/PersonIdentifierManagerInterface.php index 9bec7d1fd..b28fba7b6 100644 --- a/src/Bundle/ChillPersonBundle/PersonIdentifier/PersonIdentifierManagerInterface.php +++ b/src/Bundle/ChillPersonBundle/PersonIdentifier/PersonIdentifierManagerInterface.php @@ -18,9 +18,16 @@ interface PersonIdentifierManagerInterface /** * Build PersonIdentifierWorker's for all active definition. * + * Only active definition are returned. + * * @return list<PersonIdentifierWorker> */ public function getWorkers(): array; - public function buildWorkerByPersonIdentifierDefinition(PersonIdentifierDefinition $personIdentifierDefinition): PersonIdentifierWorker; + /** + * @param int|PersonIdentifierDefinition $personIdentifierDefinition an instance of PersonIdentifierDefinition, or his id + * + * @throw PersonIdentifierNotFoundException + */ + public function buildWorkerByPersonIdentifierDefinition(int|PersonIdentifierDefinition $personIdentifierDefinition): PersonIdentifierWorker; } diff --git a/src/Bundle/ChillPersonBundle/PersonIdentifier/PersonIdentifierWorker.php b/src/Bundle/ChillPersonBundle/PersonIdentifier/PersonIdentifierWorker.php index 94702b8eb..529a98e84 100644 --- a/src/Bundle/ChillPersonBundle/PersonIdentifier/PersonIdentifierWorker.php +++ b/src/Bundle/ChillPersonBundle/PersonIdentifier/PersonIdentifierWorker.php @@ -15,7 +15,7 @@ use Chill\PersonBundle\Entity\Identifier\PersonIdentifier; use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition; use Symfony\Component\Form\FormBuilderInterface; -final readonly class PersonIdentifierWorker +readonly class PersonIdentifierWorker { public function __construct( private PersonIdentifierEngineInterface $identifierEngine, @@ -46,4 +46,25 @@ final readonly class PersonIdentifierWorker { return $this->identifierEngine->renderAsString($identifier, $this->definition); } + + /** + * Return true if the identifier must be considered as empty. + */ + public function isEmpty(PersonIdentifier $identifier): bool + { + return $this->identifierEngine->isEmpty($identifier); + } + + /** + * @return list<IdentifierViolationDTO> + */ + public function validate(PersonIdentifier $identifier, PersonIdentifierDefinition $definition): array + { + return $this->identifierEngine->validate($identifier, $definition); + } + + public function getDefaultValue(): array + { + return $this->identifierEngine->getDefaultValue($this->definition); + } } diff --git a/src/Bundle/ChillPersonBundle/PersonIdentifier/Validator/RequiredIdentifierConstraint.php b/src/Bundle/ChillPersonBundle/PersonIdentifier/Validator/RequiredIdentifierConstraint.php new file mode 100644 index 000000000..68a75c7cf --- /dev/null +++ b/src/Bundle/ChillPersonBundle/PersonIdentifier/Validator/RequiredIdentifierConstraint.php @@ -0,0 +1,28 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\PersonBundle\PersonIdentifier\Validator; + +use Symfony\Component\Validator\Constraint; + +/** + * Test that the required constraints are present. + */ +#[\Attribute] +class RequiredIdentifierConstraint extends Constraint +{ + public string $message = 'person_identifier.This identifier must be set'; + + public function getTargets(): string + { + return self::PROPERTY_CONSTRAINT; + } +} diff --git a/src/Bundle/ChillPersonBundle/PersonIdentifier/Validator/RequiredIdentifierConstraintValidator.php b/src/Bundle/ChillPersonBundle/PersonIdentifier/Validator/RequiredIdentifierConstraintValidator.php new file mode 100644 index 000000000..240fe3a33 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/PersonIdentifier/Validator/RequiredIdentifierConstraintValidator.php @@ -0,0 +1,53 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\PersonBundle\PersonIdentifier\Validator; + +use Chill\PersonBundle\Entity\Identifier\IdentifierPresenceEnum; +use Chill\PersonBundle\Entity\Identifier\PersonIdentifier; +use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface; +use Doctrine\Common\Collections\Collection; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; +use Symfony\Component\Validator\Exception\UnexpectedValueException; + +final class RequiredIdentifierConstraintValidator extends ConstraintValidator +{ + public function __construct(private readonly PersonIdentifierManagerInterface $identifierManager) {} + + public function validate($value, Constraint $constraint) + { + if (!$constraint instanceof RequiredIdentifierConstraint) { + throw new UnexpectedTypeException($constraint, RequiredIdentifierConstraint::class); + } + + if (!$value instanceof Collection) { + throw new UnexpectedValueException($value, Collection::class); + } + + foreach ($this->identifierManager->getWorkers() as $worker) { + if (IdentifierPresenceEnum::REQUIRED !== $worker->getDefinition()->getPresence()) { + continue; + } + + $identifier = $value->findFirst(fn (int $key, PersonIdentifier $identifier) => $identifier->getDefinition() === $worker->getDefinition()); + + if (null === $identifier || $worker->isEmpty($identifier)) { + $this->context->buildViolation($constraint->message) + ->setParameter('{{ value }}', $worker->renderAsString($identifier)) + ->setParameter('definition_id', (string) $worker->getDefinition()->getId()) + ->setCode('c08b7b32-947f-11f0-8608-9b8560e9bf05') + ->addViolation(); + } + } + } +} diff --git a/src/Bundle/ChillPersonBundle/PersonIdentifier/Validator/UniqueIdentifierConstraint.php b/src/Bundle/ChillPersonBundle/PersonIdentifier/Validator/UniqueIdentifierConstraint.php new file mode 100644 index 000000000..b15c4a8b2 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/PersonIdentifier/Validator/UniqueIdentifierConstraint.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\PersonBundle\PersonIdentifier\Validator; + +use Symfony\Component\Validator\Constraint; + +#[\Attribute] +class UniqueIdentifierConstraint extends Constraint +{ + public string $message = 'person_identifier.Identifier must be unique. The same identifier already exists for {{ persons }}'; + + public function getTargets(): string + { + return self::CLASS_CONSTRAINT; + } +} diff --git a/src/Bundle/ChillPersonBundle/PersonIdentifier/Validator/UniqueIdentifierConstraintValidator.php b/src/Bundle/ChillPersonBundle/PersonIdentifier/Validator/UniqueIdentifierConstraintValidator.php new file mode 100644 index 000000000..929fba2e4 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/PersonIdentifier/Validator/UniqueIdentifierConstraintValidator.php @@ -0,0 +1,53 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\PersonBundle\PersonIdentifier\Validator; + +use Chill\PersonBundle\Entity\Identifier\PersonIdentifier; +use Chill\PersonBundle\Repository\Identifier\PersonIdentifierRepository; +use Chill\PersonBundle\Templating\Entity\PersonRenderInterface; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; +use Symfony\Component\Validator\Exception\UnexpectedValueException; + +class UniqueIdentifierConstraintValidator extends ConstraintValidator +{ + public function __construct( + private readonly PersonIdentifierRepository $personIdentifierRepository, + private readonly PersonRenderInterface $personRender, + ) {} + + public function validate($value, Constraint $constraint): void + { + if (!$constraint instanceof UniqueIdentifierConstraint) { + throw new UnexpectedTypeException($constraint, UniqueIdentifierConstraint::class); + } + + if (!$value instanceof PersonIdentifier) { + throw new UnexpectedValueException($value, PersonIdentifier::class); + } + + $identifiers = $this->personIdentifierRepository->findByDefinitionAndCanonical($value->getDefinition(), $value->getValue()); + + if (count($identifiers) > 0) { + if (count($identifiers) > 1 || $identifiers[0]->getPerson() !== $value->getPerson()) { + $persons = array_map(fn (PersonIdentifier $idf): string => $this->personRender->renderString($idf->getPerson(), []), $identifiers); + + $this->context->buildViolation($constraint->message) + ->setParameter('{{ persons }}', implode(', ', $persons)) + ->setParameter('definition_id', (string) $value->getDefinition()->getId()) + ->addViolation(); + + } + } + } +} diff --git a/src/Bundle/ChillPersonBundle/PersonIdentifier/Validator/ValidIdentifierConstraint.php b/src/Bundle/ChillPersonBundle/PersonIdentifier/Validator/ValidIdentifierConstraint.php new file mode 100644 index 000000000..24b3551b1 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/PersonIdentifier/Validator/ValidIdentifierConstraint.php @@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\PersonBundle\PersonIdentifier\Validator; + +use Symfony\Component\Validator\Constraint; + +#[\Attribute] +class ValidIdentifierConstraint extends Constraint +{ + public function getTargets(): string + { + return self::CLASS_CONSTRAINT; + } +} diff --git a/src/Bundle/ChillPersonBundle/PersonIdentifier/Validator/ValidIdentifierConstraintValidator.php b/src/Bundle/ChillPersonBundle/PersonIdentifier/Validator/ValidIdentifierConstraintValidator.php new file mode 100644 index 000000000..296f5e3ee --- /dev/null +++ b/src/Bundle/ChillPersonBundle/PersonIdentifier/Validator/ValidIdentifierConstraintValidator.php @@ -0,0 +1,47 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\PersonBundle\PersonIdentifier\Validator; + +use Chill\PersonBundle\Entity\Identifier\PersonIdentifier; +use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; +use Symfony\Component\Validator\Exception\UnexpectedValueException; + +final class ValidIdentifierConstraintValidator extends ConstraintValidator +{ + public function __construct(private readonly PersonIdentifierManagerInterface $personIdentifierManager) {} + + public function validate($value, Constraint $constraint): void + { + if (!$constraint instanceof ValidIdentifierConstraint) { + throw new UnexpectedTypeException($constraint, ValidIdentifierConstraint::class); + } + + if (!$value instanceof PersonIdentifier) { + throw new UnexpectedValueException($value, PersonIdentifier::class); + } + + $worker = $this->personIdentifierManager->buildWorkerByPersonIdentifierDefinition($value->getDefinition()); + + $violations = $worker->validate($value, $value->getDefinition()); + + foreach ($violations as $violation) { + $this->context->buildViolation($violation->message) + ->setParameters($violation->parameters) + ->setParameter('{{ code }}', $violation->code) + ->setParameter('definition_id', (string) $value->getDefinition()->getId()) + ->addViolation(); + } + } +} diff --git a/src/Bundle/ChillPersonBundle/Repository/Identifier/PersonIdentifierRepository.php b/src/Bundle/ChillPersonBundle/Repository/Identifier/PersonIdentifierRepository.php new file mode 100644 index 000000000..e3af66e31 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Repository/Identifier/PersonIdentifierRepository.php @@ -0,0 +1,42 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\PersonBundle\Repository\Identifier; + +use Chill\PersonBundle\Entity\Identifier\PersonIdentifier; +use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition; +use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface; +use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\Persistence\ManagerRegistry; + +class PersonIdentifierRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry, private readonly PersonIdentifierManagerInterface $personIdentifierManager) + { + parent::__construct($registry, PersonIdentifier::class); + } + + public function findByDefinitionAndCanonical(PersonIdentifierDefinition $definition, array|string $valueOrCanonical): array + { + return $this->createQueryBuilder('p') + ->where('p.definition = :definition') + ->andWhere('p.canonical = :canonical') + ->setParameter('definition', $definition) + ->setParameter( + 'canonical', + is_string($valueOrCanonical) ? + $valueOrCanonical : + $this->personIdentifierManager->buildWorkerByPersonIdentifierDefinition($definition)->canonicalizeValue($valueOrCanonical), + ) + ->getQuery() + ->getResult(); + } +} diff --git a/src/Bundle/ChillPersonBundle/Repository/PersonACLAwareRepository.php b/src/Bundle/ChillPersonBundle/Repository/PersonACLAwareRepository.php index 08b81b18a..46db728d2 100644 --- a/src/Bundle/ChillPersonBundle/Repository/PersonACLAwareRepository.php +++ b/src/Bundle/ChillPersonBundle/Repository/PersonACLAwareRepository.php @@ -17,15 +17,25 @@ use Chill\MainBundle\Search\ParsingException; use Chill\MainBundle\Search\SearchApiQuery; use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface; use Chill\PersonBundle\Entity\Person; +use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface; +use Chill\PersonBundle\PersonIdentifier\PersonIdentifierWorker; use Chill\PersonBundle\Security\Authorization\PersonVoter; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\NonUniqueResultException; use Doctrine\ORM\Query; +use libphonenumber\PhoneNumber; +use libphonenumber\PhoneNumberFormat; use Symfony\Bundle\SecurityBundle\Security; final readonly class PersonACLAwareRepository implements PersonACLAwareRepositoryInterface { - public function __construct(private Security $security, private EntityManagerInterface $em, private CountryRepository $countryRepository, private AuthorizationHelperInterface $authorizationHelper) {} + public function __construct( + private Security $security, + private EntityManagerInterface $em, + private CountryRepository $countryRepository, + private AuthorizationHelperInterface $authorizationHelper, + private PersonIdentifierManagerInterface $personIdentifierManager, + ) {} public function buildAuthorizedQuery( ?string $default = null, @@ -105,6 +115,15 @@ final readonly class PersonACLAwareRepository implements PersonACLAwareRepositor $query ->setFromClause('chill_person_person AS person'); + $idDefinitionWorkers = array_map( + fn (PersonIdentifierWorker $worker) => $worker->getDefinition()->getId(), + array_filter( + $this->personIdentifierManager->getWorkers(), + fn (PersonIdentifierWorker $worker) => $worker->getDefinition()->isSearchable() + ) + ); + $idDefinitionWorkerQuestionMarks = implode(', ', array_fill(0, count($idDefinitionWorkers), '?')); + $pertinence = []; $pertinenceArgs = []; $andWhereSearchClause = []; @@ -122,20 +141,53 @@ final readonly class PersonACLAwareRepository implements PersonACLAwareRepositor '(starts_with(LOWER(UNACCENT(lastname)), UNACCENT(LOWER(?))))::int'; \array_push($pertinenceArgs, $str, $str, $str, $str); - $andWhereSearchClause[] = - '(LOWER(UNACCENT(?)) <<% person.fullnamecanonical OR '. - "person.fullnamecanonical LIKE '%' || LOWER(UNACCENT(?)) || '%' )"; - \array_push($andWhereSearchClauseArgs, $str, $str); + $q = [ + 'LOWER(UNACCENT(?)) <<% person.fullnamecanonical', + "person.fullnamecanonical LIKE '%' || LOWER(UNACCENT(?)) || '%' ", + ]; + $qArguments = [$str, $str]; + + if (count($idDefinitionWorkers) > 0) { + $q[] = $mq = "EXISTS ( + SELECT 1 FROM chill_person_identifier AS identifier + WHERE identifier.canonical LIKE LOWER(UNACCENT(?)) || '%' AND identifier.definition_id IN ({$idDefinitionWorkerQuestionMarks}) + AND person.id = identifier.person_id + )"; + $pertinence[] = "({$mq})::int * 1000000"; + $qArguments = [...$qArguments, $str, ...$idDefinitionWorkers]; + $pertinenceArgs = [...$pertinenceArgs, $str, ...$idDefinitionWorkers]; + } + + $andWhereSearchClause[] = '('.implode(' OR ', $q).')'; + $andWhereSearchClauseArgs = [...$andWhereSearchClauseArgs, ...$qArguments]; } - $query->andWhereClause( - \implode(' AND ', $andWhereSearchClause), - $andWhereSearchClauseArgs - ); } else { $pertinence = ['1']; $pertinenceArgs = []; } + + if (null !== $phonenumber) { + $personPhoneClause = "person.phonenumber LIKE '%' || ? || '%' OR person.mobilenumber LIKE '%' || ? || '%' OR EXISTS (SELECT 1 FROM chill_person_phone where person_id = person.id AND phonenumber LIKE '%' || ? || '%')"; + if (count($andWhereSearchClauseArgs) > 0) { + $initialSearchClause = '(('.\implode(' AND ', $andWhereSearchClause).') OR '.$personPhoneClause.')'; + } + $andWhereSearchClauseArgs = [...$andWhereSearchClauseArgs, $phonenumber, $phonenumber, $phonenumber]; + + // drastically increase pertinence + $pertinence[] = "(person.phonenumber LIKE '%' || ? || '%' OR person.mobilenumber LIKE '%' || ? || '%' OR EXISTS (SELECT 1 FROM chill_person_phone where person_id = person.id AND phonenumber LIKE '%' || ? || '%'))::int * 1000000"; + $pertinenceArgs = [...$pertinenceArgs, $phonenumber, $phonenumber, $phonenumber]; + } else { + $initialSearchClause = \implode(' AND ', $andWhereSearchClause); + } + + if (isset($initialSearchClause)) { + $query->andWhereClause( + $initialSearchClause, + $andWhereSearchClauseArgs + ); + } + $query ->setSelectPertinence(\implode(' + ', $pertinence), $pertinenceArgs); @@ -174,14 +226,6 @@ final readonly class PersonACLAwareRepository implements PersonACLAwareRepositor ); } - if (null !== $phonenumber) { - $query->andWhereClause( - "person.phonenumber LIKE '%' || ? || '%' OR person.mobilenumber LIKE '%' || ? || '%' OR pp.phonenumber LIKE '%' || ? || '%'", - [$phonenumber, $phonenumber, $phonenumber] - ); - $query->setFromClause($query->getFromClause().' LEFT JOIN chill_person_phone pp ON pp.person_id = person.id'); - } - if (null !== $city) { $query->setFromClause($query->getFromClause().' '. 'JOIN view_chill_person_current_address vcpca ON vcpca.person_id = person.id '. @@ -298,4 +342,27 @@ final readonly class PersonACLAwareRepository implements PersonACLAwareRepositor \array_map(static fn (Center $c) => $c->getId(), $authorizedCenters) ); } + + public function findByPhone(PhoneNumber $phoneNumber, int $start = 0, int $limit = 20): array + { + $authorizedCenters = $this->authorizationHelper + ->getReachableCenters($this->security->getUser(), PersonVoter::SEE); + + if ([] === $authorizedCenters) { + return []; + } + + $util = \libphonenumber\PhoneNumberUtil::getInstance(); + + return $this->em->createQuery( + 'SELECT p FROM '.Person::class.' p LEFT JOIN p.otherPhoneNumbers opn JOIN p.centerCurrent pcc '. + 'WHERE (p.phonenumber LIKE :phone OR p.mobilenumber LIKE :phone OR opn.phonenumber LIKE :phone) '. + 'AND pcc.center IN (:centers)' + ) + ->setMaxResults($limit) + ->setFirstResult($start) + ->setParameter('phone', $util->format($phoneNumber, PhoneNumberFormat::E164)) + ->setParameter('centers', $authorizedCenters) + ->getResult(); + } } diff --git a/src/Bundle/ChillPersonBundle/Repository/PersonACLAwareRepositoryInterface.php b/src/Bundle/ChillPersonBundle/Repository/PersonACLAwareRepositoryInterface.php index 50fdcd4b3..aeaefecd4 100644 --- a/src/Bundle/ChillPersonBundle/Repository/PersonACLAwareRepositoryInterface.php +++ b/src/Bundle/ChillPersonBundle/Repository/PersonACLAwareRepositoryInterface.php @@ -13,6 +13,7 @@ namespace Chill\PersonBundle\Repository; use Chill\MainBundle\Search\SearchApiQuery; use Chill\PersonBundle\Entity\Person; +use libphonenumber\PhoneNumber; interface PersonACLAwareRepositoryInterface { @@ -60,4 +61,13 @@ interface PersonACLAwareRepositoryInterface ?string $phonenumber = null, ?string $city = null, ): array; + + /** + * @return list<Person> + */ + public function findByPhone( + PhoneNumber $phoneNumber, + int $start = 0, + int $limit = 20, + ): array; } diff --git a/src/Bundle/ChillPersonBundle/Repository/PersonRepository.php b/src/Bundle/ChillPersonBundle/Repository/PersonRepository.php index 4498b6bc2..320004594 100644 --- a/src/Bundle/ChillPersonBundle/Repository/PersonRepository.php +++ b/src/Bundle/ChillPersonBundle/Repository/PersonRepository.php @@ -12,10 +12,12 @@ declare(strict_types=1); namespace Chill\PersonBundle\Repository; use Chill\PersonBundle\Entity\Person; +use Chill\PersonBundle\Entity\PersonPhone; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ObjectRepository; +use libphonenumber\PhoneNumber; class PersonRepository implements ObjectRepository { @@ -29,6 +31,8 @@ class PersonRepository implements ObjectRepository /** * @throws \Doctrine\ORM\NoResultException * @throws \Doctrine\ORM\NonUniqueResultException + * + * @deprecated */ public function countByPhone( string $phonenumber, @@ -71,6 +75,8 @@ class PersonRepository implements ObjectRepository /** * @throws \Exception + * + * @deprecated Use @see{self::findByPhoneNumber} or use a dedicated method in PersonACLAwareRepository */ public function findByPhone( string $phonenumber, @@ -91,6 +97,25 @@ class PersonRepository implements ObjectRepository return $qb->getQuery()->getResult(); } + /** + * Find a person which is associated to the given phonenumber, without restrictions + * on any. + * + * @return list<Person> + */ + public function findByPhoneNumber(PhoneNumber $phoneNumber, int $firstResult = 0, int $maxResults = 50): array + { + $qb = $this->repository->createQueryBuilder('p'); + $qb->select('p'); + + $this->searchByPhoneNumbers($qb, $phoneNumber); + + $qb->setFirstResult($firstResult) + ->setMaxResults($maxResults); + + return $qb->getQuery()->getResult(); + } + public function findOneBy(array $criteria) { return $this->repository->findOneBy($criteria); @@ -109,6 +134,20 @@ class PersonRepository implements ObjectRepository } } + private function searchByPhoneNumbers(QueryBuilder $qb, PhoneNumber $phoneNumber): void + { + $qb->setParameter('number', $phoneNumber, 'phone_number'); + + $orX = $qb->expr()->orX(); + $orX->add($qb->expr()->eq('p.mobilenumber', ':number')); + $orX->add($qb->expr()->eq('p.phonenumber', ':number')); + $orX->add( + $qb->expr()->exists('SELECT 1 FROM '.PersonPhone::class.' k WHERE k.phonenumber = :number AND k.person = p') + ); + + $qb->andWhere($orX); + } + /** * @throws \Exception */ diff --git a/src/Bundle/ChillPersonBundle/Resources/public/mod/DuplicateSelector/AccompanyingPeriodWorkSelector.ts b/src/Bundle/ChillPersonBundle/Resources/public/mod/DuplicateSelector/AccompanyingPeriodWorkSelector.ts index 8e955070a..a5da0955e 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/mod/DuplicateSelector/AccompanyingPeriodWorkSelector.ts +++ b/src/Bundle/ChillPersonBundle/Resources/public/mod/DuplicateSelector/AccompanyingPeriodWorkSelector.ts @@ -3,50 +3,47 @@ import AccompanyingPeriodWorkSelectorModal from "../../vuejs/_components/Accompa import { AccompanyingPeriodWork } from "../../types"; document.addEventListener("DOMContentLoaded", () => { - const elements = document.querySelectorAll<HTMLDivElement>( - 'div[data-pick-entities-type="acpw"]', + const elements = document.querySelectorAll<HTMLDivElement>( + 'div[data-pick-entities-type="acpw"]', + ); + elements.forEach((el) => { + const uniqid = el.dataset.inputUniqid; + + if (undefined === uniqid) { + throw "Uniqid not found on this element"; + } + + const input = document.querySelector<HTMLInputElement>( + `input[data-input-uniqid="${uniqid}"]`, ); - elements.forEach((el) => { - const uniqid = el.dataset.inputUniqid; - if (undefined === uniqid) { - throw "Uniqid not found on this element"; - } + if (null === input) { + throw "Element with uniqid not found: " + uniqid; + } - const input = document.querySelector<HTMLInputElement>( - `input[data-input-uniqid="${uniqid}"]`, - ); + const accompanyingPeriodIdAsString = input.dataset.accompanyingPeriodId; - if (null === input) { - throw "Element with uniqid not found: " + uniqid; - } + if (undefined === accompanyingPeriodIdAsString) { + throw "accompanying period id not found"; + } - const accompanyingPeriodIdAsString = input.dataset.accompanyingPeriodId; + const accompanyingPeriodId = Number.parseInt(accompanyingPeriodIdAsString); - if (undefined === accompanyingPeriodIdAsString) { - throw "accompanying period id not found"; - } - - const accompanyingPeriodId = Number.parseInt( - accompanyingPeriodIdAsString, - ); - - const app = createApp({ - template: - '<accompanying-period-work-selector-modal :accompanying-period-id="accompanyingPeriodId" @pickWork="pickWork"></accompanying-period-work-selector-modal>', - components: { AccompanyingPeriodWorkSelectorModal }, - data() { - return { accompanyingPeriodId }; - }, - methods: { - pickWork: function (payload: { work: AccompanyingPeriodWork }) { - console.log("payload", payload); - - input.value = payload.work.id?.toString() ?? ""; - }, - }, - }); - - app.mount(el); + const app = createApp({ + template: + '<accompanying-period-work-selector-modal :accompanying-period-id="accompanyingPeriodId" @pickWork="pickWork"></accompanying-period-work-selector-modal>', + components: { AccompanyingPeriodWorkSelectorModal }, + data() { + return { accompanyingPeriodId }; + }, + methods: { + pickWork: function (payload: { work: AccompanyingPeriodWork }) { + console.log("payload", payload); + input.value = payload.work.id?.toString() ?? ""; + }, + }, }); + + app.mount(el); + }); }); diff --git a/src/Bundle/ChillPersonBundle/Resources/public/types.ts b/src/Bundle/ChillPersonBundle/Resources/public/types.ts index 13a9fe5c9..71906cfb3 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/types.ts +++ b/src/Bundle/ChillPersonBundle/Resources/public/types.ts @@ -1,253 +1,539 @@ import { - Address, - Scope, - Center, - Civility, - DateTime, - User, - WorkflowAvailable, - Job, - PrivateCommentEmbeddable, + Address, + Center, + Civility, + DateTime, + User, + UserGroup, + Household, + WorkflowAvailable, + Scope, + Job, + PrivateCommentEmbeddable, + TranslatableString, + DateTimeWrite, + SetGender, + SetCenter, + SetCivility, + Gender, } from "ChillMainAssets/types"; import { StoredObject } from "ChillDocStoreAssets/types"; import { Thirdparty } from "../../../ChillThirdPartyBundle/Resources/public/types"; import { Calendar } from "../../../ChillCalendarBundle/Resources/public/types"; +/** + * An alternative name, as configured locally + */ +export interface AltName { + labels: TranslatableString; + key: string; +} + +export interface PersonAltNameWrite { + key: string; + value: string; +} + +/** + * An altname for a person + */ +export interface PersonAltName { + label: string; + /** + * will match a key in @link{AltName} + */ + key: string; +} + export interface Person { - id: number; - type: "person"; - text: string; - textAge: string; - firstName: string; - lastName: string; - current_household_address: Address | null; - birthdate: DateTime | null; - deathdate: DateTime | null; - age: number; - phonenumber: string; - mobilenumber: string; - email: string; - gender: "woman" | "man" | "other"; - centers: Center[]; - civility: Civility | null; - current_household_id: number; - current_residential_addresses: Address[]; + id: number; + type: "person"; + text: string; + textAge: string; + firstName: string; + lastName: string; + altNames: PersonAltName[]; + suffixText: string; + current_household_address: Address | null; + birthdate: DateTime | null; + deathdate: DateTime | null; + age: number; + phonenumber: string; + mobilenumber: string; + email: string; + gender: Gender; + centers: Center[]; + civility: Civility | null; + current_household_id: number; + current_residential_addresses: ResidentialAddress[]; + /** + * The person id as configured by the user + */ + personId: string; + identifiers: PersonIdentifier[]; +} + +export interface PersonIdentifier { + id: number; + type: "person_identifier"; + value: object; + definition: PersonIdentifierDefinition; +} + +export interface PersonIdentifierDefinition { + id: number; + type: "person_identifier_definition"; + engine: string; +} + +export interface ResidentialAddress { + address: Address | null; + endDate: DateTime | null; + hostPerson: Person | null; + hostThirdParty: Thirdparty | null; + startDate: DateTime | null; +} + +export interface PersonIdentifierWrite { + type: "person_identifier"; + definition_id: number; + value: object; +} + +/** + * Person representation to create or update a Person + */ +export interface PersonWrite { + type: "person"; + firstName: string; + lastName: string; + altNames: PersonAltNameWrite[]; + addressId: number | null; + birthdate: DateTimeWrite | null; + deathdate: DateTimeWrite | null; + phonenumber: string; + mobilenumber: string; + email: string; + gender: SetGender | null; + center: SetCenter | null; + civility: SetCivility | null; + identifiers: PersonIdentifierWrite[]; } export interface AccompanyingPeriod { - id: number; - addressLocation?: Address | null; - administrativeLocation?: Location | null; - calendars: Calendar[]; - closingDate?: Date | null; - closingMotive?: ClosingMotive | null; - comments: Comment[]; - confidential: boolean; - createdAt?: Date | null; - createdBy?: User | null; - emergency: boolean; - intensity?: "occasional" | "regular"; - job?: Job | null; - locationHistories: AccompanyingPeriodLocationHistory[]; - openingDate?: Date | null; - origin?: Origin | null; - participations: AccompanyingPeriodParticipation[]; - personLocation?: Person | null; - pinnedComment?: Comment | null; - preventUserIsChangedNotification: boolean; - remark: string; - requestorAnonymous: boolean; - requestorPerson?: Person | null; - requestorThirdParty?: Thirdparty | null; - resources: AccompanyingPeriodResource[]; - scopes: Scope[]; - socialIssues: SocialIssue[]; - step?: - | "CLOSED" - | "CONFIRMED" - | "CONFIRMED_INACTIVE_SHORT" - | "CONFIRMED_INACTIVE_LONG" - | "DRAFT"; + id: number; + type: "accompanying_period"; + addressLocation?: Address | null; + administrativeLocation?: Location | null; + calendars: Calendar[]; + closingDate?: Date | null; + closingMotive?: ClosingMotive | null; + comments: Comment[]; + confidential: boolean; + createdAt?: Date | null; + createdBy?: User | null; + emergency: boolean; + intensity?: "occasional" | "regular"; + job?: Job | null; + locationHistories: AccompanyingPeriodLocationHistory[]; + openingDate?: Date | null; + origin?: Origin | null; + participations: AccompanyingPeriodParticipation[]; + personLocation?: Person | null; + pinnedComment?: Comment | null; + preventUserIsChangedNotification: boolean; + remark: string; + requestorAnonymous: boolean; + requestorPerson?: Person | null; + requestorThirdParty?: Thirdparty | null; + resources: AccompanyingPeriodResource[]; + scopes: Scope[]; + socialIssues: SocialIssue[]; + step?: + | "CLOSED" + | "CONFIRMED" + | "CONFIRMED_INACTIVE_SHORT" + | "CONFIRMED_INACTIVE_LONG" + | "DRAFT"; } export interface AccompanyingPeriodWorkEvaluationDocument { - id: number; - type: "accompanying_period_work_evaluation_document"; - storedObject: StoredObject; - title: string; - createdAt: DateTime | null; - createdBy: User | null; - updatedAt: DateTime | null; - updatedBy: User | null; - workflows_availables: WorkflowAvailable[]; - workflows: object[]; + id: number; + type: "accompanying_period_work_evaluation_document"; + storedObject: StoredObject; + title: string; + createdAt: DateTime | null; + createdBy: User | null; + updatedAt: DateTime | null; + updatedBy: User | null; + workflows_availables: WorkflowAvailable[]; + workflows: object[]; } export interface AccompanyingPeriodWork { - id?: number; - accompanyingPeriod?: AccompanyingPeriod; - accompanyingPeriodWorkEvaluations: AccompanyingPeriodWorkEvaluation[]; - createdAt?: string; - createdAutomatically: boolean; - createdAutomaticallyReason: string; - createdBy: User; - endDate?: string; - goals: AccompanyingPeriodWorkGoal[]; - handlingThierParty?: Thirdparty; - note: string; - persons: Person[]; - privateComment: PrivateCommentEmbeddable; - referrersHistory: AccompanyingPeriodWorkReferrerHistory[]; - results: Result[]; - socialAction?: SocialAction; - startDate?: string; - thirdParties: Thirdparty[]; - updatedAt?: string; - updatedBy: User; - version: number; + id?: number; + type: "accompanying_period_work"; + accompanyingPeriod?: AccompanyingPeriod; + accompanyingPeriodWorkEvaluations: AccompanyingPeriodWorkEvaluation[]; + createdAt?: string; + createdAutomatically: boolean; + createdAutomaticallyReason: string; + createdBy: User; + endDate?: string; + goals: AccompanyingPeriodWorkGoal[]; + handlingThierParty?: Thirdparty; + note: string; + persons: Person[]; + privateComment: PrivateCommentEmbeddable; + referrersHistory: AccompanyingPeriodWorkReferrerHistory[]; + results: Result[]; + socialAction?: SocialAction; + startDate?: string; + thirdParties: Thirdparty[]; + updatedAt?: string; + updatedBy: User; + version: number; } interface SocialAction { - id: number; - parent?: SocialAction | null; - children: SocialAction[]; - issue?: SocialIssue | null; - ordering: number; - title: { - fr: string; - }; - defaultNotificationDelay?: string | null; - desactivationDate?: string | null; - evaluations: Evaluation[]; - goals: Goal[]; - results: Result[]; + id: number; + parent?: SocialAction | null; + children: SocialAction[]; + issue?: SocialIssue | null; + ordering: number; + title: { + fr: string; + }; + text: string; + defaultNotificationDelay?: string | null; + desactivationDate?: string | null; + evaluations: Evaluation[]; + goals: Goal[]; + results: Result[]; } export interface AccompanyingPeriodResource { - id: number; - accompanyingPeriod: AccompanyingPeriod; - comment?: string | null; - person?: Person | null; - thirdParty?: Thirdparty | null; + id: number; + accompanyingPeriod: AccompanyingPeriod; + comment?: string | null; + person?: Person | null; + thirdParty?: Thirdparty | null; } export interface Origin { - id: number; - label: { - fr: string; - }; - noActiveAfter: DateTime; + id: number; + label: { + fr: string; + }; + noActiveAfter: DateTime; } export interface ClosingMotive { - id: number; - active: boolean; - name: { - fr: string; - }; - ordering: number; - isCanceledAccompanyingPeriod: boolean; - parent?: ClosingMotive | null; - children: ClosingMotive[]; + id: number; + active: boolean; + name: { + fr: string; + }; + ordering: number; + isCanceledAccompanyingPeriod: boolean; + parent?: ClosingMotive | null; + children: ClosingMotive[]; } export interface AccompanyingPeriodParticipation { - id: number; - startDate: DateTime; - endDate?: DateTime | null; - accompanyingPeriod: AccompanyingPeriod; - person: Person; + id: number; + startDate: DateTime; + endDate?: DateTime | null; + accompanyingPeriod: AccompanyingPeriod; + person: Person; } export interface AccompanyingPeriodLocationHistory { - id: number; - startDate: DateTime; - endDate?: DateTime | null; - addressLocation?: Address | null; - period: AccompanyingPeriod; - personLocation?: Person | null; + id: number; + startDate: DateTime; + endDate?: DateTime | null; + addressLocation?: Address | null; + period: AccompanyingPeriod; + personLocation?: Person | null; } export interface SocialIssue { - id: number; - parent?: SocialIssue | null; - children: SocialIssue[]; - socialActions?: SocialAction[] | null; - ordering: number; - title: { - fr: string; - }; - desactivationDate?: string | null; + id: number; + text: string; + parent?: SocialIssue | null; + children: SocialIssue[]; + socialActions?: SocialAction[] | null; + ordering: number; + title: { + fr: string; + }; + desactivationDate?: string | null; } export interface Goal { - id: number; - results: Result[]; - socialActions?: SocialAction[] | null; - title: { - fr: string; - }; + id: number; + results: Result[]; + socialActions?: SocialAction[] | null; + title: { + fr: string; + }; } export interface Result { - id: number; - accompanyingPeriodWorks: AccompanyingPeriodWork[]; - accompanyingPeriodWorkGoals: AccompanyingPeriodWorkGoal[]; - goals: Goal[]; - socialActions: SocialAction[]; - title: { - fr: string; - }; - desactivationDate?: string | null; + id: number; + accompanyingPeriodWorks: AccompanyingPeriodWork[]; + accompanyingPeriodWorkGoals: AccompanyingPeriodWorkGoal[]; + goals: Goal[]; + socialActions: SocialAction[]; + title: { + fr: string; + }; + desactivationDate?: string | null; } export interface AccompanyingPeriodWorkGoal { - id: number; - accompanyingPeriodWork: AccompanyingPeriodWork; - goal: Goal; - note: string; - results: Result[]; + id: number; + accompanyingPeriodWork: AccompanyingPeriodWork; + goal: Goal; + note: string; + results: Result[]; } export interface AccompanyingPeriodWorkEvaluation { - accompanyingPeriodWork: AccompanyingPeriodWork | null; - comment: string; - createdAt: DateTime | null; - createdBy: User | null; - documents: AccompanyingPeriodWorkEvaluationDocument[]; - endDate: DateTime | null; - evaluation: Evaluation | null; - id: number | null; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - key: any; - maxDate: DateTime | null; - startDate: DateTime | null; - updatedAt: DateTime | null; - updatedBy: User | null; - warningInterval: string | null; - timeSpent: number | null; + type: "accompanying_period_work_evaluation"; + accompanyingPeriodWork: AccompanyingPeriodWork | null; + comment: string; + createdAt: DateTime | null; + createdBy: User | null; + documents: AccompanyingPeriodWorkEvaluationDocument[]; + endDate: DateTime | null; + evaluation: Evaluation | null; + id: number | null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + key: any; + maxDate: DateTime | null; + startDate: DateTime | null; + updatedAt: DateTime | null; + updatedBy: User | null; + warningInterval: string | null; + timeSpent: number | null; } export interface Evaluation { - id: number; - url: string; - socialActions: SocialAction[]; - title: { - fr: string; - }; - active: boolean; - delay: string; - notificationDelay: string; + id: number; + url: string; + socialActions: SocialAction[]; + title: { + fr: string; + }; + active: boolean; + delay: string; + notificationDelay: string; +} + +export interface Step { + currentStep: { + text: string; + }; +} + +export interface Workflow { + id: number; + title: string; + type: "accompanying_period_work" | "accompanying_period"; + isOnHoldAtCurrentStep: boolean; + datas: { + persons: Person[]; + }; + steps: Step[]; +} + +export interface WorflowCc { + id: number; + title: string; + isOnHoldAtCurrentStep: boolean; + datas: { + persons: Person[]; + }; + steps: Step[]; +} + +export interface Warning { + id: number; + warningDate: DateTime; + endDate: DateTime; + title: string; +} +export interface Alert { + id: number; + warningDate: DateTime; + endDate: DateTime; + title: string; +} + +export interface Notification { + id: number; + date: DateTime; + title: string; + sender: { + text: string; + }; + relatedEntityClass: string; + relatedEntityId: number; +} + +export interface Participation { + person: Person; +} +export interface AccompanyingCourse { + id: number; + openingDate: DateTime; + socialIssues: SocialIssue[]; + participations: Participation[]; + emergency: boolean; + confidential: boolean; } export interface AccompanyingPeriodWorkReferrerHistory { - id: number; - accompanyingPeriodWork: AccompanyingPeriodWork; - user: User; - startDate: DateTime; - endDate: DateTime | null; - createdAt: DateTime; - updatedAt: DateTime | null; - createdBy: User; - updatedBy: User | null; + id: number; + accompanyingPeriodWork: AccompanyingPeriodWork; + user: User; + startDate: DateTime; + endDate: DateTime | null; + createdAt: DateTime; + updatedAt: DateTime | null; + createdBy: User; + updatedBy: User | null; +} + +export interface AccompanyingPeriodWorkEvaluationDocument { + id: number; + type: "accompanying_period_work_evaluation_document"; + storedObject: StoredObject; + title: string; + createdAt: DateTime | null; + createdBy: User | null; + updatedAt: DateTime | null; + updatedBy: User | null; + workflows_availables: WorkflowAvailable[]; + workflows: object[]; +} + +/** + * Entity types that a user can create through AddPersons component + */ +export type CreatableEntityType = "person" | "thirdparty"; + +/** + * Entities that can be search and selected by a user + */ +export type EntityType = + | CreatableEntityType + | "user_group" + | "user" + | "household"; + +export type Entities = UserGroup | User | Person | Thirdparty | Household; + +export function isEntityHousehold(e: Entities): e is Household { + return e.type === "household"; +} + +export type EntitiesOrMe = "me" | Entities; + +// Type guards to discriminate Suggestions by their result kind +export function isSuggestionForUserGroup( + s: Suggestion, +): s is Suggestion & { result: UserGroup } { + return (s as any)?.result?.type === "user_group"; +} + +export function isSuggestionForUser( + s: Suggestion, +): s is Suggestion & { result: User } { + return (s as any)?.result?.type === "user"; +} + +export function isSuggestionForPerson( + s: Suggestion, +): s is Suggestion & { result: Person } { + return (s as any)?.result?.type === "person"; +} + +export function isSuggestionForThirdParty( + s: Suggestion, +): s is Suggestion & { result: Thirdparty } { + return (s as any)?.result?.type === "thirdparty"; +} + +export function isSuggestionForHousehold( + s: Suggestion, +): s is Suggestion & { result: Household } { + return (s as any)?.result?.type === "household"; +} + +export type AddPersonResult = Entities & { + parent?: Entities | null; +}; + +export interface Suggestion { + key: string; + relevance: number; + result: Entities; +} +export interface SearchPagination { + first: number; + items_per_page: number; + next: number | null; + previous: number | null; + more: boolean; +} + +export interface Search { + count: number; + pagination: SearchPagination; + results: { relevance: number; result: Entities }[]; +} + +export interface SearchOptions { + uniq: boolean; + /** @deprecated */ + type: EntityType[]; + priority: number | null; + button: { + size: string; + class: string; + type: string; + display: string; + }; +} + +type PersonIdentifierPresence = + | "NOT_EDITABLE" + | "ON_EDIT" + | "ON_CREATION" + | "REQUIRED"; + +export interface PersonIdentifierWorker { + type: "person_identifier_worker"; + definition_id: number; + engine: string; + label: TranslatableString; + isActive: boolean; + presence: PersonIdentifierPresence; +} + +export class MakeFetchException extends Error { + sta: number; + txt: string; + violations: unknown | null; + + constructor(txt: string, sta: number, violations: unknown | null = null) { + super(txt); + this.name = "ValidationException"; + this.sta = sta; + this.txt = txt; + this.violations = violations; + Object.setPrototypeOf(this, MakeFetchException.prototype); + } } diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/App.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/App.vue index 85f031a64..e81949711 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/App.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/App.vue @@ -1,28 +1,28 @@ <template> - <banner /> - <sticky-nav /> + <banner /> + <sticky-nav /> - <h1 v-if="accompanyingCourse.step === 'DRAFT'"> - {{ $t("course.title.draft") }} - </h1> - <h1 v-else> - {{ $t("course.title.active") }} - </h1> + <h1 v-if="accompanyingCourse.step === 'DRAFT'"> + {{ $t("course.title.draft") }} + </h1> + <h1 v-else> + {{ $t("course.title.active") }} + </h1> - <persons-associated /> - <course-location /> - <origin-demand /> - <admin-location /> - <requestor :is-anonymous="accompanyingCourse.requestorAnonymous" /> - <social-issue /> - <scopes /> - <referrer /> - <resources /> - <start-date v-if="accompanyingCourse.step.startsWith('CONFIRMED')" /> - <comment v-if="accompanyingCourse.step === 'DRAFT'" /> - <confirm v-if="accompanyingCourse.step === 'DRAFT'" /> + <persons-associated /> + <course-location /> + <origin-demand /> + <admin-location /> + <requestor :is-anonymous="accompanyingCourse.requestorAnonymous" /> + <social-issue /> + <scopes /> + <referrer /> + <resources /> + <start-date v-if="accompanyingCourse.step.startsWith('CONFIRMED')" /> + <comment v-if="accompanyingCourse.step === 'DRAFT'" /> + <confirm v-if="accompanyingCourse.step === 'DRAFT'" /> - <!-- <div v-for="error in errorMsg" v-bind:key="error.id" class="vue-component errors alert alert-danger"> + <!-- <div v-for="error in errorMsg" v-bind:key="error.id" class="vue-component errors alert alert-danger"> <p> <span>{{ error.sta }} {{ error.txt }}</span><br> <span>{{ $t(error.msg) }}</span> @@ -47,26 +47,26 @@ import Confirm from "./components/Confirm.vue"; import StartDate from "./components/StartDate.vue"; export default { - name: "App", - components: { - Banner, - StickyNav, - OriginDemand, - AdminLocation, - PersonsAssociated, - Requestor, - SocialIssue, - CourseLocation, - Scopes, - Referrer, - Resources, - Comment, - Confirm, - StartDate, - }, - computed: { - ...mapState(["accompanyingCourse", "addressContext"]), - }, + name: "App", + components: { + Banner, + StickyNav, + OriginDemand, + AdminLocation, + PersonsAssociated, + Requestor, + SocialIssue, + CourseLocation, + Scopes, + Referrer, + Resources, + Comment, + Confirm, + StartDate, + }, + computed: { + ...mapState(["accompanyingCourse", "addressContext"]), + }, }; </script> @@ -75,62 +75,62 @@ export default { $chill-accourse-context: #718596; div#accompanying-course { - div.vue-component { - h2 { - margin: 1em 0.7em; - position: relative; - &:before { - position: absolute; - content: "\f142"; //ellipsis-v - font-family: "ForkAwesome"; - color: tint-color($chill-accourse-context, 10%); - left: -22px; - top: 4px; - } - a[id^="section"] { - position: absolute; - top: -2.5em; // reference for stickNav - } - } - padding: 0em 0em; - margin: 1em 0; - border-radius: 5px; - border: 1px dotted tint-color($chill-accourse-context, 10%); - border-left: 1px dotted tint-color($chill-accourse-context, 10%); - border-right: 1px dotted tint-color($chill-accourse-context, 10%); - dd { - margin-left: 1em; - } - & > div { - margin: 1em 3em 0; - - &.flex-table, - &.flex-bloc { - margin: 1em 0 0; - } - &.alert.to-confirm { - margin: 1em 0 0; - padding: 1em 3em; - } - } - - div.flex-table { - div.item-row { - div.item-col:first-child { - flex-basis: 33%; - } - } - } - - &.errors { - //display: flex; - //position: sticky; - //bottom: 0.3em; - //z-index: 1000; - margin: 1em 0; - padding: 1em; - border-radius: 0; - } + div.vue-component { + h2 { + margin: 1em 0.7em; + position: relative; + &:before { + position: absolute; + content: "\f142"; //ellipsis-v + font-family: "ForkAwesome"; + color: tint-color($chill-accourse-context, 10%); + left: -22px; + top: 4px; + } + a[id^="section"] { + position: absolute; + top: -2.5em; // reference for stickNav + } } + padding: 0em 0em; + margin: 1em 0; + border-radius: 5px; + border: 1px dotted tint-color($chill-accourse-context, 10%); + border-left: 1px dotted tint-color($chill-accourse-context, 10%); + border-right: 1px dotted tint-color($chill-accourse-context, 10%); + dd { + margin-left: 1em; + } + & > div { + margin: 1em 3em 0; + + &.flex-table, + &.flex-bloc { + margin: 1em 0 0; + } + &.alert.to-confirm { + margin: 1em 0 0; + padding: 1em 3em; + } + } + + div.flex-table { + div.item-row { + div.item-col:first-child { + flex-basis: 33%; + } + } + } + + &.errors { + //display: flex; + //position: sticky; + //bottom: 0.3em; + //z-index: 1000; + margin: 1em 0; + padding: 1em; + border-radius: 0; + } + } } </style> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/AdminLocation.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/AdminLocation.vue index 9b1bc632e..a91cd1488 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/AdminLocation.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/AdminLocation.vue @@ -1,38 +1,35 @@ <template> - <div class="vue-component"> - <h2><a id="section-40" />{{ $t("admin_location.title") }}</h2> + <div class="vue-component"> + <h2><a id="section-40" />{{ $t("admin_location.title") }}</h2> - <div class="mb-4"> - <label for="selectAdminLocation"> - {{ $t("admin_location.title") }} - </label> + <div class="mb-4"> + <label for="selectAdminLocation"> + {{ $t("admin_location.title") }} + </label> - <VueMultiselect - name="selectAdminLocation" - label="text" - :custom-label="customLabel" - track-by="id" - :multiple="false" - :searchable="true" - :placeholder="$t('admin_location.placeholder')" - v-model="value" - :options="options" - group-values="locations" - group-label="locationCategories" - :select-label="$t('multiselect.select_label')" - :deselect-label="$t('multiselect.deselect_label')" - :selected-label="$t('multiselect.selected_label')" - @select="updateAdminLocation" - /> - </div> - - <div - v-if="!isAdminLocationValid" - class="alert alert-warning to-confirm" - > - {{ $t("admin_location.not_valid") }} - </div> + <VueMultiselect + name="selectAdminLocation" + label="text" + :custom-label="customLabel" + track-by="id" + :multiple="false" + :searchable="true" + :placeholder="$t('admin_location.placeholder')" + v-model="value" + :options="options" + group-values="locations" + group-label="locationCategories" + :select-label="$t('multiselect.select_label')" + :deselect-label="$t('multiselect.deselect_label')" + :selected-label="$t('multiselect.selected_label')" + @select="updateAdminLocation" + /> </div> + + <div v-if="!isAdminLocationValid" class="alert alert-warning to-confirm"> + {{ $t("admin_location.not_valid") }} + </div> + </div> </template> <script> @@ -41,72 +38,67 @@ import { fetchResults } from "ChillMainAssets/lib/api/apiMethods"; import { mapState, mapGetters } from "vuex"; export default { - name: "AdminLocation", - components: { VueMultiselect }, - data() { - return { - options: [], - }; + name: "AdminLocation", + components: { VueMultiselect }, + data() { + return { + options: [], + }; + }, + computed: { + ...mapState({ + value: (state) => state.accompanyingCourse.administrativeLocation, + }), + ...mapGetters(["isAdminLocationValid"]), + }, + mounted() { + this.getOptions(); + }, + methods: { + getOptions() { + fetchResults(`/api/1.0/main/location.json`).then((response) => { + let uniqueLocationTypeId = [ + ...new Set(response.map((o) => o.locationType.id)), + ]; + let results = []; + for (let id of uniqueLocationTypeId) { + results.push({ + locationCategories: response.filter( + (o) => o.locationType.id === id, + )[0].locationType.title.fr, + locations: response.filter((o) => o.locationType.id === id), + }); + } + this.options = results; + }); }, - computed: { - ...mapState({ - value: (state) => state.accompanyingCourse.administrativeLocation, - }), - ...mapGetters(["isAdminLocationValid"]), + updateAdminLocation(value) { + this.$store + .dispatch("updateAdminLocation", value) + .catch(({ name, violations }) => { + if (name === "ValidationException" || name === "AccessException") { + violations.forEach((violation) => + this.$toast.open({ message: violation }), + ); + } else { + this.$toast.open({ message: "An error occurred" }); + } + }); }, - mounted() { - this.getOptions(); - }, - methods: { - getOptions() { - fetchResults(`/api/1.0/main/location.json`).then((response) => { - let uniqueLocationTypeId = [ - ...new Set(response.map((o) => o.locationType.id)), - ]; - let results = []; - for (let id of uniqueLocationTypeId) { - results.push({ - locationCategories: response.filter( - (o) => o.locationType.id === id, - )[0].locationType.title.fr, - locations: response.filter( - (o) => o.locationType.id === id, - ), - }); - } - this.options = results; - }); - }, - updateAdminLocation(value) { - this.$store - .dispatch("updateAdminLocation", value) - .catch(({ name, violations }) => { - if ( - name === "ValidationException" || - name === "AccessException" - ) { - violations.forEach((violation) => - this.$toast.open({ message: violation }), - ); - } else { - this.$toast.open({ message: "An error occurred" }); - } - }); - }, - customLabel(value) { - return value.locationType - ? value.name - ? `${value.name} (${value.locationType.title.fr})` - : value.locationType.title.fr - : ""; - }, + customLabel(value) { + return value.locationType + ? value.name + ? `${value.name} (${value.locationType.title.fr})` + : value.locationType.title.fr + : ""; }, + }, }; </script> <style src="vue-multiselect/dist/vue-multiselect.css"></style> <style lang="css" scoped> label { - display: none; + display: none; } </style> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Banner.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Banner.vue index 47a586e7d..7cf697f3c 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Banner.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Banner.vue @@ -1,132 +1,107 @@ <template> - <teleport to="#header-accompanying_course-name #banner-flags"> - <toggle-flags /> - </teleport> + <teleport to="#header-accompanying_course-name #banner-flags"> + <toggle-flags /> + </teleport> - <teleport to="#header-accompanying_course-name #banner-status"> - <span - v-if="accompanyingCourse.step === 'DRAFT'" - class="text-md-end d-md-block" - > - <span class="badge bg-secondary"> - {{ $t("course.step.draft") }} - </span> - </span> - <span - v-else-if=" - accompanyingCourse.step === 'CONFIRMED' || - accompanyingCourse.step === 'CONFIRMED_INACTIVE_SHORT' || - accompanyingCourse.step === 'CONFIRMED_INACTIVE_LONG' - " - class="text-md-end" - > - <span - v-if="accompanyingCourse.step === 'CONFIRMED'" - class="d-md-block mb-md-3" - > - <span class="badge bg-primary"> - {{ $t("course.step.active") }} - </span> - </span> - <span - v-else-if=" - accompanyingCourse.step === 'CONFIRMED_INACTIVE_SHORT' - " - class="d-md-block mb-md-3" - > - <span class="badge bg-chill-yellow text-primary"> - {{ $t("course.step.inactive_short") }} - </span> - </span> - <span - v-else-if=" - accompanyingCourse.step === 'CONFIRMED_INACTIVE_LONG' - " - class="d-md-block mb-md-3" - > - <span class="badge bg-chill-pink"> - {{ $t("course.step.inactive_long") }} - </span> - </span> - <span class="d-md-block"> - <span class="d-md-block ms-3 ms-md-0"> - <i - >{{ $t("course.open_at") - }}{{ - $d(accompanyingCourse.openingDate.datetime, "text") - }}</i - > - </span> - <span - v-if="accompanyingCourse.user" - class="d-md-block ms-3 ms-md-0" - > - <span class="item-key">{{ $t("course.referrer") }}:</span - >  - <b>{{ accompanyingCourse.user.text }}</b> - <template v-if="accompanyingCourse.user.isAbsent"> -   - <span - class="badge bg-danger rounded-pill" - title="Absent" - >A</span - > - </template> - </span> - </span> - </span> - <span v-else class="text-md-end d-md-block"> - <span class="badge bg-danger"> - {{ $t("course.step.closed") }} - </span> - <span class="d-md-block"> - <span class="d-md-block ms-3 ms-md-0"> - <i - >{{ - $d(accompanyingCourse.openingDate.datetime, "text") - }} - - - {{ - $d(accompanyingCourse.closingDate.datetime, "text") - }}</i - > - </span> - <span - v-if="accompanyingCourse.user" - class="d-md-block ms-3 ms-md-0" - > - <span class="item-key">{{ $t("course.referrer") }}:</span> - <b>{{ accompanyingCourse.user.text }}</b> - </span> - </span> - </span> - </teleport> - - <teleport - to="#header-accompanying_course-name #persons-associated-shortlist" + <teleport to="#header-accompanying_course-name #banner-status"> + <span + v-if="accompanyingCourse.step === 'DRAFT'" + class="text-md-end d-md-block" > - <persons-associated - :accompanyingCourse="accompanyingCourse" - :shortlist="true" - ></persons-associated> - </teleport> - - <teleport to="#header-accompanying_course-details #banner-social-issues"> - <social-issue - v-for="issue in accompanyingCourse.socialIssues" - :key="issue.id" - :issue="issue" - /> - </teleport> - - <teleport - to="#header-accompanying_course-details #banner-persons-associated" + <span class="badge bg-secondary"> + {{ $t("course.step.draft") }} + </span> + </span> + <span + v-else-if=" + accompanyingCourse.step === 'CONFIRMED' || + accompanyingCourse.step === 'CONFIRMED_INACTIVE_SHORT' || + accompanyingCourse.step === 'CONFIRMED_INACTIVE_LONG' + " + class="text-md-end" > - <persons-associated - :accompanying-course="accompanyingCourse" - :shortlist="false" - /> - </teleport> + <span + v-if="accompanyingCourse.step === 'CONFIRMED'" + class="d-md-block mb-md-3" + > + <span class="badge bg-primary"> + {{ $t("course.step.active") }} + </span> + </span> + <span + v-else-if="accompanyingCourse.step === 'CONFIRMED_INACTIVE_SHORT'" + class="d-md-block mb-md-3" + > + <span class="badge bg-chill-yellow text-primary"> + {{ $t("course.step.inactive_short") }} + </span> + </span> + <span + v-else-if="accompanyingCourse.step === 'CONFIRMED_INACTIVE_LONG'" + class="d-md-block mb-md-3" + > + <span class="badge bg-chill-pink"> + {{ $t("course.step.inactive_long") }} + </span> + </span> + <span class="d-md-block"> + <span class="d-md-block ms-3 ms-md-0"> + <i + >{{ $t("course.open_at") + }}{{ $d(accompanyingCourse.openingDate.datetime, "text") }}</i + > + </span> + <span v-if="accompanyingCourse.user" class="d-md-block ms-3 ms-md-0"> + <span class="item-key">{{ $t("course.referrer") }}:</span>  + <b>{{ accompanyingCourse.user.text }}</b> + <template v-if="accompanyingCourse.user.isAbsent"> +   + <span class="badge bg-danger rounded-pill" title="Absent">A</span> + </template> + </span> + </span> + </span> + <span v-else class="text-md-end d-md-block"> + <span class="badge bg-danger"> + {{ $t("course.step.closed") }} + </span> + <span class="d-md-block"> + <span class="d-md-block ms-3 ms-md-0"> + <i + >{{ $d(accompanyingCourse.openingDate.datetime, "text") }} + - + {{ $d(accompanyingCourse.closingDate.datetime, "text") }}</i + > + </span> + <span v-if="accompanyingCourse.user" class="d-md-block ms-3 ms-md-0"> + <span class="item-key">{{ $t("course.referrer") }}:</span> + <b>{{ accompanyingCourse.user.text }}</b> + </span> + </span> + </span> + </teleport> + + <teleport to="#header-accompanying_course-name #persons-associated-shortlist"> + <persons-associated + :accompanyingCourse="accompanyingCourse" + :shortlist="true" + ></persons-associated> + </teleport> + + <teleport to="#header-accompanying_course-details #banner-social-issues"> + <social-issue + v-for="issue in accompanyingCourse.socialIssues" + :key="issue.id" + :issue="issue" + /> + </teleport> + + <teleport to="#header-accompanying_course-details #banner-persons-associated"> + <persons-associated + :accompanying-course="accompanyingCourse" + :shortlist="false" + /> + </teleport> </template> <script> @@ -135,30 +110,30 @@ import SocialIssue from "./Banner/SocialIssue.vue"; import PersonsAssociated from "./Banner/PersonsAssociated.vue"; export default { - name: "Banner", - components: { - ToggleFlags, - SocialIssue, - PersonsAssociated, - }, - computed: { - accompanyingCourse() { - return this.$store.state.accompanyingCourse; - }, + name: "Banner", + components: { + ToggleFlags, + SocialIssue, + PersonsAssociated, + }, + computed: { + accompanyingCourse() { + return this.$store.state.accompanyingCourse; }, + }, }; </script> <style lang="scss"> div#banner-flags, div#banner-status { - .badge { - text-transform: uppercase; - } + .badge { + text-transform: uppercase; + } } div#banner-status { - span.badge { - font-size: 90%; - } + span.badge { + font-size: 90%; + } } </style> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Banner/PersonsAssociated.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Banner/PersonsAssociated.vue index 048dbd1df..a1837effd 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Banner/PersonsAssociated.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Banner/PersonsAssociated.vue @@ -1,129 +1,127 @@ <template> - <span v-if="shortlist"> - <span v-for="person in firstPersons" class="me-1" :key="person.id"> - <on-the-fly - :type="person.type" - :id="person.id" - :buttonText="person.textAge" - :displayBadge="'true' === 'true'" - action="show" - ></on-the-fly> - </span> - <span v-if="hasMoreThanShortListPerson"> - <a - class="showMore carousel-control" - role="button" - data-bs-target="#ACHeaderSlider" - data-bs-slide="next" - >{{ $t("more_x", { x: countMoreThanShortListPerson }) }}</a - > - </span> + <span v-if="shortlist"> + <span v-for="person in firstPersons" class="me-1" :key="person.id"> + <on-the-fly + :type="person.type" + :id="person.id" + :buttonText="person.textAge" + :displayBadge="'true' === 'true'" + action="show" + ></on-the-fly> </span> - <span v-else> - <span - v-for="([pk, persons], h) in personsByHousehold" - :class="{ household: pk > -1, 'no-household': pk === -1 }" - :key="h.id" - > - <a v-if="pk !== -1" :href="householdLink(pk)"> - <i - class="fa fa-home fa-fw text-light" - :title=" - $t('persons_associated.show_household_number', { - id: pk, - }) - " - ></i> - </a> - <span v-for="person in persons" class="me-1" :key="person.id"> - <on-the-fly - :type="person.type" - :id="person.id" - :buttonText="person.textAge" - :displayBadge="true" - action="show" - ></on-the-fly> - </span> - </span> + <span v-if="hasMoreThanShortListPerson"> + <a + class="showMore carousel-control" + role="button" + data-bs-target="#ACHeaderSlider" + data-bs-slide="next" + >{{ $t("more_x", { x: countMoreThanShortListPerson }) }}</a + > </span> + </span> + <span v-else> + <span + v-for="([pk, persons], h) in personsByHousehold" + :class="{ household: pk > -1, 'no-household': pk === -1 }" + :key="h.id" + > + <a v-if="pk !== -1" :href="householdLink(pk)"> + <i + class="fa fa-home fa-fw text-light" + :title=" + $t('persons_associated.show_household_number', { + id: pk, + }) + " + ></i> + </a> + <span v-for="person in persons" class="me-1" :key="person.id"> + <on-the-fly + :type="person.type" + :id="person.id" + :buttonText="person.textAge" + :displayBadge="true" + action="show" + ></on-the-fly> + </span> + </span> + </span> </template> <script> import OnTheFly from "ChillMainAssets/vuejs/OnTheFly/components/OnTheFly"; export default { - name: "PersonsAssociated", - components: { - OnTheFly, + name: "PersonsAssociated", + components: { + OnTheFly, + }, + props: ["accompanyingCourse", "shortlist"], + data() { + return { + showAllPersons: false, + maxTotalPersons: 2, + nbShortList: 3, + }; + }, + computed: { + participations() { + return this.accompanyingCourse.participations.filter( + (p) => p.endDate === null, + ); }, - props: ["accompanyingCourse", "shortlist"], - data() { - return { - showAllPersons: false, - maxTotalPersons: 2, - nbShortList: 3, - }; + persons() { + return this.participations.map((p) => p.person); }, - computed: { - participations() { - return this.accompanyingCourse.participations.filter( - (p) => p.endDate === null, - ); - }, - persons() { - return this.participations.map((p) => p.person); - }, - firstPersons() { - return this.participations.slice(0, 3).map((p) => p.person); - }, - hasMoreThanShortListPerson() { - return this.participations.length > 3; - }, - countMoreThanShortListPerson() { - return this.participations.length - 3; - }, - resources() { - return this.accompanyingCourse.resources; - }, - requestor() { - return this.accompanyingCourse.requestor; - }, - personsByHousehold() { - const households = new Map(); - this.accompanyingCourse.participations - .filter((part) => part.endDate === null) - .map((part) => part.person) - .forEach((person) => { - if (!households.has(person.current_household_id || -1)) { - households.set(person.current_household_id || -1, []); - } - households - .get(person.current_household_id || -1) - .push(person); - }); + firstPersons() { + return this.participations.slice(0, 3).map((p) => p.person); + }, + hasMoreThanShortListPerson() { + return this.participations.length > 3; + }, + countMoreThanShortListPerson() { + return this.participations.length - 3; + }, + resources() { + return this.accompanyingCourse.resources; + }, + requestor() { + return this.accompanyingCourse.requestor; + }, + personsByHousehold() { + const households = new Map(); + this.accompanyingCourse.participations + .filter((part) => part.endDate === null) + .map((part) => part.person) + .forEach((person) => { + if (!households.has(person.current_household_id || -1)) { + households.set(person.current_household_id || -1, []); + } + households.get(person.current_household_id || -1).push(person); + }); - return households; - }, + return households; }, - methods: { - householdLink(id) { - return `/fr/person/household/${id}/summary`; - }, + }, + methods: { + householdLink(id) { + return `/fr/person/household/${id}/summary`; }, + }, }; </script> <style lang="scss" scoped> .showMore { - cursor: pointer; - color: white; + cursor: pointer; + color: white; } span.household { - display: inline-block; - border-top: 1px solid rgba(255, 255, 255, 0.3); - background-color: rgba(255, 255, 255, 0.1); - border-radius: 10px; - margin-right: 0.3em; - padding: 5px; + display: inline-block; + border-top: 1px solid rgba(255, 255, 255, 0.3); + background-color: rgba(255, 255, 255, 0.1); + border-radius: 10px; + margin-right: 0.3em; + padding: 5px; } </style> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Banner/SocialIssue.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Banner/SocialIssue.vue index 9bbe4e513..2d3c7d031 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Banner/SocialIssue.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Banner/SocialIssue.vue @@ -1,11 +1,11 @@ <template> - <span class="badge bg-chill-l-gray text-dark">{{ issue.text }}</span> + <span class="badge bg-chill-l-gray text-dark">{{ issue.text }}</span> </template> <script> export default { - name: "SocialIssues", - props: ["issue"], + name: "SocialIssues", + props: ["issue"], }; </script> @@ -14,9 +14,9 @@ export default { @import "ChillPersonAssets/chill/scss/mixins"; @import "ChillMainAssets/chill/scss/chill_variables"; span.badge { - @include badge_social($social-issue-color); - font-size: 95%; - margin-bottom: 5px; - margin-right: 1em; + @include badge_social($social-issue-color); + font-size: 95%; + margin-bottom: 5px; + margin-right: 1em; } </style> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Banner/ToggleFlags.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Banner/ToggleFlags.vue index b759dccd2..555b0b2cd 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Banner/ToggleFlags.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Banner/ToggleFlags.vue @@ -1,158 +1,145 @@ <template> - <div class="text-md-end"> - <span class="d-block d-sm-inline-block mb-md-2"> - <a @click="toggleIntensity" class="flag-toggle"> - <span :class="{ on: !isRegular }">{{ - $t("course.occasional") - }}</span> - <i - class="fa" - :class="{ - 'fa-toggle-on': isRegular, - 'fa-toggle-on fa-flip-horizontal': !isRegular, - }" - /> - <span :class="{ on: isRegular }">{{ - $t("course.regular") - }}</span> - </a> - </span> + <div class="text-md-end"> + <span class="d-block d-sm-inline-block mb-md-2"> + <a @click="toggleIntensity" class="flag-toggle"> + <span :class="{ on: !isRegular }">{{ $t("course.occasional") }}</span> + <i + class="fa" + :class="{ + 'fa-toggle-on': isRegular, + 'fa-toggle-on fa-flip-horizontal': !isRegular, + }" + /> + <span :class="{ on: isRegular }">{{ $t("course.regular") }}</span> + </a> + </span> - <span class="d-block d-sm-inline-block ms-sm-3 ms-md-0"> - <button - class="badge rounded-pill me-1" - :class="{ - 'bg-danger': isEmergency, - 'bg-secondary': !isEmergency, - }" - @click="toggleEmergency" - > - {{ $t("course.emergency") }} - </button> - <button - class="badge rounded-pill" - :class="{ - 'bg-danger': isConfidential, - 'bg-secondary': !isConfidential, - }" - @click="toggleConfidential" - > - {{ $t("course.confidential") }} - </button> - </span> - </div> + <span class="d-block d-sm-inline-block ms-sm-3 ms-md-0"> + <button + class="badge rounded-pill me-1" + :class="{ + 'bg-danger': isEmergency, + 'bg-secondary': !isEmergency, + }" + @click="toggleEmergency" + > + {{ $t("course.emergency") }} + </button> + <button + class="badge rounded-pill" + :class="{ + 'bg-danger': isConfidential, + 'bg-secondary': !isConfidential, + }" + @click="toggleConfidential" + > + {{ $t("course.confidential") }} + </button> + </span> + </div> </template> <script> import { mapState } from "vuex"; export default { - name: "ToggleFlags", - computed: { - ...mapState({ - intensity: (state) => state.accompanyingCourse.intensity, - emergency: (state) => state.accompanyingCourse.emergency, - confidential: (state) => state.accompanyingCourse.confidential, - permissions: (state) => state.permissions, - }), - isRegular() { - return this.intensity === "regular" ? true : false; - }, - isEmergency() { - return this.emergency ? true : false; - }, - isConfidential() { - return this.confidential ? true : false; - }, + name: "ToggleFlags", + computed: { + ...mapState({ + intensity: (state) => state.accompanyingCourse.intensity, + emergency: (state) => state.accompanyingCourse.emergency, + confidential: (state) => state.accompanyingCourse.confidential, + permissions: (state) => state.permissions, + }), + isRegular() { + return this.intensity === "regular" ? true : false; }, - methods: { - toggleIntensity() { - let value; - switch (this.intensity) { - case "occasional": - value = "regular"; - break; - case "regular": - value = "occasional"; - break; - default: - //temporaire (modif backend) - value = "occasional"; - } - this.$store.dispatch("toggleIntensity", value).catch(({ name }) => { - if ( - name === "ValidationException" || - name === "AccessException" - ) { - this.$toast.open({ - message: this.$t( - "Only the referrer can toggle the intensity of an accompanying course", - ), - }); - } else { - this.$toast.open({ message: "An error occurred" }); - } - }); - }, - toggleEmergency() { - this.$store - .dispatch("toggleEmergency", !this.isEmergency) - .catch(({ name, violations }) => { - if ( - name === "ValidationException" || - name === "AccessException" - ) { - violations.forEach((violation) => - this.$toast.open({ message: violation }), - ); - } else { - this.$toast.open({ message: "An error occurred" }); - } - }); - }, - toggleConfidential() { - this.$store.dispatch("toggleConfidential").catch(({ name }) => { - console.log(name); - if ( - name === "ValidationException" || - name === "AccessException" - ) { - this.$toast.open({ - message: this.$t( - "Only the referrer can toggle the confidentiality of an accompanying course", - ), - }); - } else { - this.$toast.open({ message: "An error occurred" }); - } - }); - }, + isEmergency() { + return this.emergency ? true : false; }, + isConfidential() { + return this.confidential ? true : false; + }, + }, + methods: { + toggleIntensity() { + let value; + switch (this.intensity) { + case "occasional": + value = "regular"; + break; + case "regular": + value = "occasional"; + break; + default: + //temporaire (modif backend) + value = "occasional"; + } + this.$store.dispatch("toggleIntensity", value).catch(({ name }) => { + if (name === "ValidationException" || name === "AccessException") { + this.$toast.open({ + message: this.$t( + "Only the referrer can toggle the intensity of an accompanying course", + ), + }); + } else { + this.$toast.open({ message: "An error occurred" }); + } + }); + }, + toggleEmergency() { + this.$store + .dispatch("toggleEmergency", !this.isEmergency) + .catch(({ name, violations }) => { + if (name === "ValidationException" || name === "AccessException") { + violations.forEach((violation) => + this.$toast.open({ message: violation }), + ); + } else { + this.$toast.open({ message: "An error occurred" }); + } + }); + }, + toggleConfidential() { + this.$store.dispatch("toggleConfidential").catch(({ name }) => { + console.log(name); + if (name === "ValidationException" || name === "AccessException") { + this.$toast.open({ + message: this.$t( + "Only the referrer can toggle the confidentiality of an accompanying course", + ), + }); + } else { + this.$toast.open({ message: "An error occurred" }); + } + }); + }, + }, }; </script> <style lang="scss" scoped> a.flag-toggle { + color: white; + cursor: pointer; + &:hover { color: white; - cursor: pointer; - &:hover { - color: white; - text-decoration: underline; - border-radius: 20px; - } - i { - margin: auto 0.4em; - } - span.on { - font-weight: bolder; - } + text-decoration: underline; + border-radius: 20px; + } + i { + margin: auto 0.4em; + } + span.on { + font-weight: bolder; + } } button.badge { - &.bg-secondary { - opacity: 0.5; - &:hover { - opacity: 0.7; - } + &.bg-secondary { + opacity: 0.5; + &:hover { + opacity: 0.7; } + } } </style> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/ButtonLocation.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/ButtonLocation.vue index e84d5a9f2..1e17a1cd2 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/ButtonLocation.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/ButtonLocation.vue @@ -1,38 +1,36 @@ <template> - <li> - <button - class="btn btn-sm btn-secondary" - @click="modal.showModal = true" - :title="$t('courselocation.assign_course_address')" - > - <i class="fa fa-map-marker" /> - </button> - </li> + <li> + <button + class="btn btn-sm btn-secondary" + @click="modal.showModal = true" + :title="$t('courselocation.assign_course_address')" + > + <i class="fa fa-map-marker" /> + </button> + </li> - <teleport to="body"> - <modal - v-if="modal.showModal" - :modal-dialog-class="modal.modalDialogClass" - @close="modal.showModal = false" - > - <template #header> - <h2 class="modal-title"> - {{ $t("courselocation.sure") }} - </h2> - </template> - <template #body> - <address-render-box - :address="person.current_household_address" - /> - <p>{{ $t("courselocation.sure_description") }}</p> - </template> - <template #footer> - <button class="btn btn-danger" @click="assignAddress"> - {{ $t("courselocation.ok") }} - </button> - </template> - </modal> - </teleport> + <teleport to="body"> + <modal + v-if="modal.showModal" + :modal-dialog-class="modal.modalDialogClass" + @close="modal.showModal = false" + > + <template #header> + <h2 class="modal-title"> + {{ $t("courselocation.sure") }} + </h2> + </template> + <template #body> + <address-render-box :address="person.current_household_address" /> + <p>{{ $t("courselocation.sure_description") }}</p> + </template> + <template #footer> + <button class="btn btn-danger" @click="assignAddress"> + {{ $t("courselocation.ok") }} + </button> + </template> + </modal> + </teleport> </template> <script> @@ -41,52 +39,49 @@ import Modal from "ChillMainAssets/vuejs/_components/Modal"; import AddressRenderBox from "ChillMainAssets/vuejs/_components/Entity/AddressRenderBox.vue"; export default { - name: "ButtonLocation", - components: { - AddressRenderBox, - Modal, - }, - props: ["person"], - data() { - return { - modal: { - showModal: false, - modalDialogClass: "modal-dialog-centered modal-md", - }, - }; - }, - computed: { - ...mapState({ - context: (state) => state.addressContext, - }), - }, - methods: { - assignAddress() { - //console.log('assignAddress id', this.person.current_household_address); - let payload = { - target: this.context.target.name, - targetId: this.context.target.id, - locationStatusTo: "person", - personId: this.person.id, - }; - this.$store - .dispatch("updateLocation", payload) - .catch(({ name, violations }) => { - if ( - name === "ValidationException" || - name === "AccessException" - ) { - violations.forEach((violation) => - this.$toast.open({ message: violation }), - ); - } else { - this.$toast.open({ message: "An error occurred" }); - } - }); + name: "ButtonLocation", + components: { + AddressRenderBox, + Modal, + }, + props: ["person"], + data() { + return { + modal: { + showModal: false, + modalDialogClass: "modal-dialog-centered modal-md", + }, + }; + }, + computed: { + ...mapState({ + context: (state) => state.addressContext, + }), + }, + methods: { + assignAddress() { + //console.log('assignAddress id', this.person.current_household_address); + let payload = { + target: this.context.target.name, + targetId: this.context.target.id, + locationStatusTo: "person", + personId: this.person.id, + }; + this.$store + .dispatch("updateLocation", payload) + .catch(({ name, violations }) => { + if (name === "ValidationException" || name === "AccessException") { + violations.forEach((violation) => + this.$toast.open({ message: violation }), + ); + } else { + this.$toast.open({ message: "An error occurred" }); + } + }); - window.location.assign("#section-20"); - this.modal.showModal = false; - }, + window.location.assign("#section-20"); + this.modal.showModal = false; }, + }, }; </script> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Comment.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Comment.vue index b5b2a4466..56f8eb458 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Comment.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Comment.vue @@ -1,56 +1,56 @@ <template> - <div class="vue-component"> - <h2><a id="section-100" />{{ $t("comment.title") }}</h2> + <div class="vue-component"> + <h2><a id="section-100" />{{ $t("comment.title") }}</h2> - <!--div class="error flash_message" v-if="errors.length > 0"> + <!--div class="error flash_message" v-if="errors.length > 0"> {{ errors[0] }} TODO fix errors flashbag for app component </div--> - <div> - <form @submit.prevent="submitform"> - <label class="col-form-label" for="content">{{ - $t("comment.label") - }}</label> + <div> + <form @submit.prevent="submitform"> + <label class="col-form-label" for="content">{{ + $t("comment.label") + }}</label> - <comment-editor v-model="content" /> + <comment-editor v-model="content" /> - <div class="sub-comment"> - <div - v-if=" - pinnedComment !== null && - typeof pinnedComment.creator !== 'undefined' - " - class="metadata" - > - {{ - $t("comment.created_by", [ - pinnedComment.creator.text, - $d(pinnedComment.updatedAt.datetime, "long"), - ]) - }} - </div> - <div class="loading"> - <i - v-if="loading" - class="fa fa-circle-o-notch fa-spin" - :title="$t('loading')" - /> - </div> - </div> - - <div> - <ul class="record_actions"> - <li v-if="pinnedComment !== null"> - <a class="btn btn-delete" @click="removeComment"> - {{ $t("action.delete") }} - </a> - </li> - </ul> - </div> - </form> + <div class="sub-comment"> + <div + v-if=" + pinnedComment !== null && + typeof pinnedComment.creator !== 'undefined' + " + class="metadata" + > + {{ + $t("comment.created_by", [ + pinnedComment.creator.text, + $d(pinnedComment.updatedAt.datetime, "long"), + ]) + }} + </div> + <div class="loading"> + <i + v-if="loading" + class="fa fa-circle-o-notch fa-spin" + :title="$t('loading')" + /> + </div> </div> + + <div> + <ul class="record_actions"> + <li v-if="pinnedComment !== null"> + <a class="btn btn-delete" @click="removeComment"> + {{ $t("action.delete") }} + </a> + </li> + </ul> + </div> + </form> </div> + </div> </template> <script> @@ -58,127 +58,121 @@ import CommentEditor from "ChillMainAssets/vuejs/_components/CommentEditor/Comme import { mapState } from "vuex"; export default { - name: "Comment", - components: { - CommentEditor, - }, - data() { - return { - loading: false, - lastRecordedContent: null, - }; - }, - computed: { - ...mapState({ - pinnedComment: (state) => state.accompanyingCourse.pinnedComment, - }), - classicEditor: () => ClassicEditor, - editorConfig: () => classicEditorConfig, - content: { - set(value) { - console.log("new comment value", value); - console.log("previous value", this.lastRecordedContent); - this.lastRecordedContent = value; + name: "Comment", + components: { + CommentEditor, + }, + data() { + return { + loading: false, + lastRecordedContent: null, + }; + }, + computed: { + ...mapState({ + pinnedComment: (state) => state.accompanyingCourse.pinnedComment, + }), + classicEditor: () => ClassicEditor, + editorConfig: () => classicEditorConfig, + content: { + set(value) { + console.log("new comment value", value); + console.log("previous value", this.lastRecordedContent); + this.lastRecordedContent = value; - setTimeout(() => { - console.log("performing test on ", value); - if (this.lastRecordedContent === value) { - this.loading = true; - if (value !== "") { - this.$store - .dispatch("updatePinnedComment", value) - .then(() => { - this.loading = false; - }) - .catch(({ name, violations }) => { - if ( - name === "ValidationException" || - name === "AccessException" - ) { - violations.forEach((violation) => - this.$toast.open({ - message: violation, - }), - ); - } else { - this.$toast.open({ - message: "An error occurred", - }); - } - }); - } else { - if ( - this.$store.state.accompanyingCourse - .pinnedComment !== null - ) { - this.$store - .dispatch("removePinnedComment", { - id: this.pinnedComment.id, - }) - .then(() => { - this.loading = false; - this.lastRecoredContent = null; - }) - .catch(({ name, violations }) => { - if ( - name === "ValidationException" || - name === "AccessException" - ) { - violations.forEach((violation) => - this.$toast.open({ - message: violation, - }), - ); - } else { - this.$toast.open({ - message: "An error occurred", - }); - } - }); - } - } - } - }, 3000); - }, - get() { - return this.pinnedComment ? this.pinnedComment.content : ""; - }, - }, - errors() { - return this.$store.state.errorMsg; - }, - }, - methods: { - removeComment() { - this.$store - .dispatch("removePinnedComment", { id: this.pinnedComment.id }) + setTimeout(() => { + console.log("performing test on ", value); + if (this.lastRecordedContent === value) { + this.loading = true; + if (value !== "") { + this.$store + .dispatch("updatePinnedComment", value) + .then(() => { + this.loading = false; + }) .catch(({ name, violations }) => { - if ( - name === "ValidationException" || - name === "AccessException" - ) { - violations.forEach((violation) => - this.$toast.open({ message: violation }), - ); - } else { - this.$toast.open({ message: "An error occurred" }); - } + if ( + name === "ValidationException" || + name === "AccessException" + ) { + violations.forEach((violation) => + this.$toast.open({ + message: violation, + }), + ); + } else { + this.$toast.open({ + message: "An error occurred", + }); + } }); - }, + } else { + if (this.$store.state.accompanyingCourse.pinnedComment !== null) { + this.$store + .dispatch("removePinnedComment", { + id: this.pinnedComment.id, + }) + .then(() => { + this.loading = false; + this.lastRecoredContent = null; + }) + .catch(({ name, violations }) => { + if ( + name === "ValidationException" || + name === "AccessException" + ) { + violations.forEach((violation) => + this.$toast.open({ + message: violation, + }), + ); + } else { + this.$toast.open({ + message: "An error occurred", + }); + } + }); + } + } + } + }, 3000); + }, + get() { + return this.pinnedComment ? this.pinnedComment.content : ""; + }, }, + errors() { + return this.$store.state.errorMsg; + }, + }, + methods: { + removeComment() { + this.$store + .dispatch("removePinnedComment", { id: this.pinnedComment.id }) + .catch(({ name, violations }) => { + if (name === "ValidationException" || name === "AccessException") { + violations.forEach((violation) => + this.$toast.open({ message: violation }), + ); + } else { + this.$toast.open({ message: "An error occurred" }); + } + }); + }, + }, }; </script> <style lang="scss"> div.ck-editor.ck-reset { - margin: 0.6em 0; + margin: 0.6em 0; } div.sub-comment { - display: flex; - justify-content: space-between; - div.loading { - margin-right: 6px; - margin-left: 6px; - } + display: flex; + justify-content: space-between; + div.loading { + margin-right: 6px; + margin-left: 6px; + } } </style> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Confirm.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Confirm.vue index 408852711..74fe0467d 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Confirm.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Confirm.vue @@ -1,127 +1,120 @@ <template> - <div class="vue-component"> - <h2> - <a id="section-110" /> - {{ $t("confirm.title") }} - </h2> - <div> - <p v-html="$t('confirm.text_draft', [$t('course.step.draft')])" /> + <div class="vue-component"> + <h2> + <a id="section-110" /> + {{ $t("confirm.title") }} + </h2> + <div> + <p v-html="$t('confirm.text_draft', [$t('course.step.draft')])" /> - <div v-if="!isValidToBeConfirmed"> - <div class="alert alert-warning"> - {{ $t("confirm.alert_validation") }} - <ul class="mt-2"> - <li v-for="k in validationKeys" :key="k"> - {{ $t(notValidMessages[k].msg) }} - <a :href="notValidMessages[k].anchor"> - <i class="fa fa-level-up fa-fw" /> - </a> - </li> - </ul> - </div> - <ul class="record_actions"> - <li> - <button class="btn btn-save" disabled> - {{ $t("confirm.ok") }} - </button> - </li> - <li> - <a class="btn btn-delete" :href="deleteLink"> - {{ $t("confirm.delete") }} - </a> - </li> - </ul> - </div> - - <div v-else> - <p - v-html=" - $t('confirm.text_active', [$t('course.step.active')]) - " - /> - <ul class="record_actions"> - <li> - <button - class="btn btn-save" - @click="modal.showModal = true" - > - {{ $t("confirm.ok") }} - </button> - </li> - <li> - <a class="btn btn-delete" :href="deleteLink"> - {{ $t("confirm.delete") }} - </a> - </li> - </ul> - </div> + <div v-if="!isValidToBeConfirmed"> + <div class="alert alert-warning"> + {{ $t("confirm.alert_validation") }} + <ul class="mt-2"> + <li v-for="k in validationKeys" :key="k"> + {{ $t(notValidMessages[k].msg) }} + <a :href="notValidMessages[k].anchor"> + <i class="fa fa-level-up fa-fw" /> + </a> + </li> + </ul> </div> + <ul class="record_actions"> + <li> + <a class="btn btn-delete" :href="deleteLink"> + {{ $t("confirm.delete") }} + </a> + </li> + <li> + <button class="btn btn-save" disabled> + {{ $t("confirm.ok") }} + </button> + </li> + </ul> + </div> - <teleport to="body"> - <modal - v-if="modal.showModal" - :modal-dialog-class="modal.modalDialogClass" - @close="modal.showModal = false" - > - <template #header> - <h2 class="modal-title"> - {{ $t("confirm.sure") }} - </h2> - </template> - <template #body> - <p>{{ $t("confirm.sure_description") }}</p> - <div v-if="accompanyingCourse.user === null"> - <div v-if="usersSuggestedFilteredByJob.length === 0"> - <p class="alert alert-warning"> - {{ $t("confirm.no_suggested_referrer") }} - </p> - </div> - <div - v-if="usersSuggestedFilteredByJob.length === 1" - class="alert alert-info" - > - <p>{{ $t("confirm.one_suggested_referrer") }}:</p> - <ul class="list-suggest add-items inline"> - <li> - <user-render-box-badge - :user="usersSuggestedFilteredByJob[0]" - /> - </li> - </ul> - <p>{{ $t("confirm.choose_suggested_referrer") }}</p> - <ul class="record_actions"> - <li> - <button - class="btn btn-save mr-5" - @click="chooseSuggestedReferrer" - > - {{ $t("confirm.choose_button") }} - </button> - </li> - <li> - <button - class="btn btn-secondary" - @click="doNotChooseSuggestedReferrer" - > - {{ $t("confirm.do_not_choose_button") }} - </button> - </li> - </ul> - </div> - </div> - </template> - <template #footer> - <button - class="btn btn-danger" - :disabled="disableConfirm" - @click="confirmCourse" - > - {{ $t("confirm.ok") }} - </button> - </template> - </modal> - </teleport> + <div v-else> + <p v-html="$t('confirm.text_active', [$t('course.step.active')])" /> + <ul class="record_actions"> + <li> + <a class="btn btn-delete" :href="deleteLink"> + {{ $t("confirm.delete") }} + </a> + </li> + <li> + <button class="btn btn-save" @click="modal.showModal = true"> + {{ $t("confirm.ok") }} + </button> + </li> + </ul> + </div> </div> + + <teleport to="body"> + <modal + v-if="modal.showModal" + :modal-dialog-class="modal.modalDialogClass" + @close="modal.showModal = false" + > + <template #header> + <h2 class="modal-title"> + {{ $t("confirm.sure") }} + </h2> + </template> + <template #body> + <p>{{ $t("confirm.sure_description") }}</p> + <div v-if="accompanyingCourse.user === null"> + <div v-if="usersSuggestedFilteredByJob.length === 0"> + <p class="alert alert-warning"> + {{ $t("confirm.no_suggested_referrer") }} + </p> + </div> + <div + v-if="usersSuggestedFilteredByJob.length === 1" + class="alert alert-info" + > + <p>{{ $t("confirm.one_suggested_referrer") }}:</p> + <ul class="list-suggest add-items inline"> + <li> + <user-render-box-badge + :user="usersSuggestedFilteredByJob[0]" + /> + </li> + </ul> + <p>{{ $t("confirm.choose_suggested_referrer") }}</p> + <ul class="record_actions"> + <li> + <button + class="btn btn-save mr-5" + @click="chooseSuggestedReferrer" + > + {{ $t("confirm.choose_button") }} + </button> + </li> + <li> + <button + class="btn btn-secondary" + @click="doNotChooseSuggestedReferrer" + > + {{ $t("confirm.do_not_choose_button") }} + </button> + </li> + </ul> + </div> + </div> + </template> + <template #footer> + <button + class="btn btn-save" + :disabled="disableConfirm" + @click="confirmCourse" + > + {{ $t("confirm.ok") }} + </button> + </template> + </modal> + </teleport> + </div> </template> <script> @@ -130,112 +123,106 @@ import Modal from "ChillMainAssets/vuejs/_components/Modal"; import UserRenderBoxBadge from "ChillMainAssets/vuejs/_components/Entity/UserRenderBoxBadge"; export default { - name: "Confirm", - components: { - Modal, - UserRenderBoxBadge, - }, - data() { - return { - modal: { - showModal: false, - modalDialogClass: "modal-dialog-centered modal-md", - }, - notValidMessages: { - participation: { - msg: "confirm.participation_not_valid", - anchor: "#section-10", - }, - location: { - msg: "confirm.location_not_valid", - anchor: "#section-20", - }, - origin: { - msg: "confirm.origin_not_valid", - anchor: "#section-30", - }, - adminLocation: { - msg: "confirm.adminLocation_not_valid", - anchor: "#section-40", - }, - socialIssue: { - msg: "confirm.socialIssue_not_valid", - anchor: "#section-60", - }, - scopes: { - msg: "confirm.set_a_scope", - anchor: "#section-70", - }, - job: { - msg: "confirm.job_not_valid", - anchor: "#section-80", - }, - }, - clickedDoNotChooseReferrer: false, - }; - }, - computed: { - ...mapState({ - accompanyingCourse: (state) => state.accompanyingCourse, - }), - ...mapGetters([ - "isParticipationValid", - "isSocialIssueValid", - "isOriginValid", - "isAdminLocationValid", - "isLocationValid", - "isJobValid", - "validationKeys", - "isValidToBeConfirmed", - "usersSuggestedFilteredByJob", - ]), - deleteLink() { - return `/fr/parcours/${this.accompanyingCourse.id}/delete`; //TODO locale + name: "Confirm", + components: { + Modal, + UserRenderBoxBadge, + }, + data() { + return { + modal: { + showModal: false, + modalDialogClass: "modal-dialog-centered modal-md", + }, + notValidMessages: { + participation: { + msg: "confirm.participation_not_valid", + anchor: "#section-10", }, - disableConfirm() { - return ( - this.accompanyingCourse.user === null && - this.usersSuggestedFilteredByJob.length === 1 && - this.clickedDoNotChooseReferrer === false + location: { + msg: "confirm.location_not_valid", + anchor: "#section-20", + }, + origin: { + msg: "confirm.origin_not_valid", + anchor: "#section-30", + }, + adminLocation: { + msg: "confirm.adminLocation_not_valid", + anchor: "#section-40", + }, + socialIssue: { + msg: "confirm.socialIssue_not_valid", + anchor: "#section-60", + }, + scopes: { + msg: "confirm.set_a_scope", + anchor: "#section-70", + }, + job: { + msg: "confirm.job_not_valid", + anchor: "#section-80", + }, + }, + clickedDoNotChooseReferrer: false, + }; + }, + computed: { + ...mapState({ + accompanyingCourse: (state) => state.accompanyingCourse, + }), + ...mapGetters([ + "isParticipationValid", + "isSocialIssueValid", + "isOriginValid", + "isAdminLocationValid", + "isLocationValid", + "isJobValid", + "validationKeys", + "isValidToBeConfirmed", + "usersSuggestedFilteredByJob", + ]), + deleteLink() { + return `/fr/parcours/${this.accompanyingCourse.id}/delete`; //TODO locale + }, + disableConfirm() { + return ( + this.accompanyingCourse.user === null && + this.usersSuggestedFilteredByJob.length === 1 && + this.clickedDoNotChooseReferrer === false + ); + }, + }, + methods: { + confirmCourse() { + this.$store + .dispatch("confirmAccompanyingCourse") + .catch(({ name, violations }) => { + if (name === "ValidationException" || name === "AccessException") { + violations.forEach((violation) => + this.$toast.open({ message: violation }), ); - }, + } else { + this.$toast.open({ message: "An error occurred" }); + } + }); }, - methods: { - confirmCourse() { - this.$store - .dispatch("confirmAccompanyingCourse") - .catch(({ name, violations }) => { - if ( - name === "ValidationException" || - name === "AccessException" - ) { - violations.forEach((violation) => - this.$toast.open({ message: violation }), - ); - } else { - this.$toast.open({ message: "An error occurred" }); - } - }); - }, - chooseSuggestedReferrer() { - this.$store - .dispatch("updateReferrer", this.usersSuggestedFilteredByJob[0]) - .catch(({ name, violations }) => { - if ( - name === "ValidationException" || - name === "AccessException" - ) { - violations.forEach((violation) => - this.$toast.open({ message: violation }), - ); - } else { - this.$toast.open({ message: "An error occurred" }); - } - }); - }, - doNotChooseSuggestedReferrer() { - this.clickedDoNotChooseReferrer = true; - }, + chooseSuggestedReferrer() { + this.$store + .dispatch("updateReferrer", this.usersSuggestedFilteredByJob[0]) + .catch(({ name, violations }) => { + if (name === "ValidationException" || name === "AccessException") { + violations.forEach((violation) => + this.$toast.open({ message: violation }), + ); + } else { + this.$toast.open({ message: "An error occurred" }); + } + }); }, + doNotChooseSuggestedReferrer() { + this.clickedDoNotChooseReferrer = true; + }, + }, }; </script> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/CourseLocation.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/CourseLocation.vue index f781eca0b..af985e502 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/CourseLocation.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/CourseLocation.vue @@ -1,95 +1,85 @@ <template> - <div class="vue-component"> - <h2> - <a id="section-20" /> - {{ $t("courselocation.title") }} - </h2> + <div class="vue-component"> + <h2> + <a id="section-20" /> + {{ $t("courselocation.title") }} + </h2> - <div - v-for="error in displayErrors" - class="alert alert-danger my-2" - :key="error" - > - {{ error }} - </div> - - <div v-if="hasNoLocation"> - <label class="chill-no-data-statement"> - {{ $t("courselocation.no_address") }} - </label> - </div> - - <div class="flex-table" v-if="accompanyingCourse.location"> - <div class="item-bloc"> - <address-render-box :address="accompanyingCourse.location" /> - - <div - v-if="isPersonLocation" - class="alert alert-secondary separator" - > - <label class="col-form-label"> - {{ - $t("courselocation.person_locator", [ - accompanyingCourse.personLocation.text, - ]) - }} - </label> - </div> - - <div - v-if="isTemporaryAddress" - class="alert alert-warning separator" - > - <p> - {{ - $t( - "courselocation.temporary_address_must_be_changed", - ) - }} - <i class="fa fa-fw fa-map-marker" /> - </p> - </div> - </div> - </div> - - <div - v-if="hasNoPersonLocation" - class="alert alert-danger no-person-location" - > - <i class="fa fa-warning fa-2x" /> - <div> - <p> - {{ - $t( - "courselocation.associate_at_least_one_person_with_one_household_with_address", - ) - }} - <a href="#section-10"> - <i class="fa fa-level-up fa-fw" /> - </a> - </p> - </div> - </div> - - <div> - <ul class="record_actions"> - <li> - <add-address - v-if="!isPersonLocation" - :key="key" - :context="context" - :options="options" - :address-changed-callback="submitTemporaryAddress" - ref="addAddress" - /> - </li> - </ul> - </div> - - <div v-if="!isLocationValid" class="alert alert-warning to-confirm"> - {{ $t("courselocation.not_valid") }} - </div> + <div + v-for="error in displayErrors" + class="alert alert-danger my-2" + :key="error" + > + {{ error }} </div> + + <div v-if="hasNoLocation"> + <label class="chill-no-data-statement"> + {{ $t("courselocation.no_address") }} + </label> + </div> + + <div class="flex-table" v-if="accompanyingCourse.location"> + <div class="item-bloc"> + <address-render-box :address="accompanyingCourse.location" /> + + <div v-if="isPersonLocation" class="alert alert-secondary separator"> + <label class="col-form-label"> + {{ + $t("courselocation.person_locator", [ + accompanyingCourse.personLocation.text, + ]) + }} + </label> + </div> + + <div v-if="isTemporaryAddress" class="alert alert-warning separator"> + <p> + {{ $t("courselocation.temporary_address_must_be_changed") }} + <i class="fa fa-fw fa-map-marker" /> + </p> + </div> + </div> + </div> + + <div + v-if="hasNoPersonLocation" + class="alert alert-danger no-person-location" + > + <i class="fa fa-warning fa-2x" /> + <div> + <p> + {{ + $t( + "courselocation.associate_at_least_one_person_with_one_household_with_address", + ) + }} + <a href="#section-10"> + <i class="fa fa-level-up fa-fw" /> + </a> + </p> + </div> + </div> + + <div> + <ul class="record_actions"> + <li> + <add-address + v-if="!isPersonLocation" + :key="key" + :context="context" + :options="options" + :address-changed-callback="submitTemporaryAddress" + ref="addAddress" + /> + </li> + </ul> + </div> + + <div v-if="!isLocationValid" class="alert alert-warning to-confirm"> + {{ $t("courselocation.not_valid") }} + </div> + </div> </template> <script> @@ -98,177 +88,171 @@ import AddAddress from "ChillMainAssets/vuejs/Address/components/AddAddress.vue" import AddressRenderBox from "ChillMainAssets/vuejs/_components/Entity/AddressRenderBox.vue"; export default { - name: "CourseLocation", - components: { - AddAddress, - AddressRenderBox, - }, - data() { - return { - addAddress: { - options: { - button: { - text: { - create: "courselocation.add_temporary_address", - edit: "courselocation.edit_temporary_address", - }, - }, - title: { - create: "courselocation.add_temporary_address", - edit: "courselocation.edit_temporary_address", - }, - onlyButton: true, - }, + name: "CourseLocation", + components: { + AddAddress, + AddressRenderBox, + }, + data() { + return { + addAddress: { + options: { + button: { + text: { + create: "courselocation.add_temporary_address", + edit: "courselocation.edit_temporary_address", }, - }; + }, + title: { + create: "courselocation.add_temporary_address", + edit: "courselocation.edit_temporary_address", + }, + onlyButton: true, + }, + }, + }; + }, + computed: { + ...mapState({ + accompanyingCourse: (state) => state.accompanyingCourse, + context: (state) => state.addressContext, + }), + ...mapGetters(["isLocationValid"]), + options() { + return this.addAddress.options; }, - computed: { - ...mapState({ - accompanyingCourse: (state) => state.accompanyingCourse, - context: (state) => state.addressContext, - }), - ...mapGetters(["isLocationValid"]), - options() { - return this.addAddress.options; + key() { + return this.context.edit + ? "address_" + this.context.addressId + : this.accompanyingCourse.type + "_" + this.accompanyingCourse.id; + }, + isTemporaryAddress() { + return this.accompanyingCourse.locationStatus === "address"; + }, + isPersonLocation() { + return this.accompanyingCourse.locationStatus === "person"; + }, + hasNoLocation() { + return this.accompanyingCourse.locationStatus === "none"; + }, + currentParticipations() { + return this.accompanyingCourse.participations.filter( + (p) => p.enddate !== null, + ); + }, + hasNoPersonLocation() { + let addressInParticipations_ = []; + this.currentParticipations.forEach((p) => { + addressInParticipations_.push( + this.checkHouseholdAddressForParticipation(p), + ); + }); + + const booleanReducer = (previousValue, currentValue) => + previousValue || currentValue; + + let addressInParticipations = + addressInParticipations_.length > 0 + ? addressInParticipations_.reduce(booleanReducer) + : false; + + //console.log(addressInParticipations_, addressInParticipations); + return ( + this.accompanyingCourse.step !== "DRAFT" && + this.isTemporaryAddress && + !addressInParticipations + ); + }, + isContextEdit() { + return this.context.edit; + }, + }, + methods: { + checkHouseholdAddressForParticipation(participation) { + if (participation.person.current_household_id === null) { + return false; + } + return participation.person.current_household_address !== null; + }, + initAddressContext() { + let context = { + target: { + name: this.accompanyingCourse.type, + id: this.accompanyingCourse.id, }, - key() { - return this.context.edit - ? "address_" + this.context.addressId - : this.accompanyingCourse.type + - "_" + - this.accompanyingCourse.id; - }, - isTemporaryAddress() { - return this.accompanyingCourse.locationStatus === "address"; - }, - isPersonLocation() { - return this.accompanyingCourse.locationStatus === "person"; - }, - hasNoLocation() { - return this.accompanyingCourse.locationStatus === "none"; - }, - currentParticipations() { - return this.accompanyingCourse.participations.filter( - (p) => p.enddate !== null, + edit: false, + addressId: null, + defaults: window.addaddress, + }; + if (this.accompanyingCourse.location) { + context["edit"] = true; + context["addressId"] = this.accompanyingCourse.location.address_id; + } + this.$store.commit("setAddressContext", context); + }, + displayErrors() { + return this.$refs.addAddress.errorMsg; + }, + submitTemporaryAddress(payload) { + //console.log('@@@ click on Submit Temporary Address Button', payload); + payload["locationStatusTo"] = "address"; // <== temporary, not none, not person + this.$store + .dispatch("updateLocation", payload) + .catch(({ name, violations }) => { + if (name === "ValidationException" || name === "AccessException") { + violations.forEach((violation) => + this.$toast.open({ message: violation }), ); - }, - hasNoPersonLocation() { - let addressInParticipations_ = []; - this.currentParticipations.forEach((p) => { - addressInParticipations_.push( - this.checkHouseholdAddressForParticipation(p), - ); - }); + } else { + this.$toast.open({ message: "An error occurred" }); + } + }); - const booleanReducer = (previousValue, currentValue) => - previousValue || currentValue; - - let addressInParticipations = - addressInParticipations_.length > 0 - ? addressInParticipations_.reduce(booleanReducer) - : false; - - //console.log(addressInParticipations_, addressInParticipations); - return ( - this.accompanyingCourse.step !== "DRAFT" && - this.isTemporaryAddress && - !addressInParticipations - ); - }, - isContextEdit() { - return this.context.edit; - }, + this.$store.commit("setEditContextTrue", payload); }, - methods: { - checkHouseholdAddressForParticipation(participation) { - if (participation.person.current_household_id === null) { - return false; - } - return participation.person.current_household_address !== null; - }, - initAddressContext() { - let context = { - target: { - name: this.accompanyingCourse.type, - id: this.accompanyingCourse.id, - }, - edit: false, - addressId: null, - defaults: window.addaddress, - }; - if (this.accompanyingCourse.location) { - context["edit"] = true; - context["addressId"] = - this.accompanyingCourse.location.address_id; - } - this.$store.commit("setAddressContext", context); - }, - displayErrors() { - return this.$refs.addAddress.errorMsg; - }, - submitTemporaryAddress(payload) { - //console.log('@@@ click on Submit Temporary Address Button', payload); - payload["locationStatusTo"] = "address"; // <== temporary, not none, not person - this.$store - .dispatch("updateLocation", payload) - .catch(({ name, violations }) => { - if ( - name === "ValidationException" || - name === "AccessException" - ) { - violations.forEach((violation) => - this.$toast.open({ message: violation }), - ); - } else { - this.$toast.open({ message: "An error occurred" }); - } - }); + }, + created() { + this.initAddressContext(); - this.$store.commit("setEditContextTrue", payload); - }, - }, - created() { - this.initAddressContext(); - - //console.log('ac.locationStatus', this.accompanyingCourse.locationStatus); - //console.log('ac.location (temporary location)', this.accompanyingCourse.location); - //console.log('ac.personLocation', this.accompanyingCourse.personLocation); - }, + //console.log('ac.locationStatus', this.accompanyingCourse.locationStatus); + //console.log('ac.location (temporary location)', this.accompanyingCourse.location); + //console.log('ac.personLocation', this.accompanyingCourse.personLocation); + }, }; </script> <style lang="scss" scoped> div#accompanying-course { - div.vue-component { - & > div.alert.no-person-location { - margin: 1px 0 0; - } - div.no-person-location { - padding-top: 1.5em; - display: flex; - flex-direction: row; - & > i { - flex-basis: 1.5em; - flex-grow: 0; - flex-shrink: 0; - padding-top: 0.2em; - opacity: 0.75; - } - & > div { - flex-basis: auto; - div.action { - button.btn-update { - margin-right: 2em; - } - } - } - } - div.flex-table { - div.item-bloc { - div.alert { - margin: 0 -0.9em -1em; - } - } - } + div.vue-component { + & > div.alert.no-person-location { + margin: 1px 0 0; } + div.no-person-location { + padding-top: 1.5em; + display: flex; + flex-direction: row; + & > i { + flex-basis: 1.5em; + flex-grow: 0; + flex-shrink: 0; + padding-top: 0.2em; + opacity: 0.75; + } + & > div { + flex-basis: auto; + div.action { + button.btn-update { + margin-right: 2em; + } + } + } + } + div.flex-table { + div.item-bloc { + div.alert { + margin: 0 -0.9em -1em; + } + } + } + } } </style> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/OriginDemand.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/OriginDemand.vue index 641c9ef37..053eb058f 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/OriginDemand.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/OriginDemand.vue @@ -1,33 +1,33 @@ <template> - <div class="vue-component"> - <h2><a id="section-30" />{{ $t("origin.title") }}</h2> + <div class="vue-component"> + <h2><a id="section-30" />{{ $t("origin.title") }}</h2> - <div class="mb-4"> - <label for="selectOrigin"> - {{ $t("origin.title") }} - </label> + <div class="mb-4"> + <label for="selectOrigin"> + {{ $t("origin.title") }} + </label> - <VueMultiselect - name="selectOrigin" - label="text" - :custom-label="transText" - track-by="id" - :multiple="false" - :searchable="true" - :placeholder="$t('origin.placeholder')" - v-model="value" - :options="options" - :select-label="$t('multiselect.select_label')" - :deselect-label="$t('multiselect.deselect_label')" - :selected-label="$t('multiselect.selected_label')" - @select="updateOrigin" - /> - </div> - - <div v-if="!isOriginValid" class="alert alert-warning to-confirm"> - {{ $t("origin.not_valid") }} - </div> + <VueMultiselect + name="selectOrigin" + label="text" + :custom-label="transText" + track-by="id" + :multiple="false" + :searchable="true" + :placeholder="$t('origin.placeholder')" + v-model="value" + :options="options" + :select-label="$t('multiselect.select_label')" + :deselect-label="$t('multiselect.deselect_label')" + :selected-label="$t('multiselect.selected_label')" + @select="updateOrigin" + /> </div> + + <div v-if="!isOriginValid" class="alert alert-warning to-confirm"> + {{ $t("origin.not_valid") }} + </div> + </div> </template> <script> @@ -36,61 +36,58 @@ import { makeFetch } from "ChillMainAssets/lib/api/apiMethods"; import { mapState, mapGetters } from "vuex"; export default { - name: "OriginDemand", - components: { VueMultiselect }, - data() { - return { - options: [], - }; + name: "OriginDemand", + components: { VueMultiselect }, + data() { + return { + options: [], + }; + }, + computed: { + ...mapState({ + value: (state) => state.accompanyingCourse.origin, + }), + ...mapGetters(["isOriginValid"]), + }, + mounted() { + this.getOptions(); + }, + methods: { + getOptions() { + const url = `/api/1.0/person/accompanying-period/origin.json`; + makeFetch("GET", url) + .then((response) => { + this.options = response.results; + return response; + }) + .catch((error) => { + commit("catchError", error); + this.$toast.open({ message: error.txt }); + }); }, - computed: { - ...mapState({ - value: (state) => state.accompanyingCourse.origin, - }), - ...mapGetters(["isOriginValid"]), + updateOrigin(value) { + this.$store + .dispatch("updateOrigin", value) + .catch(({ name, violations }) => { + if (name === "ValidationException" || name === "AccessException") { + violations.forEach((violation) => + this.$toast.open({ message: violation }), + ); + } else { + this.$toast.open({ message: "An error occurred" }); + } + }); }, - mounted() { - this.getOptions(); - }, - methods: { - getOptions() { - const url = `/api/1.0/person/accompanying-period/origin.json`; - makeFetch("GET", url) - .then((response) => { - this.options = response.results; - return response; - }) - .catch((error) => { - commit("catchError", error); - this.$toast.open({ message: error.txt }); - }); - }, - updateOrigin(value) { - this.$store - .dispatch("updateOrigin", value) - .catch(({ name, violations }) => { - if ( - name === "ValidationException" || - name === "AccessException" - ) { - violations.forEach((violation) => - this.$toast.open({ message: violation }), - ); - } else { - this.$toast.open({ message: "An error occurred" }); - } - }); - }, - transText({ text }) { - return text.fr; - }, + transText({ text }) { + return text.fr; }, + }, }; </script> <style src="vue-multiselect/dist/vue-multiselect.css"></style> <style lang="css" scoped> label { - display: none; + display: none; } </style> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/PersonsAssociated.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/PersonsAssociated.vue index d6979928b..583062275 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/PersonsAssociated.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/PersonsAssociated.vue @@ -1,121 +1,110 @@ <template> - <div class="vue-component"> - <h2><a id="section-10" />{{ $t("persons_associated.title") }}</h2> + <div class="vue-component"> + <h2><a id="section-10" />{{ $t("persons_associated.title") }}</h2> - <div v-if="currentParticipations.length > 0"> - <label class="col-form-label">{{ - $tc("persons_associated.counter", counter) - }}</label> - </div> - <div v-else> - <label class="chill-no-data-statement">{{ - $tc("persons_associated.counter", counter) - }}</label> - </div> - - <div - v-if="participationWithoutHousehold.length > 0" - class="alert alert-warning no-household" - > - <i class="fa fa-warning fa-2x" /> - <form method="GET" action="/fr/person/household/members/editor"> - <div class="float-button bottom"> - <div class="box"> - <div class="action"> - <button class="btn btn-update" type="submit"> - {{ $t("persons_associated.update_household") }} - </button> - </div> - <p class="mb-3"> - {{ - $t( - "persons_associated.person_without_household_warning", - ) - }} - </p> - <div - class="form-check" - v-for="p in participationWithoutHousehold" - :key="p.id" - > - <input - type="checkbox" - class="form-check-input" - name="persons[]" - checked="checked" - :id="p.person.id" - :value="p.person.id" - /> - <label class="form-check-label"> - <person-text :person="p.person" /> - </label> - </div> - <input - type="hidden" - name="expand_suggestions" - value="true" - /> - <input - type="hidden" - name="returnPath" - :value="getReturnPath" - /> - <input - type="hidden" - name="accompanying_period_id" - :value="courseId" - /> - </div> - </div> - </form> - </div> - - <div class="flex-table mb-3"> - <participation-item - v-for="participation in currentParticipations" - :participation="participation" - :key="participation.id" - @remove="removeParticipation" - @close="closeParticipation" - /> - </div> - - <div v-if="suggestedPersons.length > 0"> - <ul class="list-suggest add-items inline"> - <li - v-for="p in suggestedPersons" - :key="p.id" - @click="addSuggestedPerson(p)" - > - <person-text :person="p" /> - </li> - </ul> - </div> - - <div> - <ul class="record_actions"> - <li class="add-persons"> - <add-persons - button-title="persons_associated.add_persons" - modal-title="add_persons.title" - :key="addPersons.key" - :options="addPersons.options" - @add-new-persons="addNewPersons" - ref="addPersons" - > - <!-- to cast child method --> - </add-persons> - </li> - </ul> - </div> - - <div - v-if="!isParticipationValid" - class="alert alert-warning to-confirm" - > - {{ $t("persons_associated.participation_not_valid") }} - </div> + <div v-if="currentParticipations.length > 0"> + <label class="col-form-label">{{ + $tc("persons_associated.counter", counter) + }}</label> </div> + <div v-else> + <label class="chill-no-data-statement">{{ + $tc("persons_associated.counter", counter) + }}</label> + </div> + + <div + v-if="participationWithoutHousehold.length > 0" + class="alert alert-warning no-household" + > + <i class="fa fa-warning fa-2x" /> + <form method="GET" action="/fr/person/household/members/editor"> + <div class="float-button bottom"> + <div class="box"> + <div class="action"> + <button class="btn btn-update" type="submit"> + {{ $t("persons_associated.update_household") }} + </button> + </div> + <p class="mb-3"> + {{ $t("persons_associated.person_without_household_warning") }} + </p> + <div + class="form-check" + v-for="p in participationWithoutHousehold" + :key="p.id" + > + <input + type="checkbox" + class="form-check-input" + name="persons[]" + checked="checked" + :id="p.person.id" + :value="p.person.id" + /> + <label class="form-check-label"> + <person-text :person="p.person" /> + </label> + </div> + <input type="hidden" name="expand_suggestions" value="true" /> + <input type="hidden" name="returnPath" :value="getReturnPath" /> + <input + type="hidden" + name="accompanying_period_id" + :value="courseId" + /> + </div> + </div> + </form> + </div> + + <div class="flex-table mb-3"> + <participation-item + v-for="participation in currentParticipations" + :participation="participation" + :key="participation.id" + @remove="removeParticipation" + @close="closeParticipation" + /> + </div> + + <div v-if="suggestedPersons.length > 0"> + <ul class="list-suggest add-items inline"> + <li + v-for="p in suggestedPersons" + :key="p.id" + @click="addSuggestedPerson(p)" + > + <person-text :person="p" /> + </li> + </ul> + </div> + + <div> + <ul class="record_actions"> + <li class="add-persons"> + <add-persons + :button-title=" + trans(ACCOMPANYING_COURSE_PERSONS_ASSOCIATED_ADD_PERSON) + " + :modal-title=" + trans(ACCOMPANYING_COURSE_PERSONS_ASSOCIATED_ADD_PERSON) + " + :key="addPersons.key" + :options="addPersons.options" + @add-new-persons="addNewPersons" + ref="addPersons" + > + <!-- to cast child method --> + </add-persons> + </li> + </ul> + </div> + + <div v-if="!isParticipationValid" class="alert alert-warning to-confirm"> + {{ $t("persons_associated.participation_not_valid") }} + </div> + </div> </template> <script> @@ -123,178 +112,168 @@ import { mapGetters, mapState } from "vuex"; import ParticipationItem from "./PersonsAssociated/ParticipationItem.vue"; import AddPersons from "ChillPersonAssets/vuejs/_components/AddPersons.vue"; import PersonText from "ChillPersonAssets/vuejs/_components/Entity/PersonText.vue"; +import { + ACCOMPANYING_COURSE_PERSONS_ASSOCIATED_ADD_PERSON, + trans, +} from "translator"; export default { - name: "PersonsAssociated", - components: { - ParticipationItem, - AddPersons, - PersonText, - }, - data() { - return { - addPersons: { - key: "persons_associated", - options: { - type: ["person"], - priority: null, - uniq: false, - }, - }, - }; - }, - computed: { - ...mapState({ - courseId: (state) => state.accompanyingCourse.id, - participations: (state) => state.accompanyingCourse.participations, - suggestedPersons: (state) => - [ - state.accompanyingCourse.requestor, - ...state.accompanyingCourse.resources.map( - (r) => r.resource, - ), - ] - .filter((e) => e !== null) - .filter((e) => e.type === "person") - .filter( - (p) => - !state.accompanyingCourse.participations - .filter((pa) => pa.endDate === null) - .map((pa) => pa.person.id) - .includes(p.id), - ) - // filter persons appearing twice in requestor and resources - .filter((e, index, suggested) => { - for (let i = 0; i < suggested.length; i = i + 1) { - if (i < index && e.id === suggested[i].id) { - return false; - } - } + name: "PersonsAssociated", + components: { + ParticipationItem, + AddPersons, + PersonText, + }, + data() { + return { + ACCOMPANYING_COURSE_PERSONS_ASSOCIATED_ADD_PERSON, + addPersons: { + key: "persons_associated", + options: { + type: ["person"], + priority: null, + uniq: false, + }, + }, + }; + }, + computed: { + ...mapState({ + courseId: (state) => state.accompanyingCourse.id, + participations: (state) => state.accompanyingCourse.participations, + suggestedPersons: (state) => + [ + state.accompanyingCourse.requestor, + ...state.accompanyingCourse.resources.map((r) => r.resource), + ] + .filter((e) => e !== null) + .filter((e) => e.type === "person") + .filter( + (p) => + !state.accompanyingCourse.participations + .filter((pa) => pa.endDate === null) + .map((pa) => pa.person.id) + .includes(p.id), + ) + // filter persons appearing twice in requestor and resources + .filter((e, index, suggested) => { + for (let i = 0; i < suggested.length; i = i + 1) { + if (i < index && e.id === suggested[i].id) { + return false; + } + } - return true; - }), - }), - ...mapGetters(["isParticipationValid"]), - currentParticipations() { - return this.participations.filter((p) => p.endDate === null); - }, - counter() { - return this.currentParticipations.length; - }, - participationWithoutHousehold() { - return this.currentParticipations.filter( - (p) => p.person.current_household_id === null, - ); - }, - getReturnPath() { - return ( - window.location.pathname + - window.location.search + - window.location.hash - ); - }, + return true; + }), + }), + ...mapGetters(["isParticipationValid"]), + currentParticipations() { + return this.participations.filter((p) => p.endDate === null); }, - methods: { - removeParticipation(item) { - this.$store - .dispatch("removeParticipation", item) - .catch(({ name, violations }) => { - if ( - name === "ValidationException" || - name === "AccessException" - ) { - violations.forEach((violation) => - this.$toast.open({ message: violation }), - ); - } else { - this.$toast.open({ message: "An error occurred" }); - } - }); - }, - closeParticipation(item) { - this.$store - .dispatch("closeParticipation", item) - .catch(({ name, violations }) => { - if ( - name === "ValidationException" || - name === "AccessException" - ) { - violations.forEach((violation) => - this.$toast.open({ message: violation }), - ); - } else { - this.$toast.open({ message: "An error occurred" }); - } - }); - }, - addNewPersons({ selected, modal }) { - selected.forEach(function (item) { - this.$store - .dispatch("addParticipation", item) - .catch(({ name, violations }) => { - if ( - name === "ValidationException" || - name === "AccessException" - ) { - violations.forEach((violation) => - this.$toast.open({ message: violation }), - ); - } else { - this.$toast.open({ message: "An error occurred" }); - } - }); - }, this); - this.$refs.addPersons.resetSearch(); // to cast child method - modal.showModal = false; - }, - addSuggestedPerson(person) { - this.$store - .dispatch("addParticipation", { - result: person, - type: "person", - }) - .catch(({ name, violations }) => { - if ( - name === "ValidationException" || - name === "AccessException" - ) { - violations.forEach((violation) => - this.$toast.open({ message: violation }), - ); - } else { - this.$toast.open({ message: "An error occurred" }); - } - }); - }, + counter() { + return this.currentParticipations.length; }, + participationWithoutHousehold() { + return this.currentParticipations.filter( + (p) => p.person.current_household_id === null, + ); + }, + getReturnPath() { + return ( + window.location.pathname + window.location.search + window.location.hash + ); + }, + }, + methods: { + trans, + removeParticipation(item) { + this.$store + .dispatch("removeParticipation", item) + .catch(({ name, violations }) => { + if (name === "ValidationException" || name === "AccessException") { + violations.forEach((violation) => + this.$toast.open({ message: violation }), + ); + } else { + this.$toast.open({ message: "An error occurred" }); + } + }); + }, + closeParticipation(item) { + this.$store + .dispatch("closeParticipation", item) + .catch(({ name, violations }) => { + if (name === "ValidationException" || name === "AccessException") { + violations.forEach((violation) => + this.$toast.open({ message: violation }), + ); + } else { + this.$toast.open({ message: "An error occurred" }); + } + }); + }, + addNewPersons({ selected, modal }) { + selected.forEach(function (item) { + this.$store + .dispatch("addParticipation", item) + .catch(({ name, violations }) => { + if (name === "ValidationException" || name === "AccessException") { + violations.forEach((violation) => + this.$toast.open({ message: violation }), + ); + } else { + this.$toast.open({ message: "An error occurred" }); + } + }); + }, this); + this.$refs.addPersons.resetSearch(); // to cast child method + modal.showModal = false; + }, + addSuggestedPerson(person) { + this.$store + .dispatch("addParticipation", { + result: person, + type: "person", + }) + .catch(({ name, violations }) => { + if (name === "ValidationException" || name === "AccessException") { + violations.forEach((violation) => + this.$toast.open({ message: violation }), + ); + } else { + this.$toast.open({ message: "An error occurred" }); + } + }); + }, + }, }; </script> <style lang="scss" scoped> div#accompanying-course { - div.vue-component { - & > div.alert.no-household { - margin: 0 0 -1em; - } - div.no-household { - padding-bottom: 1.5em; - display: flex; - flex-direction: row; - & > i { - flex-basis: 1.5em; - flex-grow: 0; - flex-shrink: 0; - padding-top: 0.2em; - opacity: 0.75; - } - & > form { - flex-basis: auto; - div.action { - button.btn-update { - margin-right: 2em; - } - } - } - } + div.vue-component { + & > div.alert.no-household { + margin: 0 0 -1em; } + div.no-household { + padding-bottom: 1.5em; + display: flex; + flex-direction: row; + & > i { + flex-basis: 1.5em; + flex-grow: 0; + flex-shrink: 0; + padding-top: 0.2em; + opacity: 0.75; + } + & > form { + flex-basis: auto; + div.action { + button.btn-update { + margin-right: 2em; + } + } + } + } + } } </style> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/PersonsAssociated/ParticipationItem.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/PersonsAssociated/ParticipationItem.vue index 61a9d2571..c2dbd6e0c 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/PersonsAssociated/ParticipationItem.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/PersonsAssociated/ParticipationItem.vue @@ -1,98 +1,97 @@ <template> - <person-render-box - render="bloc" - :options="{ - addInfo: true, - addId: false, - addEntity: false, - addLink: false, - addHouseholdLink: false, - addAltNames: true, - addAge: true, - hLevel: 3, - isConfidential: false, - isMultiline: true, - }" - :person="participation.person" - :return-path="getAccompanyingCourseReturnPath" - > - <template #end-bloc> - <div class="item-row separator"> - <ul class="record_actions"> - <button-location - v-if=" - hasCurrentHouseholdAddress && - !isPersonLocatingCourse(participation.person) - " - :person="participation.person" - /> - <li v-if="participation.person.current_household_id"> - <a - class="btn btn-sm btn-chill-beige" - :href="getCurrentHouseholdUrl" - :title=" - $t('persons_associated.show_household_number', { - id: participation.person - .current_household_id, - }) - " - > - <i class="fa fa-fw fa-home" /> - </a> - </li> - <li> - <on-the-fly - :type="participation.person.type" - :id="participation.person.id" - action="show" - /> - </li> - <li> - <on-the-fly - :type="participation.person.type" - :id="participation.person.id" - action="edit" - @save-form-on-the-fly="saveFormOnTheFly" - ref="onTheFly" - /> - </li> - <li> - <button - v-if="!participation.endDate" - class="btn btn-sm btn-remove" - :title="$t('persons_associated.leave_course')" - @click="modal.showModal = true" - /> - </li> - </ul> - </div> - </template> - </person-render-box> + <person-render-box + render="bloc" + :options="{ + addInfo: true, + addId: false, + addEntity: false, + addLink: false, + addHouseholdLink: false, + addAltNames: true, + addAge: true, + hLevel: 3, + isConfidential: false, + isMultiline: true, + }" + :person="participation.person" + :return-path="getAccompanyingCourseReturnPath" + > + <template #end-bloc> + <div class="item-row separator"> + <ul class="record_actions"> + <button-location + v-if=" + hasCurrentHouseholdAddress && + !isPersonLocatingCourse(participation.person) + " + :person="participation.person" + /> + <li v-if="participation.person.current_household_id"> + <a + class="btn btn-sm btn-chill-beige" + :href="getCurrentHouseholdUrl" + :title=" + $t('persons_associated.show_household_number', { + id: participation.person.current_household_id, + }) + " + > + <i class="fa fa-fw fa-home" /> + </a> + </li> + <li> + <on-the-fly + :type="participation.person.type" + :id="participation.person.id" + action="show" + /> + </li> + <li> + <on-the-fly + :type="participation.person.type" + :id="participation.person.id" + action="edit" + @save-form-on-the-fly="saveFormOnTheFly" + ref="onTheFly" + /> + </li> + <li> + <button + v-if="!participation.endDate" + class="btn btn-sm btn-remove" + :title="$t('persons_associated.leave_course')" + @click="modal.showModal = true" + /> + </li> + </ul> + </div> + </template> + </person-render-box> - <teleport to="body"> - <modal - v-if="modal.showModal" - :modal-dialog-class="modal.modalDialogClass" - @close="modal.showModal = false" + <teleport to="body"> + <modal + v-if="modal.showModal" + :modal-dialog-class="modal.modalDialogClass" + @close="modal.showModal = false" + > + <template #header> + <h2 class="modal-title"> + {{ $t("persons_associated.sure") }} + </h2> + </template> + <template #body> + <p>{{ $t("persons_associated.sure_description") }}</p> + </template> + <template #footer> + <button + class="btn btn-danger" + @click.prevent="$emit('close', participation)" > - <template #header> - <h2 class="modal-title"> - {{ $t("persons_associated.sure") }} - </h2> - </template> - <template #body> - <p>{{ $t("persons_associated.sure_description") }}</p> - </template> - <template #footer> - <button - class="btn btn-danger" - @click.prevent="$emit('close', participation)" - > - {{ $t("persons_associated.ok") }} - </button> - </template> - </modal> - </teleport> + {{ $t("persons_associated.ok") }} + </button> + </template> + </modal> + </teleport> </template> <script> @@ -103,135 +102,135 @@ import Modal from "ChillMainAssets/vuejs/_components/Modal"; import { makeFetch } from "ChillMainAssets/lib/api/apiMethods"; export default { - name: "ParticipationItem", - components: { - OnTheFly, - ButtonLocation, - PersonRenderBox, - Modal, + name: "ParticipationItem", + components: { + OnTheFly, + ButtonLocation, + PersonRenderBox, + Modal, + }, + props: ["participation"], + emits: ["remove", "close"], + data() { + return { + modal: { + showModal: false, + modalDialogClass: "modal-dialog-centered modal-md", + }, + PersonRenderBox: { + participation: "participation", + options: { + addInfo: false, + addId: true, + addAge: false, + hLevel: 1, + }, + }, + }; + }, + computed: { + hasCurrentHouseholdAddress() { + if ( + !this.participation.endDate && + this.participation.person.current_household_address !== null + ) { + return true; + } + return false; }, - props: ["participation"], - emits: ["remove", "close"], - data() { - return { - modal: { - showModal: false, - modalDialogClass: "modal-dialog-centered modal-md", - }, - PersonRenderBox: { - participation: "participation", - options: { - addInfo: false, - addId: true, - addAge: false, - hLevel: 1, - }, - }, + getAccompanyingCourseReturnPath() { + return `fr/parcours/${this.$store.state.accompanyingCourse.id}/edit#section-10`; + }, + getCurrentHouseholdUrl() { + return `/fr/person/household/${this.participation.person.current_household_id}/summary?returnPath=${this.getAccompanyingCourseReturnPath}`; + }, + }, + methods: { + isPersonLocatingCourse(person) { + return this.$store.getters.isPersonLocatingCourse(person); + }, + saveFormOnTheFly(payload) { + console.log( + "saveFormOnTheFly: type", + payload.type, + ", data", + payload.data, + ); + payload.target = "participation"; + + let body = { type: payload.type }; + if (payload.type === "person") { + body.firstName = payload.data.firstName; + body.lastName = payload.data.lastName; + if (payload.data.birthdate !== null) { + body.birthdate = payload.data.birthdate; + } + body.phonenumber = payload.data.phonenumber; + body.mobilenumber = payload.data.mobilenumber; + body.email = payload.data.email; + body.altNames = payload.data.altNames; + body.gender = { + id: payload.data.gender.id, + type: payload.data.gender.type, }; - }, - computed: { - hasCurrentHouseholdAddress() { - if ( - !this.participation.endDate && - this.participation.person.current_household_address !== null - ) { - return true; + if (payload.data.civility !== null) { + body.civility = { + id: payload.data.civility.id, + type: payload.data.civility.type, + }; + } + + makeFetch( + "PATCH", + `/api/1.0/person/person/${payload.data.id}.json`, + body, + ) + .then((response) => { + this.$store.dispatch("addPerson", { + target: payload.target, + body: response, + }); + this.$refs.onTheFly.closeModal(); + }) + .catch((error) => { + if (error.name === "ValidationException") { + for (let v of error.violations) { + this.$toast.open({ message: v }); + } + } else { + this.$toast.open({ message: "An error occurred" }); } - return false; - }, - getAccompanyingCourseReturnPath() { - return `fr/parcours/${this.$store.state.accompanyingCourse.id}/edit#section-10`; - }, - getCurrentHouseholdUrl() { - return `/fr/person/household/${this.participation.person.current_household_id}/summary?returnPath=${this.getAccompanyingCourseReturnPath}`; - }, - }, - methods: { - isPersonLocatingCourse(person) { - return this.$store.getters.isPersonLocatingCourse(person); - }, - saveFormOnTheFly(payload) { - console.log( - "saveFormOnTheFly: type", - payload.type, - ", data", - payload.data, - ); - payload.target = "participation"; + }); + } else if (payload.type === "thirdparty") { + body.name = payload.data.text; + body.email = payload.data.email; + body.telephone = payload.data.telephone; + body.telephone2 = payload.data.telephone2; + body.address = { id: payload.data.address.address_id }; - let body = { type: payload.type }; - if (payload.type === "person") { - body.firstName = payload.data.firstName; - body.lastName = payload.data.lastName; - if (payload.data.birthdate !== null) { - body.birthdate = payload.data.birthdate; - } - body.phonenumber = payload.data.phonenumber; - body.mobilenumber = payload.data.mobilenumber; - body.email = payload.data.email; - body.altNames = payload.data.altNames; - body.gender = { - id: payload.data.gender.id, - type: payload.data.gender.type, - }; - if (payload.data.civility !== null) { - body.civility = { - id: payload.data.civility.id, - type: payload.data.civility.type, - }; - } - - makeFetch( - "PATCH", - `/api/1.0/person/person/${payload.data.id}.json`, - body, - ) - .then((response) => { - this.$store.dispatch("addPerson", { - target: payload.target, - body: response, - }); - this.$refs.onTheFly.closeModal(); - }) - .catch((error) => { - if (error.name === "ValidationException") { - for (let v of error.violations) { - this.$toast.open({ message: v }); - } - } else { - this.$toast.open({ message: "An error occurred" }); - } - }); - } else if (payload.type === "thirdparty") { - body.name = payload.data.text; - body.email = payload.data.email; - body.telephone = payload.data.telephone; - body.telephone2 = payload.data.telephone2; - body.address = { id: payload.data.address.address_id }; - - makeFetch( - "PATCH", - `/api/1.0/thirdparty/thirdparty/${payload.data.id}.json`, - body, - ) - .then((response) => { - this.$store.dispatch("addThirdparty", { - target: payload.target, - body: response, - }); - this.$refs.onTheFly.closeModal(); - }) - .catch((error) => { - if (error.name === "ValidationException") { - for (let v of error.violations) { - this.$toast.open({ message: v }); - } - } else { - this.$toast.open({ message: "An error occurred" }); - } - }); + makeFetch( + "PATCH", + `/api/1.0/thirdparty/thirdparty/${payload.data.id}.json`, + body, + ) + .then((response) => { + this.$store.dispatch("addThirdparty", { + target: payload.target, + body: response, + }); + this.$refs.onTheFly.closeModal(); + }) + .catch((error) => { + if (error.name === "ValidationException") { + for (let v of error.violations) { + this.$toast.open({ message: v }); + } + } else { + this.$toast.open({ message: "An error occurred" }); } - }, + }); + } }, + }, }; </script> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Referrer.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Referrer.vue index dbb21fa40..963ff65b5 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Referrer.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Referrer.vue @@ -1,116 +1,113 @@ <template> - <div class="vue-component"> - <h2><a id="section-80" />{{ $t("referrer.title") }}</h2> + <div class="vue-component"> + <h2><a id="section-80" />{{ $t("referrer.title") }}</h2> - <teleport to="body"> - <modal - v-if="modal.showModal" - :modal-dialog-class="modal.modalDialogClass" - @close="cancelChange" - > - <template #header> - <h3 class="modal-title"> - {{ $t("confirm.title") }} - </h3> - </template> + <teleport to="body"> + <modal + v-if="modal.showModal" + :modal-dialog-class="modal.modalDialogClass" + @close="cancelChange" + > + <template #header> + <h3 class="modal-title"> + {{ $t("confirm.title") }} + </h3> + </template> - <template #body-head> - <div class="modal-body"> - <p - v-html=" - $t('confirm.sure_referrer', { - referrer: this.value.text, - }) - " - /> - </div> - </template> - - <template #footer> - <button - class="btn btn-save" - @click.prevent="this.confirmReferrer" - > - {{ $t("confirm.ok_referrer") }} - </button> - </template> - </modal> - </teleport> - - <div> - <label class="col-form-label" for="selectJob"> - {{ $t("job.label") }} - </label> - - <VueMultiselect - name="selectJob" - label="text" - :custom-label="customJobLabel" - track-by="id" - :multiple="false" - :searchable="true" - :placeholder="$t('job.placeholder')" - v-model="valueJob" - :options="jobs" - :select-label="$t('multiselect.select_label')" - :deselect-label="$t('multiselect.deselect_label')" - :selected-label="$t('multiselect.selected_label')" + <template #body-head> + <div class="modal-body"> + <p + v-html=" + $t('confirm.sure_referrer', { + referrer: this.value.text, + }) + " /> + </div> + </template> - <label class="col-form-label" for="selectReferrer"> - {{ $t("referrer.label") }} - </label> + <template #footer> + <button class="btn btn-save" @click.prevent="this.confirmReferrer"> + {{ $t("confirm.ok_referrer") }} + </button> + </template> + </modal> + </teleport> - <VueMultiselect - name="selectReferrer" - label="text" - track-by="id" - :multiple="false" - :searchable="true" - :placeholder="$t('referrer.placeholder')" - v-model="value" - @select="updateReferrer" - @remove="removeReferrer" - :options="users" - :select-label="$t('multiselect.select_label')" - :deselect-label="$t('multiselect.deselect_label')" - :selected-label="$t('multiselect.selected_label')" - /> + <div> + <label class="col-form-label" for="selectJob"> + {{ $t("job.label") }} + </label> - <template v-if="usersSuggestedFilteredByJob.length > 0"> - <ul class="list-suggest add-items inline"> - <li - v-for="(u, i) in usersSuggestedFilteredByJob" - @click="updateReferrer(u)" - :key="`referrer-${i}`" - > - <span> - <user-render-box-badge :user="u" /> - </span> - </li> - </ul> - </template> - </div> + <VueMultiselect + name="selectJob" + label="text" + :custom-label="customJobLabel" + track-by="id" + :multiple="false" + :searchable="true" + :placeholder="$t('job.placeholder')" + v-model="valueJob" + :options="jobs" + :select-label="$t('multiselect.select_label')" + :deselect-label="$t('multiselect.deselect_label')" + :selected-label="$t('multiselect.selected_label')" + /> - <div> - <ul class="record_actions"> - <li> - <button - class="btn btn-create" - type="button" - name="button" - @click="assignMe" - > - {{ $t("referrer.assign_me") }} - </button> - </li> - </ul> - </div> + <label class="col-form-label" for="selectReferrer"> + {{ $t("referrer.label") }} + </label> - <div v-if="!isJobValid" class="alert alert-warning to-confirm"> - {{ $t("job.not_valid") }} - </div> + <VueMultiselect + name="selectReferrer" + label="text" + track-by="id" + :multiple="false" + :searchable="true" + :placeholder="$t('referrer.placeholder')" + v-model="value" + @select="updateReferrer" + @remove="removeReferrer" + :options="users" + :select-label="$t('multiselect.select_label')" + :deselect-label="$t('multiselect.deselect_label')" + :selected-label="$t('multiselect.selected_label')" + /> + + <template v-if="usersSuggestedFilteredByJob.length > 0"> + <ul class="list-suggest add-items inline"> + <li + v-for="(u, i) in usersSuggestedFilteredByJob" + @click="updateReferrer(u)" + :key="`referrer-${i}`" + > + <span> + <user-render-box-badge :user="u" /> + </span> + </li> + </ul> + </template> </div> + + <div> + <ul class="record_actions"> + <li> + <button + class="btn btn-create" + type="button" + name="button" + @click="assignMe" + > + {{ $t("referrer.assign_me") }} + </button> + </li> + </ul> + </div> + + <div v-if="!isJobValid" class="alert alert-warning to-confirm"> + {{ $t("job.not_valid") }} + </div> + </div> </template> <script> @@ -121,134 +118,124 @@ import UserRenderBoxBadge from "ChillMainAssets/vuejs/_components/Entity/UserRen import Modal from "ChillMainAssets/vuejs/_components/Modal"; export default { - name: "Referrer", - components: { - UserRenderBoxBadge, - VueMultiselect, - Modal, + name: "Referrer", + components: { + UserRenderBoxBadge, + VueMultiselect, + Modal, + }, + data() { + return { + jobs: [], + modal: { + showModal: false, + modalDialogClass: "modal-dialog-scrollable modal-xl", + }, + value: this.$store.state.accompanyingCourse.user, + confirmed: false, + }; + }, + computed: { + ...mapState({ + valueJob: (state) => state.accompanyingCourse.job, + }), + ...mapGetters(["isJobValid", "usersSuggestedFilteredByJob"]), + users: function () { + let users = this.$store.getters.usersFilteredByJob; + // ensure that the selected user is in the list. add it if necessary + if ( + this.$store.state.accompanyingCourse.user !== null && + users.find( + (u) => this.$store.state.accompanyingCourse.user.id === u.id, + ) === undefined + ) { + users.push(this.$store.state.accompanyingCourse.user); + } + return users; }, - data() { - return { - jobs: [], - modal: { - showModal: false, - modalDialogClass: "modal-dialog-scrollable modal-xl", - }, - value: this.$store.state.accompanyingCourse.user, - confirmed: false, - }; - }, - computed: { - ...mapState({ - valueJob: (state) => state.accompanyingCourse.job, - }), - ...mapGetters(["isJobValid", "usersSuggestedFilteredByJob"]), - users: function () { - let users = this.$store.getters.usersFilteredByJob; - // ensure that the selected user is in the list. add it if necessary - if ( - this.$store.state.accompanyingCourse.user !== null && - users.find( - (u) => - this.$store.state.accompanyingCourse.user.id === u.id, - ) === undefined - ) { - users.push(this.$store.state.accompanyingCourse.user); + valueJob: { + get() { + return this.$store.state.accompanyingCourse.job; + }, + set(value) { + this.$store + .dispatch("updateJob", value) + .catch(({ name, violations }) => { + if (name === "ValidationException" || name === "AccessException") { + violations.forEach((violation) => + this.$toast.open({ message: violation }), + ); + } else { + this.$toast.open({ message: "An error occurred" }); } - return users; - }, - valueJob: { - get() { - return this.$store.state.accompanyingCourse.job; - }, - set(value) { - this.$store - .dispatch("updateJob", value) - .catch(({ name, violations }) => { - if ( - name === "ValidationException" || - name === "AccessException" - ) { - violations.forEach((violation) => - this.$toast.open({ message: violation }), - ); - } else { - this.$toast.open({ message: "An error occurred" }); - } - }); - }, - }, + }); + }, }, - mounted() { - this.getJobs(); + }, + mounted() { + this.getJobs(); + }, + methods: { + updateReferrer(value) { + this.value = value; + this.toggleModal(); }, - methods: { - updateReferrer(value) { - this.value = value; - this.toggleModal(); - }, - getJobs() { - const url = "/api/1.0/main/user-job.json"; - makeFetch("GET", url) - .then((response) => { - this.jobs = response.results; - }) - .catch((error) => { - this.$toast.open({ message: error.txt }); - }); - }, - customJobLabel(value) { - return value.label.fr; - }, - assignMe() { - const url = `/api/1.0/main/whoami.json`; - makeFetch("GET", url).then((user) => { - // this.value = user - this.updateReferrer(user); - }); - }, - toggleModal() { - this.modal.showModal = !this.modal.showModal; - }, - confirmReferrer() { - this.$store - .dispatch("updateReferrer", this.value) - .catch(({ name, violations }) => { - if ( - name === "ValidationException" || - name === "AccessException" - ) { - violations.forEach((violation) => - this.$toast.open({ message: violation }), - ); - } else { - this.$toast.open({ message: "An error occurred" }); - } - }); - this.toggleModal(); - }, - removeReferrer() { - console.log("remove option"); - this.$store - .dispatch("updateReferrer", null) - .catch(({ name, violations }) => { - if ( - name === "ValidationException" || - name === "AccessException" - ) { - violations.forEach((violation) => - this.$toast.open({ message: violation }), - ); - } else { - this.$toast.open({ message: "An error occurred" }); - } - }); - }, - cancelChange() { - this.value = this.$store.state.accompanyingCourse.user; - this.toggleModal(); - }, + getJobs() { + const url = "/api/1.0/main/user-job.json"; + makeFetch("GET", url) + .then((response) => { + this.jobs = response.results; + }) + .catch((error) => { + this.$toast.open({ message: error.txt }); + }); }, + customJobLabel(value) { + return value.label.fr; + }, + assignMe() { + const url = `/api/1.0/main/whoami.json`; + makeFetch("GET", url).then((user) => { + // this.value = user + this.updateReferrer(user); + }); + }, + toggleModal() { + this.modal.showModal = !this.modal.showModal; + }, + confirmReferrer() { + this.$store + .dispatch("updateReferrer", this.value) + .catch(({ name, violations }) => { + if (name === "ValidationException" || name === "AccessException") { + violations.forEach((violation) => + this.$toast.open({ message: violation }), + ); + } else { + this.$toast.open({ message: "An error occurred" }); + } + }); + this.toggleModal(); + }, + removeReferrer() { + console.log("remove option"); + this.$store + .dispatch("updateReferrer", null) + .catch(({ name, violations }) => { + if (name === "ValidationException" || name === "AccessException") { + violations.forEach((violation) => + this.$toast.open({ message: violation }), + ); + } else { + this.$toast.open({ message: "An error occurred" }); + } + }); + }, + cancelChange() { + this.value = this.$store.state.accompanyingCourse.user; + this.toggleModal(); + }, + }, }; </script> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Requestor.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Requestor.vue index c2b9acfc4..6aad2109e 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Requestor.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Requestor.vue @@ -1,263 +1,239 @@ <template> - <div class="vue-component"> - <h2><a id="section-50" />{{ $t("requestor.title") }}</h2> + <div class="vue-component"> + <h2><a id="section-50" />{{ $t("requestor.title") }}</h2> - <div - v-if="accompanyingCourse.requestor && isAnonymous" - class="flex-table" - > - <label> - <input - type="checkbox" - v-model="requestorIsAnonymous" - class="me-2" - /> - {{ $t("requestor.is_anonymous") }} - </label> - <confidential - v-if="accompanyingCourse.requestor.type === 'thirdparty'" - > - <template #confidential-content> - <third-party-render-box - :thirdparty="accompanyingCourse.requestor" - :options="{ - addLink: false, - addId: false, - addEntity: true, - addInfo: false, - hLevel: 3, - isMultiline: true, - isConfidential: true, - }" - > - <template #record-actions> - <ul class="record_actions"> - <li> - <on-the-fly - :type=" - accompanyingCourse.requestor.type - " - :id="accompanyingCourse.requestor.id" - action="show" - /> - </li> - <li> - <on-the-fly - :type=" - accompanyingCourse.requestor.type - " - :id="accompanyingCourse.requestor.id" - action="edit" - @save-form-on-the-fly="saveFormOnTheFly" - ref="onTheFly" - /> - </li> - </ul> - </template> - </third-party-render-box> - </template> - </confidential> - - <confidential - v-else-if="accompanyingCourse.requestor.type === 'person'" - > - <template #confidential-content> - <person-render-box - render="bloc" - :person="accompanyingCourse.requestor" - :options="{ - addLink: false, - addId: false, - addAltNames: false, - addEntity: true, - addInfo: true, - hLevel: 3, - isMultiline: true, - isConfidential: false, - addAge: true, - }" - > - <template #record-actions> - <ul class="record_actions"> - <li> - <on-the-fly - :type=" - accompanyingCourse.requestor.type - " - :id="accompanyingCourse.requestor.id" - action="show" - /> - </li> - <li> - <on-the-fly - :type=" - accompanyingCourse.requestor.type - " - :id="accompanyingCourse.requestor.id" - action="edit" - @save-form-on-the-fly="saveFormOnTheFly" - ref="onTheFly" - /> - </li> - </ul> - </template> - </person-render-box> - </template> - </confidential> - - <ul class="record_actions"> + <div v-if="accompanyingCourse.requestor && isAnonymous" class="flex-table"> + <label> + <input type="checkbox" v-model="requestorIsAnonymous" class="me-2" /> + {{ $t("requestor.is_anonymous") }} + </label> + <confidential v-if="accompanyingCourse.requestor.type === 'thirdparty'"> + <template #confidential-content> + <third-party-render-box + :thirdparty="accompanyingCourse.requestor" + :options="{ + addLink: false, + addId: false, + addEntity: true, + addInfo: false, + hLevel: 3, + isMultiline: true, + isConfidential: true, + }" + > + <template #record-actions> + <ul class="record_actions"> <li> - <button - class="btn btn-remove" - :title="$t('action.remove')" - @click="removeRequestor" - > - {{ $t("action.remove") }} - </button> + <on-the-fly + :type="accompanyingCourse.requestor.type" + :id="accompanyingCourse.requestor.id" + action="show" + /> </li> - </ul> - </div> - - <div - v-else-if="accompanyingCourse.requestor && !isAnonymous" - class="flex-table" - > - <label> - <input - type="checkbox" - v-model="requestorIsAnonymous" - class="me-2" - /> - {{ $t("requestor.is_anonymous") }} - </label> - - <third-party-render-box - v-if="accompanyingCourse.requestor.type === 'thirdparty'" - :thirdparty="accompanyingCourse.requestor" - :options="{ - addLink: false, - addId: false, - addEntity: true, - addInfo: false, - hLevel: 3, - isMultiline: true, - isConfidential: true, - }" - > - <template #record-actions> - <ul class="record_actions"> - <li> - <on-the-fly - :type="accompanyingCourse.requestor.type" - :id="accompanyingCourse.requestor.id" - action="show" - /> - </li> - <li> - <on-the-fly - :type="accompanyingCourse.requestor.type" - :id="accompanyingCourse.requestor.id" - action="edit" - @save-form-on-the-fly="saveFormOnTheFly" - ref="onTheFly" - /> - </li> - </ul> - </template> - </third-party-render-box> - - <person-render-box - render="bloc" - v-if="accompanyingCourse.requestor.type === 'person'" - :person="accompanyingCourse.requestor" - :options="{ - addLink: false, - addId: false, - addAltNames: false, - addEntity: true, - addInfo: true, - hLevel: 3, - isMultiline: true, - isConfidential: false, - }" - > - <template #record-actions> - <ul class="record_actions"> - <li> - <on-the-fly - :type="accompanyingCourse.requestor.type" - :id="accompanyingCourse.requestor.id" - action="show" - /> - </li> - <li> - <on-the-fly - :type="accompanyingCourse.requestor.type" - :id="accompanyingCourse.requestor.id" - action="edit" - @save-form-on-the-fly="saveFormOnTheFly" - ref="onTheFly" - /> - </li> - </ul> - </template> - </person-render-box> - - <ul class="record_actions"> <li> - <button - class="btn btn-remove" - :title="$t('action.remove')" - @click="removeRequestor" - > - {{ $t("action.remove") }} - </button> + <on-the-fly + :type="accompanyingCourse.requestor.type" + :id="accompanyingCourse.requestor.id" + action="edit" + @save-form-on-the-fly="saveFormOnTheFly" + ref="onTheFly" + /> </li> - </ul> - </div> + </ul> + </template> + </third-party-render-box> + </template> + </confidential> - <div v-else> - <label class="chill-no-data-statement">{{ - $t("requestor.counter") - }}</label> - </div> - - <div - v-if=" - accompanyingCourse.requestor === null && - suggestedEntities.length > 0 - " - > - <ul class="list-suggest add-items inline"> - <li - v-for="p in suggestedEntities" - :key="uniqueId(p)" - @click="addSuggestedEntity(p)" - > - <person-text v-if="p.type === 'person'" :person="p" /> - <span v-else>{{ p.text }}</span> + <confidential v-else-if="accompanyingCourse.requestor.type === 'person'"> + <template #confidential-content> + <person-render-box + render="bloc" + :person="accompanyingCourse.requestor" + :options="{ + addLink: false, + addId: false, + addAltNames: false, + addEntity: true, + addInfo: true, + hLevel: 3, + isMultiline: true, + isConfidential: false, + addAge: true, + }" + > + <template #record-actions> + <ul class="record_actions"> + <li> + <on-the-fly + :type="accompanyingCourse.requestor.type" + :id="accompanyingCourse.requestor.id" + action="show" + /> </li> - </ul> - </div> - - <div> - <ul class="record_actions"> - <li class="add-persons"> - <add-persons - v-if="accompanyingCourse.requestor === null" - button-title="requestor.add_requestor" - modal-title="requestor.add_requestor" - :key="addPersons.key" - :options="addPersons.options" - @add-new-persons="addNewPersons" - ref="addPersons" - > - <!-- to cast child method --> - </add-persons> + <li> + <on-the-fly + :type="accompanyingCourse.requestor.type" + :id="accompanyingCourse.requestor.id" + action="edit" + @save-form-on-the-fly="saveFormOnTheFly" + ref="onTheFly" + /> </li> - </ul> - </div> + </ul> + </template> + </person-render-box> + </template> + </confidential> + + <ul class="record_actions"> + <li> + <button + class="btn btn-remove" + :title="$t('action.remove')" + @click="removeRequestor" + > + {{ $t("action.remove") }} + </button> + </li> + </ul> </div> + + <div + v-else-if="accompanyingCourse.requestor && !isAnonymous" + class="flex-table" + > + <label> + <input type="checkbox" v-model="requestorIsAnonymous" class="me-2" /> + {{ $t("requestor.is_anonymous") }} + </label> + + <third-party-render-box + v-if="accompanyingCourse.requestor.type === 'thirdparty'" + :thirdparty="accompanyingCourse.requestor" + :options="{ + addLink: false, + addId: false, + addEntity: true, + addInfo: false, + hLevel: 3, + isMultiline: true, + isConfidential: true, + }" + > + <template #record-actions> + <ul class="record_actions"> + <li> + <on-the-fly + :type="accompanyingCourse.requestor.type" + :id="accompanyingCourse.requestor.id" + action="show" + /> + </li> + <li> + <on-the-fly + :type="accompanyingCourse.requestor.type" + :id="accompanyingCourse.requestor.id" + action="edit" + @save-form-on-the-fly="saveFormOnTheFly" + ref="onTheFly" + /> + </li> + </ul> + </template> + </third-party-render-box> + + <person-render-box + render="bloc" + v-if="accompanyingCourse.requestor.type === 'person'" + :person="accompanyingCourse.requestor" + :options="{ + addLink: false, + addId: false, + addAltNames: false, + addEntity: true, + addInfo: true, + hLevel: 3, + isMultiline: true, + isConfidential: false, + }" + > + <template #record-actions> + <ul class="record_actions"> + <li> + <on-the-fly + :type="accompanyingCourse.requestor.type" + :id="accompanyingCourse.requestor.id" + action="show" + /> + </li> + <li> + <on-the-fly + :type="accompanyingCourse.requestor.type" + :id="accompanyingCourse.requestor.id" + action="edit" + @save-form-on-the-fly="saveFormOnTheFly" + ref="onTheFly" + /> + </li> + </ul> + </template> + </person-render-box> + + <ul class="record_actions"> + <li> + <button + class="btn btn-remove" + :title="$t('action.remove')" + @click="removeRequestor" + > + {{ $t("action.remove") }} + </button> + </li> + </ul> + </div> + + <div v-else> + <label class="chill-no-data-statement">{{ + $t("requestor.counter") + }}</label> + </div> + + <div + v-if=" + accompanyingCourse.requestor === null && suggestedEntities.length > 0 + " + > + <ul class="list-suggest add-items inline"> + <li + v-for="p in suggestedEntities" + :key="uniqueId(p)" + @click="addSuggestedEntity(p)" + > + <person-text v-if="p.type === 'person'" :person="p" /> + <span v-else>{{ p.text }}</span> + </li> + </ul> + </div> + + <div> + <ul class="record_actions"> + <li class="add-persons"> + <add-persons + v-if="accompanyingCourse.requestor === null" + :button-title="trans(ACCOMPANYING_COURSE_REQUESTOR_ADD)" + :modal-title="trans(ACCOMPANYING_COURSE_REQUESTOR_ADD)" + :key="addPersons.key" + :options="addPersons.options" + @add-new-persons="addNewPersons" + ref="addPersons" + > + <!-- to cast child method --> + </add-persons> + </li> + </ul> + </div> + </div> </template> <script> @@ -269,223 +245,212 @@ import Confidential from "ChillMainAssets/vuejs/_components/Confidential.vue"; import { mapState } from "vuex"; import { makeFetch } from "ChillMainAssets/lib/api/apiMethods"; import PersonText from "ChillPersonAssets/vuejs/_components/Entity/PersonText.vue"; +import { ACCOMPANYING_COURSE_REQUESTOR_ADD, trans } from "translator"; export default { - name: "Requestor", - components: { - AddPersons, - OnTheFly, - PersonRenderBox, - ThirdPartyRenderBox, - Confidential, - PersonText, - }, - props: ["isAnonymous"], - data() { - return { - addPersons: { - key: "requestor", - options: { - type: ["person", "thirdparty"], - priority: null, - uniq: true, - }, - }, - }; - }, - computed: { - ...mapState({ - suggestedEntities: (state) => { - return ( - [ - ...state.accompanyingCourse.participations - .filter((p) => p.endDate === null) - .map((p) => p.person), - ...state.accompanyingCourse.resources.map( - (r) => r.resource, - ), - ] - .filter((e) => e !== null) - // filter for same entity appearing twice - .filter((e, index, suggested) => { - for (let i = 0; i < suggested.length; i = i + 1) { - if ( - i < index && - e.id === suggested[i].id && - e.type === suggested[i].type - ) { - return false; - } - } + name: "Requestor", + components: { + AddPersons, + OnTheFly, + PersonRenderBox, + ThirdPartyRenderBox, + Confidential, + PersonText, + }, + props: ["isAnonymous"], + data() { + return { + ACCOMPANYING_COURSE_REQUESTOR_ADD, + addPersons: { + key: "requestor", + options: { + type: ["person", "thirdparty"], + priority: null, + uniq: true, + }, + }, + }; + }, + computed: { + ...mapState({ + suggestedEntities: (state) => { + return ( + [ + ...state.accompanyingCourse.participations + .filter((p) => p.endDate === null) + .map((p) => p.person), + ...state.accompanyingCourse.resources.map((r) => r.resource), + ] + .filter((e) => e !== null) + // filter for same entity appearing twice + .filter((e, index, suggested) => { + for (let i = 0; i < suggested.length; i = i + 1) { + if ( + i < index && + e.id === suggested[i].id && + e.type === suggested[i].type + ) { + return false; + } + } - return true; - }) - ); - }, - }), - accompanyingCourse() { - return this.$store.state.accompanyingCourse; - }, - requestorIsAnonymous: { - set(value) { - this.$store.dispatch("requestorIsAnonymous", value); - }, - get() { - return this.$store.state.accompanyingCourse.requestorAnonymous; - }, - }, + return true; + }) + ); + }, + }), + accompanyingCourse() { + return this.$store.state.accompanyingCourse; }, - methods: { - removeRequestor() { - //console.log('@@ CLICK remove requestor: item'); - this.$store - .dispatch("removeRequestor") - .catch(({ name, violations }) => { - if ( - name === "ValidationException" || - name === "AccessException" - ) { - violations.forEach((violation) => - this.$toast.open({ message: violation }), - ); - } else { - this.$toast.open({ message: "An error occurred" }); - } - }); - }, - addNewPersons({ selected, modal }) { - //console.log('@@@ CLICK button addNewPersons', selected); - this.$store - .dispatch("addRequestor", selected.shift()) - .catch(({ name, violations }) => { - if ( - name === "ValidationException" || - name === "AccessException" - ) { - violations.forEach((violation) => - this.$toast.open({ message: violation }), - ); - } else { - this.$toast.open({ message: "An error occurred" }); - } - }); - - this.$refs.addPersons.resetSearch(); // to cast child method - modal.showModal = false; - }, - saveFormOnTheFly(payload) { - console.log( - "saveFormOnTheFly: type", - payload.type, - ", data", - payload.data, + requestorIsAnonymous: { + set(value) { + this.$store.dispatch("requestorIsAnonymous", value); + }, + get() { + return this.$store.state.accompanyingCourse.requestorAnonymous; + }, + }, + }, + methods: { + trans, + removeRequestor() { + //console.log('@@ CLICK remove requestor: item'); + this.$store.dispatch("removeRequestor").catch(({ name, violations }) => { + if (name === "ValidationException" || name === "AccessException") { + violations.forEach((violation) => + this.$toast.open({ message: violation }), + ); + } else { + this.$toast.open({ message: "An error occurred" }); + } + }); + }, + addNewPersons({ selected }) { + //console.log('@@@ CLICK button addNewPersons', selected); + this.$store + .dispatch("addRequestor", selected.shift()) + .catch(({ name, violations }) => { + if (name === "ValidationException" || name === "AccessException") { + violations.forEach((violation) => + this.$toast.open({ message: violation }), ); - payload.target = "requestor"; + } else { + this.$toast.open({ message: "An error occurred" }); + } + }); - let body = { type: payload.type }; - if (payload.type === "person") { - body.firstName = payload.data.firstName; - body.lastName = payload.data.lastName; - if (payload.data.birthdate !== null) { - body.birthdate = payload.data.birthdate; - } - body.phonenumber = payload.data.phonenumber; - body.mobilenumber = payload.data.mobilenumber; - body.email = payload.data.email; - body.altNames = payload.data.altNames; - body.gender = payload.data.gender; - - makeFetch( - "PATCH", - `/api/1.0/person/person/${payload.data.id}.json`, - body, - ) - .then((response) => { - this.$store.dispatch("addPerson", { - target: payload.target, - body: response, - }); - this.$refs.onTheFly.closeModal(); - }) - .catch((error) => { - if (error.name === "ValidationException") { - for (let v of error.violations) { - this.$toast.open({ message: v }); - } - } else { - this.$toast.open({ message: "An error occurred" }); - } - }); - } else if (payload.type === "thirdparty") { - body.name = payload.data.text; - body.email = payload.data.email; - body.telephone = payload.data.telephone; - body.telephone2 = payload.data.telephone2; - if (payload.data.address) { - body.address = { id: payload.data.address.address_id }; - } - - makeFetch( - "PATCH", - `/api/1.0/thirdparty/thirdparty/${payload.data.id}.json`, - body, - ) - .then((response) => { - this.$store.dispatch("addThirdparty", { - target: payload.target, - body: response, - }); - this.$refs.onTheFly.closeModal(); - }) - .catch((error) => { - if (error.name === "ValidationException") { - for (let v of error.violations) { - this.$toast.open({ message: v }); - } - } else { - this.$toast.open({ message: "An error occurred" }); - } - }); - } - }, - addSuggestedEntity(e) { - this.$store - .dispatch("addRequestor", { result: e, type: e.type }) - .catch(({ name, violations }) => { - if ( - name === "ValidationException" || - name === "AccessException" - ) { - violations.forEach((violation) => - this.$toast.open({ message: violation }), - ); - } else { - this.$toast.open({ message: "An error occurred" }); - } - }); - }, - uniqueId(e) { - return `${e.type}-${e.id}`; - }, + this.$refs.addPersons.resetSearch(); // to cast child method }, + saveFormOnTheFly(payload) { + console.log( + "saveFormOnTheFly: type", + payload.type, + ", data", + payload.data, + ); + payload.target = "requestor"; + + let body = { type: payload.type }; + if (payload.type === "person") { + body.firstName = payload.data.firstName; + body.lastName = payload.data.lastName; + if (payload.data.birthdate !== null) { + body.birthdate = payload.data.birthdate; + } + body.phonenumber = payload.data.phonenumber; + body.mobilenumber = payload.data.mobilenumber; + body.email = payload.data.email; + body.altNames = payload.data.altNames; + body.gender = payload.data.gender; + + makeFetch( + "PATCH", + `/api/1.0/person/person/${payload.data.id}.json`, + body, + ) + .then((response) => { + this.$store.dispatch("addPerson", { + target: payload.target, + body: response, + }); + this.$refs.onTheFly.closeModal(); + }) + .catch((error) => { + if (error.name === "ValidationException") { + for (let v of error.violations) { + this.$toast.open({ message: v }); + } + } else { + this.$toast.open({ message: "An error occurred" }); + } + }); + } else if (payload.type === "thirdparty") { + body.name = payload.data.text; + body.email = payload.data.email; + body.telephone = payload.data.telephone; + body.telephone2 = payload.data.telephone2; + if (payload.data.address) { + body.address = { id: payload.data.address.address_id }; + } + + makeFetch( + "PATCH", + `/api/1.0/thirdparty/thirdparty/${payload.data.id}.json`, + body, + ) + .then((response) => { + this.$store.dispatch("addThirdparty", { + target: payload.target, + body: response, + }); + this.$refs.onTheFly.closeModal(); + }) + .catch((error) => { + if (error.name === "ValidationException") { + for (let v of error.violations) { + this.$toast.open({ message: v }); + } + } else { + this.$toast.open({ message: "An error occurred" }); + } + }); + } + }, + addSuggestedEntity(e) { + this.$store + .dispatch("addRequestor", { result: e, type: e.type }) + .catch(({ name, violations }) => { + if (name === "ValidationException" || name === "AccessException") { + violations.forEach((violation) => + this.$toast.open({ message: violation }), + ); + } else { + this.$toast.open({ message: "An error occurred" }); + } + }); + }, + uniqueId(e) { + return `${e.type}-${e.id}`; + }, + }, }; </script> <style lang="scss" scoped> div.flex-table { - margin: 1em 0 0 !important; - & > label, - & > ul.record_actions { - margin: 1em 3em 0 !important; - } - div.item-bloc { - background-color: white !important; - margin-top: 1em; - } + margin: 1em 0 0 !important; + & > label, + & > ul.record_actions { + margin: 1em 3em 0 !important; + } + div.item-bloc { + background-color: white !important; + margin-top: 1em; + } } .confidential { - display: block; - margin-right: 0px !important; + display: block; + margin-right: 0px !important; } </style> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Resources.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Resources.vue index 1ef9bfe45..8cf1fccdc 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Resources.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Resources.vue @@ -1,57 +1,57 @@ <template> - <div class="vue-component"> - <h2><a id="section-90" />{{ $t("resources.title") }}</h2> + <div class="vue-component"> + <h2><a id="section-90" />{{ $t("resources.title") }}</h2> - <div v-if="resources.length > 0"> - <label class="col-form-label">{{ - $tc("resources.counter", counter) - }}</label> - </div> - <div v-else> - <label class="chill-no-data-statement">{{ - $tc("resources.counter", counter) - }}</label> - </div> - - <div class="flex-table mb-3"> - <resource-item - v-for="resource in resources" - :resource="resource" - :key="resource.id" - @remove="removeResource" - /> - </div> - - <div v-if="suggestedEntities.length > 0"> - <ul class="list-suggest add-items inline"> - <li - v-for="p in suggestedEntities" - :key="uniqueId(p)" - @click="addSuggestedEntity(p)" - > - <person-text v-if="p.type === 'person'" :person="p" /> - <span v-else>{{ p.text }}</span> - </li> - </ul> - </div> - - <div> - <ul class="record_actions"> - <li class="add-persons"> - <add-persons - button-title="resources.add_resources" - modal-title="resources.add_resources" - :key="addPersons.key" - :options="addPersons.options" - @add-new-persons="addNewPersons" - ref="addPersons" - > - <!-- to cast child method --> - </add-persons> - </li> - </ul> - </div> + <div v-if="resources.length > 0"> + <label class="col-form-label">{{ + $tc("resources.counter", counter) + }}</label> </div> + <div v-else> + <label class="chill-no-data-statement">{{ + $tc("resources.counter", counter) + }}</label> + </div> + + <div class="flex-table mb-3"> + <resource-item + v-for="resource in resources" + :resource="resource" + :key="resource.id" + @remove="removeResource" + /> + </div> + + <div v-if="suggestedEntities.length > 0"> + <ul class="list-suggest add-items inline"> + <li + v-for="p in suggestedEntities" + :key="uniqueId(p)" + @click="addSuggestedEntity(p)" + > + <person-text v-if="p.type === 'person'" :person="p" /> + <span v-else>{{ p.text }}</span> + </li> + </ul> + </div> + + <div> + <ul class="record_actions"> + <li class="add-persons"> + <add-persons + :button-title="trans(ACCOMPANYING_COURSE_RESOURCES_ADD_RESOURCES)" + :modal-title="trans(ACCOMPANYING_COURSE_RESOURCES_ADD_RESOURCES)" + :key="addPersons.key" + :options="addPersons.options" + @add-new-persons="addNewPersons" + ref="addPersons" + > + <!-- to cast child method --> + </add-persons> + </li> + </ul> + </div> + </div> </template> <script> @@ -59,128 +59,122 @@ import { mapState } from "vuex"; import AddPersons from "ChillPersonAssets/vuejs/_components/AddPersons.vue"; import ResourceItem from "./Resources/ResourceItem.vue"; import PersonText from "ChillPersonAssets/vuejs/_components/Entity/PersonText.vue"; +import { ACCOMPANYING_COURSE_RESOURCES_ADD_RESOURCES, trans } from "translator"; export default { - name: "Resources", - components: { - AddPersons, - ResourceItem, - PersonText, - }, - data() { - return { - addPersons: { - key: "resources", - options: { - type: ["person", "thirdparty"], - priority: null, - uniq: false, - }, - }, - }; - }, - computed: mapState({ - resources: (state) => state.accompanyingCourse.resources, - counter: (state) => state.accompanyingCourse.resources.length, - suggestedEntities: (state) => - [ - state.accompanyingCourse.requestor, - ...state.accompanyingCourse.participations - .filter((p) => p.endDate === null) - .map((p) => p.person), - ] - .filter((e) => e !== null) - .filter((e) => { - if (e.type === "person") { - return !state.accompanyingCourse.resources - .filter((r) => r.resource.type === "person") - .map((r) => r.resource.id) - .includes(e.id); - } - if (e.type === "thirdparty") { - return !state.accompanyingCourse.resources - .filter((r) => r.resource.type === "thirdparty") - .map((r) => r.resource.id) - .includes(e.id); - } - }) - // filter persons appearing twice in requestor and resources - .filter((e, index, suggested) => { - for (let i = 0; i < suggested.length; i = i + 1) { - if (i < index && e.id === suggested[i].id) { - return false; - } - } + name: "Resources", + components: { + AddPersons, + ResourceItem, + PersonText, + }, + data() { + return { + ACCOMPANYING_COURSE_RESOURCES_ADD_RESOURCES, + addPersons: { + key: "resources", + options: { + type: ["person", "thirdparty"], + priority: null, + uniq: false, + }, + }, + }; + }, + computed: mapState({ + resources: (state) => state.accompanyingCourse.resources, + counter: (state) => state.accompanyingCourse.resources.length, + suggestedEntities: (state) => + [ + state.accompanyingCourse.requestor, + ...state.accompanyingCourse.participations + .filter((p) => p.endDate === null) + .map((p) => p.person), + ] + .filter((e) => e !== null) + .filter((e) => { + if (e.type === "person") { + return !state.accompanyingCourse.resources + .filter((r) => r.resource.type === "person") + .map((r) => r.resource.id) + .includes(e.id); + } + if (e.type === "thirdparty") { + return !state.accompanyingCourse.resources + .filter((r) => r.resource.type === "thirdparty") + .map((r) => r.resource.id) + .includes(e.id); + } + }) + // filter persons appearing twice in requestor and resources + .filter((e, index, suggested) => { + for (let i = 0; i < suggested.length; i = i + 1) { + if (i < index && e.id === suggested[i].id) { + return false; + } + } - return true; - }), - }), - methods: { - removeResource(item) { - //console.log('@@ CLICK remove resource: item', item); - this.$store - .dispatch("removeResource", item) - .catch(({ name, violations }) => { - if ( - name === "ValidationException" || - name === "AccessException" - ) { - violations.forEach((violation) => - this.$toast.open({ message: violation }), - ); - } else { - this.$toast.open({ message: "An error occurred" }); - } - }); - }, - addNewPersons({ selected, modal }) { - //console.log('@@@ CLICK button addNewPersons', selected); - selected.forEach(function (item) { - this.$store - .dispatch("addResource", item) - .catch(({ name, violations }) => { - if ( - name === "ValidationException" || - name === "AccessException" - ) { - violations.forEach((violation) => - this.$toast.open({ message: violation }), - ); - } else { - this.$toast.open({ message: violations }); - } - }); - }, this); - this.$refs.addPersons.resetSearch(); // to cast child method - modal.showModal = false; - }, - addSuggestedEntity(e) { - this.$store - .dispatch("addResource", { result: e, type: e.type }) - .catch(({ name, violations }) => { - if ( - name === "ValidationException" || - name === "AccessException" - ) { - violations.forEach((violation) => - this.$toast.open({ message: violation }), - ); - } else { - this.$toast.open({ message: "An error occurred" }); - } - }); - }, - uniqueId(e) { - return `${e.type}-${e.id}`; - }, + return true; + }), + }), + methods: { + trans, + removeResource(item) { + //console.log('@@ CLICK remove resource: item', item); + this.$store + .dispatch("removeResource", item) + .catch(({ name, violations }) => { + if (name === "ValidationException" || name === "AccessException") { + violations.forEach((violation) => + this.$toast.open({ message: violation }), + ); + } else { + this.$toast.open({ message: "An error occurred" }); + } + }); }, + addNewPersons({ selected, modal }) { + //console.log('@@@ CLICK button addNewPersons', selected); + selected.forEach(function (item) { + this.$store + .dispatch("addResource", item) + .catch(({ name, violations }) => { + if (name === "ValidationException" || name === "AccessException") { + violations.forEach((violation) => + this.$toast.open({ message: violation }), + ); + } else { + this.$toast.open({ message: violations }); + } + }); + }, this); + this.$refs.addPersons.resetSearch(); // to cast child method + modal.showModal = false; + }, + addSuggestedEntity(e) { + this.$store + .dispatch("addResource", { result: e, type: e.type }) + .catch(({ name, violations }) => { + if (name === "ValidationException" || name === "AccessException") { + violations.forEach((violation) => + this.$toast.open({ message: violation }), + ); + } else { + this.$toast.open({ message: "An error occurred" }); + } + }); + }, + uniqueId(e) { + return `${e.type}-${e.id}`; + }, + }, }; </script> <style lang="scss"> div.flex-bloc { - div.item-bloc { - flex-basis: 50%; - } + div.item-bloc { + flex-basis: 50%; + } } </style> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Resources/ResourceItem.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Resources/ResourceItem.vue index 8a52748d4..87daacf47 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Resources/ResourceItem.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Resources/ResourceItem.vue @@ -1,107 +1,107 @@ <template> - <person-render-box - render="bloc" - v-if="resource.resource.type === 'person'" - :person="resource.resource" - :options="{ - addInfo: true, - addId: false, - addEntity: true, - addLink: false, - addAltNames: true, - addAge: false, - hLevel: 3, - isConfidential: true, - }" - > - <template #end-bloc> - <div class="item-row separator"> - <ul class="record_actions"> - <li> - <write-comment - :resource="resource" - @update-comment="updateComment" - /> - </li> - <li> - <on-the-fly - :parent="parent" - :type="resource.resource.type" - :id="resource.resource.id" - action="show" - /> - </li> - <li> - <on-the-fly - :parent="parent" - :type="resource.resource.type" - :id="resource.resource.id" - action="edit" - @save-form-on-the-fly="saveFormOnTheFly" - ref="onTheFly" - /> - </li> - <li> - <button - class="btn btn-sm btn-remove" - :title="$t('action.remove')" - @click.prevent="$emit('remove', resource)" - /> - </li> - </ul> - </div> - </template> - </person-render-box> + <person-render-box + render="bloc" + v-if="resource.resource.type === 'person'" + :person="resource.resource" + :options="{ + addInfo: true, + addId: false, + addEntity: true, + addLink: false, + addAltNames: true, + addAge: false, + hLevel: 3, + isConfidential: true, + }" + > + <template #end-bloc> + <div class="item-row separator"> + <ul class="record_actions"> + <li> + <write-comment + :resource="resource" + @update-comment="updateComment" + /> + </li> + <li> + <on-the-fly + :parent="parent" + :type="resource.resource.type" + :id="resource.resource.id" + action="show" + /> + </li> + <li> + <on-the-fly + :parent="parent" + :type="resource.resource.type" + :id="resource.resource.id" + action="edit" + @save-form-on-the-fly="saveFormOnTheFly" + ref="onTheFly" + /> + </li> + <li> + <button + class="btn btn-sm btn-remove" + :title="$t('action.remove')" + @click.prevent="$emit('remove', resource)" + /> + </li> + </ul> + </div> + </template> + </person-render-box> - <third-party-render-box - v-if="resource.resource.type === 'thirdparty'" - :thirdparty="resource.resource" - :options="{ - addLink: false, - addId: false, - addEntity: true, - addInfo: false, - hLevel: 3, - }" - > - <template #end-bloc> - <div class="item-row separator"> - <ul class="record_actions"> - <li> - <write-comment - :resource="resource" - @update-comment="updateComment" - /> - </li> - <li> - <on-the-fly - :parent="parent" - :type="resource.resource.type" - :id="resource.resource.id" - action="show" - /> - </li> - <li> - <on-the-fly - :parent="parent" - :type="resource.resource.type" - :id="resource.resource.id" - action="edit" - @save-form-on-the-fly="saveFormOnTheFly" - ref="onTheFly" - /> - </li> - <li> - <button - class="btn btn-sm btn-remove" - :title="$t('action.remove')" - @click.prevent="$emit('remove', resource)" - /> - </li> - </ul> - </div> - </template> - </third-party-render-box> + <third-party-render-box + v-if="resource.resource.type === 'thirdparty'" + :thirdparty="resource.resource" + :options="{ + addLink: false, + addId: false, + addEntity: true, + addInfo: false, + hLevel: 3, + }" + > + <template #end-bloc> + <div class="item-row separator"> + <ul class="record_actions"> + <li> + <write-comment + :resource="resource" + @update-comment="updateComment" + /> + </li> + <li> + <on-the-fly + :parent="parent" + :type="resource.resource.type" + :id="resource.resource.id" + action="show" + /> + </li> + <li> + <on-the-fly + :parent="parent" + :type="resource.resource.type" + :id="resource.resource.id" + action="edit" + @save-form-on-the-fly="saveFormOnTheFly" + ref="onTheFly" + /> + </li> + <li> + <button + class="btn btn-sm btn-remove" + :title="$t('action.remove')" + @click.prevent="$emit('remove', resource)" + /> + </li> + </ul> + </div> + </template> + </third-party-render-box> </template> <script> @@ -112,130 +112,130 @@ import WriteComment from "./WriteComment"; import { makeFetch } from "ChillMainAssets/lib/api/apiMethods"; export default { - name: "ResourceItem", - components: { - OnTheFly, - PersonRenderBox, - ThirdPartyRenderBox, - WriteComment, - }, - props: ["resource"], - emits: ["remove"], - computed: { - parent() { - return { - type: this.resource.type, - id: this.resource.id, - comment: this.resource.comment, - parent: { - type: this.$store.state.accompanyingCourse.type, - id: this.$store.state.accompanyingCourse.id, - }, - }; + name: "ResourceItem", + components: { + OnTheFly, + PersonRenderBox, + ThirdPartyRenderBox, + WriteComment, + }, + props: ["resource"], + emits: ["remove"], + computed: { + parent() { + return { + type: this.resource.type, + id: this.resource.id, + comment: this.resource.comment, + parent: { + type: this.$store.state.accompanyingCourse.type, + id: this.$store.state.accompanyingCourse.id, }, - hasCurrentHouseholdAddress() { - if (this.resource.resource.current_household_address !== null) { - return true; + }; + }, + hasCurrentHouseholdAddress() { + if (this.resource.resource.current_household_address !== null) { + return true; + } + return false; + }, + }, + methods: { + saveFormOnTheFly(payload) { + // console.log('saveFormOnTheFly: type', payload.type, ', data', payload.data); + payload.target = "resource"; + + let body = { type: payload.type }; + if (payload.type === "person") { + body.firstName = payload.data.firstName; + body.lastName = payload.data.lastName; + if (payload.data.birthdate !== null) { + body.birthdate = payload.data.birthdate; + } + body.phonenumber = payload.data.phonenumber; + body.mobilenumber = payload.data.mobilenumber; + body.email = payload.data.email; + body.altNames = payload.data.altNames; + body.gender = { + id: payload.data.gender.id, + type: payload.data.gender.type, + }; + if (payload.data.civility !== null) { + body.civility = { + id: payload.data.civility.id, + type: payload.data.civility.type, + }; + } + + makeFetch( + "PATCH", + `/api/1.0/person/person/${payload.data.id}.json`, + body, + ) + .then((response) => { + this.$store.dispatch("addPerson", { + target: payload.target, + body: response, + }); + this.$refs.onTheFly.closeModal(); + }) + .catch((error) => { + if (error.name === "ValidationException") { + for (let v of error.violations) { + this.$toast.open({ message: v }); + } + } else { + this.$toast.open({ message: "An error occurred" }); } - return false; - }, - }, - methods: { - saveFormOnTheFly(payload) { - // console.log('saveFormOnTheFly: type', payload.type, ', data', payload.data); - payload.target = "resource"; + }); + } else if (payload.type === "thirdparty") { + // console.log('data', payload.data) + body.firstname = payload.data.firstname; + body.name = payload.data.name; + body.email = payload.data.email; + body.telephone = payload.data.telephone; + body.telephone2 = payload.data.telephone2; + body.address = payload.data.address + ? { id: payload.data.address.address_id } + : null; + if (null !== payload.data.civility) { + body.civility = { + type: "chill_main_civility", + id: payload.data.civility.id, + }; + } + if (null !== payload.data.profession) { + body.profession = payload.data.profession; + } + // console.log('body', body); - let body = { type: payload.type }; - if (payload.type === "person") { - body.firstName = payload.data.firstName; - body.lastName = payload.data.lastName; - if (payload.data.birthdate !== null) { - body.birthdate = payload.data.birthdate; - } - body.phonenumber = payload.data.phonenumber; - body.mobilenumber = payload.data.mobilenumber; - body.email = payload.data.email; - body.altNames = payload.data.altNames; - body.gender = { - id: payload.data.gender.id, - type: payload.data.gender.type, - }; - if (payload.data.civility !== null) { - body.civility = { - id: payload.data.civility.id, - type: payload.data.civility.type, - }; - } - - makeFetch( - "PATCH", - `/api/1.0/person/person/${payload.data.id}.json`, - body, - ) - .then((response) => { - this.$store.dispatch("addPerson", { - target: payload.target, - body: response, - }); - this.$refs.onTheFly.closeModal(); - }) - .catch((error) => { - if (error.name === "ValidationException") { - for (let v of error.violations) { - this.$toast.open({ message: v }); - } - } else { - this.$toast.open({ message: "An error occurred" }); - } - }); - } else if (payload.type === "thirdparty") { - // console.log('data', payload.data) - body.firstname = payload.data.firstname; - body.name = payload.data.name; - body.email = payload.data.email; - body.telephone = payload.data.telephone; - body.telephone2 = payload.data.telephone2; - body.address = payload.data.address - ? { id: payload.data.address.address_id } - : null; - if (null !== payload.data.civility) { - body.civility = { - type: "chill_main_civility", - id: payload.data.civility.id, - }; - } - if (null !== payload.data.profession) { - body.profession = payload.data.profession; - } - // console.log('body', body); - - makeFetch( - "PATCH", - `/api/1.0/thirdparty/thirdparty/${payload.data.id}.json`, - body, - ) - .then((response) => { - this.$store.dispatch("addThirdparty", { - target: payload.target, - body: response, - }); - this.$refs.onTheFly.closeModal(); - }) - .catch((error) => { - if (error.name === "ValidationException") { - for (let v of error.violations) { - this.$toast.open({ message: v }); - } - } else { - this.$toast.open({ message: "An error occurred" }); - } - }); + makeFetch( + "PATCH", + `/api/1.0/thirdparty/thirdparty/${payload.data.id}.json`, + body, + ) + .then((response) => { + this.$store.dispatch("addThirdparty", { + target: payload.target, + body: response, + }); + this.$refs.onTheFly.closeModal(); + }) + .catch((error) => { + if (error.name === "ValidationException") { + for (let v of error.violations) { + this.$toast.open({ message: v }); + } + } else { + this.$toast.open({ message: "An error occurred" }); } - }, - updateComment(resource) { - console.log("updateComment", resource); - this.$store.commit("updateResource", resource); - }, + }); + } }, + updateComment(resource) { + console.log("updateComment", resource); + this.$store.commit("updateResource", resource); + }, + }, }; </script> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Resources/WriteComment.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Resources/WriteComment.vue index 26b561c5d..416c6085c 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Resources/WriteComment.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Resources/WriteComment.vue @@ -1,34 +1,32 @@ <template> - <a - class="btn btn-sm btn-misc change-icon" - :title="$t('write_comment')" - @click="openModal" - ><i class="fa fa-pencil-square-o" /> - </a> + <a + class="btn btn-sm btn-misc change-icon" + :title="$t('write_comment')" + @click="openModal" + ><i class="fa fa-pencil-square-o" /> + </a> - <teleport to="body"> - <modal - v-if="modal.showModal" - :modal-dialog-class="modal.modalDialogClass" - @close="modal.showModal = false" - > - <template #header> - <h3 class="modal-title"> - {{ - $t("write_comment_about", { r: resource.resource.text }) - }} - </h3> - </template> - <template #body> - <comment-editor v-model="content" /> - </template> - <template #footer> - <a class="btn btn-save" @click="saveAction"> - {{ $t("action.save") }} - </a> - </template> - </modal> - </teleport> + <teleport to="body"> + <modal + v-if="modal.showModal" + :modal-dialog-class="modal.modalDialogClass" + @close="modal.showModal = false" + > + <template #header> + <h3 class="modal-title"> + {{ $t("write_comment_about", { r: resource.resource.text }) }} + </h3> + </template> + <template #body> + <comment-editor v-model="content" /> + </template> + <template #footer> + <a class="btn btn-save" @click="saveAction"> + {{ $t("action.save") }} + </a> + </template> + </modal> + </teleport> </template> <script> @@ -37,71 +35,71 @@ import { makeFetch } from "ChillMainAssets/lib/api/apiMethods"; import CommentEditor from "ChillMainAssets/vuejs/_components/CommentEditor/CommentEditor.vue"; export default { - name: "WriteComment", - components: { - Modal, - CommentEditor, + name: "WriteComment", + components: { + Modal, + CommentEditor, + }, + props: ["resource"], + emits: ["updateComment"], + data() { + return { + modal: { + showModal: false, + modalDialogClass: "modal-dialog-scrollable modal-xl", + }, + formdata: { + content: this.resource.comment, + }, + }; + }, + i18n: { + messages: { + fr: { + write_comment: "Écrire un commentaire", + write_comment_about: "Écrire un commentaire à propos de {r}", + comment_placeholder: "Commencez à écrire...", + }, }, - props: ["resource"], - emits: ["updateComment"], - data() { - return { - modal: { - showModal: false, - modalDialogClass: "modal-dialog-scrollable modal-xl", - }, - formdata: { - content: this.resource.comment, - }, + }, + computed: { + editor: () => ClassicEditor, + editorConfig: () => classicEditorConfig, + content: { + set(value) { + this.formdata.content = value; + }, + get() { + return this.formdata.content; + }, + }, + }, + methods: { + openModal() { + //console.log('write comment for', this.resource.resource.type, this.resource.resource.id); + this.modal.showModal = true; + }, + saveAction() { + //console.log('save comment', this.resource.id, this.formdata.content); + this.patchResource(this.resource.id, this.formdata.content); + }, + patchResource(id, comment) { + let url = `/api/1.0/person/accompanying-period/resource/${id}.json`, + body = { + type: "accompanying_period_resource", + comment: comment, }; + makeFetch("PATCH", url, body).then((r) => { + let resource = { + type: "accompanying_period_resource", + id: r.id, + comment: r.comment, + resource: r.resource, + }; + this.$emit("updateComment", resource); + this.modal.showModal = false; + }); }, - i18n: { - messages: { - fr: { - write_comment: "Écrire un commentaire", - write_comment_about: "Écrire un commentaire à propos de {r}", - comment_placeholder: "Commencez à écrire...", - }, - }, - }, - computed: { - editor: () => ClassicEditor, - editorConfig: () => classicEditorConfig, - content: { - set(value) { - this.formdata.content = value; - }, - get() { - return this.formdata.content; - }, - }, - }, - methods: { - openModal() { - //console.log('write comment for', this.resource.resource.type, this.resource.resource.id); - this.modal.showModal = true; - }, - saveAction() { - //console.log('save comment', this.resource.id, this.formdata.content); - this.patchResource(this.resource.id, this.formdata.content); - }, - patchResource(id, comment) { - let url = `/api/1.0/person/accompanying-period/resource/${id}.json`, - body = { - type: "accompanying_period_resource", - comment: comment, - }; - makeFetch("PATCH", url, body).then((r) => { - let resource = { - type: "accompanying_period_resource", - id: r.id, - comment: r.comment, - resource: r.resource, - }; - this.$emit("updateComment", resource); - this.modal.showModal = false; - }); - }, - }, + }, }; </script> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Scopes.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Scopes.vue index 1d05e0bbe..fee6a89ad 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Scopes.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Scopes.vue @@ -1,25 +1,25 @@ <template> - <div class="vue-component"> - <h2><a id="section-70" />{{ $t("scopes.title") }}</h2> + <div class="vue-component"> + <h2><a id="section-70" />{{ $t("scopes.title") }}</h2> - <div class="mb-4"> - <div class="form-check" v-for="s in scopes" :key="s.id"> - <input - class="form-check-input" - type="checkbox" - v-model="checkedScopes" - :value="s" - /> - <label class="form-check-label"> - {{ localizeString(s.name) }} - </label> - </div> - </div> - - <div v-if="!isScopeValid" class="alert alert-warning to-confirm"> - {{ $t("scopes.add_at_least_one") }} - </div> + <div class="mb-4"> + <div class="form-check" v-for="s in scopes" :key="s.id"> + <input + class="form-check-input" + type="checkbox" + v-model="checkedScopes" + :value="s" + /> + <label class="form-check-label"> + {{ localizeString(s.name) }} + </label> + </div> </div> + + <div v-if="!isScopeValid" class="alert alert-warning to-confirm"> + {{ $t("scopes.add_at_least_one") }} + </div> + </div> </template> <script> @@ -27,38 +27,33 @@ import { mapState, mapGetters } from "vuex"; import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper"; export default { - name: "Scopes", - computed: { - ...mapState(["scopes", "scopesAtStart"]), - ...mapGetters(["isScopeValid"]), - checkedScopes: { - get: function () { - return this.$store.state.accompanyingCourse.scopes; - }, - set: function (v) { - this.$store - .dispatch("setScopes", v) - .catch(({ name, violations }) => { - if ( - name === "ValidationException" || - name === "AccessException" - ) { - violations.forEach((violation) => - this.$toast.open({ message: violation }), - ); - } else { - this.$toast.open({ message: "An error occurred" }); - } - }); - }, - }, + name: "Scopes", + computed: { + ...mapState(["scopes", "scopesAtStart"]), + ...mapGetters(["isScopeValid"]), + checkedScopes: { + get: function () { + return this.$store.state.accompanyingCourse.scopes; + }, + set: function (v) { + this.$store.dispatch("setScopes", v).catch(({ name, violations }) => { + if (name === "ValidationException" || name === "AccessException") { + violations.forEach((violation) => + this.$toast.open({ message: violation }), + ); + } else { + this.$toast.open({ message: "An error occurred" }); + } + }); + }, }, - methods: { - localizeString, - restore() { - console.log("restore"); - }, + }, + methods: { + localizeString, + restore() { + console.log("restore"); }, + }, }; </script> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/SocialIssue.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/SocialIssue.vue index 02d3b0c84..21c290f48 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/SocialIssue.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/SocialIssue.vue @@ -1,30 +1,30 @@ <template> - <div class="vue-component"> - <h2><a id="section-60" />{{ $t("social_issue.title") }}</h2> + <div class="vue-component"> + <h2><a id="section-60" />{{ $t("social_issue.title") }}</h2> - <div class="my-4"> - <!--label for="field">{{ $t('social_issue.label') }}</label + <div class="my-4"> + <!--label for="field">{{ $t('social_issue.label') }}</label --> - <VueMultiselect - name="field" - :close-on-select="true" - :allow-empty="true" - :show-labels="false" - track-by="id" - label="text" - :multiple="true" - :searchable="true" - :placeholder="$t('social_issue.label')" - @update:model-value="updateSocialIssues" - :model-value="value" - :options="options" - /> - </div> - - <div v-if="!isSocialIssueValid" class="alert alert-warning to-confirm"> - {{ $t("social_issue.not_valid") }} - </div> + <VueMultiselect + name="field" + :close-on-select="true" + :allow-empty="true" + :show-labels="false" + track-by="id" + label="text" + :multiple="true" + :searchable="true" + :placeholder="$t('social_issue.label')" + @update:model-value="updateSocialIssues" + :model-value="value" + :options="options" + /> </div> + + <div v-if="!isSocialIssueValid" class="alert alert-warning to-confirm"> + {{ $t("social_issue.not_valid") }} + </div> + </div> </template> <script> @@ -33,59 +33,54 @@ import { fetchResults } from "ChillMainAssets/lib/api/apiMethods"; import { mapGetters, mapState } from "vuex"; export default { - name: "SocialIssue", - components: { VueMultiselect }, - data() { - return { - options: [], - }; + name: "SocialIssue", + components: { VueMultiselect }, + data() { + return { + options: [], + }; + }, + computed: { + ...mapState({ + value: (state) => state.accompanyingCourse.socialIssues, + }), + ...mapGetters(["isSocialIssueValid"]), + }, + mounted() { + this.getOptions(); + }, + methods: { + getOptions() { + fetchResults(`/api/1.0/person/social-work/social-issue.json`).then( + (response) => { + this.options = response; + }, + ); }, - computed: { - ...mapState({ - value: (state) => state.accompanyingCourse.socialIssues, - }), - ...mapGetters(["isSocialIssueValid"]), - }, - mounted() { - this.getOptions(); - }, - methods: { - getOptions() { - fetchResults(`/api/1.0/person/social-work/social-issue.json`).then( - (response) => { - this.options = response; - }, + updateSocialIssues(value) { + this.$store + .dispatch("updateSocialIssues", this.transformValue(value)) + .catch(({ name, violations }) => { + if (name === "ValidationException" || name === "AccessException") { + violations.forEach((violation) => + this.$toast.open({ message: violation }), ); - }, - updateSocialIssues(value) { - this.$store - .dispatch("updateSocialIssues", this.transformValue(value)) - .catch(({ name, violations }) => { - if ( - name === "ValidationException" || - name === "AccessException" - ) { - violations.forEach((violation) => - this.$toast.open({ message: violation }), - ); - } else { - this.$toast.open({ message: "An error occurred" }); - } - }); - }, - transformValue(updated) { - let stored = this.value; - let added = updated.filter((x) => stored.indexOf(x) === -1).shift(); - let removed = stored - .filter((x) => updated.indexOf(x) === -1) - .shift(); - let method = typeof removed === "undefined" ? "POST" : "DELETE"; - let changed = typeof removed === "undefined" ? added : removed; - let body = { type: "social_issue", id: changed.id }; - let payload = updated; - return { payload, body, method }; - }, + } else { + this.$toast.open({ message: "An error occurred" }); + } + }); }, + transformValue(updated) { + let stored = this.value; + let added = updated.filter((x) => stored.indexOf(x) === -1).shift(); + let removed = stored.filter((x) => updated.indexOf(x) === -1).shift(); + let method = typeof removed === "undefined" ? "POST" : "DELETE"; + let changed = typeof removed === "undefined" ? added : removed; + let body = { type: "social_issue", id: changed.id }; + let payload = updated; + return { payload, body, method }; + }, + }, }; </script> @@ -96,20 +91,20 @@ export default { @import "ChillPersonAssets/chill/scss/mixins"; @import "ChillMainAssets/chill/scss/chill_variables"; div#accompanying-course { - span.multiselect__tag { - @include badge_social($social-issue-color); - background: $chill-l-gray; - color: $dark; + span.multiselect__tag { + @include badge_social($social-issue-color); + background: $chill-l-gray; + color: $dark; + } + span.multiselect__option--highlight { + &::after { + background: $green; } - span.multiselect__option--highlight { - &::after { - background: $green; - } - &.multiselect__option--selected { - &::after { - background: $red; - } - } + &.multiselect__option--selected { + &::after { + background: $red; + } } + } } </style> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/StartDate.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/StartDate.vue index 0bfd87b18..d3e9ae97a 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/StartDate.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/StartDate.vue @@ -1,22 +1,22 @@ <template> - <div class="vue-component"> - <h2> - <a id="section-110" /> - {{ $t("startdate.change") }} - </h2> - <div> - <div class="mb-3 row"> - <div class="col-sm-12 date-update"> - <input - class="form-control" - type="date" - id="startDate" - v-model="startDateInput" - /> - </div> - </div> + <div class="vue-component"> + <h2> + <a id="section-110" /> + {{ $t("startdate.change") }} + </h2> + <div> + <div class="mb-3 row"> + <div class="col-sm-12 date-update"> + <input + class="form-control" + type="date" + id="startDate" + v-model="startDateInput" + /> </div> + </div> </div> + </div> </template> <script> @@ -24,69 +24,60 @@ import { dateToISO, ISOToDatetime } from "ChillMainAssets/chill/js/date"; import { mapState } from "vuex"; export default { - name: "StartDate", - data() { - return { - lastRecordedDate: null, - }; - }, - computed: { - ...mapState({ - startDate: (state) => - dateToISO( - ISOToDatetime( - state.accompanyingCourse.openingDate.datetime, - ), - ), - }), - startDateInput: { - get() { - return this.startDate; - }, - set(value) { - this.lastRecordedDate = value; + name: "StartDate", + data() { + return { + lastRecordedDate: null, + }; + }, + computed: { + ...mapState({ + startDate: (state) => + dateToISO(ISOToDatetime(state.accompanyingCourse.openingDate.datetime)), + }), + startDateInput: { + get() { + return this.startDate; + }, + set(value) { + this.lastRecordedDate = value; - setTimeout(() => { - console.log("timeout finished"); - if (this.lastRecordedDate === value) { - console.log( - "last recorded", - this.lastRecordedDate, - "value", - value, - ); - this.$store - .dispatch("updateStartDate", value) - .catch(({ name, violations }) => { - if ( - name === "ValidationException" || - name === "AccessException" - ) { - violations.forEach((violation) => - this.$toast.open({ - message: violation, - }), - ); - } else { - this.$toast.open({ - message: "An error occurred", - }); - } - }); - } - }, 3000); - }, - }, + setTimeout(() => { + console.log("timeout finished"); + if (this.lastRecordedDate === value) { + console.log("last recorded", this.lastRecordedDate, "value", value); + this.$store + .dispatch("updateStartDate", value) + .catch(({ name, violations }) => { + if ( + name === "ValidationException" || + name === "AccessException" + ) { + violations.forEach((violation) => + this.$toast.open({ + message: violation, + }), + ); + } else { + this.$toast.open({ + message: "An error occurred", + }); + } + }); + } + }, 3000); + }, }, + }, }; </script> <style lang="scss" scoped> .date-update { - display: flex; - justify-content: space-between; - &-btn { - margin-left: 1rem; - } + display: flex; + justify-content: space-between; + &-btn { + margin-left: 1rem; + } } </style> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/StickyNav.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/StickyNav.vue index 890aa5153..3ff18f03a 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/StickyNav.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/StickyNav.vue @@ -1,207 +1,195 @@ <template> - <teleport to="#content"> - <div id="navmap"> - <nav> - <a class="top" href="#top"> - <i class="fa fa-fw fa-square" /> - <span>{{ $t("nav.top") }}</span> - </a> - <item - v-for="item of items" - :key="item.key" - :item="item" - :step="step" - /> - </nav> - </div> - </teleport> + <teleport to="#content"> + <div id="navmap"> + <nav> + <a class="top" href="#top"> + <i class="fa fa-fw fa-square" /> + <span>{{ $t("nav.top") }}</span> + </a> + <item v-for="item of items" :key="item.key" :item="item" :step="step" /> + </nav> + </div> + </teleport> </template> <script> import Item from "./StickyNav/Item.vue"; export default { - name: "StickyNav", - components: { - Item, + name: "StickyNav", + components: { + Item, + }, + data() { + return { + header: document.querySelector("header nav.navbar"), + bannerName: document.querySelector("#header-accompanying_course-name"), + bannerDetails: document.querySelector( + "#header-accompanying_course-details", + ), + container: null, + heightSum: null, + stickyNav: null, + limit: 25, + anchors: null, + items: [], + }; + }, + computed: { + accompanyingCourse() { + return this.$store.state.accompanyingCourse; }, - data() { - return { - header: document.querySelector("header nav.navbar"), - bannerName: document.querySelector( - "#header-accompanying_course-name", - ), - bannerDetails: document.querySelector( - "#header-accompanying_course-details", - ), - container: null, - heightSum: null, - stickyNav: null, - limit: 25, - anchors: null, - items: [], - }; + step() { + return this.accompanyingCourse.step; }, - computed: { - accompanyingCourse() { - return this.$store.state.accompanyingCourse; - }, - step() { - return this.accompanyingCourse.step; - }, - top() { - return parseInt( - window - .getComputedStyle(this.stickyNav) - .getPropertyValue("top") - .slice(0, -2), - ); - }, + top() { + return parseInt( + window + .getComputedStyle(this.stickyNav) + .getPropertyValue("top") + .slice(0, -2), + ); }, - mounted() { - this.ready(); - window.addEventListener("scroll", this.handleScroll); + }, + mounted() { + this.ready(); + window.addEventListener("scroll", this.handleScroll); + }, + unmounted() { + window.removeEventListener("scroll", this.handleScroll); + }, + methods: { + ready() { + // load datas DOM when mounted ready + this.container = document.querySelector("#content"); + this.stickyNav = document.querySelector("#navmap"); + this.anchors = document.querySelectorAll("h2 a[id^='section']"); + this.initItemsMap(); + + // TODO resizeObserver not supports IE ! + // Listen when elements change size, then recalculate heightSum and initItemsMap + const resizeObserver = new ResizeObserver(() => { + this.refreshPos(); + }); + + resizeObserver.observe(this.header); + resizeObserver.observe(this.bannerName); + resizeObserver.observe(this.bannerDetails); + resizeObserver.observe(this.container); }, - unmounted() { - window.removeEventListener("scroll", this.handleScroll); + initItemsMap() { + this.anchors.forEach((anchor) => { + this.items.push({ + pos: null, + active: false, + key: parseInt(anchor.id.slice(8).slice(0, -1)), + id: "#" + anchor.id, + }); + }); }, - methods: { - ready() { - // load datas DOM when mounted ready - this.container = document.querySelector("#content"); - this.stickyNav = document.querySelector("#navmap"); - this.anchors = document.querySelectorAll("h2 a[id^='section']"); - this.initItemsMap(); + refreshPos() { + //console.log('refreshPos'); + this.heightSum = + this.header.offsetHeight + + this.bannerName.offsetHeight + + this.bannerDetails.offsetHeight; - // TODO resizeObserver not supports IE ! - // Listen when elements change size, then recalculate heightSum and initItemsMap - const resizeObserver = new ResizeObserver(() => { - this.refreshPos(); - }); - - resizeObserver.observe(this.header); - resizeObserver.observe(this.bannerName); - resizeObserver.observe(this.bannerDetails); - resizeObserver.observe(this.container); - }, - initItemsMap() { - this.anchors.forEach((anchor) => { - this.items.push({ - pos: null, - active: false, - key: parseInt(anchor.id.slice(8).slice(0, -1)), - id: "#" + anchor.id, - }); - }); - }, - refreshPos() { - //console.log('refreshPos'); - this.heightSum = - this.header.offsetHeight + - this.bannerName.offsetHeight + - this.bannerDetails.offsetHeight; - - this.anchors.forEach((anchor, i) => { - this.items[i].pos = this.findPos(anchor)["y"]; - }); - }, - findPos(element) { - let posX = 0, - posY = 0; - do { - posX += element.offsetLeft; - posY += element.offsetTop; - element = element.offsetParent; - } while (element != null); - - let pos = []; - pos["x"] = posX; - pos["y"] = posY; - - return pos; - }, - handleScroll() { - let pos = this.findPos(this.stickyNav); - let top = this.heightSum + this.top - window.scrollY; - //console.log(window.scrollY); - - if (top > this.limit) { - this.stickyNav.style.position = "absolute"; - this.stickyNav.style.left = "10px"; - } else { - this.stickyNav.style.position = "fixed"; - this.stickyNav.style.left = pos["x"] + "px"; - } - - this.switchActive(); - }, - switchActive() { - this.items.forEach((item, i) => { - let next = this.items[i + 1] ? this.items[i + 1].pos : "100000"; - item.active = - (window.scrollY >= item.pos) & (window.scrollY < next) - ? true - : false; - }, this); - - // last item never switch active because scroll reach bottom of page - if ( - document.body.scrollHeight == - window.scrollY + window.innerHeight - ) { - this.items[this.items.length - 1].active = true; - this.items[this.items.length - 2].active = false; - } else { - this.items[this.items.length - 1].active = false; - } - }, + this.anchors.forEach((anchor, i) => { + this.items[i].pos = this.findPos(anchor)["y"]; + }); }, + findPos(element) { + let posX = 0, + posY = 0; + do { + posX += element.offsetLeft; + posY += element.offsetTop; + element = element.offsetParent; + } while (element != null); + + let pos = []; + pos["x"] = posX; + pos["y"] = posY; + + return pos; + }, + handleScroll() { + let pos = this.findPos(this.stickyNav); + let top = this.heightSum + this.top - window.scrollY; + //console.log(window.scrollY); + + if (top > this.limit) { + this.stickyNav.style.position = "absolute"; + this.stickyNav.style.left = "10px"; + } else { + this.stickyNav.style.position = "fixed"; + this.stickyNav.style.left = pos["x"] + "px"; + } + + this.switchActive(); + }, + switchActive() { + this.items.forEach((item, i) => { + let next = this.items[i + 1] ? this.items[i + 1].pos : "100000"; + item.active = + (window.scrollY >= item.pos) & (window.scrollY < next) ? true : false; + }, this); + + // last item never switch active because scroll reach bottom of page + if (document.body.scrollHeight == window.scrollY + window.innerHeight) { + this.items[this.items.length - 1].active = true; + this.items[this.items.length - 2].active = false; + } else { + this.items[this.items.length - 1].active = false; + } + }, + }, }; </script> <style lang="scss"> div#content { - position: relative; + position: relative; - div#navmap { - position: absolute; - top: 30px; - left: 10px; //-10%; - nav { - font-size: small; - a { - display: block; - box-sizing: border-box; - margin-bottom: -3px; - color: #71859669; - text-decoration: none; - &.top { - color: #718596; - } - span { - display: none; - } - &:hover, - &.active { - span { - display: inline; - padding-left: 8px; - } - } - &:hover { - color: #718596b5; - } - &.active { - color: #e2793d; //orange - //color: #eec84a; //jaune - } - } + div#navmap { + position: absolute; + top: 30px; + left: 10px; //-10%; + nav { + font-size: small; + a { + display: block; + box-sizing: border-box; + margin-bottom: -3px; + color: #71859669; + text-decoration: none; + &.top { + color: #718596; } + span { + display: none; + } + &:hover, + &.active { + span { + display: inline; + padding-left: 8px; + } + } + &:hover { + color: #718596b5; + } + &.active { + color: #e2793d; //orange + //color: #eec84a; //jaune + } + } } + } } @media only screen and (max-width: 768px) { - div#navmap { - display: none; - } + div#navmap { + display: none; + } } </style> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/StickyNav/Item.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/StickyNav/Item.vue index 8e175f72b..ecdf3026c 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/StickyNav/Item.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/StickyNav/Item.vue @@ -1,26 +1,22 @@ <template> - <a v-if="item.key <= 8" :href="item.id" :class="{ active: isActive }"> - <i class="fa fa-fw fa-square" /> - <span>{{ item.key }}</span> - </a> - <a - v-else-if="step === 'DRAFT'" - :href="item.id" - :class="{ active: isActive }" - > - <i class="fa fa-fw fa-square" /> - <span>{{ item.key }}</span> - </a> + <a v-if="item.key <= 8" :href="item.id" :class="{ active: isActive }"> + <i class="fa fa-fw fa-square" /> + <span>{{ item.key }}</span> + </a> + <a v-else-if="step === 'DRAFT'" :href="item.id" :class="{ active: isActive }"> + <i class="fa fa-fw fa-square" /> + <span>{{ item.key }}</span> + </a> </template> <script> export default { - name: "Item", - props: ["item", "step"], - computed: { - isActive() { - return this.item.active; - }, + name: "Item", + props: ["item", "step"], + computed: { + isActive() { + return this.item.active; }, + }, }; </script> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Test.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Test.vue index 904d4ff42..929b4edd8 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Test.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/components/Test.vue @@ -1,155 +1,146 @@ <template> - <div class="vue-component"> - <h2>Tests</h2> + <div class="vue-component"> + <h2>Tests</h2> - <!-- Modal --> - <ul class="record_actions"> - <li> - <button class="btn btn-create" @click="modal1.showModal = true"> - {{ $t("action.show_modal") }} - </button> - </li> - <li> - <button class="btn btn-create" @click="modal2.showModal = true"> - Ouvrir une seconde modale - </button> - </li> - </ul> + <!-- Modal --> + <ul class="record_actions"> + <li> + <button class="btn btn-create" @click="modal1.showModal = true"> + {{ $t("action.show_modal") }} + </button> + </li> + <li> + <button class="btn btn-create" @click="modal2.showModal = true"> + Ouvrir une seconde modale + </button> + </li> + </ul> - <teleport to="body"> - <modal - v-if="modal1.showModal" - :modal-dialog-class="modal1.modalDialogClass" - @close="modal1.showModal = false" - > - <template #header> - <h2 class="modal-title">Le titre de ma modale</h2> - </template> - <template #body> - <p> - Lorem ipsum dolor sit amet, consectetur adipiscing elit. - Phasellus luctus facilisis suscipit. Cras pulvinar, - purus sagittis pulvinar porta, enim ex posuere lacus, in - pulvinar lectus magna in odio. Nullam iaculis congue - lorem ac suscipit. Proin ut rutrum augue. Ut vehicula - risus nec hendrerit ullamcorper. Ut volutpat eu mi eget - viverra. Morbi dictum placerat suscipit. - </p> - <p> - Quisque non erat tincidunt, lacinia justo ut, pulvinar - nisl. Nunc id enim ut sem pretium interdum consectetur - eu quam. Vestibulum ante ipsum primis in faucibus orci - luctus et ultrices posuere cubilia curae; Etiam posuere - erat eget augue finibus luctus. Maecenas auctor, tortor - non luctus ultrices, neque neque porttitor ex, nec - lacinia lorem ligula et elit. Sed tempor nulla vitae - lorem sollicitudin dictum. Vestibulum nec arcu eget elit - pulvinar pretium. Phasellus facilisis metus sed diam - luctus, feugiat scelerisque velit dignissim. - </p> - <p> - Lorem ipsum dolor sit amet, consectetur adipiscing elit. - Phasellus luctus facilisis suscipit. Cras pulvinar, - purus sagittis pulvinar porta, enim ex posuere lacus, in - pulvinar lectus magna in odio. Nullam iaculis congue - lorem ac suscipit. Proin ut rutrum augue. Ut vehicula - risus nec hendrerit ullamcorper. Ut volutpat eu mi eget - viverra. Morbi dictum placerat suscipit. - </p> - <p> - Quisque non erat tincidunt, lacinia justo ut, pulvinar - nisl. Nunc id enim ut sem pretium interdum consectetur - eu quam. Vestibulum ante ipsum primis in faucibus orci - luctus et ultrices posuere cubilia curae; Etiam posuere - erat eget augue finibus luctus. Maecenas auctor, tortor - non luctus ultrices, neque neque porttitor ex, nec - lacinia lorem ligula et elit. Sed tempor nulla vitae - lorem sollicitudin dictum. Vestibulum nec arcu eget elit - pulvinar pretium. Phasellus facilisis metus sed diam - luctus, feugiat scelerisque velit dignissim. - </p> - <p> - Lorem ipsum dolor sit amet, consectetur adipiscing elit. - Phasellus luctus facilisis suscipit. Cras pulvinar, - purus sagittis pulvinar porta, enim ex posuere lacus, in - pulvinar lectus magna in odio. Nullam iaculis congue - lorem ac suscipit. Proin ut rutrum augue. Ut vehicula - risus nec hendrerit ullamcorper. Ut volutpat eu mi eget - viverra. Morbi dictum placerat suscipit. - </p> - <p> - Quisque non erat tincidunt, lacinia justo ut, pulvinar - nisl. Nunc id enim ut sem pretium interdum consectetur - eu quam. Vestibulum ante ipsum primis in faucibus orci - luctus et ultrices posuere cubilia curae; Etiam posuere - erat eget augue finibus luctus. Maecenas auctor, tortor - non luctus ultrices, neque neque porttitor ex, nec - lacinia lorem ligula et elit. Sed tempor nulla vitae - lorem sollicitudin dictum. Vestibulum nec arcu eget elit - pulvinar pretium. Phasellus facilisis metus sed diam - luctus, feugiat scelerisque velit dignissim. - </p> - </template> - <template #footer> - <button - class="btn btn-create" - @click=" - modal1.showModal = false; - modal2.showModal = true; - " - > - {{ $t("action.next") }} - </button> - </template> - </modal> - </teleport> + <teleport to="body"> + <modal + v-if="modal1.showModal" + :modal-dialog-class="modal1.modalDialogClass" + @close="modal1.showModal = false" + > + <template #header> + <h2 class="modal-title">Le titre de ma modale</h2> + </template> + <template #body> + <p> + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus + luctus facilisis suscipit. Cras pulvinar, purus sagittis pulvinar + porta, enim ex posuere lacus, in pulvinar lectus magna in odio. + Nullam iaculis congue lorem ac suscipit. Proin ut rutrum augue. Ut + vehicula risus nec hendrerit ullamcorper. Ut volutpat eu mi eget + viverra. Morbi dictum placerat suscipit. + </p> + <p> + Quisque non erat tincidunt, lacinia justo ut, pulvinar nisl. Nunc id + enim ut sem pretium interdum consectetur eu quam. Vestibulum ante + ipsum primis in faucibus orci luctus et ultrices posuere cubilia + curae; Etiam posuere erat eget augue finibus luctus. Maecenas + auctor, tortor non luctus ultrices, neque neque porttitor ex, nec + lacinia lorem ligula et elit. Sed tempor nulla vitae lorem + sollicitudin dictum. Vestibulum nec arcu eget elit pulvinar pretium. + Phasellus facilisis metus sed diam luctus, feugiat scelerisque velit + dignissim. + </p> + <p> + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus + luctus facilisis suscipit. Cras pulvinar, purus sagittis pulvinar + porta, enim ex posuere lacus, in pulvinar lectus magna in odio. + Nullam iaculis congue lorem ac suscipit. Proin ut rutrum augue. Ut + vehicula risus nec hendrerit ullamcorper. Ut volutpat eu mi eget + viverra. Morbi dictum placerat suscipit. + </p> + <p> + Quisque non erat tincidunt, lacinia justo ut, pulvinar nisl. Nunc id + enim ut sem pretium interdum consectetur eu quam. Vestibulum ante + ipsum primis in faucibus orci luctus et ultrices posuere cubilia + curae; Etiam posuere erat eget augue finibus luctus. Maecenas + auctor, tortor non luctus ultrices, neque neque porttitor ex, nec + lacinia lorem ligula et elit. Sed tempor nulla vitae lorem + sollicitudin dictum. Vestibulum nec arcu eget elit pulvinar pretium. + Phasellus facilisis metus sed diam luctus, feugiat scelerisque velit + dignissim. + </p> + <p> + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Phasellus + luctus facilisis suscipit. Cras pulvinar, purus sagittis pulvinar + porta, enim ex posuere lacus, in pulvinar lectus magna in odio. + Nullam iaculis congue lorem ac suscipit. Proin ut rutrum augue. Ut + vehicula risus nec hendrerit ullamcorper. Ut volutpat eu mi eget + viverra. Morbi dictum placerat suscipit. + </p> + <p> + Quisque non erat tincidunt, lacinia justo ut, pulvinar nisl. Nunc id + enim ut sem pretium interdum consectetur eu quam. Vestibulum ante + ipsum primis in faucibus orci luctus et ultrices posuere cubilia + curae; Etiam posuere erat eget augue finibus luctus. Maecenas + auctor, tortor non luctus ultrices, neque neque porttitor ex, nec + lacinia lorem ligula et elit. Sed tempor nulla vitae lorem + sollicitudin dictum. Vestibulum nec arcu eget elit pulvinar pretium. + Phasellus facilisis metus sed diam luctus, feugiat scelerisque velit + dignissim. + </p> + </template> + <template #footer> + <button + class="btn btn-create" + @click=" + modal1.showModal = false; + modal2.showModal = true; + " + > + {{ $t("action.next") }} + </button> + </template> + </modal> + </teleport> - <teleport to="body"> - <modal - v-if="modal2.showModal" - :modal-dialog-class="modal2.modalDialogClass" - @close="modal2.showModal = false" - > - <template #header> - <h2 class="modal-title">Une autre modale</h2> - </template> - <template #body> - <p>modal 2</p> - </template> - <template #footer> - <button - class="btn btn-create" - @click="modal2.showModal = false" - > - {{ $t("action.save") }} - </button> - </template> - </modal> - </teleport> - <!-- END Modal --> - </div> + <teleport to="body"> + <modal + v-if="modal2.showModal" + :modal-dialog-class="modal2.modalDialogClass" + @close="modal2.showModal = false" + > + <template #header> + <h2 class="modal-title">Une autre modale</h2> + </template> + <template #body> + <p>modal 2</p> + </template> + <template #footer> + <button class="btn btn-create" @click="modal2.showModal = false"> + {{ $t("action.save") }} + </button> + </template> + </modal> + </teleport> + <!-- END Modal --> + </div> </template> <script> import Modal from "ChillMainAssets/vuejs/_components/Modal"; export default { - name: "Test", - components: { - Modal, - }, - data() { - return { - modal1: { - showModal: false, - modalDialogClass: "modal-dialog-scrollable modal-xl", // modal-lg modal-md modal-sm - }, - modal2: { - showModal: false, - modalDialogClass: "modal-dialog-centered modal-sm", // modal-lg modal-md modal-sm - }, - }; - }, - computed: {}, + name: "Test", + components: { + Modal, + }, + data() { + return { + modal1: { + showModal: false, + modalDialogClass: "modal-dialog-scrollable modal-xl", // modal-lg modal-md modal-sm + }, + modal2: { + showModal: false, + modalDialogClass: "modal-dialog-centered modal-sm", // modal-lg modal-md modal-sm + }, + }; + }, + computed: {}, }; </script> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/js/i18n.js b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/js/i18n.js index 6756381de..9d00136ea 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/js/i18n.js +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourse/js/i18n.js @@ -80,7 +80,7 @@ const appMessages = { firstName: "Prénom", lastName: "Nom", birthdate: "Date de naissance", - center: "Centre", + center: "Territoire", phonenumber: "Téléphone", mobilenumber: "Mobile", altNames: "Autres noms", diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkCreate/App.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkCreate/App.vue index 745317211..f8b5389f4 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkCreate/App.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkCreate/App.vue @@ -1,152 +1,135 @@ <template> - <h2>{{ $t("pick_social_issue") }}</h2> + <h2>{{ $t("pick_social_issue") }}</h2> - <div id="awc_create_form"> - <div id="picking" class=""> - <p>{{ $t("pick_social_issue_linked_with_action") }}</p> - <div v-for="si in socialIssues" :key="si.id"> - <input - type="radio" - :value="si.id" - name="socialIssue" - v-model="socialIssuePicked" - /><span class="badge bg-chill-l-gray text-dark">{{ - si.text - }}</span> - </div> - <div class="my-3"> - <div class="col-11"> - <vue-multiselect - name="otherIssues" - label="text" - track-by="id" - open-direction="bottom" - :close-on-select="true" - :preserve-search="false" - :reset-after="true" - :hide-selected="true" - :taggable="false" - :multiple="false" - :searchable="true" - :allow-empty="true" - :show-labels="false" - :loading="issueIsLoading" - :placeholder="$t('choose_other_social_issue')" - :options="socialIssuesOther" - @select="addIssueInList" - /> - </div> - </div> - <div v-if="hasSocialIssuePicked" class="mb-3"> - <h2>{{ $t("pick_an_action") }}</h2> - <div class="col-11"> - <vue-multiselect - v-model="socialActionPicked" - label="text" - :options="socialActionsReachables" - :searchable="true" - :close-on-select="true" - :show-labels="true" - track-by="id" - /> - </div> - </div> - - <div v-if="isLoadingSocialActions"> - <i class="fa fa-circle-o-notch fa-spin fa-fw" /> - </div> - - <div v-if="hasSocialActionPicked" id="persons" class="mb-5"> - <h2>{{ $t("persons_involved") }}</h2> - - <ul> - <li v-for="p in personsReachables" :key="p.id"> - <div class="form-check"> - <input - type="checkbox" - :value="p.id" - v-model="personsPicked" - class="form-check-input" - :id="'person_check' + p.id" - /> - <label - class="form-check-label" - :for="'person_check' + p.id" - > - <person-text :person="p" /> - </label> - </div> - </li> - </ul> - </div> + <div id="awc_create_form"> + <div id="picking" class=""> + <p>{{ $t("pick_social_issue_linked_with_action") }}</p> + <div v-for="si in socialIssues" :key="si.id"> + <input + type="radio" + :value="si.id" + name="socialIssue" + v-model="socialIssuePicked" + /><span class="badge bg-chill-l-gray text-dark">{{ si.text }}</span> + </div> + <div class="my-3"> + <div class="col-11"> + <vue-multiselect + name="otherIssues" + label="text" + track-by="id" + open-direction="bottom" + :close-on-select="true" + :preserve-search="false" + :reset-after="true" + :hide-selected="true" + :taggable="false" + :multiple="false" + :searchable="true" + :allow-empty="true" + :show-labels="false" + :loading="issueIsLoading" + :placeholder="$t('choose_other_social_issue')" + :options="socialIssuesOther" + @select="addIssueInList" + /> </div> - <!-- <div v-if="hasSocialActionPicked" id="start_date"> + </div> + <div v-if="hasSocialIssuePicked" class="mb-3"> + <h2>{{ $t("pick_an_action") }}</h2> + <div class="col-11"> + <vue-multiselect + v-model="socialActionPicked" + label="text" + :options="socialActionsReachables" + :searchable="true" + :close-on-select="true" + :show-labels="true" + track-by="id" + /> + </div> + </div> + + <div v-if="isLoadingSocialActions"> + <i class="fa fa-circle-o-notch fa-spin fa-fw" /> + </div> + + <div v-if="hasSocialActionPicked" id="persons" class="mb-5"> + <h2>{{ $t("persons_involved") }}</h2> + + <ul> + <li v-for="p in personsReachables" :key="p.id"> + <div class="form-check"> + <input + type="checkbox" + :value="p.id" + v-model="personsPicked" + class="form-check-input" + :id="'person_check' + p.id" + /> + <label class="form-check-label" :for="'person_check' + p.id"> + <person-text :person="p" /> + </label> + </div> + </li> + </ul> + </div> + </div> + <!-- <div v-if="hasSocialActionPicked" id="start_date"> <p><label>{{ $t('startDate') }}</label> <input type="date" v-model="startDate" /></p> </div> --> - <div class="row"> - <div v-if="hasSocialActionPicked" id="start_date" class="mb-3 row"> - <label class="col-form-label col-sm-4">{{ - $t("startDate") - }}</label> - <div class="col-sm-8"> - <input - class="form-control" - type="date" - v-model="startDate" - /> - </div> - </div> + <div class="row"> + <div v-if="hasSocialActionPicked" id="start_date" class="mb-3 row"> + <label class="col-form-label col-sm-4">{{ $t("startDate") }}</label> + <div class="col-sm-8"> + <input class="form-control" type="date" v-model="startDate" /> + </div> + </div> - <!-- <div v-if="hasSocialActionPicked" id="end_date"> + <!-- <div v-if="hasSocialActionPicked" id="end_date"> <p><label>{{ $t('endDate') }}</label> <input type="date" v-model="endDate" /></p> </div> --> - <div v-if="hasSocialActionPicked" id="end_date" class="mb-3 row"> - <label class="col-form-label col-sm-4">{{ - $t("endDate") - }}</label> - <div class="col-sm-8"> - <input class="form-control" type="date" v-model="endDate" /> - </div> - </div> - </div> - <div id="confirm"> - <div v-if="hasErrors"> - <p>{{ $t("form_has_errors") }}</p> - - <ul> - <li v-for="e in errors" :key="e.id"> - {{ e }} - </li> - </ul> - </div> - - <div> - <ul class="record_actions"> - <li class="cancel"> - <button class="btn btn-cancel" @click="goToPrevious"> - {{ $t("action.cancel") }} - </button> - </li> - <li v-if="hasSocialActionPicked"> - <button - class="btn btn-save" - v-show="!isPostingWork" - @click="submit" - > - {{ $t("action.save") }} - </button> - <button - class="btn btn-save" - v-show="isPostingWork" - disabled - > - {{ $t("action.save") }} - </button> - </li> - </ul> - </div> + <div v-if="hasSocialActionPicked" id="end_date" class="mb-3 row"> + <label class="col-form-label col-sm-4">{{ $t("endDate") }}</label> + <div class="col-sm-8"> + <input class="form-control" type="date" v-model="endDate" /> </div> + </div> </div> + <div id="confirm"> + <div v-if="hasErrors"> + <p>{{ $t("form_has_errors") }}</p> + + <ul> + <li v-for="e in errors" :key="e.id"> + {{ e }} + </li> + </ul> + </div> + + <div> + <ul class="record_actions"> + <li class="cancel"> + <button class="btn btn-cancel" @click="goToPrevious"> + {{ $t("action.cancel") }} + </button> + </li> + <li v-if="hasSocialActionPicked"> + <button + class="btn btn-save" + v-show="!isPostingWork" + @click="submit" + > + {{ $t("action.save") }} + </button> + <button class="btn btn-save" v-show="isPostingWork" disabled> + {{ $t("action.save") }} + </button> + </li> + </ul> + </div> + </div> + </div> </template> <script> @@ -155,131 +138,127 @@ import VueMultiselect from "vue-multiselect"; import PersonText from "ChillPersonAssets/vuejs/_components/Entity/PersonText.vue"; const i18n = { - messages: { - fr: { - startDate: "Date de début", - endDate: "Date de fin", - form_has_errors: "Le formulaire comporte des erreurs", - pick_social_issue: "Choisir une problématique sociale", - pick_other_social_issue: "Veuillez choisir un autre problématique", - pick_an_action: "Choisir une action d'accompagnement", - pick_social_issue_linked_with_action: - "Indiquez la problématique sociale liée à l'action d'accompagnement", - persons_involved: "Usagers concernés", - choose_other_social_issue: - "Veuillez choisir un autre problématique", - }, + messages: { + fr: { + startDate: "Date de début", + endDate: "Date de fin", + form_has_errors: "Le formulaire comporte des erreurs", + pick_social_issue: "Choisir une problématique sociale", + pick_other_social_issue: "Veuillez choisir un autre problématique", + pick_an_action: "Choisir une action d'accompagnement", + pick_social_issue_linked_with_action: + "Indiquez la problématique sociale liée à l'action d'accompagnement", + persons_involved: "Usagers concernés", + choose_other_social_issue: "Veuillez choisir un autre problématique", }, + }, }; export default { - name: "App", - components: { - VueMultiselect, - PersonText, + name: "App", + components: { + VueMultiselect, + PersonText, + }, + methods: { + submit() { + this.$store.dispatch("submit").catch(({ name, violations }) => { + if (name === "ValidationException" || name === "AccessException") { + violations.forEach((violation) => + this.$toast.open({ message: violation }), + ); + } else { + this.$toast.open({ message: "An error occurred" }); + } + }); }, - methods: { - submit() { - this.$store.dispatch("submit").catch(({ name, violations }) => { - if ( - name === "ValidationException" || - name === "AccessException" - ) { - violations.forEach((violation) => - this.$toast.open({ message: violation }), - ); - } else { - this.$toast.open({ message: "An error occurred" }); - } - }); - }, - addIssueInList(value) { - this.$store.commit("addIssueInList", value); - this.$store.commit("removeIssueInOther", value); - this.$store.dispatch("pickSocialIssue", value.id); - }, - goToPrevious() { - let params = new URLSearchParams(window.location.search); - if (params.has("returnPath")) { - window.location.replace(params.get("returnPath")); - } else { - return; - } - }, + addIssueInList(value) { + this.$store.commit("addIssueInList", value); + this.$store.commit("removeIssueInOther", value); + this.$store.dispatch("pickSocialIssue", value.id); }, - i18n, - computed: { - ...mapState([ - "socialIssues", - "socialIssuesOther", - "socialActionsReachables", - "errors", - "personsReachables", - ]), - ...mapGetters([ - "hasSocialIssuePicked", - "hasSocialActionPicked", - "isLoadingSocialActions", - "isPostingWork", - "hasErrors", - ]), - personsPicked: { - get() { - let s = this.$store.state.personsPicked.map((p) => p.id); - - return s; - }, - set(v) { - this.$store.commit("setPersonsPickedIds", v); - }, - }, - socialIssuePicked: { - get() { - let s = this.$store.state.socialIssuePicked; - - if (s === null) { - return null; - } - - return s.id; - }, - set(value) { - this.$store.dispatch("pickSocialIssue", value); - }, - }, - socialActionPicked: { - get() { - return this.$store.state.socialActionPicked; - }, - set(value) { - this.$store.commit("setSocialAction", value); - }, - }, - startDate: { - get() { - return this.$store.state.startDate; - }, - set(value) { - this.$store.commit("setStartDate", value); - }, - }, - endDate: { - get() { - return this.$store.state.endDate; - }, - set(value) { - this.$store.commit("setEndDate", value); - }, - }, - setSocialIssue: { - set() { - this.$store.dispatch( - "setSocialIssue", - socialIssues[socialIssues.length - 1], - ); - }, - }, + goToPrevious() { + let params = new URLSearchParams(window.location.search); + if (params.has("returnPath")) { + window.location.replace(params.get("returnPath")); + } else { + return; + } }, + }, + i18n, + computed: { + ...mapState([ + "socialIssues", + "socialIssuesOther", + "socialActionsReachables", + "errors", + "personsReachables", + ]), + ...mapGetters([ + "hasSocialIssuePicked", + "hasSocialActionPicked", + "isLoadingSocialActions", + "isPostingWork", + "hasErrors", + ]), + personsPicked: { + get() { + let s = this.$store.state.personsPicked.map((p) => p.id); + + return s; + }, + set(v) { + this.$store.commit("setPersonsPickedIds", v); + }, + }, + socialIssuePicked: { + get() { + let s = this.$store.state.socialIssuePicked; + + if (s === null) { + return null; + } + + return s.id; + }, + set(value) { + this.$store.dispatch("pickSocialIssue", value); + }, + }, + socialActionPicked: { + get() { + return this.$store.state.socialActionPicked; + }, + set(value) { + this.$store.commit("setSocialAction", value); + }, + }, + startDate: { + get() { + return this.$store.state.startDate; + }, + set(value) { + this.$store.commit("setStartDate", value); + }, + }, + endDate: { + get() { + return this.$store.state.endDate; + }, + set(value) { + this.$store.commit("setEndDate", value); + }, + }, + setSocialIssue: { + set() { + this.$store.dispatch( + "setSocialIssue", + socialIssues[socialIssues.length - 1], + ); + }, + }, + }, }; </script> @@ -288,46 +267,46 @@ export default { @import "ChillPersonAssets/chill/scss/mixins"; @import "ChillMainAssets/chill/scss/chill_variables"; span.badge { - @include badge_social($social-issue-color); - font-size: 95%; - margin-bottom: 5px; - margin-right: 1em; - margin-left: 1em; + @include badge_social($social-issue-color); + font-size: 95%; + margin-bottom: 5px; + margin-right: 1em; + margin-left: 1em; } </style> <style lang="scss"> #awc_create_form { - display: grid; - grid-template-areas: - "picking picking" - "start_date end_date" - "confirm confirm"; - grid-template-columns: 50% 50%; - column-gap: 1.5rem; + display: grid; + grid-template-areas: + "picking picking" + "start_date end_date" + "confirm confirm"; + grid-template-columns: 50% 50%; + column-gap: 1.5rem; - #picking { - grid-area: picking; + #picking { + grid-area: picking; - #persons { - ul { - padding: 0; + #persons { + ul { + padding: 0; - list-style-type: none; - } - } + list-style-type: none; + } } + } - #start_date { - grid-area: start_date; - } + #start_date { + grid-area: start_date; + } - #end_date { - grid-area: end_date; - } + #end_date { + grid-area: end_date; + } - #confirm { - grid-area: confirm; - } + #confirm { + grid-area: confirm; + } } </style> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/App.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/App.vue index bf403291e..6cd65b4a3 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/App.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/App.vue @@ -1,482 +1,468 @@ <template> - <div id="workEditor" class="my-4"> - <div id="title" class="action-row"> - <div> - <p class="wl-item social-issues"> - <span class="chill-entity entity-social-issue"> - <span class="badge bg-chill-l-gray text-dark">{{ - work.socialAction.issue.text - }}</span> - </span> - </p> - </div> - <h2 class="badge-title"> - <span class="title_label"></span> - <span class="title_action">{{ work.socialAction.text }}</span> - </h2> - </div> - - <div id="startDate" class="action-row"> - <label class="col-form-label">{{ $t("startDate") }}</label> - <input - v-model="startDate" - type="date" - required="true" - class="form-control" - v-once - /> - </div> - - <div id="endDate" class="action-row"> - <label class="col-form-label">{{ $t("endDate") }}</label> - <input v-model="endDate" type="date" class="form-control" /> - </div> - - <div id="privateComment" class="action-row"> - <label class="col-form-label">{{ $t("private_comment") }}</label> - <comment-editor v-model="privateComment"></comment-editor> - </div> - - <div id="comment" class="action-row"> - <label class="col-form-label">{{ $t("comments") }}</label> - <comment-editor v-model="note"></comment-editor> - </div> - - <div id="objectives" class="action-row"> - <div aria="hidden" class="title"> - <div> - <h3>{{ $t("goals_title") }}</h3> - </div> - <div> - <h3>{{ $t("results_title") }}</h3> - </div> - </div> - - <!-- results which are not attached to an objective --> - <div v-if="hasResultsForAction"> - <div class="results_without_objective"> - {{ $t("results_without_objective") }} - </div> - <div> - <add-result - :availableResults="resultsForAction" - destination="action" - ></add-result> - </div> - </div> - - <!-- results which **are** attached to an objective --> - <div v-for="g in goalsPicked" :key="g.goal.id"> - <div class="item-title" @click="removeGoal(g)"> - <span class="removable">{{ - localizeString(g.goal.title) - }}</span> - </div> - <div> - <add-result :goal="g.goal" destination="goal"></add-result> - </div> - </div> - <div class="accordion" id="expandedSuggestions"> - <div - v-if="availableForCheckGoal.length > 0" - class="accordion-item" - > - <h2 - class="accordion-header" - id="heading_expanded_suggestions" - > - <button - v-if="isExpanded" - class="accordion-button" - type="button" - data-bs-toggle="collapse" - aria-expanded="true" - @click="toggleSelect" - > - Masquer - </button> - - <button - v-else - class="accordion-button collapsed" - type="button" - data-bs-toggle="collapse" - aria-expanded="false" - @click="toggleSelect" - > - Motifs, objectifs et dispositfs disponibles - </button> - </h2> - <div - class="accordion-collapse" - id="collapse_expanded_suggestions" - aria-labelledby="heading_expanded_suggestions" - data-bs-parent="#expandedSuggestions" - > - <template v-if="isExpanded"> - <ul class="list-suggest add-items"> - <li - v-for="g in availableForCheckGoal" - @click="addGoal(g)" - :key="g.id" - > - <span>{{ localizeString(g.title) }}</span> - </li> - </ul> - </template> - </div> - <p - v-if="goalsPicked.length === 0" - class="chill-no-data-statement" - > - Aucun objectif associé - </p> - </div> - <div v-else> - <span class="chill-no-data-statement">{{ - $t("no_goals_available") - }}</span> - </div> - </div> - </div> - - <div id="evaluations" class="action-row"> - <div aria="hidden" class="title"> - <div> - <h3> - {{ $t("Evaluations") }} - {{ $t("Forms") }} - - {{ $t("Post") }} - </h3> - </div> - </div> - - <!-- list evaluations --> - <add-evaluation - v-for="e in pickedEvaluations" - v-bind:key="e.key" - v-bind:evaluation="e" - v-bind:docAnchorId="this.docAnchorId" - > - </add-evaluation> - - <!-- box to add new evaluation --> - <div class="add_evaluation"> - <div v-if="showAddEvaluation"> - <p>{{ $t("available_evaluations_text") }}</p> - <ul class="list-suggest add-items"> - <li - v-for="e in evaluationsForAction" - @click="addEvaluation(e)" - :key="e.id" - > - <span>{{ localizeString(e.title) }}</span> - </li> - </ul> - </div> - <ul - class="record_actions" - v-if="evaluationsForAction.length > 0" - > - <li> - <button - :title="$t('add_an_evaluation')" - class="btn btn-create" - @click="toggleAddEvaluation" - > - {{ $t("add_an_evaluation") }} - </button> - </li> - </ul> - <div v-else> - <span class="chill-no-data-statement">{{ - $t("no_evaluations_available") - }}</span> - </div> - </div> - </div> - - <div id="persons" class="action-row"> - <h3 class="mb-3">{{ $t("persons_involved") }}</h3> - - <ul class="list-unstyled"> - <li v-for="p in personsReachables" :key="p.id"> - <div class="form-check"> - <input - v-model="personsPicked" - :value="p.id" - type="checkbox" - class="me-2 form-check-input" - :id="'person_check' + p.id" - /> - <label - :for="'person_check' + p.id" - class="form-check-label" - > - <person-text :person="p"></person-text> - </label> - </div> - </li> - <li - v-for="p in getPreviousPersons" - :key="p.id" - class="alert alert-danger" - > - <div class="form-check"> - <input - v-model="personsPicked" - :value="p.id" - type="checkbox" - class="me-2 form-check-input" - :id="'person_check' + p.id" - /> - <label - :for="'person_check' + p.id" - class="form-check-label" - > - <person-text :person="p"></person-text> - </label> - </div> - <span - ><i class="fa fa-warning"></i> {{ - $t("warning_previous_persons") - }}</span - > - </li> - </ul> - </div> - - <div id="referrers" class="action-row"> - <h3>{{ $t("referrers") }}</h3> - - <div v-if="!hasReferrers"> - <p class="chill-no-data-statement">{{ $t("no_referrers") }}</p> - </div> - - <div v-else> - <ul class="list-suggest remove-items inline"> - <li - v-for="u in referrers" - :key="u.id" - :title="$t('remove_referrer')" - @click="removeReferrer(u)" - > - <span> - {{ u.text }} - </span> - </li> - </ul> - </div> - - <ul class="record_actions"> - <li class="add-persons"> - <add-persons - ref="referrerPicker" - :key="referrerPicker.key" - :buttonTitle="$t('add_referrers')" - :modalTitle="$t('choose_referrers')" - :options="referrerPicker.options" - @addNewPersons="addReferrers" - > - </add-persons> - </li> - </ul> - </div> - - <div id="handlingThirdParty" class="action-row"> - <h3>{{ $t("handling_thirdparty") }}</h3> - - <div v-if="!hasHandlingThirdParty"> - <p class="chill-no-data-statement"> - {{ $t("no_handling_thirdparty") }} - </p> - - <ul class="record_actions"> - <li class="add-persons"> - <add-persons - ref="handlingThirdPartyPicker" - v-bind:key="handlingThirdPartyPicker.key" - v-bind:buttonTitle=" - $t('precise_handling_thirdparty') - " - v-bind:modalTitle="$t('choose_a_thirdparty')" - v-bind:options="handlingThirdPartyPicker.options" - @addNewPersons="setHandlingThirdParty" - > - <!-- to cast child method --> - </add-persons> - </li> - </ul> - </div> - <div v-else class="flex-table"> - <third-party-render-box - :thirdparty="handlingThirdParty" - :options="{ - addLink: false, - addId: false, - addEntity: true, - addInfo: false, - hLevel: 3, - isMultiline: true, - isConfidential: false, - }" - ></third-party-render-box> - <ul class="record_actions"> - <li> - <button - :title="$t('remove_handling_thirdparty')" - class="btn btn-remove" - @click="removeHandlingThirdParty" - /> - </li> - </ul> - </div> - </div> - - <div id="thirdParties" class="action-row"> - <h3>{{ $t("thirdparty_intervener") }}</h3> - - <div v-if="!hasThirdParties"> - <p class="chill-no-data-statement"> - {{ $t("no_thirdparty_intervener") }} - </p> - </div> - - <div v-else> - <div class="flex-bloc mb-3"> - <third-party-render-box - v-for="thirdparty in thirdParties" - :key="thirdparty.id" - :thirdparty="thirdparty" - :options="{ - addLink: false, - addId: false, - addEntity: true, - addInfo: false, - hLevel: 3, - }" - > - <template v-slot:record-actions> - <ul class="record_actions"> - <li> - <on-the-fly - :type="thirdparty.type" - :id="thirdparty.id" - action="show" - ></on-the-fly> - </li> - <li> - <on-the-fly - :type="thirdparty.type" - :id="thirdparty.id" - action="edit" - @saveFormOnTheFly="saveFormOnTheFly" - ref="onTheFly" - ></on-the-fly> - </li> - <li> - <button - :title="$t('remove_thirdparty')" - class="btn btn-sm btn-remove" - @click="removeThirdParty(thirdparty)" - /> - </li> - </ul> - </template> - </third-party-render-box> - </div> - </div> - - <ul class="record_actions"> - <li class="add-persons"> - <add-persons - ref="thirdPartyPicker" - v-bind:key="thirdPartyPicker.key" - v-bind:buttonTitle="$t('add_thirdparties')" - v-bind:modalTitle="$t('choose_thirdparties')" - v-bind:options="thirdPartyPicker.options" - @addNewPersons="addThirdParties" - > - <!-- to cast child method --> - </add-persons> - </li> - </ul> - </div> - - <div - v-if="errors.length > 0" - id="errors" - class="alert alert-danger flashbag" - > - <p>{{ $t("fix_these_errors") }}</p> - <ul> - <li v-for="e in errors" :key="e.id">{{ e }}</li> - </ul> - </div> + <div id="workEditor" class="my-4"> + <div id="title" class="action-row"> + <div> + <p class="wl-item social-issues"> + <span class="chill-entity entity-social-issue"> + <span class="badge bg-chill-l-gray text-dark">{{ + work.socialAction.issue.text + }}</span> + </span> + </p> + </div> + <h2 class="badge-title"> + <span class="title_label"></span> + <span class="title_action">{{ work.socialAction.text }}</span> + </h2> </div> - <ul class="record_actions sticky-form-buttons"> - <li> - <list-workflow-modal - :workflows="this.work.workflows" - :allowCreate="true" - relatedEntityClass="Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWork" - :relatedEntityId="this.work.id" - :workflowsAvailables="this.work.workflows_availables" - :preventDefaultMoveToGenerate="true" - @go-to-generate-workflow="goToGenerateWorkflow" - ></list-workflow-modal> - </li> - <li> + <div id="startDate" class="action-row"> + <label class="col-form-label">{{ $t("startDate") }}</label> + <input + v-model="startDate" + type="date" + required="true" + class="form-control" + v-once + /> + </div> + + <div id="endDate" class="action-row"> + <label class="col-form-label">{{ $t("endDate") }}</label> + <input v-model="endDate" type="date" class="form-control" /> + </div> + + <div id="privateComment" class="action-row"> + <label class="col-form-label">{{ $t("private_comment") }}</label> + <comment-editor v-model="privateComment"></comment-editor> + </div> + + <div id="comment" class="action-row"> + <label class="col-form-label">{{ $t("comments") }}</label> + <comment-editor v-model="note"></comment-editor> + </div> + + <div id="objectives" class="action-row"> + <div aria="hidden" class="title"> + <div> + <h3>{{ $t("goals_title") }}</h3> + </div> + <div> + <h3>{{ $t("results_title") }}</h3> + </div> + </div> + + <!-- 1. Goals with results that were already selected/saved to the entity --> + <div v-for="g in goalsPicked" :key="g.goal.id"> + <div class="item-title" @click="removeGoal(g)"> + <span class="removable">{{ localizeString(g.goal.title) }}</span> + </div> + <div> + <add-result :goal="g.goal" destination="goal"></add-result> + </div> + </div> + + <!-- 2. Results without objectives that were already selected/saved to the entity --> + <div v-if="hasResultsForAction"> + <div + class="results_without_objective" + style=" + background: repeating-linear-gradient( + 45deg, + #e6e6e6, + #e6e6e6 10px, + #f3f3f3 0, + #f3f3f3 20px + ); + " + > + {{ $t("results_without_objective") }} + </div> + <div> + <add-result + :availableResults="resultsForAction" + destination="action" + ></add-result> + </div> + </div> + + <!-- 3. Selector for objectives with results --> + <div class="accordion" id="expandedSuggestions"> + <div v-if="availableForCheckGoal.length > 0" class="accordion-item"> + <h2 class="accordion-header" id="heading_expanded_suggestions"> <button - v-if="AmIRefferer" - class="btn btn-notify" - @click="goToGenerateNotification(false)" - ></button> - <template v-else> - <button - id="btnGroupNotifyButtons" - type="button" - class="btn btn-notify dropdown-toggle" - :title="$t('notification_send')" - data-bs-toggle="dropdown" - aria-expanded="false" + v-if="isExpanded" + class="accordion-button" + type="button" + data-bs-toggle="collapse" + aria-expanded="true" + @click="toggleSelect" + > + Masquer + </button> + + <button + v-else + class="accordion-button collapsed" + type="button" + data-bs-toggle="collapse" + aria-expanded="false" + @click="toggleSelect" + > + Motifs, objectifs et dispositfs disponibles + </button> + </h2> + <div + class="accordion-collapse" + id="collapse_expanded_suggestions" + aria-labelledby="heading_expanded_suggestions" + data-bs-parent="#expandedSuggestions" + > + <template v-if="isExpanded"> + <ul class="list-suggest add-items"> + <li + v-for="g in availableForCheckGoal" + @click="addGoal(g)" + :key="g.id" > -   - </button> - <ul - class="dropdown-menu" - aria-labelledby="btnGroupNotifyButtons" - > - <li> - <a - class="dropdown-item" - @click="goToGenerateNotification(true)" - >{{ $t("notification_notify_referrer") }}</a - > - </li> - <li> - <a - class="dropdown-item" - @click="goToGenerateNotification(false)" - >{{ $t("notification_notify_any") }}</a - > - </li> - </ul> + <span>{{ localizeString(g.title) }}</span> + </li> + </ul> </template> - </li> + </div> + <p v-if="goalsPicked.length === 0" class="chill-no-data-statement"> + Aucun objectif associé + </p> + </div> + <div v-else> + <span class="chill-no-data-statement">{{ + $t("no_goals_available") + }}</span> + </div> + </div> - <li v-if="!isPosting"> - <button class="btn btn-save" @click="submit"> - {{ $t("action.save") }} - </button> - </li> + <!-- 4. Selector for results without objectives is already included above in section 2 --> + </div> - <li v-if="isPosting"> - <button class="btn btn-save" disabled> - {{ $t("action.save") }} + <div id="evaluations" class="action-row"> + <div aria="hidden" class="title"> + <div> + <h3> + {{ $t("Evaluations") }} - {{ $t("Forms") }} - + {{ $t("Post") }} + </h3> + </div> + </div> + + <!-- list evaluations --> + <add-evaluation + v-for="e in pickedEvaluations" + v-bind:key="e.key" + v-bind:evaluation="e" + v-bind:docAnchorId="this.docAnchorId" + > + </add-evaluation> + + <!-- box to add new evaluation --> + <div class="add_evaluation"> + <div v-if="showAddEvaluation"> + <p>{{ $t("available_evaluations_text") }}</p> + <ul class="list-suggest add-items"> + <li + v-for="e in evaluationsForAction" + @click="addEvaluation(e)" + :key="e.id" + > + <span>{{ localizeString(e.title) }}</span> + </li> + </ul> + </div> + <ul class="record_actions" v-if="evaluationsForAction.length > 0"> + <li> + <button + :title="$t('add_an_evaluation')" + class="btn btn-create" + @click="toggleAddEvaluation" + > + {{ $t("add_an_evaluation") }} </button> + </li> + </ul> + <div v-else> + <span class="chill-no-data-statement">{{ + $t("no_evaluations_available") + }}</span> + </div> + </div> + </div> + + <div id="persons" class="action-row"> + <h3 class="mb-3">{{ $t("persons_involved") }}</h3> + + <ul class="list-unstyled"> + <li v-for="p in personsReachables" :key="p.id"> + <div class="form-check"> + <input + v-model="personsPicked" + :value="p.id" + type="checkbox" + class="me-2 form-check-input" + :id="'person_check' + p.id" + /> + <label :for="'person_check' + p.id" class="form-check-label"> + <person-text :person="p"></person-text> + </label> + </div> </li> - </ul> + <li + v-for="p in getPreviousPersons" + :key="p.id" + class="alert alert-danger" + > + <div class="form-check"> + <input + v-model="personsPicked" + :value="p.id" + type="checkbox" + class="me-2 form-check-input" + :id="'person_check' + p.id" + /> + <label :for="'person_check' + p.id" class="form-check-label"> + <person-text :person="p"></person-text> + </label> + </div> + <span + ><i class="fa fa-warning"></i> {{ + $t("warning_previous_persons") + }}</span + > + </li> + </ul> + </div> + + <div id="referrers" class="action-row"> + <h3>{{ $t("referrers") }}</h3> + + <div v-if="!hasReferrers"> + <p class="chill-no-data-statement">{{ $t("no_referrers") }}</p> + </div> + + <div v-else> + <ul class="list-suggest remove-items inline"> + <li + v-for="u in referrers" + :key="u.id" + :title="$t('remove_referrer')" + @click="removeReferrer(u)" + > + <span> + {{ u.text }} + </span> + </li> + </ul> + </div> + + <ul class="record_actions"> + <li class="add-persons"> + <add-persons + ref="referrerPicker" + :key="referrerPicker.key" + :buttonTitle="$t('add_referrers')" + :modalTitle="$t('choose_referrers')" + :options="referrerPicker.options" + @addNewPersons="addReferrers" + > + </add-persons> + </li> + </ul> + </div> + + <div id="handlingThirdParty" class="action-row"> + <h3>{{ $t("handling_thirdparty") }}</h3> + + <div v-if="!hasHandlingThirdParty"> + <p class="chill-no-data-statement"> + {{ $t("no_handling_thirdparty") }} + </p> + + <ul class="record_actions"> + <li class="add-persons"> + <add-persons + ref="handlingThirdPartyPicker" + v-bind:key="handlingThirdPartyPicker.key" + v-bind:buttonTitle="$t('precise_handling_thirdparty')" + v-bind:modalTitle="$t('choose_a_thirdparty')" + v-bind:options="handlingThirdPartyPicker.options" + @addNewPersons="setHandlingThirdParty" + > + <!-- to cast child method --> + </add-persons> + </li> + </ul> + </div> + <div v-else class="flex-table"> + <third-party-render-box + :thirdparty="handlingThirdParty" + :options="{ + addLink: false, + addId: false, + addEntity: true, + addInfo: false, + hLevel: 3, + isMultiline: true, + isConfidential: false, + }" + ></third-party-render-box> + <ul class="record_actions"> + <li> + <button + :title="$t('remove_handling_thirdparty')" + class="btn btn-remove" + @click="removeHandlingThirdParty" + /> + </li> + </ul> + </div> + </div> + + <div id="thirdParties" class="action-row"> + <h3>{{ $t("thirdparty_intervener") }}</h3> + + <div v-if="!hasThirdParties"> + <p class="chill-no-data-statement"> + {{ $t("no_thirdparty_intervener") }} + </p> + </div> + + <div v-else> + <div class="flex-bloc mb-3"> + <third-party-render-box + v-for="thirdparty in thirdParties" + :key="thirdparty.id" + :thirdparty="thirdparty" + :options="{ + addLink: false, + addId: false, + addEntity: true, + addInfo: false, + hLevel: 3, + }" + > + <template v-slot:record-actions> + <ul class="record_actions"> + <li> + <on-the-fly + :type="thirdparty.type" + :id="thirdparty.id" + action="show" + ></on-the-fly> + </li> + <li> + <on-the-fly + :type="thirdparty.type" + :id="thirdparty.id" + action="edit" + @saveFormOnTheFly="saveFormOnTheFly" + ref="onTheFly" + ></on-the-fly> + </li> + <li> + <button + :title="$t('remove_thirdparty')" + class="btn btn-sm btn-remove" + @click="removeThirdParty(thirdparty)" + /> + </li> + </ul> + </template> + </third-party-render-box> + </div> + </div> + + <ul class="record_actions"> + <li class="add-persons"> + <add-persons + ref="thirdPartyPicker" + v-bind:key="thirdPartyPicker.key" + v-bind:buttonTitle="$t('add_thirdparties')" + v-bind:modalTitle="$t('choose_thirdparties')" + v-bind:options="thirdPartyPicker.options" + @addNewPersons="addThirdParties" + > + <!-- to cast child method --> + </add-persons> + </li> + </ul> + </div> + + <div + v-if="errors.length > 0" + id="errors" + class="alert alert-danger flashbag" + > + <p>{{ $t("fix_these_errors") }}</p> + <ul> + <li v-for="e in errors" :key="e.id">{{ e }}</li> + </ul> + </div> + </div> + <ul class="record_actions sticky-form-buttons"> + <li> + <list-workflow-modal + :workflows="this.work.workflows" + :allowCreate="true" + relatedEntityClass="Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWork" + :relatedEntityId="this.work.id" + :workflowsAvailables="this.work.workflows_availables" + :preventDefaultMoveToGenerate="true" + @go-to-generate-workflow="goToGenerateWorkflow" + ></list-workflow-modal> + </li> + + <li> + <button + v-if="AmIRefferer" + class="btn btn-notify" + @click="goToGenerateNotification(false)" + ></button> + <template v-else> + <button + id="btnGroupNotifyButtons" + type="button" + class="btn btn-notify dropdown-toggle" + :title="$t('notification_send')" + data-bs-toggle="dropdown" + aria-expanded="false" + > +   + </button> + <ul class="dropdown-menu" aria-labelledby="btnGroupNotifyButtons"> + <li> + <a class="dropdown-item" @click="goToGenerateNotification(true)">{{ + $t("notification_notify_referrer") + }}</a> + </li> + <li> + <a class="dropdown-item" @click="goToGenerateNotification(false)">{{ + $t("notification_notify_any") + }}</a> + </li> + </ul> + </template> + </li> + + <li v-if="!isPosting"> + <button class="btn btn-save" @click="submit"> + {{ $t("action.save") }} + </button> + </li> + + <li v-if="isPosting"> + <button class="btn btn-save" disabled> + {{ $t("action.save") }} + </button> + </li> + </ul> </template> <script> @@ -493,346 +479,340 @@ import { makeFetch } from "ChillMainAssets/lib/api/apiMethods"; import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper"; const i18n = { - messages: { - fr: { - action: { - save: "Enregistrer", - }, - conflict_on_save: - "Désolé, cette action d'accompagnement a été modifiée dans une autre fenêtre ou par un autre utilisateur. Rechargez la page pour voir ses changements.", - action_title: "Action d'accompagnement", - comments: "Commentaire", - startDate: "Date de début", - endDate: "Date de fin", - goals_title: "Motifs - objectifs - dispositifs", - available_goals_text: - "Motifs, objectifs et dispositifs disponibles pour ajout :", - results_title: "Orientations - résultats", - results_without_objective: - "Résultats - orientations sans objectifs", - add_objectif: "Ajouter un motif - objectif - dispositif", - add_an_objective: "Ajouter un objectif", - Evaluations: "Évaluations", - Forms: "Formulaires", - Post: "Courriers", - add_an_evaluation: "Ajouter une évaluation", - persons_involved: "Usagers concernés", - handling_thirdparty: "Tiers traitant", - no_handling_thirdparty: "Aucun tiers traitant", - precise_handling_thirdparty: "Indiquer un tiers traitant", - choose_a_thirdparty: "Choisir un tiers", - remove_thirdparty: "Enlever le tiers", - remove_handling_thirdparty: "Enlever le tiers traitant", - thirdparty_intervener: "Tiers intervenants", - no_thirdparty_intervener: "Aucun tiers intervenant", - add_thirdparties: "Ajouter des tiers", - choose_thirdparties: "Choisir des tiers", - fix_these_errors: "Veuillez corriger les erreurs suivantes :", - available_evaluations_text: "Documents disponibles pour ajout :", - no_evaluations_available: "Aucune évaluation disponible", - no_goals_available: "Aucun objectif disponible", - referrers: "Agents traitants", - add_referrers: "Ajouter des agents traitants", - no_referrers: "Aucun agent traitant", - choose_referrers: "Choisir des agents traitants", - remove_referrer: "Enlever l'agent", - private_comment: "Commentaire privé", - notification_notify_referrer: "Notifier le référent", - notification_notify_any: "Notifier d'autres utilisateurs", - notification_send: "Envoyer une notification", - warning_previous_persons: - "Cet usager n'est désormais plus concerné par le parcours, bien qu'il ait été associé à l'action par le passé.", - }, + messages: { + fr: { + action: { + save: "Enregistrer", + }, + conflict_on_save: + "Désolé, cette action d'accompagnement a été modifiée dans une autre fenêtre ou par un autre utilisateur. Rechargez la page pour voir ses changements.", + action_title: "Action d'accompagnement", + comments: "Commentaire", + startDate: "Date de début", + endDate: "Date de fin", + goals_title: "Motifs - objectifs - dispositifs", + available_goals_text: + "Motifs, objectifs et dispositifs disponibles pour ajout :", + results_title: "Orientations - résultats", + results_without_objective: "Résultats - orientations sans objectifs", + add_objectif: "Ajouter un motif - objectif - dispositif", + add_an_objective: "Ajouter un objectif", + Evaluations: "Évaluations", + Forms: "Formulaires", + Post: "Courriers", + add_an_evaluation: "Ajouter une évaluation", + persons_involved: "Usagers concernés", + handling_thirdparty: "Tiers traitant", + no_handling_thirdparty: "Aucun tiers traitant", + precise_handling_thirdparty: "Indiquer un tiers traitant", + choose_a_thirdparty: "Choisir un tiers", + remove_thirdparty: "Enlever le tiers", + remove_handling_thirdparty: "Enlever le tiers traitant", + thirdparty_intervener: "Tiers intervenants", + no_thirdparty_intervener: "Aucun tiers intervenant", + add_thirdparties: "Ajouter des tiers", + choose_thirdparties: "Choisir des tiers", + fix_these_errors: "Veuillez corriger les erreurs suivantes :", + available_evaluations_text: "Documents disponibles pour ajout :", + no_evaluations_available: "Aucune évaluation disponible", + no_goals_available: "Aucun objectif disponible", + referrers: "Agents traitants", + add_referrers: "Ajouter des agents traitants", + no_referrers: "Aucun agent traitant", + choose_referrers: "Choisir des agents traitants", + remove_referrer: "Enlever l'agent", + private_comment: "Commentaire privé", + notification_notify_referrer: "Notifier le référent", + notification_notify_any: "Notifier d'autres utilisateurs", + notification_send: "Envoyer une notification", + warning_previous_persons: + "Cet usager n'est désormais plus concerné par le parcours, bien qu'il ait été associé à l'action par le passé.", }, + }, }; export default { - name: "App", - components: { - CommentEditor, - AddResult, - AddEvaluation, - AddPersons, - ThirdPartyRenderBox, - ListWorkflowModal, - OnTheFly, - PersonText, + name: "App", + components: { + CommentEditor, + AddResult, + AddEvaluation, + AddPersons, + ThirdPartyRenderBox, + ListWorkflowModal, + OnTheFly, + PersonText, + }, + i18n, + data() { + return { + docAnchorId: null, + isExpanded: false, + showAddObjective: false, + showAddEvaluation: false, + handlingThirdPartyPicker: { + key: "handling-third-party", + options: { + type: ["thirdparty"], + priority: null, + uniq: true, + button: { + display: false, + }, + }, + }, + thirdPartyPicker: { + key: "third-party", + options: { + type: ["thirdparty"], + priority: null, + uniq: false, + button: { + display: false, + }, + }, + }, + referrerPicker: { + key: "referrer", + options: { + type: ["user"], + priority: null, + uniq: false, + button: { + display: false, + }, + }, + }, + }; + }, + beforeMount() { + const urlParams = new URLSearchParams(window.location.search); + this.docAnchorId = urlParams.get("doc_id"); + }, + mounted() { + this.scrollToElement(this.docAnchorId); + }, + computed: { + ...mapState([ + "work", + "resultsForAction", + "evaluationsForAction", + "goalsPicked", + "personsReachables", + "handlingThirdParty", + "thirdParties", + "referrers", + "isPosting", + "errors", + "templatesAvailablesForAction", + "me", + "version", + ]), + ...mapGetters([ + "hasResultsForAction", + "hasHandlingThirdParty", + "hasThirdParties", + "hasReferrers", + "getPreviousPersons", + ]), + classicEditor: () => ClassicEditor, + editorConfig: () => classicEditorConfig, + startDate: { + get() { + return this.$store.state.startDate; + }, + set(v) { + this.$store.commit("setStartDate", v); + }, }, - i18n, - data() { - return { - docAnchorId: null, - isExpanded: false, - showAddObjective: false, - showAddEvaluation: false, - handlingThirdPartyPicker: { - key: "handling-third-party", - options: { - type: ["thirdparty"], - priority: null, - uniq: true, - button: { - display: false, - }, - }, - }, - thirdPartyPicker: { - key: "third-party", - options: { - type: ["thirdparty"], - priority: null, - uniq: false, - button: { - display: false, - }, - }, - }, - referrerPicker: { - key: "referrer", - options: { - type: ["user"], - priority: null, - uniq: false, - button: { - display: false, - }, - }, - }, - }; + endDate: { + get() { + return this.$store.state.endDate; + }, + set(v) { + this.$store.commit("setEndDate", v); + }, }, - beforeMount() { - const urlParams = new URLSearchParams(window.location.search); - this.docAnchorId = urlParams.get("doc_id"); + note: { + get() { + return this.$store.state.note; + }, + set(v) { + this.$store.commit("setNote", v); + }, }, - mounted() { - this.scrollToElement(this.docAnchorId); + privateComment: { + get() { + return this.$store.state.privateComment; + }, + set(v) { + this.$store.commit("setPrivateComment", v); + }, }, - computed: { - ...mapState([ - "work", - "resultsForAction", - "evaluationsForAction", - "goalsPicked", - "personsReachables", - "handlingThirdParty", - "thirdParties", - "referrers", - "isPosting", - "errors", - "templatesAvailablesForAction", - "me", - "version", - ]), - ...mapGetters([ - "hasResultsForAction", - "hasHandlingThirdParty", - "hasThirdParties", - "hasReferrers", - "getPreviousPersons", - ]), - classicEditor: () => ClassicEditor, - editorConfig: () => classicEditorConfig, - startDate: { - get() { - return this.$store.state.startDate; - }, - set(v) { - this.$store.commit("setStartDate", v); - }, - }, - endDate: { - get() { - return this.$store.state.endDate; - }, - set(v) { - this.$store.commit("setEndDate", v); - }, - }, - note: { - get() { - return this.$store.state.note; - }, - set(v) { - this.$store.commit("setNote", v); - }, - }, - privateComment: { - get() { - return this.$store.state.privateComment; - }, - set(v) { - this.$store.commit("setPrivateComment", v); - }, - }, - availableForCheckGoal() { - let pickedIds = this.$store.state.goalsPicked.map((g) => g.goal.id); + availableForCheckGoal() { + let pickedIds = this.$store.state.goalsPicked.map((g) => g.goal.id); - return this.$store.state.goalsForAction.filter( - (g) => !pickedIds.includes(g.id), - ); - }, - pickedEvaluations() { - return this.$store.state.evaluationsPicked; - }, - personsPicked: { - get() { - let s = this.$store.state.personsPicked.map((p) => p.id); - - return s; - }, - set(v) { - this.$store.commit("setPersonsPickedIds", v); - }, - }, - AmIRefferer() { - return !( - this.work.accompanyingPeriod.user && - this.me && - this.work.accompanyingPeriod.user.id !== this.me.id - ); - }, + return this.$store.state.goalsForAction.filter( + (g) => !pickedIds.includes(g.id), + ); }, - methods: { - localizeString, - toggleSelect() { - this.isExpanded = !this.isExpanded; - }, - addGoal(g) { - this.$store.commit("addGoal", g); - }, - removeGoal(g) { - this.$store.commit("removeGoal", g); - }, - addEvaluation(e) { - this.$store.dispatch("addEvaluation", e); - }, - toggleAddEvaluation() { - this.showAddEvaluation = !this.showAddEvaluation; - }, - setHandlingThirdParty({ selected, modal }) { - this.$store.commit( - "setHandlingThirdParty", - selected.shift().result, - ); - this.$refs.handlingThirdPartyPicker.resetSearch(); - modal.showModal = false; - }, - removeHandlingThirdParty() { - this.$store.commit("setHandlingThirdParty", null); - }, - addThirdParties({ selected, modal }) { - this.$store.commit( - "addThirdParties", - selected.map((r) => r.result), - ); - this.$refs.thirdPartyPicker.resetSearch(); - modal.showModal = false; - }, - removeThirdParty(t) { - this.$store.commit("removeThirdParty", t); - }, - addReferrers({ selected, modal }) { - this.$store.commit( - "addReferrers", - selected.map((r) => r.result), - ); - this.$refs.referrerPicker.resetSearch(); - modal.showModal = false; - }, - removeReferrer(u) { - this.$store.commit("removeReferrer", u); - }, - goToGenerateWorkflow({ link }) { - // console.log('save before leave to generate workflow') - const callback = () => { - window.location.assign(link); - }; + pickedEvaluations() { + return this.$store.state.evaluationsPicked; + }, + personsPicked: { + get() { + let s = this.$store.state.personsPicked.map((p) => p.id); - return this.$store.dispatch("submit", callback).catch((e) => { - console.log(e); - throw e; - }); - }, - goToGenerateNotification(tos) { - console.log("save before leave to notification"); - const callback = () => { - if (tos === true) { - window.location.assign( - `/fr/notification/create?entityClass=Chill\\PersonBundle\\Entity\\AccompanyingPeriod\\AccompanyingPeriodWork&entityId=${this.work.id}&tos[0]=${this.work.accompanyingPeriod.user.id}&returnPath=/fr/person/accompanying-period/${this.work.accompanyingPeriod.id}/work`, - ); - } else { - window.location.assign( - `/fr/notification/create?entityClass=Chill\\PersonBundle\\Entity\\AccompanyingPeriod\\AccompanyingPeriodWork&entityId=${this.work.id}&returnPath=/fr/person/accompanying-period/${this.work.accompanyingPeriod.id}/work`, - ); - } - }; + return s; + }, + set(v) { + this.$store.commit("setPersonsPickedIds", v); + }, + }, + AmIRefferer() { + return !( + this.work.accompanyingPeriod.user && + this.me && + this.work.accompanyingPeriod.user.id !== this.me.id + ); + }, + }, + methods: { + localizeString, + toggleSelect() { + this.isExpanded = !this.isExpanded; + }, + addGoal(g) { + this.$store.commit("addGoal", g); + }, + removeGoal(g) { + this.$store.commit("removeGoal", g); + }, + addEvaluation(e) { + this.$store.dispatch("addEvaluation", e); + }, + toggleAddEvaluation() { + this.showAddEvaluation = !this.showAddEvaluation; + }, + setHandlingThirdParty({ selected, modal }) { + this.$store.commit("setHandlingThirdParty", selected.shift().result); + this.$refs.handlingThirdPartyPicker.resetSearch(); + modal.showModal = false; + }, + removeHandlingThirdParty() { + this.$store.commit("setHandlingThirdParty", null); + }, + addThirdParties({ selected, modal }) { + this.$store.commit( + "addThirdParties", + selected.map((r) => r.result), + ); + this.$refs.thirdPartyPicker.resetSearch(); + modal.showModal = false; + }, + removeThirdParty(t) { + this.$store.commit("removeThirdParty", t); + }, + addReferrers({ selected, modal }) { + this.$store.commit( + "addReferrers", + selected.map((r) => r.result), + ); + this.$refs.referrerPicker.resetSearch(); + modal.showModal = false; + }, + removeReferrer(u) { + this.$store.commit("removeReferrer", u); + }, + goToGenerateWorkflow({ link }) { + // console.log('save before leave to generate workflow') + const callback = () => { + window.location.assign(link); + }; - return this.$store.dispatch("submit", callback).catch((e) => { - console.log(e); - throw e; - }); - }, - submit() { - this.$store.dispatch("submit").catch((error) => { - if ( - error.name === "ValidationException" || - error.name === "AccessException" - ) { - error.violations.forEach((violation) => - this.$toast.open({ message: violation }), - ); - } else if (error.name === "ConflictHttpException") { - this.$toast.open({ message: this.$t("conflict_on_save") }); - } else { - this.$toast.open({ message: "An error occurred" }); - throw error; - } - }); - }, - saveFormOnTheFly(payload) { - // console.log('saveFormOnTheFly: type', payload.type, ', data', payload.data); + return this.$store.dispatch("submit", callback).catch((e) => { + console.log(e); + throw e; + }); + }, + goToGenerateNotification(tos) { + console.log("save before leave to notification"); + const callback = () => { + if (tos === true) { + window.location.assign( + `/fr/notification/create?entityClass=Chill\\PersonBundle\\Entity\\AccompanyingPeriod\\AccompanyingPeriodWork&entityId=${this.work.id}&tos[0]=${this.work.accompanyingPeriod.user.id}&returnPath=/fr/person/accompanying-period/${this.work.accompanyingPeriod.id}/work`, + ); + } else { + window.location.assign( + `/fr/notification/create?entityClass=Chill\\PersonBundle\\Entity\\AccompanyingPeriod\\AccompanyingPeriodWork&entityId=${this.work.id}&returnPath=/fr/person/accompanying-period/${this.work.accompanyingPeriod.id}/work`, + ); + } + }; - let body = { type: payload.type }; - body.name = payload.data.text; - body.email = payload.data.email; - body.telephone = payload.data.telephone; - body.telephone2 = payload.data.telephone2; - body.address = { id: payload.data.address.address_id }; + return this.$store.dispatch("submit", callback).catch((e) => { + console.log(e); + throw e; + }); + }, + submit() { + this.$store.dispatch("submit").catch((error) => { + if ( + error.name === "ValidationException" || + error.name === "AccessException" + ) { + error.violations.forEach((violation) => + this.$toast.open({ message: violation }), + ); + } else if (error.name === "ConflictHttpException") { + this.$toast.open({ message: this.$t("conflict_on_save") }); + } else { + this.$toast.open({ message: "An error occurred" }); + throw error; + } + }); + }, + saveFormOnTheFly(payload) { + // console.log('saveFormOnTheFly: type', payload.type, ', data', payload.data); - makeFetch( - "PATCH", - `/api/1.0/thirdparty/thirdparty/${payload.data.id}.json`, - body, - ) - .then((response) => { - this.$store.dispatch("updateThirdParty", response); - for (let otf of this.$refs.onTheFly) { - otf.closeModal(); - } - }) - .catch((error) => { - if (error.name === "ValidationException") { - for (let v of error.violations) { - this.$toast.open({ message: v }); - } - } else if (error.name === "ConflictHttpException") { - this.$toast.open({ - message: this.$t("conflict_on_save"), - }); - } else { - this.$toast.open({ message: "An error occurred" }); - } - }); - }, - scrollToElement(docAnchorId) { - const documentEl = document.getElementById( - `document_${docAnchorId}`, - ); - if (documentEl) { - documentEl.scrollIntoView({ behavior: "smooth" }); + let body = { type: payload.type }; + body.name = payload.data.text; + body.email = payload.data.email; + body.telephone = payload.data.telephone; + body.telephone2 = payload.data.telephone2; + body.address = { id: payload.data.address.address_id }; + + makeFetch( + "PATCH", + `/api/1.0/thirdparty/thirdparty/${payload.data.id}.json`, + body, + ) + .then((response) => { + this.$store.dispatch("updateThirdParty", response); + for (let otf of this.$refs.onTheFly) { + otf.closeModal(); + } + }) + .catch((error) => { + if (error.name === "ValidationException") { + for (let v of error.violations) { + this.$toast.open({ message: v }); } - }, + } else if (error.name === "ConflictHttpException") { + this.$toast.open({ + message: this.$t("conflict_on_save"), + }); + } else { + this.$toast.open({ message: "An error occurred" }); + } + }); }, + scrollToElement(docAnchorId) { + const documentEl = document.getElementById(`document_${docAnchorId}`); + if (documentEl) { + documentEl.scrollIntoView({ behavior: "smooth" }); + } + }, + }, }; </script> @@ -841,172 +821,172 @@ export default { @import "~ChillMainAssets/chill/scss/mixins"; div#workEditor { - display: grid; - grid-template-columns: 50%; - column-gap: 0rem; - grid-template-areas: - "title title" - "startDate endDate" - "comment comment" - "privateComment privateComment" - "objectives objectives" - "evaluations evaluations" - "persons persons" - "referrers referrers" - "handling handling" - "tparties tparties" - "errors errors"; + display: grid; + grid-template-columns: 50%; + column-gap: 0rem; + grid-template-areas: + "title title" + "startDate endDate" + "comment comment" + "privateComment privateComment" + "objectives objectives" + "evaluations evaluations" + "persons persons" + "referrers referrers" + "handling handling" + "tparties tparties" + "errors errors"; - #title { - grid-area: title; - } - #startDate { - grid-area: startDate; - } - #endDate { - grid-area: endDate; - } - #comment { - grid-area: comment; - } - #privateComment { - grid-area: privateComment; - } - #objectives { - grid-area: objectives; - } - #evaluations { - grid-area: evaluations; - } - #persons { - grid-area: persons; - } - #handlingThirdParty { - grid-area: handling; - } - #thirdParties { - grid-area: tparties; - } - #referrers { - grid-area: referrers; - } - #errors { - grid-area: errors; + #title { + grid-area: title; + } + #startDate { + grid-area: startDate; + } + #endDate { + grid-area: endDate; + } + #comment { + grid-area: comment; + } + #privateComment { + grid-area: privateComment; + } + #objectives { + grid-area: objectives; + } + #evaluations { + grid-area: evaluations; + } + #persons { + grid-area: persons; + } + #handlingThirdParty { + grid-area: handling; + } + #thirdParties { + grid-area: tparties; + } + #referrers { + grid-area: referrers; + } + #errors { + grid-area: errors; + } + + div.action-row { + @include border-collapse; + padding: 1em; + + &#title { + label { + margin-bottom: 0; + } + p { + margin-top: 0; + font-weight: bold; + font-size: 1rem; + } } - div.action-row { + &#objectives { + & > div { + display: grid; + grid-template-columns: 50%; + column-gap: 0rem; + grid-template-areas: "obj res"; + + & > div { + @include border-collapse; + padding: 1em; + + &:nth-child(1) { + grid-area: obj; + } + + &:nth-child(2) { + grid-area: res; + } + } + + & > div.results_without_objective { + background: repeating-linear-gradient( + 45deg, + $gray-200, + $gray-200 10px, + $gray-100 10px, + $gray-100 20px + ); + text-align: center; + font-weight: 700; + padding-top: 1.5rem; + } + } + } + + &#evaluations { + & > div { @include border-collapse; padding: 1em; - - &#title { - label { - margin-bottom: 0; - } - p { - margin-top: 0; - font-weight: bold; - font-size: 1rem; - } - } - - &#objectives { - & > div { - display: grid; - grid-template-columns: 50%; - column-gap: 0rem; - grid-template-areas: "obj res"; - - & > div { - @include border-collapse; - padding: 1em; - - &:nth-child(1) { - grid-area: obj; - } - - &:nth-child(2) { - grid-area: res; - } - } - - & > div.results_without_objective { - background: repeating-linear-gradient( - 45deg, - $gray-200, - $gray-200 10px, - $gray-100 10px, - $gray-100 20px - ); - text-align: center; - font-weight: 700; - padding-top: 1.5rem; - } - } - } - - &#evaluations { - & > div { - @include border-collapse; - padding: 1em; - } - } - - &#objectives, - &#evaluations { - padding: 0; - - & > div.title { - background-color: $gray-200; - color: $gray-700; - - h3 { - text-align: center; - } - } - - .item-title { - font-weight: bold; - } - .item-details { - margin: 1em 2em; - font-size: 85%; - } - - & > i.fa { - padding: 0.25rem; - color: $white; - - &.fa-times { - color: $red; - } - } - } - - &#persons { - margin-top: 1.5em; - } - - ul.record_actions { - margin-bottom: 0; - } + } } - div#errors { - &.alert { - margin-top: 2em; + &#objectives, + &#evaluations { + padding: 0; + + & > div.title { + background-color: $gray-200; + color: $gray-700; + + h3 { + text-align: center; } + } + + .item-title { + font-weight: bold; + } + .item-details { + margin: 1em 2em; + font-size: 85%; + } + + & > i.fa { + padding: 0.25rem; + color: $white; + + &.fa-times { + color: $red; + } + } } + + &#persons { + margin-top: 1.5em; + } + + ul.record_actions { + margin-bottom: 0; + } + } + + div#errors { + &.alert { + margin-top: 2em; + } + } } .accordion-item:first-of-type, .accordion-item:last-of-type { - border-radius: 0rem; - border: 0px; - .accordion-button { - padding: 0.25rem; - border: 1px solid rgba(17, 17, 17, 0.125); - margin-top: 20px; - margin-bottom: 20px; - } + border-radius: 0rem; + border: 0px; + .accordion-button { + padding: 0.25rem; + border: 1px solid rgba(17, 17, 17, 0.125); + margin-top: 20px; + margin-bottom: 20px; + } } </style> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/AddEvaluation.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/AddEvaluation.vue index 77b6810bb..ee8cca532 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/AddEvaluation.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/AddEvaluation.vue @@ -1,70 +1,67 @@ <template> - <div> - <a id="evaluations"></a> - <div class="item-title" :title="evaluation.id || 'no id yet'"> - <span>{{ localizeString(evaluation.evaluation.title) }}</span> - </div> - - <div class="item-url mt-3 mb-4" v-if="evaluation.evaluation.url"> - <i class="fa fa-link fa-lg"></i> - <a :href="evaluation.evaluation.url" target="_blank">{{ - evaluation.evaluation.url - }}</a> - </div> - - <div> - <form-evaluation - ref="FormEvaluation" - :key="evaluation.key" - :evaluation="evaluation" - :docAnchorId="docAnchorId" - ></form-evaluation> - - <ul class="record_actions"> - <li v-if="evaluation.workflows_availables.length > 0"> - <list-workflow-modal - :workflows="evaluation.workflows" - :allowCreate="true" - relatedEntityClass="Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWorkEvaluation" - :relatedEntityId="evaluation.id" - :workflowsAvailables="evaluation.workflows_availables" - @go-to-generate-workflow="goToGenerateWorkflow" - ></list-workflow-modal> - </li> - <li v-if="canDelete"> - <a - class="btn btn-delete" - @click="modal.showModal = true" - :title="$t('action.delete')" - >{{ $t("delete_evaluation") }}</a - > - </li> - </ul> - </div> - - <teleport to="body"> - <modal - v-if="modal.showModal" - :modalDialogClass="modal.modalDialogClass" - @close="modal.showModal = false" - > - <template v-slot:header> - <h2 class="modal-title">{{ $t("delete.sure") }}</h2> - </template> - <template v-slot:body> - <p>{{ $t("delete.sure_description") }}</p> - </template> - <template v-slot:footer> - <button - class="btn btn-danger" - @click="removeEvaluation(evaluation)" - > - {{ $t("delete.ok") }} - </button> - </template> - </modal> - </teleport> + <div> + <a id="evaluations"></a> + <div class="item-title" :title="evaluation.id || 'no id yet'"> + <span>{{ localizeString(evaluation.evaluation.title) }}</span> </div> + + <div class="item-url mt-3 mb-4" v-if="evaluation.evaluation.url"> + <i class="fa fa-link fa-lg"></i> + <a :href="evaluation.evaluation.url" target="_blank">{{ + evaluation.evaluation.url + }}</a> + </div> + + <div> + <form-evaluation + ref="FormEvaluation" + :key="evaluation.key" + :evaluation="evaluation" + :docAnchorId="docAnchorId" + ></form-evaluation> + + <ul class="record_actions"> + <li v-if="evaluation.workflows_availables.length > 0"> + <list-workflow-modal + :workflows="evaluation.workflows" + :allowCreate="true" + relatedEntityClass="Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWorkEvaluation" + :relatedEntityId="evaluation.id" + :workflowsAvailables="evaluation.workflows_availables" + @go-to-generate-workflow="goToGenerateWorkflow" + ></list-workflow-modal> + </li> + <li v-if="canDelete"> + <a + class="btn btn-delete" + @click="modal.showModal = true" + :title="$t('action.delete')" + >{{ $t("delete_evaluation") }}</a + > + </li> + </ul> + </div> + + <teleport to="body"> + <modal + v-if="modal.showModal" + :modalDialogClass="modal.modalDialogClass" + @close="modal.showModal = false" + > + <template v-slot:header> + <h2 class="modal-title">{{ $t("delete.sure") }}</h2> + </template> + <template v-slot:body> + <p>{{ $t("delete.sure_description") }}</p> + </template> + <template v-slot:footer> + <button class="btn btn-danger" @click="removeEvaluation(evaluation)"> + {{ $t("delete.ok") }} + </button> + </template> + </modal> + </teleport> + </div> </template> <script> @@ -75,114 +72,114 @@ import { buildLinkCreate } from "ChillMainAssets/lib/entity-workflow/api"; import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper"; const i18n = { - messages: { - fr: { - no_evaluation_associated: "Aucune évaluation associée", - add_an_evaluation: "Évaluations disponibles", - evaluation_has_no_evaluation: "Aucune évaluation disponible", - startDate: "Date d'ouverture", - endDate: "Date de fin", - maxDate: "Date d'échéance", - warningInterval: "Rappel (jours)", - comment: "Note publique", - documents: "Documents", - delete: { - sure: "Êtes-vous sûr?", - sure_description: - "Cette évaluation sera supprimée de cette action d'accompagnement", - ok: "Supprimer", - }, - delete_evaluation: "Supprimer l'évaluation", - }, + messages: { + fr: { + no_evaluation_associated: "Aucune évaluation associée", + add_an_evaluation: "Évaluations disponibles", + evaluation_has_no_evaluation: "Aucune évaluation disponible", + startDate: "Date d'ouverture", + endDate: "Date de fin", + maxDate: "Date d'échéance", + warningInterval: "Rappel (jours)", + comment: "Note publique", + documents: "Documents", + delete: { + sure: "Êtes-vous sûr?", + sure_description: + "Cette évaluation sera supprimée de cette action d'accompagnement", + ok: "Supprimer", + }, + delete_evaluation: "Supprimer l'évaluation", }, + }, }; export default { - name: "AddEvaluation", - components: { - FormEvaluation, - Modal, - ListWorkflowModal, + name: "AddEvaluation", + components: { + FormEvaluation, + Modal, + ListWorkflowModal, + }, + props: ["evaluation", "docAnchorId"], + i18n, + data() { + return { + modal: { + showModal: false, + modalDialogClass: "modal-dialog-centered modal-md", + }, + }; + }, + computed: { + pickedEvaluations() { + return this.$store.state.evaluationsPicked; }, - props: ["evaluation", "docAnchorId"], - i18n, - data() { - return { - modal: { - showModal: false, - modalDialogClass: "modal-dialog-centered modal-md", - }, - }; - }, - computed: { - pickedEvaluations() { - return this.$store.state.evaluationsPicked; - }, - canDelete() { - if (this.evaluation.workflows.length > 0) { - return false; - } + canDelete() { + if (this.evaluation.workflows.length > 0) { + return false; + } - for (let doc of this.evaluation.documents) { - if (doc.workflows.length > 0) { - return false; - } - } + for (let doc of this.evaluation.documents) { + if (doc.workflows.length > 0) { + return false; + } + } - return true; - }, + return true; }, - methods: { - localizeString, - removeEvaluation(e) { - this.$store.commit("removeEvaluation", e); - return; - }, - toggleEditEvaluation() { - this.$store.commit("toggleEvaluationEdit", { - key: this.evaluation.key, - }); - }, - submitForm() { - this.toggleEditEvaluation(); - }, - goToGenerateWorkflow({ workflowName }) { - const callback = (data) => { - let evaluationId = data.accompanyingPeriodWorkEvaluations.find( - (e) => e.key === this.evaluation.key, - ).id; - window.location.assign( - buildLinkCreate( - workflowName, - "Chill\\PersonBundle\\Entity\\AccompanyingPeriod\\AccompanyingPeriodWorkEvaluation", - evaluationId, - ), - ); - }; + }, + methods: { + localizeString, + removeEvaluation(e) { + this.$store.commit("removeEvaluation", e); + return; + }, + toggleEditEvaluation() { + this.$store.commit("toggleEvaluationEdit", { + key: this.evaluation.key, + }); + }, + submitForm() { + this.toggleEditEvaluation(); + }, + goToGenerateWorkflow({ workflowName }) { + const callback = (data) => { + let evaluationId = data.accompanyingPeriodWorkEvaluations.find( + (e) => e.key === this.evaluation.key, + ).id; + window.location.assign( + buildLinkCreate( + workflowName, + "Chill\\PersonBundle\\Entity\\AccompanyingPeriod\\AccompanyingPeriodWorkEvaluation", + evaluationId, + ), + ); + }; - return this.$store.dispatch("submit", callback).catch((e) => { - console.log(e); - throw e; - }); - }, + return this.$store.dispatch("submit", callback).catch((e) => { + console.log(e); + throw e; + }); }, + }, }; </script> <style lang="scss" scoped> div.item-title { - .evaluation-title { - cursor: default; - &::before { - content: ""; - } + .evaluation-title { + cursor: default; + &::before { + content: ""; } + } } div.item-url { - i { - color: unset !important; - margin-left: 1rem; - margin-right: 0.5rem; - } + i { + color: unset !important; + margin-left: 1rem; + margin-right: 0.5rem; + } } </style> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/AddResult.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/AddResult.vue index aa8948a4e..f25de72c8 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/AddResult.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/AddResult.vue @@ -1,179 +1,167 @@ <template> - <div v-if="hasResult" class="addResult"> - <p v-if="pickedResults.length === 0" class="chill-no-data-statement"> - Aucun résultat associé - </p> + <div v-if="hasResult" class="addResult"> + <p v-if="pickedResults.length === 0" class="chill-no-data-statement"> + Aucun résultat associé + </p> - <ul class="list-suggest remove-items"> - <li v-for="r in pickedResults" @click="removeResult(r)" :key="r.id"> - <span> - {{ localizeString(r.title) }} - </span> - </li> - </ul> + <ul class="list-suggest remove-items"> + <li v-for="r in pickedResults" @click="removeResult(r)" :key="r.id"> + <span> + {{ localizeString(r.title) }} + </span> + </li> + </ul> - <div class="accordion" id="expandedSuggestions"> - <div class="accordion-item"> - <h2 class="accordion-header" id="heading_expanded_suggestions"> - <button - v-if="isExpanded" - class="accordion-button" - type="button" - data-bs-toggle="collapse" - aria-expanded="true" - @click="toggleSelect" - > - Masquer - </button> + <div class="accordion" id="expandedSuggestions"> + <div class="accordion-item"> + <h2 class="accordion-header" id="heading_expanded_suggestions"> + <button + v-if="isExpanded" + class="accordion-button" + type="button" + data-bs-toggle="collapse" + aria-expanded="true" + @click="toggleSelect" + > + Masquer + </button> - <button - v-else - class="accordion-button collapsed" - type="button" - data-bs-toggle="collapse" - aria-expanded="false" - @click="toggleSelect" - > - Résultats et orientations disponibles - </button> - </h2> - <div - class="accordion-collapse" - id="collapse_expanded_suggestions" - aria-labelledby="heading_expanded_suggestions" - data-bs-parent="#expandedSuggestions" - > - <template v-if="isExpanded"> - <ul class="list-suggest add-items"> - <li - v-for="r in availableForCheckResults" - @click="addResult(r)" - :key="r.id" - > - <span>{{ localizeString(r.title) }}</span> - </li> - </ul> - </template> - </div> - </div> + <button + v-else + class="accordion-button collapsed" + type="button" + data-bs-toggle="collapse" + aria-expanded="false" + @click="toggleSelect" + > + Résultats et orientations disponibles + </button> + </h2> + <div + class="accordion-collapse" + id="collapse_expanded_suggestions" + aria-labelledby="heading_expanded_suggestions" + data-bs-parent="#expandedSuggestions" + > + <template v-if="isExpanded"> + <ul class="list-suggest add-items"> + <li + v-for="r in availableForCheckResults" + @click="addResult(r)" + :key="r.id" + > + <span>{{ localizeString(r.title) }}</span> + </li> + </ul> + </template> </div> + </div> </div> - <div v-if="!hasResult" class="noResult"> - <div class="chill-no-data-statement"> - {{ $t("goal_has_no_result") }} - </div> + </div> + <div v-if="!hasResult" class="noResult"> + <div class="chill-no-data-statement"> + {{ $t("goal_has_no_result") }} </div> + </div> </template> <script> import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper"; const i18n = { - messages: { - fr: { - add_a_result: "Résultat - orientation disponibles", - goal_has_no_result: "Aucun résultat - orientation disponible", - }, + messages: { + fr: { + add_a_result: "Résultat - orientation disponibles", + goal_has_no_result: "Aucun résultat - orientation disponible", }, + }, }; export default { - name: "AddResult", - props: ["destination", "goal", "availableResults"], - i18n, - data() { - return { - isExpanded: false, - }; + name: "AddResult", + props: ["destination", "goal", "availableResults"], + i18n, + data() { + return { + isExpanded: false, + }; + }, + computed: { + hasResult() { + if (this.destination === "action") { + return this.$store.state.resultsForAction.length > 0; + } else if (this.destination === "goal") { + return this.$store.getters.resultsForGoal(this.goal).length > 0; + } + + throw Error(`this.destination is not implemented: ${this.destination}`); }, - computed: { - hasResult() { - if (this.destination === "action") { - return this.$store.state.resultsForAction.length > 0; - } else if (this.destination === "goal") { - return this.$store.getters.resultsForGoal(this.goal).length > 0; - } + pickedResults() { + if (this.destination === "action") { + return this.$store.state.resultsPicked; + } else if (this.destination === "goal") { + return this.$store.getters.resultsPickedForGoal(this.goal); + } - throw Error( - `this.destination is not implemented: ${this.destination}`, - ); - }, - pickedResults() { - if (this.destination === "action") { - return this.$store.state.resultsPicked; - } else if (this.destination === "goal") { - return this.$store.getters.resultsPickedForGoal(this.goal); - } - - throw Error( - `this.destination is not implemented: ${this.destination}`, - ); - }, - availableForCheckResults() { - if (this.destination === "action") { - let pickedIds = this.$store.state.resultsPicked.map( - (r) => r.id, - ); - - return this.$store.state.resultsForAction.filter( - (r) => !pickedIds.includes(r.id), - ); - } else if (this.destination === "goal") { - let pickedIds = this.$store.getters - .resultsPickedForGoal(this.goal) - .map((r) => r.id); - - return this.$store.getters - .resultsForGoal(this.goal) - .filter((r) => !pickedIds.includes(r.id)); - } - - throw Error( - `this.destination is not implemented: ${this.destination}`, - ); - }, + throw Error(`this.destination is not implemented: ${this.destination}`); }, - methods: { - localizeString, - toggleSelect() { - this.isExpanded = !this.isExpanded; - }, - addResult(r) { - if (this.destination === "action") { - this.$store.commit("addResultPicked", r); - return; - } else if (this.destination === "goal") { - this.$store.commit("addResultForGoalPicked", { - goal: this.goal, - result: r, - }); - return; - } - throw Error( - `this.destination is not implemented: ${this.destination}`, - ); - }, - removeResult(r) { - if (this.destination === "action") { - this.$store.commit("removeResultPicked", r); - return; - } else if (this.destination === "goal") { - this.$store.commit("removeResultForGoalPicked", { - goal: this.goal, - result: r, - }); - return; - } - throw Error( - `this.destination is not implemented: ${this.destination}`, - ); - }, + availableForCheckResults() { + if (this.destination === "action") { + let pickedIds = this.$store.state.resultsPicked.map((r) => r.id); + + return this.$store.state.resultsForAction.filter( + (r) => !pickedIds.includes(r.id), + ); + } else if (this.destination === "goal") { + let pickedIds = this.$store.getters + .resultsPickedForGoal(this.goal) + .map((r) => r.id); + + return this.$store.getters + .resultsForGoal(this.goal) + .filter((r) => !pickedIds.includes(r.id)); + } + + throw Error(`this.destination is not implemented: ${this.destination}`); }, + }, + methods: { + localizeString, + toggleSelect() { + this.isExpanded = !this.isExpanded; + }, + addResult(r) { + if (this.destination === "action") { + this.$store.commit("addResultPicked", r); + return; + } else if (this.destination === "goal") { + this.$store.commit("addResultForGoalPicked", { + goal: this.goal, + result: r, + }); + return; + } + throw Error(`this.destination is not implemented: ${this.destination}`); + }, + removeResult(r) { + if (this.destination === "action") { + this.$store.commit("removeResultPicked", r); + return; + } else if (this.destination === "goal") { + this.$store.commit("removeResultForGoalPicked", { + goal: this.goal, + result: r, + }); + return; + } + throw Error(`this.destination is not implemented: ${this.destination}`); + }, + }, }; </script> <style lang="scss" scoped> .accordion-button { - padding: 0.25rem; + padding: 0.25rem; } </style> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/CommentInput.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/CommentInput.vue index 84a27a5ed..62b46d2da 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/CommentInput.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/CommentInput.vue @@ -1,19 +1,19 @@ <template> - <div class="row mb-3"> - <label class="col-sm-4 col-form-label visually-hidden">{{ - trans(EVALUATION_PUBLIC_COMMENT) - }}</label> - <div class="col-sm-12"> - <ckeditor - :editor="ClassicEditor" - :config="classicEditorConfig" - :placeholder="trans(EVALUATION_COMMENT_PLACEHOLDER)" - :value="comment" - @input="$emit('update:comment', $event)" - tag-name="textarea" - ></ckeditor> - </div> + <div class="row mb-3"> + <label class="col-sm-4 col-form-label visually-hidden">{{ + trans(EVALUATION_PUBLIC_COMMENT) + }}</label> + <div class="col-sm-12"> + <ckeditor + :editor="ClassicEditor" + :config="classicEditorConfig" + :placeholder="trans(EVALUATION_COMMENT_PLACEHOLDER)" + :value="comment" + @input="$emit('update:comment', $event)" + tag-name="textarea" + ></ckeditor> </div> + </div> </template> <script setup> @@ -21,9 +21,9 @@ import { Ckeditor } from "@ckeditor/ckeditor5-vue"; import { ClassicEditor } from "ckeditor5"; import classicEditorConfig from "ChillMainAssets/module/ckeditor5/editor_config"; import { - EVALUATION_PUBLIC_COMMENT, - EVALUATION_COMMENT_PLACEHOLDER, - trans, + EVALUATION_PUBLIC_COMMENT, + EVALUATION_COMMENT_PLACEHOLDER, + trans, } from "translator"; defineProps(["comment"]); diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/DateInputs.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/DateInputs.vue index ac16b467f..86c9a69e7 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/DateInputs.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/DateInputs.vue @@ -1,71 +1,71 @@ <template> - <div class="row mb-3"> - <label class="col-4 col-sm-2 col-md-4 col-lg-2 col-form-label"> - {{ trans(EVALUATION_STARTDATE) }} - </label> - <div class="col-8 col-sm-4 col-md-8 col-lg-4"> - <input - class="form-control form-control-sm" - type="date" - :value="startDate" - @input="$emit('update:startDate', $event.target.value)" - /> - </div> - - <label class="col-4 col-sm-2 col-md-4 col-lg-2 col-form-label"> - {{ trans(EVALUATION_ENDDATE) }} - </label> - <div class="col-8 col-sm-4 col-md-8 col-lg-4"> - <input - class="form-control form-control-sm" - type="date" - :value="endDate" - @input="$emit('update:endDate', $event.target.value)" - /> - </div> + <div class="row mb-3"> + <label class="col-4 col-sm-2 col-md-4 col-lg-2 col-form-label"> + {{ trans(EVALUATION_STARTDATE) }} + </label> + <div class="col-8 col-sm-4 col-md-8 col-lg-4"> + <input + class="form-control form-control-sm" + type="date" + :value="startDate" + @input="$emit('update:startDate', $event.target.value)" + /> </div> - <div class="row mb-3"> - <label class="col-4 col-sm-2 col-md-4 col-lg-2 col-form-label"> - {{ trans(EVALUATION_MAXDATE) }} - </label> - <div class="col-8 col-sm-4 col-md-8 col-lg-4"> - <input - class="form-control form-control-sm" - type="date" - :value="maxDate" - @input="$emit('update:maxDate', $event.target.value)" - /> - </div> - - <label class="col-4 col-sm-2 col-md-4 col-lg-2 col-form-label"> - {{ trans(EVALUATION_WARNING_INTERVAL) }} - </label> - <div class="col-8 col-sm-4 col-md-8 col-lg-4"> - <input - class="form-control form-control-sm" - type="number" - :value="warningInterval" - @input="$emit('update:warningInterval', $event.target.value)" - /> - </div> + <label class="col-4 col-sm-2 col-md-4 col-lg-2 col-form-label"> + {{ trans(EVALUATION_ENDDATE) }} + </label> + <div class="col-8 col-sm-4 col-md-8 col-lg-4"> + <input + class="form-control form-control-sm" + type="date" + :value="endDate" + @input="$emit('update:endDate', $event.target.value)" + /> </div> + </div> + + <div class="row mb-3"> + <label class="col-4 col-sm-2 col-md-4 col-lg-2 col-form-label"> + {{ trans(EVALUATION_MAXDATE) }} + </label> + <div class="col-8 col-sm-4 col-md-8 col-lg-4"> + <input + class="form-control form-control-sm" + type="date" + :value="maxDate" + @input="$emit('update:maxDate', $event.target.value)" + /> + </div> + + <label class="col-4 col-sm-2 col-md-4 col-lg-2 col-form-label"> + {{ trans(EVALUATION_WARNING_INTERVAL) }} + </label> + <div class="col-8 col-sm-4 col-md-8 col-lg-4"> + <input + class="form-control form-control-sm" + type="number" + :value="warningInterval" + @input="$emit('update:warningInterval', $event.target.value)" + /> + </div> + </div> </template> <script setup> import { - EVALUATION_STARTDATE, - EVALUATION_ENDDATE, - EVALUATION_MAXDATE, - EVALUATION_WARNING_INTERVAL, - trans, + EVALUATION_STARTDATE, + EVALUATION_ENDDATE, + EVALUATION_MAXDATE, + EVALUATION_WARNING_INTERVAL, + trans, } from "translator"; defineProps(["startDate", "endDate", "maxDate", "warningInterval"]); defineEmits([ - "update:startDate", - "update:endDate", - "update:maxDate", - "update:warningInterval", + "update:startDate", + "update:endDate", + "update:maxDate", + "update:warningInterval", ]); </script> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/DocumentActions.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/DocumentActions.vue index 126fbffe6..2f7701210 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/DocumentActions.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/DocumentActions.vue @@ -1,43 +1,43 @@ <template> - <div class="row mb-3"> - <h6>{{ trans(EVALUATION_DOCUMENT_ADD) }} :</h6> - <pick-template - entityClass="Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWorkEvaluation" - :id="evaluation.id" - :templates="templates" - :preventDefaultMoveToGenerate="true" - @go-to-generate-document="submitBeforeGenerate" - > - <template v-slot:title> - <label class="col-form-label">{{ - trans(EVALUATION_GENERATE_A_DOCUMENT) - }}</label> - </template> - </pick-template> - <div> - <label class="col-form-label">{{ - trans(EVALUATION_DOCUMENT_UPLOAD) - }}</label> - <ul class="record_actions document-upload"> - <li> - <drop-file-modal - :allow-remove="false" - @add-document="emit('addDocument', $event)" - ></drop-file-modal> - </li> - </ul> - </div> + <div class="row mb-3"> + <h6>{{ trans(EVALUATION_DOCUMENT_ADD) }} :</h6> + <pick-template + entityClass="Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWorkEvaluation" + :id="evaluation.id" + :templates="templates" + :preventDefaultMoveToGenerate="true" + @go-to-generate-document="submitBeforeGenerate" + > + <template v-slot:title> + <label class="col-form-label">{{ + trans(EVALUATION_GENERATE_A_DOCUMENT) + }}</label> + </template> + </pick-template> + <div> + <label class="col-form-label">{{ + trans(EVALUATION_DOCUMENT_UPLOAD) + }}</label> + <ul class="record_actions document-upload"> + <li> + <drop-file-modal + :allow-remove="false" + @add-document="emit('addDocument', $event)" + ></drop-file-modal> + </li> + </ul> </div> + </div> </template> <script setup> import PickTemplate from "ChillDocGeneratorAssets/vuejs/_components/PickTemplate.vue"; import DropFileModal from "ChillDocStoreAssets/vuejs/DropFileWidget/DropFileModal.vue"; import { - EVALUATION_DOCUMENT_ADD, - EVALUATION_DOCUMENT_UPLOAD, - EVALUATION_GENERATE_A_DOCUMENT, - trans, + EVALUATION_DOCUMENT_ADD, + EVALUATION_DOCUMENT_UPLOAD, + EVALUATION_GENERATE_A_DOCUMENT, + trans, } from "translator"; import { buildLink } from "ChillDocGeneratorAssets/lib/document-generator"; import { useStore } from "vuex"; @@ -48,29 +48,29 @@ const props = defineProps(["evaluation", "templates"]); const emit = defineEmits(["addDocument"]); async function submitBeforeGenerate({ template }) { - const callback = (data) => { - let evaluationId = data.accompanyingPeriodWorkEvaluations.find( - (e) => e.key === props.evaluation.key, - ).id; + const callback = (data) => { + let evaluationId = data.accompanyingPeriodWorkEvaluations.find( + (e) => e.key === props.evaluation.key, + ).id; - window.location.assign( - buildLink( - template, - evaluationId, - "Chill\\PersonBundle\\Entity\\AccompanyingPeriod\\AccompanyingPeriodWorkEvaluation", - ), - ); - }; + window.location.assign( + buildLink( + template, + evaluationId, + "Chill\\PersonBundle\\Entity\\AccompanyingPeriod\\AccompanyingPeriodWorkEvaluation", + ), + ); + }; - return store.dispatch("submit", callback).catch((e) => { - console.log(e); - throw e; - }); + return store.dispatch("submit", callback).catch((e) => { + console.log(e); + throw e; + }); } </script> <style scoped> ul.document-upload { - justify-content: flex-start; + justify-content: flex-start; } </style> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/DocumentsList.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/DocumentsList.vue index 77fa443d4..1a6f428ba 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/DocumentsList.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/DocumentsList.vue @@ -1,288 +1,205 @@ <template> - <div class="row mb-3"> - <h5>{{ trans(EVALUATION_DOCUMENTS) }} :</h5> - <div class="flex-table"> - <div - class="item-bloc" - v-for="(d, i) in documents" - :key="d.id" - :class="[ - parseInt(docAnchorId) === d.id ? 'bg-blink' : 'nothing', - ]" - > - <div :id="'document_' + d.id" class="item-row"> - <div class="input-group input-group-lg mb-3 row"> - <label class="col-sm-3 col-form-label" - >Titre du document:</label - > - <div class="col-sm-9"> - <input - class="form-control document-title" - type="text" - :value="d.title" - :id="d.id" - :data-key="i" - @input="$emit('inputDocumentTitle', $event)" - /> - </div> - </div> - </div> - <div class="item-row"> - <div class="item-col item-meta"> - <p v-if="d.createdBy" class="createdBy"> - Créé par {{ d.createdBy.text }}<br /> - Le - {{ - $d(ISOToDatetime(d.createdAt.datetime), "long") - }} - </p> - </div> - </div> - <div class="item-row"> - <div class="item-col"> - <ul class="record_actions"> - <li - v-if=" - d.workflows_availables.length > 0 || - d.workflows.length > 0 - " - > - <list-workflow-modal - :workflows="d.workflows" - :allowCreate="true" - relatedEntityClass="Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWorkEvaluationDocument" - :relatedEntityId="d.id" - :workflowsAvailables=" - d.workflows_availables - " - :preventDefaultMoveToGenerate="true" - :goToGenerateWorkflowPayload="{ doc: d }" - @go-to-generate-workflow=" - goToGenerateWorkflowEvaluationDocument - " - ></list-workflow-modal> - </li> - <li> - <button - v-if="AmIRefferer" - class="btn btn-notify" - @click=" - $emit( - 'goToGenerateNotification', - d, - false, - ) - " - ></button> - <template v-else> - <button - id="btnGroupNotifyButtons" - type="button" - class="btn btn-notify dropdown-toggle" - :title=" - trans(EVALUATION_NOTIFICATION_SEND) - " - data-bs-toggle="dropdown" - aria-expanded="false" - > -   - </button> - <ul - class="dropdown-menu" - aria-labelledby="btnGroupNotifyButtons" - > - <li> - <a - class="dropdown-item" - @click=" - goToGenerateDocumentNotification( - d, - false, - ) - " - > - {{ - trans( - EVALUATION_NOTIFICATION_NOTIFY_REFERRER, - ) - }} - </a> - </li> - <li> - <a - class="dropdown-item" - @click=" - goToGenerateDocumentNotification( - d, - false, - ) - " - > - {{ - trans( - EVALUATION_NOTIFICATION_NOTIFY_ANY, - ) - }} - </a> - </li> - </ul> - </template> - </li> - <li> - <document-action-buttons-group - :stored-object="d.storedObject" - :filename="d.title" - :can-edit="true" - :execute-before-leave=" - submitBeforeLeaveToEditor - " - :davLink=" - d.storedObject._links?.dav_link.href - " - :davLinkExpiration=" - d.storedObject._links?.dav_link - .expiration - " - @on-stored-object-status-change=" - $emit('statusDocumentChanged', $event) - " - ></document-action-buttons-group> - </li> - <!--replace document--> - <li - v-if=" - Number.isInteger(d.id) && - d.storedObject._permissions.canEdit - " - > - <drop-file-modal - :existing-doc="d.storedObject" - :allow-remove="false" - @add-document=" - (arg) => - replaceDocument( - d, - arg.stored_object, - arg.stored_object_version, - ) - " - ></drop-file-modal> - </li> - <li v-if="Number.isInteger(d.id)"> - <div class="duplicate-dropdown"> - <button - class="btn btn-outline-primary dropdown-toggle" - type="button" - data-bs-toggle="dropdown" - aria-expanded="false" - > - <i class="bi bi-lightning-fill"></i> - </button> - <ul class="dropdown-menu"> - <!--delete--> - <li v-if="d.workflows.length === 0"> - <a - class="dropdown-item" - @click=" - $emit('removeDocument', d) - " - > - <i - class="fa fa-trash-o" - aria-hidden="true" - ></i> - {{ - trans( - EVALUATION_DOCUMENT_DELETE, - ) - }} - </a> - </li> - <!--duplicate document--> - <li> - <a - class="dropdown-item" - @click=" - $emit( - 'duplicateDocument', - d, - ) - " - > - <i - class="fa fa-copy" - aria-hidden="true" - ></i> - {{ - trans( - EVALUATION_DOCUMENT_DUPLICATE_HERE, - ) - }} - </a> - </li> - <li> - <a - class="dropdown-item" - @click=" - prepareDocumentDuplicationToWork( - d, - ) - " - > - <i - class="fa fa-copy" - aria-hidden="true" - ></i> - {{ - trans( - EVALUATION_DOCUMENT_DUPLICATE_TO_OTHER_EVALUATION, - ) - }}</a - > - </li> - <!--move document--> - <li - v-if=" - d.storedObject._permissions - .canEdit - " - > - <a - class="dropdown-item" - @click=" - prepareDocumentMoveToWork(d) - " - > - <i - class="fa fa-arrows" - aria-hidden="true" - ></i> - {{ - trans( - EVALUATION_DOCUMENT_MOVE, - ) - }}</a - > - </li> - </ul> - </div> - </li> - </ul> - </div> - </div> + <div class="row mb-3"> + <h5>{{ trans(EVALUATION_DOCUMENTS) }} :</h5> + <div class="flex-table"> + <div + class="item-bloc" + v-for="(d, i) in documents" + :key="d.id" + :class="[parseInt(docAnchorId) === d.id ? 'bg-blink' : 'nothing']" + > + <div :id="'document_' + d.id" class="item-row"> + <div class="input-group input-group-lg mb-3 row"> + <label class="col-sm-3 col-form-label">Titre du document:</label> + <div class="col-sm-9"> + <input + class="form-control document-title" + type="text" + :value="d.title" + :id="d.id" + :data-key="i" + @input="$emit('inputDocumentTitle', $event)" + /> </div> + </div> </div> + <div class="item-row"> + <div class="item-col item-meta"> + <p v-if="d.createdBy" class="createdBy"> + Créé par {{ d.createdBy.text }}<br /> + Le + {{ $d(ISOToDatetime(d.createdAt.datetime), "long") }} + </p> + </div> + </div> + <div class="item-row"> + <div class="item-col"> + <ul class="record_actions"> + <li + v-if=" + d.workflows_availables.length > 0 || d.workflows.length > 0 + " + > + <list-workflow-modal + :workflows="d.workflows" + :allowCreate="true" + relatedEntityClass="Chill\PersonBundle\Entity\AccompanyingPeriod\AccompanyingPeriodWorkEvaluationDocument" + :relatedEntityId="d.id" + :workflowsAvailables="d.workflows_availables" + :preventDefaultMoveToGenerate="true" + :goToGenerateWorkflowPayload="{ doc: d }" + @go-to-generate-workflow=" + goToGenerateWorkflowEvaluationDocument + " + ></list-workflow-modal> + </li> + <li> + <button + v-if="AmIRefferer" + class="btn btn-notify" + @click="$emit('goToGenerateNotification', d, false)" + ></button> + <template v-else> + <button + id="btnGroupNotifyButtons" + type="button" + class="btn btn-notify dropdown-toggle" + :title="trans(EVALUATION_NOTIFICATION_SEND)" + data-bs-toggle="dropdown" + aria-expanded="false" + > +   + </button> + <ul + class="dropdown-menu" + aria-labelledby="btnGroupNotifyButtons" + > + <li> + <a + class="dropdown-item" + @click="goToGenerateDocumentNotification(d, true)" + > + {{ trans(EVALUATION_NOTIFICATION_NOTIFY_REFERRER) }} + </a> + </li> + <li> + <a + class="dropdown-item" + @click="goToGenerateDocumentNotification(d, false)" + > + {{ trans(EVALUATION_NOTIFICATION_NOTIFY_ANY) }} + </a> + </li> + </ul> + </template> + </li> + <li> + <document-action-buttons-group + :stored-object="d.storedObject" + :filename="d.title" + :can-edit="true" + :execute-before-leave="submitBeforeLeaveToEditor" + :davLink="d.storedObject._links?.dav_link.href" + :davLinkExpiration=" + d.storedObject._links?.dav_link.expiration + " + @on-stored-object-status-change=" + $emit('statusDocumentChanged', $event) + " + ></document-action-buttons-group> + </li> + <!--replace document--> + <li + v-if=" + Number.isInteger(d.id) && d.storedObject._permissions.canEdit + " + > + <drop-file-modal + :existing-doc="d.storedObject" + :allow-remove="false" + @add-document=" + (arg) => + replaceDocument( + d, + arg.stored_object, + arg.stored_object_version, + ) + " + ></drop-file-modal> + </li> + <li v-if="Number.isInteger(d.id)"> + <div class="duplicate-dropdown"> + <button + class="btn btn-outline-primary dropdown-toggle" + type="button" + data-bs-toggle="dropdown" + aria-expanded="false" + > + <i class="bi bi-lightning-fill"></i> + </button> + <ul class="dropdown-menu"> + <!--delete--> + <li v-if="d.workflows.length === 0"> + <a + class="dropdown-item" + @click="$emit('removeDocument', d)" + > + <i class="fa fa-trash-o" aria-hidden="true"></i> + {{ trans(EVALUATION_DOCUMENT_DELETE) }} + </a> + </li> + <!--duplicate document--> + <li> + <a + class="dropdown-item" + @click="$emit('duplicateDocument', d)" + > + <i class="fa fa-copy" aria-hidden="true"></i> + {{ trans(EVALUATION_DOCUMENT_DUPLICATE_HERE) }} + </a> + </li> + <li> + <a + class="dropdown-item" + @click="prepareDocumentDuplicationToWork(d)" + > + <i class="fa fa-copy" aria-hidden="true"></i> + {{ + trans( + EVALUATION_DOCUMENT_DUPLICATE_TO_OTHER_EVALUATION, + ) + }}</a + > + </li> + <!--move document--> + <li v-if="d.storedObject._permissions.canEdit"> + <a + class="dropdown-item" + @click="prepareDocumentMoveToWork(d)" + > + <i class="fa fa-arrows" aria-hidden="true"></i> + {{ trans(EVALUATION_DOCUMENT_MOVE) }}</a + > + </li> + </ul> + </div> + </li> + </ul> + </div> + </div> + </div> </div> + </div> - <AccompanyingPeriodWorkSelectorModal - v-if="showAccompanyingPeriodSelector" - v-model:selectedAcpw="selectedAcpw" - :accompanying-period-id="accompanyingPeriodId" - :is-evaluation-selector="true" - :ignore-accompanying-period-work-ids="[]" - @close-modal="showAccompanyingPeriodSelector = false" - @update:selectedEvaluation="selectedEvaluation = $event" - /> + <AccompanyingPeriodWorkSelectorModal + v-if="showAccompanyingPeriodSelector" + v-model:selectedAcpw="selectedAcpw" + :accompanying-period-id="accompanyingPeriodId" + :is-evaluation-selector="true" + :ignore-accompanying-period-work-ids="[]" + @close-modal="showAccompanyingPeriodSelector = false" + @update:selectedEvaluation="selectedEvaluation = $event" + /> </template> <script setup> @@ -291,15 +208,15 @@ import ListWorkflowModal from "ChillMainAssets/vuejs/_components/EntityWorkflow/ import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue"; import DropFileModal from "ChillDocStoreAssets/vuejs/DropFileWidget/DropFileModal.vue"; import { - EVALUATION_NOTIFICATION_NOTIFY_REFERRER, - EVALUATION_NOTIFICATION_NOTIFY_ANY, - EVALUATION_NOTIFICATION_SEND, - EVALUATION_DOCUMENTS, - EVALUATION_DOCUMENT_MOVE, - EVALUATION_DOCUMENT_DELETE, - EVALUATION_DOCUMENT_DUPLICATE_HERE, - EVALUATION_DOCUMENT_DUPLICATE_TO_OTHER_EVALUATION, - trans, + EVALUATION_NOTIFICATION_NOTIFY_REFERRER, + EVALUATION_NOTIFICATION_NOTIFY_ANY, + EVALUATION_NOTIFICATION_SEND, + EVALUATION_DOCUMENTS, + EVALUATION_DOCUMENT_MOVE, + EVALUATION_DOCUMENT_DELETE, + EVALUATION_DOCUMENT_DUPLICATE_HERE, + EVALUATION_DOCUMENT_DUPLICATE_TO_OTHER_EVALUATION, + trans, } from "translator"; import { computed, ref, watch } from "vue"; import AccompanyingPeriodWorkSelectorModal from "ChillPersonAssets/vuejs/_components/AccompanyingPeriodWorkSelector/AccompanyingPeriodWorkSelectorModal.vue"; @@ -308,17 +225,17 @@ import { buildLinkCreate as buildLinkCreateNotification } from "ChillMainAssets/ import { useStore } from "vuex"; const props = defineProps([ - "documents", - "docAnchorId", - "accompanyingPeriodId", - "evaluation", + "documents", + "docAnchorId", + "accompanyingPeriodId", + "evaluation", ]); const emit = defineEmits([ - "inputDocumentTitle", - "removeDocument", - "duplicateDocument", - "statusDocumentChanged", - "duplicateDocumentToWork", + "inputDocumentTitle", + "removeDocument", + "duplicateDocument", + "statusDocumentChanged", + "duplicateDocumentToWork", ]); const store = useStore(); @@ -329,67 +246,67 @@ const selectedDocumentToDuplicate = ref(null); const selectedDocumentToMove = ref(null); const AmIRefferer = computed(() => { - return !( - store.state.work.accompanyingPeriod.user && - store.state.me && - store.state.work.accompanyingPeriod.user.id !== store.state.me.id - ); + return !( + store.state.work.accompanyingPeriod.user && + store.state.me && + store.state.work.accompanyingPeriod.user.id !== store.state.me.id + ); }); const prepareDocumentDuplicationToWork = (d) => { - selectedDocumentToDuplicate.value = d; - /** ensure selectedDocumentToMove is null */ - selectedDocumentToMove.value = null; + selectedDocumentToDuplicate.value = d; + /** ensure selectedDocumentToMove is null */ + selectedDocumentToMove.value = null; - showAccompanyingPeriodSelector.value = true; + showAccompanyingPeriodSelector.value = true; }; const prepareDocumentMoveToWork = (d) => { - selectedDocumentToMove.value = d; - /** ensure selectedDocumentToDuplicate is null */ - selectedDocumentToDuplicate.value = null; + selectedDocumentToMove.value = d; + /** ensure selectedDocumentToDuplicate is null */ + selectedDocumentToDuplicate.value = null; - showAccompanyingPeriodSelector.value = true; + showAccompanyingPeriodSelector.value = true; }; watch(selectedEvaluation, (val) => { - if (selectedDocumentToDuplicate.value) { - emit("duplicateDocumentToEvaluation", { - evaluation: val, - document: selectedDocumentToDuplicate.value, - }); - } else { - emit("moveDocumentToEvaluation", { - evaluationDest: val, - document: selectedDocumentToMove.value, - }); - } + if (selectedDocumentToDuplicate.value) { + emit("duplicateDocumentToEvaluation", { + evaluation: val, + document: selectedDocumentToDuplicate.value, + }); + } else { + emit("moveDocumentToEvaluation", { + evaluationDest: val, + document: selectedDocumentToMove.value, + }); + } }); async function goToGenerateWorkflowEvaluationDocument({ - workflowName, - payload, + workflowName, + payload, }) { - const callback = (data) => { - let evaluation = data.accompanyingPeriodWorkEvaluations.find( - (e) => e.key === props.evaluation.key, - ); - let updatedDocument = evaluation.documents.find( - (d) => d.key === payload.doc.key, - ); - window.location.assign( - buildLinkCreate( - workflowName, - "Chill\\PersonBundle\\Entity\\AccompanyingPeriod\\AccompanyingPeriodWorkEvaluationDocument", - updatedDocument.id, - ), - ); - }; + const callback = (data) => { + let evaluation = data.accompanyingPeriodWorkEvaluations.find( + (e) => e.key === props.evaluation.key, + ); + let updatedDocument = evaluation.documents.find( + (d) => d.key === payload.doc.key, + ); + window.location.assign( + buildLinkCreate( + workflowName, + "Chill\\PersonBundle\\Entity\\AccompanyingPeriod\\AccompanyingPeriodWorkEvaluationDocument", + updatedDocument.id, + ), + ); + }; - return store.dispatch("submit", callback).catch((e) => { - console.log(e); - throw e; - }); + return store.dispatch("submit", callback).catch((e) => { + console.log(e); + throw e; + }); } /** @@ -401,55 +318,53 @@ async function goToGenerateWorkflowEvaluationDocument({ * @return {void} */ async function replaceDocument(oldDocument, storedObject, storedObjectVersion) { - let document = { - type: "accompanying_period_work_evaluation_document", - storedObject: storedObject, - title: oldDocument.title, - }; + let document = { + type: "accompanying_period_work_evaluation_document", + storedObject: storedObject, + title: oldDocument.title, + }; - return store.commit("replaceDocument", { - key: props.evaluation.key, - document, - oldDocument: oldDocument, - stored_object_version: storedObjectVersion, - }); + return store.commit("replaceDocument", { + key: props.evaluation.key, + document, + oldDocument: oldDocument, + stored_object_version: storedObjectVersion, + }); } async function goToGenerateDocumentNotification(document, tos) { - const callback = (data) => { - let evaluation = data.accompanyingPeriodWorkEvaluations.find( - (e) => e.key === props.evaluation.key, - ); - let updatedDocument = evaluation.documents.find( - (d) => d.key === document.key, - ); - window.location.assign( - buildLinkCreateNotification( - "Chill\\PersonBundle\\Entity\\AccompanyingPeriod\\AccompanyingPeriodWorkEvaluationDocument", - updatedDocument.id, - tos === true - ? store.state.work.accompanyingPeriod.user?.id - : null, - window.location.pathname + - window.location.search + - window.location.hash, - ), - ); - }; + const callback = (data) => { + let evaluation = data.accompanyingPeriodWorkEvaluations.find( + (e) => e.key === props.evaluation.key, + ); + let updatedDocument = evaluation.documents.find( + (d) => d.key === document.key, + ); + window.location.assign( + buildLinkCreateNotification( + "Chill\\PersonBundle\\Entity\\AccompanyingPeriod\\AccompanyingPeriodWorkEvaluationDocument", + updatedDocument.id, + tos === true ? store.state.work.accompanyingPeriod.user?.id : null, + window.location.pathname + + window.location.search + + window.location.hash, + ), + ); + }; - return store.dispatch("submit", callback).catch((e) => { - console.log(e); - throw e; - }); + return store.dispatch("submit", callback).catch((e) => { + console.log(e); + throw e; + }); } async function submitBeforeLeaveToEditor() { - console.log("submit beore edit 2"); - // empty callback - const callback = () => null; - return store.dispatch("submit", callback).catch((e) => { - console.log(e); - throw e; - }); + console.log("submit beore edit 2"); + // empty callback + const callback = () => null; + return store.dispatch("submit", callback).catch((e) => { + console.log(e); + throw e; + }); } </script> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/FormEvaluation.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/FormEvaluation.vue index 76eaf2e9e..91adc4bb1 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/FormEvaluation.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/FormEvaluation.vue @@ -1,49 +1,47 @@ <template> - <div> - <div class="m-md-3"> - <DateInputs - :startDate="startDate" - :endDate="endDate" - :maxDate="maxDate" - :warningInterval="warningInterval" - @update:startDate="updateStartDate" - @update:endDate="updateEndDate" - @update:maxDate="updateMaxDate" - @update:warningInterval="updateWarningInterval" - /> + <div> + <div class="m-md-3"> + <DateInputs + :startDate="startDate" + :endDate="endDate" + :maxDate="maxDate" + :warningInterval="warningInterval" + @update:startDate="updateStartDate" + @update:endDate="updateEndDate" + @update:maxDate="updateMaxDate" + @update:warningInterval="updateWarningInterval" + /> - <TimeSpentInput - :timeSpent="timeSpent" - :timeSpentChoices="timeSpentChoices" - @update:timeSpent="updateTimeSpent" - /> + <TimeSpentInput + :timeSpent="timeSpent" + :timeSpentChoices="timeSpentChoices" + @update:timeSpent="updateTimeSpent" + /> - <CommentInput :comment="comment" @update:comment="updateComment" /> + <CommentInput :comment="comment" @update:comment="updateComment" /> - <DocumentsList - v-if="evaluation.documents.length > 0" - :documents="evaluation.documents" - :docAnchorId="docAnchorId" - :evaluation="evaluation" - :accompanyingPeriodId="store.state.work.accompanyingPeriod.id" - @inputDocumentTitle="onInputDocumentTitle" - @removeDocument="removeDocument" - @duplicateDocument="duplicateDocument" - @duplicate-document-to-evaluation=" - duplicateDocumentToEvaluation - " - @move-document-to-evaluation="moveDocumentToEvaluation" - @statusDocumentChanged="onStatusDocumentChanged" - @goToGenerateNotification="goToGenerateDocumentNotification" - /> + <DocumentsList + v-if="evaluation.documents.length > 0" + :documents="evaluation.documents" + :docAnchorId="docAnchorId" + :evaluation="evaluation" + :accompanyingPeriodId="store.state.work.accompanyingPeriod.id" + @inputDocumentTitle="onInputDocumentTitle" + @removeDocument="removeDocument" + @duplicateDocument="duplicateDocument" + @duplicate-document-to-evaluation="duplicateDocumentToEvaluation" + @move-document-to-evaluation="moveDocumentToEvaluation" + @statusDocumentChanged="onStatusDocumentChanged" + @goToGenerateNotification="goToGenerateDocumentNotification" + /> - <DocumentActions - :evaluation="evaluation" - :templates="getTemplatesAvailables" - @addDocument="addDocument" - /> - </div> + <DocumentActions + :evaluation="evaluation" + :templates="getTemplatesAvailables" + @addDocument="addDocument" + /> </div> + </div> </template> <script setup> @@ -55,291 +53,370 @@ import CommentInput from "./CommentInput.vue"; import DocumentsList from "./DocumentsList.vue"; import DocumentActions from "./DocumentActions.vue"; import { - trans, - EVALUATION_DOCUMENT_DUPLICATE_SUCCESS, - EVALUATION_DOCUMENT_MOVE_SUCCESS, + trans, + EVALUATION_DOCUMENT_DUPLICATE_SUCCESS, + EVALUATION_DOCUMENT_MOVE_SUCCESS, } from "translator"; import { useToast } from "vue-toast-notification"; +import { buildLinkCreate as buildLinkCreateNotification } from "ChillMainAssets/lib/entity-notification/api"; const props = defineProps(["evaluation", "docAnchorId"]); const store = useStore(); const $toast = useToast(); -const timeSpentChoices = [ - { text: "1 minute", value: 60 }, - { text: "2 minutes", value: 120 }, - { text: "3 minutes", value: 180 }, - { text: "4 minutes", value: 240 }, - { text: "5 minutes", value: 300 }, - { text: "10 minutes", value: 600 }, - { text: "15 minutes", value: 900 }, - { text: "20 minutes", value: 1200 }, - { text: "25 minutes", value: 1500 }, - { text: "30 minutes", value: 1800 }, - { text: "45 minutes", value: 2700 }, - { text: "1 hour", value: 3600 }, - { text: "1 hour 15 minutes", value: 4500 }, - { text: "1 hour 30 minutes", value: 5400 }, - { text: "1 hour 45 minutes", value: 6300 }, - { text: "2 hours", value: 7200 }, - { text: "2 hours 30 minutes", value: 9000 }, - { text: "3 hours", value: 10800 }, - { text: "3 hours 30 minutes", value: 12600 }, - { text: "4 hours", value: 14400 }, - { text: "4 hours 30 minutes", value: 16200 }, - { text: "5 hours", value: 18000 }, - { text: "5 hours 30 minutes", value: 19800 }, - { text: "6 hours", value: 21600 }, - { text: "6 hours 30 minutes", value: 23400 }, - { text: "7 hours", value: 25200 }, - { text: "7 hours 30 minutes", value: 27000 }, - { text: "8 hours", value: 28800 }, +const timeSpentValues = [ + 60, + 120, + 180, + 240, + 300, + 600, + 900, + 1200, + 1500, + 1800, + 2700, + 3600, + 4500, + 5400, + 6300, + 7200, + 9000, + 10800, + 12600, + 14400, + 16200, + 18000, + 19800, + 21600, + 23400, + 25200, + 27000, + 28800, + 43200, + 57600, + 72000, + 86400, + 100800, + 115200, + 129600, + 144000, // goes from 1 minute to 40 hours ]; +const formatDuration = (seconds, locale) => { + const currentLocale = locale || navigator.language || "fr"; + + const totalHours = Math.floor(seconds / 3600); + const remainingMinutes = Math.floor((seconds % 3600) / 60); + + if (totalHours >= 8) { + const days = Math.floor(totalHours / 8); + const remainingHours = totalHours % 8; + + const parts = []; + + if (days > 0) { + parts.push( + new Intl.NumberFormat(currentLocale, { + style: "unit", + unit: "day", + unitDisplay: "long", + }).format(days), + ); + } + + if (remainingHours > 0) { + parts.push( + new Intl.NumberFormat(currentLocale, { + style: "unit", + unit: "hour", + unitDisplay: "long", + }).format(remainingHours), + ); + } + + return parts.join(" "); + } + + // For less than 8 hours, use hour and minute format + const parts = []; + + if (totalHours > 0) { + parts.push( + new Intl.NumberFormat(currentLocale, { + style: "unit", + unit: "hour", + unitDisplay: "long", + }).format(totalHours), + ); + } + + if (remainingMinutes > 0) { + parts.push( + new Intl.NumberFormat(currentLocale, { + style: "unit", + unit: "minute", + unitDisplay: "long", + }).format(remainingMinutes), + ); + } + + console.log(parts); + console.log(parts.join(" ")); + + return parts.join(" "); +}; + +const timeSpentChoices = computed(() => { + const locale = "fr"; + return timeSpentValues.map((value) => ({ + text: formatDuration(value, locale), + value: parseInt(value), + })); +}); + const startDate = computed({ - get() { - return props.evaluation.startDate; - }, - set(v) { - store.commit("setEvaluationStartDate", { - key: props.evaluation.key, - date: v, - }); - }, + get() { + return props.evaluation.startDate; + }, + set(v) { + store.commit("setEvaluationStartDate", { + key: props.evaluation.key, + date: v, + }); + }, }); const endDate = computed({ - get() { - return props.evaluation.endDate; - }, - set(v) { - store.commit("setEvaluationEndDate", { - key: props.evaluation.key, - date: v, - }); - }, + get() { + return props.evaluation.endDate; + }, + set(v) { + store.commit("setEvaluationEndDate", { + key: props.evaluation.key, + date: v, + }); + }, }); const maxDate = computed({ - get() { - return props.evaluation.maxDate; - }, - set(v) { - store.commit("setEvaluationMaxDate", { - key: props.evaluation.key, - date: v, - }); - }, + get() { + return props.evaluation.maxDate; + }, + set(v) { + store.commit("setEvaluationMaxDate", { + key: props.evaluation.key, + date: v, + }); + }, }); const warningInterval = computed({ - get() { - return props.evaluation.warningInterval; - }, - set(v) { - store.commit("setEvaluationWarningInterval", { - key: props.evaluation.key, - days: v, - }); - }, + get() { + return props.evaluation.warningInterval; + }, + set(v) { + store.commit("setEvaluationWarningInterval", { + key: props.evaluation.key, + days: v, + }); + }, }); const timeSpent = computed({ - get() { - return props.evaluation.timeSpent; - }, - set(v) { - store.commit("setEvaluationTimeSpent", { - key: props.evaluation.key, - time: v, - }); - }, + get() { + return props.evaluation.timeSpent; + }, + set(v) { + store.commit("setEvaluationTimeSpent", { + key: props.evaluation.key, + time: v, + }); + }, }); const comment = computed({ - get() { - return props.evaluation.comment; - }, - set(v) { - store.commit("setEvaluationComment", { - key: props.evaluation.key, - comment: v, - }); - }, + get() { + return props.evaluation.comment; + }, + set(v) { + store.commit("setEvaluationComment", { + key: props.evaluation.key, + comment: v, + }); + }, }); const getTemplatesAvailables = computed(() => { - return store.getters.getTemplatesAvailablesForEvaluation( - props.evaluation.evaluation, - ); + return store.getters.getTemplatesAvailablesForEvaluation( + props.evaluation.evaluation, + ); }); // const getAccompanyingPeriod = computed(() => store.work) function updateStartDate(value) { - startDate.value = value; + startDate.value = value; } function updateEndDate(value) { - endDate.value = value; + endDate.value = value; } function updateMaxDate(value) { - maxDate.value = value; + maxDate.value = value; } function updateWarningInterval(value) { - warningInterval.value = value; + warningInterval.value = value; } function updateTimeSpent(value) { - timeSpent.value = value; + timeSpent.value = parseInt(value); } function updateComment(value) { - comment.value = value; + comment.value = value; } function onInputDocumentTitle(event) { - const id = Number(event.target.id); - const key = Number(event.target.dataset.key) + 1; - const title = event.target.value; - store.commit("updateDocumentTitle", { - id: id, - key: key, - evaluationKey: props.evaluation.key, - title: title, - }); + const id = Number(event.target.id); + const key = Number(event.target.dataset.key) + 1; + const title = event.target.value; + store.commit("updateDocumentTitle", { + id: id, + key: key, + evaluationKey: props.evaluation.key, + title: title, + }); } -function addDocument({ stored_object, stored_object_version }) { - let document = { - type: "accompanying_period_work_evaluation_document", - storedObject: stored_object, - title: "Nouveau document", - }; - store.commit("addDocument", { - key: props.evaluation.key, - document, - stored_object_version, - }); +function addDocument({ stored_object, stored_object_version, file_name }) { + let document = { + type: "accompanying_period_work_evaluation_document", + storedObject: stored_object, + title: file_name, + }; + store.commit("addDocument", { + key: props.evaluation.key, + document, + stored_object_version, + }); } function removeDocument(document) { - if ( - window.confirm( - 'Êtes-vous sûr·e de vouloir supprimer le document qui a pour titre "' + - document.title + - '" ?', - ) - ) { - store.commit("removeDocument", { - key: props.evaluation.key, - document: document, - }); - } + if ( + window.confirm( + 'Êtes-vous sûr·e de vouloir supprimer le document qui a pour titre "' + + document.title + + '" ?', + ) + ) { + store.commit("removeDocument", { + key: props.evaluation.key, + document: document, + }); + } } function duplicateDocument(document) { - store.dispatch("duplicateDocument", { - evaluation_key: props.evaluation.key, - document: document, - }); + store.dispatch("duplicateDocument", { + evaluation_key: props.evaluation.key, + document: document, + }); } function duplicateDocumentToEvaluation({ evaluation, document }) { - store - .dispatch("duplicateDocumentToEvaluation", { - evaluation: evaluation, - document: document, - }) - .then(() => { - $toast.open({ - message: trans(EVALUATION_DOCUMENT_DUPLICATE_SUCCESS), - }); - }) - .catch((e) => { - console.log(e); - }); + store + .dispatch("duplicateDocumentToEvaluation", { + evaluation: evaluation, + document: document, + }) + .then(() => { + $toast.open({ + message: trans(EVALUATION_DOCUMENT_DUPLICATE_SUCCESS), + }); + }) + .catch((e) => { + console.log(e); + }); } function moveDocumentToEvaluation({ evaluationDest, document }) { - console.log("dest eval in formEvaluation", evaluationDest); - store - .dispatch("moveDocumentToEvaluation", { - evaluationInitial: props.evaluation, - evaluationDest: evaluationDest, - document: document, - }) - .then(() => { - $toast.open({ - message: trans(EVALUATION_DOCUMENT_MOVE_SUCCESS), - }); - }) - .catch((e) => { - console.log(e); - }); + console.log("dest eval in formEvaluation", evaluationDest); + store + .dispatch("moveDocumentToEvaluation", { + evaluationInitial: props.evaluation, + evaluationDest: evaluationDest, + document: document, + }) + .then(() => { + $toast.open({ + message: trans(EVALUATION_DOCUMENT_MOVE_SUCCESS), + }); + }) + .catch((e) => { + console.log(e); + }); } function onStatusDocumentChanged(newStatus) { - store.commit("statusDocumentChanged", { - key: props.evaluation.key, - newStatus: newStatus, - }); + store.commit("statusDocumentChanged", { + key: props.evaluation.key, + newStatus: newStatus, + }); } function goToGenerateDocumentNotification(document, tos) { - const callback = (data) => { - let evaluation = data.accompanyingPeriodWorkEvaluations.find( - (e) => e.key === props.evaluation.key, - ); - let updatedDocument = evaluation.documents.find( - (d) => d.key === document.key, - ); - window.location.assign( - buildLinkCreateNotification( - "Chill\\PersonBundle\\Entity\\AccompanyingPeriod\\AccompanyingPeriodWorkEvaluationDocument", - updatedDocument.id, - tos === true - ? store.state.work.accompanyingPeriod.user.id - : null, - window.location.pathname + - window.location.search + - window.location.hash, - ), - ); - }; - store.dispatch("submit", callback).catch((e) => { - console.log(e); - throw e; - }); + const callback = (data) => { + let evaluation = data.accompanyingPeriodWorkEvaluations.find( + (e) => e.key === props.evaluation.key, + ); + let updatedDocument = evaluation.documents.find( + (d) => d.key === document.key, + ); + window.location.assign( + buildLinkCreateNotification( + "Chill\\PersonBundle\\Entity\\AccompanyingPeriod\\AccompanyingPeriodWorkEvaluationDocument", + updatedDocument.id, + tos === true ? store.state.work.accompanyingPeriod.user.id : null, + window.location.pathname + + window.location.search + + window.location.hash, + ), + ); + }; + store.dispatch("submit", callback).catch((e) => { + console.log(e); + throw e; + }); } </script> <style lang="scss" scoped> input.document-title { - font-weight: bold; - font-size: 1rem; + font-weight: bold; + font-size: 1rem; } .bg-blink { - color: #050000; - padding: 10px; - display: inline-block; - border-radius: 5px; - animation: blinkingBackground 2.2s infinite; - animation-iteration-count: 2; + color: #050000; + padding: 10px; + display: inline-block; + border-radius: 5px; + animation: blinkingBackground 2.2s infinite; + animation-iteration-count: 2; } @keyframes blinkingBackground { - 0% { - background-color: #ed776d; - } - 50% { - background-color: #ffffff; - } - 100% { - background-color: #ed776d; - } + 0% { + background-color: #ed776d; + } + 50% { + background-color: #ffffff; + } + 100% { + background-color: #ed776d; + } } </style> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/TimeSpentInput.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/TimeSpentInput.vue index e03ec0ed5..a253f87d3 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/TimeSpentInput.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/AccompanyingCourseWorkEdit/components/TimeSpentInput.vue @@ -1,27 +1,27 @@ <template> - <div class="row mb-3"> - <label class="col-4 col-sm-2 col-md-4 col-lg-2 col-form-label"> - {{ trans(EVALUATION_TIME_SPENT) }} - </label> - <div class="col-8 col-sm-4 col-md-8 col-lg-4"> - <select - class="form-control form-control-sm" - :value="timeSpent" - @input="$emit('update:timeSpent', $event.target.value)" - > - <option disabled value=""> - {{ trans(EVALUATION_TIME_SPENT) }} - </option> - <option - v-for="time in timeSpentChoices" - :value="time.value" - :key="time.value" - > - {{ time.text }} - </option> - </select> - </div> + <div class="row mb-3"> + <label class="col-4 col-sm-2 col-md-4 col-lg-2 col-form-label"> + {{ trans(EVALUATION_TIME_SPENT) }} + </label> + <div class="col-8 col-sm-4 col-md-8 col-lg-4"> + <select + class="form-control form-control-sm" + :value="timeSpent" + @input="$emit('update:timeSpent', $event.target.value)" + > + <option disabled value=""> + {{ trans(EVALUATION_TIME_SPENT) }} + </option> + <option + v-for="time in timeSpentChoices" + :value="time.value" + :key="time.value" + > + {{ time.text }} + </option> + </select> </div> + </div> </template> <script setup> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/ExportFormActionGoalResult/App.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/ExportFormActionGoalResult/App.vue index 29b707842..7922362ca 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/ExportFormActionGoalResult/App.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/ExportFormActionGoalResult/App.vue @@ -1,445 +1,447 @@ <template> - <fieldset class="mb-3" id="actionType"> - <div class="row"> - <legend class="col-sm-4 col-form-label"> - {{ $t("action.label") }} - </legend> - <div class="col-sm-8"> - <VueMultiselect - v-model="action" - :options="actions.options" - @select="selectAction" - @remove="unselectAction" - :multiple="true" - :close-on-select="false" - :placeholder="$t('action.placeholder')" - :custom-label="formatSocialAction" - track-by="id" - :searchable="true" - /> - </div> - </div> - </fieldset> + <fieldset class="mb-3" id="actionType"> + <div class="row"> + <legend class="col-sm-4 col-form-label"> + {{ $t("action.label") }} + </legend> + <div class="col-sm-8"> + <VueMultiselect + v-model="action" + :options="actions.options" + @select="selectAction" + @remove="unselectAction" + :multiple="true" + :close-on-select="false" + :placeholder="$t('action.placeholder')" + :custom-label="formatSocialAction" + track-by="id" + :searchable="true" + /> + </div> + </div> + </fieldset> - <fieldset class="mb-3" id="goal"> - <div class="row"> - <legend class="col-sm-4 col-form-label"> - {{ $t("goal.label") }} - </legend> - <div class="col-sm-8"> - <VueMultiselect - v-model="goal" - :options="goals.options" - @select="selectGoal" - @remove="unselectGoal" - :multiple="true" - :close-on-select="false" - :placeholder="$t('goal.placeholder')" - label="title" - :custom-label="transTitle" - track-by="id" - :searchable="true" - /> - </div> - </div> - </fieldset> + <fieldset class="mb-3" id="goal"> + <div class="row"> + <legend class="col-sm-4 col-form-label"> + {{ $t("goal.label") }} + </legend> + <div class="col-sm-8"> + <VueMultiselect + v-model="goal" + :options="goals.options" + @select="selectGoal" + @remove="unselectGoal" + :multiple="true" + :close-on-select="false" + :placeholder="$t('goal.placeholder')" + label="title" + :custom-label="transTitle" + track-by="id" + :searchable="true" + /> + </div> + </div> + </fieldset> - <fieldset class="mb-3" id="result"> - <div class="row"> - <legend class="col-sm-4 col-form-label"> - {{ $t("result.label") }} - </legend> - <div class="col-sm-8"> - <VueMultiselect - v-model="result" - :options="results.options" - @select="selectResult" - @remove="unselectResult" - :multiple="true" - :close-on-select="false" - :placeholder="$t('result.placeholder')" - label="title" - :custom-label="transTitle" - track-by="id" - :searchable="true" - /> - </div> - </div> - </fieldset> + <fieldset class="mb-3" id="result"> + <div class="row"> + <legend class="col-sm-4 col-form-label"> + {{ $t("result.label") }} + </legend> + <div class="col-sm-8"> + <VueMultiselect + v-model="result" + :options="results.options" + @select="selectResult" + @remove="unselectResult" + :multiple="true" + :close-on-select="false" + :placeholder="$t('result.placeholder')" + label="title" + :custom-label="transTitle" + track-by="id" + :searchable="true" + /> + </div> + </div> + </fieldset> </template> <script> import VueMultiselect from "vue-multiselect"; import { - getSocialActions, - getGoalByAction, - getResultByAction, - getResultByGoal, + getSocialActions, + getGoalByAction, + getResultByAction, + getResultByGoal, } from "./api"; export default { - name: "App", - components: { - VueMultiselect, - }, - i18n: { - messages: { - fr: { - action: { - label: "Types d'actions", - placeholder: "Choisissez une ou plusieurs actions", - }, - goal: { - label: "Objectifs", - placeholder: "Choisissez un ou plusieurs objectifs", - }, - result: { - label: "Résultats", - placeholder: "Choisissez un ou plusieurs résultats", - }, - }, - }, - }, - data() { - return { - actions: { - options: [], // array with multiselect options - value: [], // array with selected values - hiddenField: document.getElementById( - "export_filters_social_work_type_filter_form_actionType", - ), - }, - goals: { - options: [], - value: [], - hiddenField: document.getElementById( - "export_filters_social_work_type_filter_form_goal", - ), - }, - results: { - options: [], - value: [], - hiddenField: document.getElementById( - "export_filters_social_work_type_filter_form_result", - ), - }, - }; - }, - computed: { + name: "App", + components: { + VueMultiselect, + }, + i18n: { + messages: { + fr: { action: { - get() { - return this.actions.value; - }, - set(value) { - this.actions.value = value; - this.rebuildHiddenFieldValues("actions"); - }, + label: "Types d'actions", + placeholder: "Choisissez une ou plusieurs actions", }, goal: { - get() { - return this.goals.value; - }, - set(value) { - this.goals.value = value; - this.rebuildHiddenFieldValues("goals"); - }, + label: "Objectifs", + placeholder: "Choisissez un ou plusieurs objectifs", }, result: { - get() { - return this.results.value; - }, - set(value) { - this.results.value = value; - this.rebuildHiddenFieldValues("results"); - }, + label: "Résultats", + placeholder: "Choisissez un ou plusieurs résultats", }, + }, }, - async mounted() { - await this.getSocialActionsList(); - - if ("" !== this.actions.hiddenField.value) { - const actionIds = this.actions.hiddenField.value.split(","); - for (const aid of actionIds) { - let action = this.actions.options.find( - (a) => Number.parseInt(aid) === a.id, - ); - if (undefined !== action) { - this.action.push(action); - await this.selectAction(action); - } - } - } - - if ("" !== this.goals.hiddenField.value) { - const goalsIds = this.goals.hiddenField.value - .split(",") - .map((s) => Number.parseInt(s)); - for (const gid of goalsIds) { - let goal = this.goals.options.find((g) => gid === g.id); - if (undefined !== goal) { - this.goal.push(goal); - await this.selectGoal(goal); - } - } - } - - if ("" !== this.results.hiddenField.value) { - const resultsIds = this.results.hiddenField.value - .split(",") - .map((s) => Number.parseInt(s)); - for (const rid of resultsIds) { - let result = this.results.options.find((r) => rid === r.id); - if (undefined !== result) { - this.result.push(result); - } - } - } + }, + data() { + return { + actions: { + options: [], // array with multiselect options + value: [], // array with selected values + hiddenField: document.getElementById( + "export_filters_social_work_type_filter_form_actionType", + ), + }, + goals: { + options: [], + value: [], + hiddenField: document.getElementById( + "export_filters_social_work_type_filter_form_goal", + ), + }, + results: { + options: [], + value: [], + hiddenField: document.getElementById( + "export_filters_social_work_type_filter_form_result", + ), + }, + }; + }, + computed: { + action: { + get() { + return this.actions.value; + }, + set(value) { + this.actions.value = value; + this.rebuildHiddenFieldValues("actions"); + }, }, - methods: { - async getSocialActionsList() { - let actions = await getSocialActions(); - this.actions.options = actions.toSorted(function (a, b) { - if (a.issue.ordering === b.issue.ordering) { - if (a.ordering === b.ordering) { - return 0; - } - if (a.ordering < b.ordering) { - return -1; - } - return 1; - } + goal: { + get() { + return this.goals.value; + }, + set(value) { + this.goals.value = value; + this.rebuildHiddenFieldValues("goals"); + }, + }, + result: { + get() { + return this.results.value; + }, + set(value) { + this.results.value = value; + this.rebuildHiddenFieldValues("results"); + }, + }, + }, + async mounted() { + await this.getSocialActionsList(); - if (a.issue.ordering < b.issue.ordering) { - return -1; - } + if ("" !== this.actions.hiddenField.value) { + const actionIds = this.actions.hiddenField.value.split(","); + for (const aid of actionIds) { + let action = this.actions.options.find( + (a) => Number.parseInt(aid) === a.id, + ); + if (undefined !== action) { + this.action.push(action); + await this.selectAction(action); + } + } + } - return 1; - }); + if ("" !== this.goals.hiddenField.value) { + const goalsIds = this.goals.hiddenField.value + .split(",") + .map((s) => Number.parseInt(s)); + for (const gid of goalsIds) { + let goal = this.goals.options.find((g) => gid === g.id); + if (undefined !== goal) { + this.goal.push(goal); + await this.selectGoal(goal); + } + } + } + if ("" !== this.results.hiddenField.value) { + const resultsIds = this.results.hiddenField.value + .split(",") + .map((s) => Number.parseInt(s)); + for (const rid of resultsIds) { + let result = this.results.options.find((r) => rid === r.id); + if (undefined !== result) { + this.result.push(result); + } + } + } + }, + methods: { + async getSocialActionsList() { + let actions = await getSocialActions(); + this.actions.options = actions.toSorted(function (a, b) { + if (a.issue.ordering === b.issue.ordering) { + if (a.ordering === b.ordering) { + return 0; + } + if (a.ordering < b.ordering) { + return -1; + } + return 1; + } + + if (a.issue.ordering < b.issue.ordering) { + return -1; + } + + return 1; + }); + + return Promise.resolve(); + }, + + formatSocialAction({ text, issue }) { + return text + " (" + issue.text + ")"; + }, + + /** + * Select/unselect in Action Multiselect + * @param value + */ + async selectAction(value) { + //console.log('----'); console.log('select action', value.id); + let children = this.getChildrensFromParent(value); + this.addSelectedElement("actions", children); + + let parentAndChildren = [...[value], ...children]; + const promises = []; + parentAndChildren.forEach((elem) => { + promises.push( + getGoalByAction(elem.id).then((goals) => { + this.addElementInData("goals", goals); return Promise.resolve(); - }, - - formatSocialAction({ text, issue }) { - return text + " (" + issue.text + ")"; - }, - - /** - * Select/unselect in Action Multiselect - * @param value - */ - async selectAction(value) { - //console.log('----'); console.log('select action', value.id); - let children = this.getChildrensFromParent(value); - this.addSelectedElement("actions", children); - - let parentAndChildren = [...[value], ...children]; - const promises = []; - parentAndChildren.forEach((elem) => { - promises.push( - getGoalByAction(elem.id).then((goals) => { - this.addElementInData("goals", goals); - return Promise.resolve(); - }), - ); - promises.push( - getResultByAction(elem.id).then((results) => { - this.addElementInData("results", results); - return Promise.resolve(); - }), - ); - }); - - await Promise.all(promises); + }), + ); + promises.push( + getResultByAction(elem.id).then((results) => { + this.addElementInData("results", results); return Promise.resolve(); - }, + }), + ); + }); - unselectAction(value) { - getGoalByAction(value.id).then((goals) => { - [this.results.options, this.results.value] = - this.removeElementInData("goals", goals); - }); - getResultByAction(value.id).then((results) => { - [this.results.options, this.results.value] = - this.removeElementInData("results", results); - }); - }, - - /** - * Select/unselect in Goal Multiselect - * @param value - */ - async selectGoal(value) { - return getResultByGoal(value.id).then((results) => { - this.addElementInData("results", results); - }); - }, - - unselectGoal(value) { - getResultByGoal(value.id).then( - (results) => - ([this.results.options, this.results.value] = - this.removeElementInData("results", results)), - ); - }, - - // selectResult(value) { - //console.log('----'); console.log('select result', value.id); - // }, - - // unselectResult(value) { - //console.log('----'); console.log('unselect result', value.id); - // }, - - /** - * Choose parent action will involve retaining the "children" actions. - * @param value - * @return array - */ - getChildrensFromParent(value) { - if (null === value.parent) { - let excludeParent = this.actions.options.filter( - (o) => o.parent !== null, - ); - let children = excludeParent.filter( - (o) => o.parent.id === value.id, - ); - //console.log("get childrens", children.map(e => e.id)); - return children; - } - return []; - }, - - /** - * Add response elements in data target - * @param target string -> 'actions', 'goals' or 'results' - * @param response array of objects with fetch results - */ - addElementInData(target, response) { - let data = this[target]; - let dump = []; - response.forEach((elem) => { - let found = data.options.some((e) => e.id === elem.id); - if (!found) { - data.options.push(elem); - dump.push(elem.id); - } - }); - if (dump.length > 0) { - //console.log('push ' + dump.length + ' elems in', target, dump); - } - data.options.sort(); - }, - - /** - * Remove response elements from data target - * @param target string -> 'actions', 'goals' or 'results' - * @param response array of objects with fetch results - * @returns data.<target>.options - */ - removeElementInData(target, response) { - let data = this[target]; - let dump = []; - response.forEach((elem) => { - let found = data.options.some((e) => e.id === elem.id); - if (found) { - data.options = data.options.filter((e) => e.id !== elem.id); - dump.push(elem.id); - - this.removeSelectedElement(target, elem); - } - }); - if (dump.length > 0) { - //console.log('remove ' + dump.length + ' elems from ' + target + ' options', dump); - } - return [data.options, data.value]; - }, - - /** - * - * @param target - * @param elements - */ - addSelectedElement(target, elements) { - let data = this[target]; - let dump = []; - elements.forEach((elem) => { - let selected = data.value.some((e) => e.id === elem.id); - if (!selected) { - data.value.push(elem); - dump.push(elem.id); - - // add in hiddenField - this.rebuildHiddenFieldValues(target); - } - }); - if (dump.length > 0) { - //console.log('add ' + dump.length + ' selected elems in', target, dump); - } - }, - - /** - * Remove element from selected and from hiddenField - * @param target - * @param elem - */ - removeSelectedElement(target, elem) { - let data = this[target]; - let selected = data.value.some((e) => e.id === elem.id); - if (selected) { - // remove from selected - data.value = data.value.filter((e) => e.id !== elem.id); - //console.log('remove ' + elem.id + ' from selected ' + target); - - // remove from hiddenField - this.rebuildHiddenFieldValues(target); - - // in any cases, remove should be recursive - this.unselectToNextField(target, elem); - } - }, - - /** - * When unselect Action, it could remove elements in goals multiselect. - * In that case, we have to unselect Goal to remove elements in results too. - * @param target - * @param elem - */ - unselectToNextField(target, elem) { - if (target === "goals") { - //console.log('!!!! target is goal: unselect goal', elem.id); - this.unselectGoal(elem); - //console.log('!!!! done'); - } - }, - - /** - * Rebuild values serie (string) in target HiddenField - * @param target - */ - rebuildHiddenFieldValues(target) { - let data = this[target]; - //console.log('rebuild hiddenFields ' + target + ' values :'); - data.hiddenField.value = ""; // reset - data.value.forEach((elem) => { - data.hiddenField.value = this.addIdToValue( - data.hiddenField.value, - elem.id, - ); - }); - //console.log(data.hiddenField); - }, - - addIdToValue(string, id) { - let array = string ? string.split(",") : []; - array.push(id.toString()); - let str = array.join(); - return str; - }, - - transTitle({ title }) { - return title.fr; //TODO multilang - }, + await Promise.all(promises); + return Promise.resolve(); }, + + unselectAction(value) { + getGoalByAction(value.id).then((goals) => { + [this.results.options, this.results.value] = this.removeElementInData( + "goals", + goals, + ); + }); + getResultByAction(value.id).then((results) => { + [this.results.options, this.results.value] = this.removeElementInData( + "results", + results, + ); + }); + }, + + /** + * Select/unselect in Goal Multiselect + * @param value + */ + async selectGoal(value) { + return getResultByGoal(value.id).then((results) => { + this.addElementInData("results", results); + }); + }, + + unselectGoal(value) { + getResultByGoal(value.id).then( + (results) => + ([this.results.options, this.results.value] = + this.removeElementInData("results", results)), + ); + }, + + // selectResult(value) { + //console.log('----'); console.log('select result', value.id); + // }, + + // unselectResult(value) { + //console.log('----'); console.log('unselect result', value.id); + // }, + + /** + * Choose parent action will involve retaining the "children" actions. + * @param value + * @return array + */ + getChildrensFromParent(value) { + if (null === value.parent) { + let excludeParent = this.actions.options.filter( + (o) => o.parent !== null, + ); + let children = excludeParent.filter((o) => o.parent.id === value.id); + //console.log("get childrens", children.map(e => e.id)); + return children; + } + return []; + }, + + /** + * Add response elements in data target + * @param target string -> 'actions', 'goals' or 'results' + * @param response array of objects with fetch results + */ + addElementInData(target, response) { + let data = this[target]; + let dump = []; + response.forEach((elem) => { + let found = data.options.some((e) => e.id === elem.id); + if (!found) { + data.options.push(elem); + dump.push(elem.id); + } + }); + if (dump.length > 0) { + //console.log('push ' + dump.length + ' elems in', target, dump); + } + data.options.sort(); + }, + + /** + * Remove response elements from data target + * @param target string -> 'actions', 'goals' or 'results' + * @param response array of objects with fetch results + * @returns data.<target>.options + */ + removeElementInData(target, response) { + let data = this[target]; + let dump = []; + response.forEach((elem) => { + let found = data.options.some((e) => e.id === elem.id); + if (found) { + data.options = data.options.filter((e) => e.id !== elem.id); + dump.push(elem.id); + + this.removeSelectedElement(target, elem); + } + }); + if (dump.length > 0) { + //console.log('remove ' + dump.length + ' elems from ' + target + ' options', dump); + } + return [data.options, data.value]; + }, + + /** + * + * @param target + * @param elements + */ + addSelectedElement(target, elements) { + let data = this[target]; + let dump = []; + elements.forEach((elem) => { + let selected = data.value.some((e) => e.id === elem.id); + if (!selected) { + data.value.push(elem); + dump.push(elem.id); + + // add in hiddenField + this.rebuildHiddenFieldValues(target); + } + }); + if (dump.length > 0) { + //console.log('add ' + dump.length + ' selected elems in', target, dump); + } + }, + + /** + * Remove element from selected and from hiddenField + * @param target + * @param elem + */ + removeSelectedElement(target, elem) { + let data = this[target]; + let selected = data.value.some((e) => e.id === elem.id); + if (selected) { + // remove from selected + data.value = data.value.filter((e) => e.id !== elem.id); + //console.log('remove ' + elem.id + ' from selected ' + target); + + // remove from hiddenField + this.rebuildHiddenFieldValues(target); + + // in any cases, remove should be recursive + this.unselectToNextField(target, elem); + } + }, + + /** + * When unselect Action, it could remove elements in goals multiselect. + * In that case, we have to unselect Goal to remove elements in results too. + * @param target + * @param elem + */ + unselectToNextField(target, elem) { + if (target === "goals") { + //console.log('!!!! target is goal: unselect goal', elem.id); + this.unselectGoal(elem); + //console.log('!!!! done'); + } + }, + + /** + * Rebuild values serie (string) in target HiddenField + * @param target + */ + rebuildHiddenFieldValues(target) { + let data = this[target]; + //console.log('rebuild hiddenFields ' + target + ' values :'); + data.hiddenField.value = ""; // reset + data.value.forEach((elem) => { + data.hiddenField.value = this.addIdToValue( + data.hiddenField.value, + elem.id, + ); + }); + //console.log(data.hiddenField); + }, + + addIdToValue(string, id) { + let array = string ? string.split(",") : []; + array.push(id.toString()); + let str = array.join(); + return str; + }, + + transTitle({ title }) { + return title.fr; //TODO multilang + }, + }, }; </script> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/App.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/App.vue index b8e8ee8c4..a58b8a98a 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/App.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/App.vue @@ -1,53 +1,53 @@ <template> - <ol class="breadcrumb"> - <li - v-for="s in steps" - :key="s" - class="breadcrumb-item" - :class="{ active: step === s }" - > - {{ $t("household_members_editor.app.steps." + s) }} - </li> - </ol> - <concerned v-if="step === 'concerned'" /> - <household v-if="step === 'household'" @ready-to-go="goToNext" /> - <household-address v-if="step === 'household_address'" /> - <positioning v-if="step === 'positioning'" /> - <dates v-if="step === 'confirm'" /> - <confirmation v-if="step === 'confirm'" /> + <ol class="breadcrumb"> + <li + v-for="s in steps" + :key="s" + class="breadcrumb-item" + :class="{ active: step === s }" + > + {{ $t("household_members_editor.app.steps." + s) }} + </li> + </ol> + <concerned v-if="step === 'concerned'" /> + <household v-if="step === 'household'" @ready-to-go="goToNext" /> + <household-address v-if="step === 'household_address'" /> + <positioning v-if="step === 'positioning'" /> + <dates v-if="step === 'confirm'" /> + <confirmation v-if="step === 'confirm'" /> - <ul class="record_actions sticky-form-buttons"> - <li class="cancel" v-if="step !== 'concerned'"> - <button class="btn btn-cancel" @click="goToPrevious"> - {{ $t("household_members_editor.app.previous") }} - </button> - </li> - <li class="cancel" v-else-if="hasReturnPath"> - <button class="btn btn-cancel" @click="goToPrevious"> - {{ $t("household_members_editor.app.cancel") }} - </button> - </li> - <li v-if="step !== 'confirm'"> - <button - class="btn btn-action" - @click="goToNext" - :disabled="!isNextAllowed" - > - {{ $t("household_members_editor.app.next") }} <i - class="fa fa-arrow-right" - /> - </button> - </li> - <li v-else> - <button - class="btn btn-save" - @click="confirm" - :disabled="hasWarnings || !lastStepIsSaveAllowed" - > - {{ $t("household_members_editor.app.save") }} - </button> - </li> - </ul> + <ul class="record_actions sticky-form-buttons"> + <li class="cancel" v-if="step !== 'concerned'"> + <button class="btn btn-cancel" @click="goToPrevious"> + {{ $t("household_members_editor.app.previous") }} + </button> + </li> + <li class="cancel" v-else-if="hasReturnPath"> + <button class="btn btn-cancel" @click="goToPrevious"> + {{ $t("household_members_editor.app.cancel") }} + </button> + </li> + <li v-if="step !== 'confirm'"> + <button + class="btn btn-action" + @click="goToNext" + :disabled="!isNextAllowed" + > + {{ $t("household_members_editor.app.next") }} <i + class="fa fa-arrow-right" + /> + </button> + </li> + <li v-else> + <button + class="btn btn-save" + @click="confirm" + :disabled="hasWarnings || !lastStepIsSaveAllowed" + > + {{ $t("household_members_editor.app.save") }} + </button> + </li> + </ul> </template> <script> @@ -60,123 +60,123 @@ import Confirmation from "./components/Confirmation.vue"; import Positioning from "./components/Positioning"; export default { - name: "App", - components: { - Positioning, - Concerned, - Household, - HouseholdAddress, - Dates, - Confirmation, + name: "App", + components: { + Positioning, + Concerned, + Household, + HouseholdAddress, + Dates, + Confirmation, + }, + data() { + return { + step: "concerned", + }; + }, + computed: { + ...mapState({ + hasWarnings: (state) => + state.warnings.length > 0 || state.errors.length > 0, + }), + steps() { + let s = ["concerned", "household"]; + + if (this.$store.getters.isHouseholdNew) { + s.push("household_address"); + } + + if (!this.$store.getters.isModeLeave) { + s.push("positioning"); + } + + s.push("confirm"); + + return s; }, - data() { - return { - step: "concerned", - }; + hasReturnPath() { + let params = new URLSearchParams(window.location.search); + + return params.has("returnPath"); }, - computed: { - ...mapState({ - hasWarnings: (state) => - state.warnings.length > 0 || state.errors.length > 0, - }), - steps() { - let s = ["concerned", "household"]; + // return true if the next step is allowed + isNextAllowed() { + switch (this.$data.step) { + case "concerned": + return this.$store.state.concerned.length > 0; + case "household": + return this.$store.state.mode !== null; + case "household_address": + return ( + this.$store.getters.hasHouseholdAddress || + this.$store.getters.isHouseholdForceNoAddress + ); + case "positioning": + return ( + this.$store.getters.hasHouseholdOrLeave && + this.$store.getters.hasPersonsWellPositionnated + ); + } - if (this.$store.getters.isHouseholdNew) { - s.push("household_address"); - } - - if (!this.$store.getters.isModeLeave) { - s.push("positioning"); - } - - s.push("confirm"); - - return s; - }, - hasReturnPath() { - let params = new URLSearchParams(window.location.search); - - return params.has("returnPath"); - }, - // return true if the next step is allowed - isNextAllowed() { - switch (this.$data.step) { - case "concerned": - return this.$store.state.concerned.length > 0; - case "household": - return this.$store.state.mode !== null; - case "household_address": - return ( - this.$store.getters.hasHouseholdAddress || - this.$store.getters.isHouseholdForceNoAddress - ); - case "positioning": - return ( - this.$store.getters.hasHouseholdOrLeave && - this.$store.getters.hasPersonsWellPositionnated - ); - } - - return false; - }, - lastStepIsSaveAllowed() { - let r = - !this.$store.getters.isHouseholdNew || - (this.$store.state.numberOfChildren !== null && - this.$store.state.householdCompositionType !== null); - console.log("is saved allowed ?", r); - - return r; - }, + return false; }, - methods: { - goToNext() { - console.log("go to next"); - switch (this.$data.step) { - case "concerned": - this.$data.step = "household"; - break; - case "household": - if (this.$store.getters.isHouseholdNew) { - this.$data.step = "household_address"; - break; - } else if (this.$store.getters.isModeLeave) { - this.$data.step = "confirm"; - break; - } else { - this.$data.step = "positioning"; - break; - } - case "household_address": - this.$data.step = "positioning"; - break; - case "positioning": - this.$data.step = "confirm"; - break; - } - }, - goToPrevious() { - if (this.$data.step === "concerned") { - let params = new URLSearchParams(window.location.search); - if (params.has("returnPath")) { - window.location.replace(params.get("returnPath")); - } else { - return; - } - } + lastStepIsSaveAllowed() { + let r = + !this.$store.getters.isHouseholdNew || + (this.$store.state.numberOfChildren !== null && + this.$store.state.householdCompositionType !== null); + console.log("is saved allowed ?", r); - let s = this.steps; - let index = s.indexOf(this.$data.step); - if (s[index - 1] === undefined) { - throw Error("step not found"); - } - - this.$data.step = s[index - 1]; - }, - confirm() { - this.$store.dispatch("confirm"); - }, + return r; }, + }, + methods: { + goToNext() { + console.log("go to next"); + switch (this.$data.step) { + case "concerned": + this.$data.step = "household"; + break; + case "household": + if (this.$store.getters.isHouseholdNew) { + this.$data.step = "household_address"; + break; + } else if (this.$store.getters.isModeLeave) { + this.$data.step = "confirm"; + break; + } else { + this.$data.step = "positioning"; + break; + } + case "household_address": + this.$data.step = "positioning"; + break; + case "positioning": + this.$data.step = "confirm"; + break; + } + }, + goToPrevious() { + if (this.$data.step === "concerned") { + let params = new URLSearchParams(window.location.search); + if (params.has("returnPath")) { + window.location.replace(params.get("returnPath")); + } else { + return; + } + } + + let s = this.steps; + let index = s.indexOf(this.$data.step); + if (s[index - 1] === undefined) { + throw Error("step not found"); + } + + this.$data.step = s[index - 1]; + }, + confirm() { + this.$store.dispatch("confirm"); + }, + }, }; </script> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/Concerned.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/Concerned.vue index d4bacd855..ae422f150 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/Concerned.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/Concerned.vue @@ -1,88 +1,74 @@ <template> - <h2 class="mt-4"> - {{ $t("household_members_editor.concerned.title") }} - </h2> + <h2 class="mt-4"> + {{ $t("household_members_editor.concerned.title") }} + </h2> - <div v-if="noPerson"> - <div class="alert alert-info"> - {{ - $t("household_members_editor.concerned.add_at_least_onePerson") - }} - </div> + <div v-if="noPerson"> + <div class="alert alert-info"> + {{ $t("household_members_editor.concerned.add_at_least_onePerson") }} </div> - <div v-else> - <p> - {{ - $t("household_members_editor.concerned.persons_will_be_moved") - }} : - </p> + </div> + <div v-else> + <p> + {{ + $t("household_members_editor.concerned.persons_will_be_moved") + }} : + </p> - <ul class="list-suggest remove-items inline"> - <li - v-for="c in concerned" - :key="c.person.id" - @click="removeConcerned(c)" - > - <span><person-text :person="c.person" /></span> - </li> - </ul> - - <div - class="alert alert-info" - v-if="concernedPersonsWithHouseholds.length > 0" - > - <p> - {{ - $t( - "household_members_editor.concerned.persons_with_household", - ) - }} - </p> - <ul v-for="c in concernedPersonsWithHouseholds" :key="c.person.id"> - <li> - {{ c.person.text }} - {{ - $t( - "household_members_editor.concerned.already_belongs_to_household", - ) - }} - <a - target="_blank" - :href=" - this.makeHouseholdLink( - c.person.current_household_id, - ) - " - >{{ c.person.current_household_id }}</a - >. - </li> - </ul> - </div> - </div> - - <ul class="record_actions"> - <li class="add-persons"> - <add-persons - button-title="household_members_editor.concerned.add_persons" - modal-title="household_members_editor.concerned.search" - :key="addPersons.key" - :options="addPersons.options" - @add-new-persons="addNewPersons" - ref="addPersons" - > - <!-- to cast child method --> - </add-persons> - </li> + <ul class="list-suggest remove-items inline"> + <li v-for="c in concerned" :key="c.person.id" @click="removeConcerned(c)"> + <span><person-text :person="c.person" /></span> + </li> </ul> + + <div + class="alert alert-info" + v-if="concernedPersonsWithHouseholds.length > 0" + > + <p> + {{ $t("household_members_editor.concerned.persons_with_household") }} + </p> + <ul v-for="c in concernedPersonsWithHouseholds" :key="c.person.id"> + <li> + {{ c.person.text }} + {{ + $t( + "household_members_editor.concerned.already_belongs_to_household", + ) + }} + <a + target="_blank" + :href="this.makeHouseholdLink(c.person.current_household_id)" + >{{ c.person.current_household_id }}</a + >. + </li> + </ul> + </div> + </div> + + <ul class="record_actions"> + <li class="add-persons"> + <add-persons + button-title="household_members_editor.concerned.add_persons" + modal-title="household_members_editor.concerned.search" + :key="addPersons.key" + :options="addPersons.options" + @add-new-persons="addNewPersons" + ref="addPersons" + > + <!-- to cast child method --> + </add-persons> + </li> + </ul> </template> <style lang="scss"> .move_to { - .move_hint { - text-align: center; - display: inline-block; - padding: 0.4rem 0.5rem; - } + .move_hint { + text-align: center; + display: inline-block; + padding: 0.4rem 0.5rem; + } } </style> @@ -92,62 +78,61 @@ import AddPersons from "ChillPersonAssets/vuejs/_components/AddPersons.vue"; import PersonText from "ChillPersonAssets/vuejs/_components/Entity/PersonText.vue"; export default { - name: "Concerned", - components: { - AddPersons, - PersonText, + name: "Concerned", + components: { + AddPersons, + PersonText, + }, + computed: { + ...mapState(["concerned", "household"]), + ...mapGetters(["persons"]), + noPerson() { + return this.$store.getters.persons.length === 0; }, - computed: { - ...mapState(["concerned", "household"]), - ...mapGetters(["persons"]), - noPerson() { - return this.$store.getters.persons.length === 0; - }, - concernedPersonsWithHouseholds() { - if (this.$store.state.household) { - return this.$store.state.concerned.filter( - (c) => - c.person.current_household_id !== null && - c.person.current_household_id !== - this.$store.state.household.id, - ); - } else { - return []; - } - }, + concernedPersonsWithHouseholds() { + if (this.$store.state.household) { + return this.$store.state.concerned.filter( + (c) => + c.person.current_household_id !== null && + c.person.current_household_id !== this.$store.state.household.id, + ); + } else { + return []; + } }, - data() { - return { - addPersons: { - key: "household_members_editor_concerned", - options: { - type: ["person"], - priority: null, - uniq: false, - }, - }, - }; - }, - methods: { - addNewPersons({ selected, modal }) { - selected.forEach(function (item) { - this.$store.dispatch("addConcerned", item.result); - }, this); - this.$refs.addPersons.resetSearch(); // to cast child method - modal.showModal = false; + }, + data() { + return { + addPersons: { + key: "household_members_editor_concerned", + options: { + type: ["person"], + priority: null, + uniq: false, }, - removeConcerned(concerned) { - console.log("removedconcerned", concerned); + }, + }; + }, + methods: { + addNewPersons({ selected, modal }) { + selected.forEach(function (item) { + this.$store.dispatch("addConcerned", item.result); + }, this); + this.$refs.addPersons.resetSearch(); // to cast child method + modal.showModal = false; + }, + removeConcerned(concerned) { + console.log("removedconcerned", concerned); - if (!concerned.allowRemove) { - return; - } + if (!concerned.allowRemove) { + return; + } - this.$store.dispatch("removePerson", concerned.person); - }, - makeHouseholdLink(id) { - return `/fr/person/household/${id}/summary`; - }, + this.$store.dispatch("removePerson", concerned.person); }, + makeHouseholdLink(id) { + return `/fr/person/household/${id}/summary`; + }, + }, }; </script> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/Confirmation.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/Confirmation.vue index 902e95648..5441bc1af 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/Confirmation.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/Confirmation.vue @@ -1,20 +1,20 @@ <template> - <div v-if="hasWarning" class="alert alert-warning"> - {{ $t("household_members_editor.confirmation.there_are_warnings") }} - </div> + <div v-if="hasWarning" class="alert alert-warning"> + {{ $t("household_members_editor.confirmation.there_are_warnings") }} + </div> - <p v-if="hasWarning"> - {{ $t("household_members_editor.confirmation.check_those_items") }} - </p> + <p v-if="hasWarning"> + {{ $t("household_members_editor.confirmation.check_those_items") }} + </p> - <ul> - <li v-for="(msg, i) in warnings" class="warning" :key="i"> - {{ $t(msg.m, msg.a) }} - </li> - <li v-for="(msg, i) in errors" class="error" :key="i"> - {{ msg }} - </li> - </ul> + <ul> + <li v-for="(msg, i) in warnings" class="warning" :key="i"> + {{ $t(msg.m, msg.a) }} + </li> + <li v-for="(msg, i) in errors" class="error" :key="i"> + {{ msg }} + </li> + </ul> </template> <style scoped lang="scss"></style> @@ -23,14 +23,14 @@ import { mapState } from "vuex"; export default { - name: "Confirmation", - computed: { - ...mapState({ - hasWarnings: (state) => - state.warnings.length > 0 || state.errors.length > 0, - warnings: (state) => state.warnings, - errors: (state) => state.errors, - }), - }, + name: "Confirmation", + computed: { + ...mapState({ + hasWarnings: (state) => + state.warnings.length > 0 || state.errors.length > 0, + warnings: (state) => state.warnings, + errors: (state) => state.errors, + }), + }, }; </script> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/CurrentHousehold.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/CurrentHousehold.vue index 34002972c..3ae8c1ab6 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/CurrentHousehold.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/CurrentHousehold.vue @@ -1,37 +1,35 @@ <template> - <div class="flex-table mb-5" v-if="hasHousehold"> - <div class="item-bloc"> - <household-render-box :household="fakeHouseholdWithConcerned" /> - </div> + <div class="flex-table mb-5" v-if="hasHousehold"> + <div class="item-bloc"> + <household-render-box :household="fakeHouseholdWithConcerned" /> </div> - <div class="flex-table" v-if="isModeLeave"> - <div class="item-bloc"> - <section> - <div class="item-row"> - <div class="item-col"> - <div class="h4"> - <span class="fa-stack fa-lg"> - <i class="fa fa-home fa-stack-1x" /> - <i class="fa fa-ban fa-stack-2x text-danger" /> - </span> - {{ - $t( - "household_members_editor.household.leave_without_household", - ) - }} - </div> - </div> - </div> - <div class="item-row"> - {{ - $t( - "household_members_editor.household.will_leave_any_household_explanation", - ) - }} - </div> - </section> + </div> + <div class="flex-table" v-if="isModeLeave"> + <div class="item-bloc"> + <section> + <div class="item-row"> + <div class="item-col"> + <div class="h4"> + <span class="fa-stack fa-lg"> + <i class="fa fa-home fa-stack-1x" /> + <i class="fa fa-ban fa-stack-2x text-danger" /> + </span> + {{ + $t("household_members_editor.household.leave_without_household") + }} + </div> + </div> </div> + <div class="item-row"> + {{ + $t( + "household_members_editor.household.will_leave_any_household_explanation", + ) + }} + </div> + </section> </div> + </div> </template> <script> @@ -39,17 +37,17 @@ import { mapGetters } from "vuex"; import HouseholdRenderBox from "ChillPersonAssets/vuejs/_components/Entity/HouseholdRenderBox.vue"; export default { - name: "CurrentHousehold", - components: { - HouseholdRenderBox, - }, - computed: { - ...mapGetters([ - "hasHousehold", - "fakeHouseholdWithConcerned", - "isModeLeave", - ]), - }, + name: "CurrentHousehold", + components: { + HouseholdRenderBox, + }, + computed: { + ...mapGetters([ + "hasHousehold", + "fakeHouseholdWithConcerned", + "isModeLeave", + ]), + }, }; </script> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/Dates.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/Dates.vue index 8757f23bc..1139d2fe9 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/Dates.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/Dates.vue @@ -1,83 +1,83 @@ <template> - <current-household /> + <current-household /> - <h2>{{ $t("household_members_editor.dates.dates_title") }}</h2> + <h2>{{ $t("household_members_editor.dates.dates_title") }}</h2> + <div class="mb-3 row"> + <label for="start_date" class="col-form-label col-sm-4 required"> + {{ $t("household_members_editor.dates.start_date") }} + </label> + <div class="col-sm-8"> + <input type="date" v-model="startDate" class="form-control" /> + </div> + </div> + + <div v-if="this.isHouseholdNew"> + <h2>{{ $t("household_members_editor.composition.composition") }}</h2> <div class="mb-3 row"> - <label for="start_date" class="col-form-label col-sm-4 required"> - {{ $t("household_members_editor.dates.start_date") }} - </label> - <div class="col-sm-8"> - <input type="date" v-model="startDate" class="form-control" /> - </div> + <label class="col-form-label col-sm-4 required">{{ + $t("household_members_editor.composition.household_composition") + }}</label> + <div class="col-sm-8"> + <select + v-model="householdCompositionType" + class="form-select form-control" + > + <option + v-for="t in householdCompositionTypes" + :key="t.id" + :value="t.id" + > + {{ localizeString(t.label) }} + </option> + </select> + </div> </div> - - <div v-if="this.isHouseholdNew"> - <h2>{{ $t("household_members_editor.composition.composition") }}</h2> - <div class="mb-3 row"> - <label class="col-form-label col-sm-4 required">{{ - $t("household_members_editor.composition.household_composition") - }}</label> - <div class="col-sm-8"> - <select - v-model="householdCompositionType" - class="form-select form-control" - > - <option - v-for="t in householdCompositionTypes" - :key="t.id" - :value="t.id" - > - {{ localizeString(t.label) }} - </option> - </select> - </div> - </div> - <div class="mb-3 row"> - <label class="col-form-label col-sm-4 required">{{ - $t("household_members_editor.composition.number_of_children") - }}</label> - <div class="col-sm-8"> - <input - type="number" - v-model="numberOfChildren" - min="0" - max="30" - class="form-control" - /> - </div> - </div> - <div v-if="this.displayDependents" class="mb-3 row"> - <label class="col-form-label col-sm-4 required">{{ - $t("household_members_editor.composition.number_of_dependents") - }}</label> - <div class="col-sm-8"> - <input - type="number" - v-model="numberOfDependents" - min="0" - max="30" - class="form-control" - /> - </div> - </div> - <div v-if="this.displayDependents" class="mb-3 row"> - <label class="col-form-label col-sm-4 required">{{ - $t( - "household_members_editor.composition.number_of_dependents_with_disabilities", - ) - }}</label> - <div class="col-sm-8"> - <input - type="number" - v-model="numberOfDependentsWithDisabilities" - min="0" - max="30" - class="form-control" - /> - </div> - </div> + <div class="mb-3 row"> + <label class="col-form-label col-sm-4 required">{{ + $t("household_members_editor.composition.number_of_children") + }}</label> + <div class="col-sm-8"> + <input + type="number" + v-model="numberOfChildren" + min="0" + max="30" + class="form-control" + /> + </div> </div> + <div v-if="this.displayDependents" class="mb-3 row"> + <label class="col-form-label col-sm-4 required">{{ + $t("household_members_editor.composition.number_of_dependents") + }}</label> + <div class="col-sm-8"> + <input + type="number" + v-model="numberOfDependents" + min="0" + max="30" + class="form-control" + /> + </div> + </div> + <div v-if="this.displayDependents" class="mb-3 row"> + <label class="col-form-label col-sm-4 required">{{ + $t( + "household_members_editor.composition.number_of_dependents_with_disabilities", + ) + }}</label> + <div class="col-sm-8"> + <input + type="number" + v-model="numberOfDependentsWithDisabilities" + min="0" + max="30" + class="form-control" + /> + </div> + </div> + </div> </template> <script> @@ -86,74 +86,71 @@ import { mapGetters, mapState } from "vuex"; import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper"; export default { - name: "Dates", - methods: { localizeString }, - components: { - CurrentHousehold, + name: "Dates", + methods: { localizeString }, + components: { + CurrentHousehold, + }, + computed: { + ...mapState(["householdCompositionTypes"]), + ...mapGetters(["isHouseholdNew"]), + displayDependents: { + get() { + return window.household_members_editor_data.displayDependents; + }, }, - computed: { - ...mapState(["householdCompositionTypes"]), - ...mapGetters(["isHouseholdNew"]), - displayDependents: { - get() { - return window.household_members_editor_data.displayDependents; - }, - }, - householdCompositionType: { - get() { - if (this.$store.state.householdCompositionType !== null) { - return this.$store.state.householdCompositionType.id; - } - return null; - }, - set(value) { - this.$store.dispatch("setHouseholdCompositionType", value); - }, - }, - numberOfChildren: { - get() { - return this.$store.state.numberOfChildren; - }, - set(value) { - this.$store.commit("setNumberOfChildren", value); - }, - }, - numberOfDependents: { - get() { - return this.$store.state.numberOfDependents; - }, - set(value) { - this.$store.commit("setNumberOfDependents", value); - }, - }, - numberOfDependentsWithDisabilities: { - get() { - return this.$store.state.numberOfDependentsWithDisabilities; - }, - set(value) { - this.$store.commit( - "setNumberOfDependentsWithDisabilities", - value, - ); - }, - }, - startDate: { - get() { - return this.$store.state.startDate; - // return [ - // this.$store.state.startDate.getFullYear(), - // (this.$store.state.startDate.getMonth() + 1).toString().padStart(2, '0'), - // this.$store.state.startDate.getDate().toString().padStart(2, '0') - // ].join('-'); - }, - set(value) { - // let - // [year, month, day] = value.split('-'), - // dValue = new Date(year, month-1, day); + householdCompositionType: { + get() { + if (this.$store.state.householdCompositionType !== null) { + return this.$store.state.householdCompositionType.id; + } + return null; + }, + set(value) { + this.$store.dispatch("setHouseholdCompositionType", value); + }, + }, + numberOfChildren: { + get() { + return this.$store.state.numberOfChildren; + }, + set(value) { + this.$store.commit("setNumberOfChildren", value); + }, + }, + numberOfDependents: { + get() { + return this.$store.state.numberOfDependents; + }, + set(value) { + this.$store.commit("setNumberOfDependents", value); + }, + }, + numberOfDependentsWithDisabilities: { + get() { + return this.$store.state.numberOfDependentsWithDisabilities; + }, + set(value) { + this.$store.commit("setNumberOfDependentsWithDisabilities", value); + }, + }, + startDate: { + get() { + return this.$store.state.startDate; + // return [ + // this.$store.state.startDate.getFullYear(), + // (this.$store.state.startDate.getMonth() + 1).toString().padStart(2, '0'), + // this.$store.state.startDate.getDate().toString().padStart(2, '0') + // ].join('-'); + }, + set(value) { + // let + // [year, month, day] = value.split('-'), + // dValue = new Date(year, month-1, day); - this.$store.dispatch("setStartDate", value); - }, - }, + this.$store.dispatch("setStartDate", value); + }, }, + }, }; </script> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/Household.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/Household.vue index 9773d178e..56798341a 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/Household.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/Household.vue @@ -1,130 +1,116 @@ <template> - <h2 class="mt-4"> - {{ $t("household_members_editor.household_part") }} - </h2> + <h2 class="mt-4"> + {{ $t("household_members_editor.household_part") }} + </h2> - <div class="alert alert-info" v-if="!hasHousehold"> - {{ $t("household_members_editor.household.no_household_choose_one") }} - </div> - <template v-else> - <current-household /> - </template> + <div class="alert alert-info" v-if="!hasHousehold"> + {{ $t("household_members_editor.household.no_household_choose_one") }} + </div> + <template v-else> + <current-household /> + </template> - <div v-if="hasHouseholdSuggestion" class="householdSuggestions my-5"> - <h4 class="mb-3"> - {{ $t("household_members_editor.household.household_suggested") }} - </h4> - <p> + <div v-if="hasHouseholdSuggestion" class="householdSuggestions my-5"> + <h4 class="mb-3"> + {{ $t("household_members_editor.household.household_suggested") }} + </h4> + <p> + {{ + $t("household_members_editor.household.household_suggested_explanation") + }} + </p> + <div class="accordion" id="householdSuggestions"> + <div class="accordion-item"> + <h2 class="accordion-header" id="heading_household_suggestions"> + <button + v-if="!showHouseholdSuggestion" + class="accordion-button collapsed" + type="button" + data-bs-toggle="collapse" + aria-expanded="false" + @click="toggleHouseholdSuggestion" + > {{ - $t( - "household_members_editor.household.household_suggested_explanation", - ) + $tc( + "household_members_editor.show_household_suggestion", + countHouseholdSuggestion, + ) }} - </p> - <div class="accordion" id="householdSuggestions"> - <div class="accordion-item"> - <h2 class="accordion-header" id="heading_household_suggestions"> + </button> + <button + v-if="showHouseholdSuggestion" + class="accordion-button" + type="button" + data-bs-toggle="collapse" + aria-expanded="true" + @click="toggleHouseholdSuggestion" + > + {{ $t("household_members_editor.hide_household_suggestion") }} + </button> + <!-- disabled bootstrap behaviour: data-bs-target="#collapse_household_suggestions" aria-controls="collapse_household_suggestions" --> + </h2> + <div + class="accordion-collapse" + id="collapse_household_suggestions" + aria-labelledby="heading_household_suggestions" + data-bs-parent="#householdSuggestions" + > + <div v-if="showHouseholdSuggestion"> + <div class="flex-table householdSuggestionList"> + <div + v-for="(s, i) in getSuggestions" + class="item-bloc" + :key="`householdSuggestions-${i}`" + > + <household-render-box :household="s.household" /> + <ul class="record_actions"> + <li> <button - v-if="!showHouseholdSuggestion" - class="accordion-button collapsed" - type="button" - data-bs-toggle="collapse" - aria-expanded="false" - @click="toggleHouseholdSuggestion" + class="btn btn-sm btn-choose" + @click="selectHousehold(s.household)" > - {{ - $tc( - "household_members_editor.show_household_suggestion", - countHouseholdSuggestion, - ) - }} + {{ $t("household_members_editor.select_household") }} </button> - <button - v-if="showHouseholdSuggestion" - class="accordion-button" - type="button" - data-bs-toggle="collapse" - aria-expanded="true" - @click="toggleHouseholdSuggestion" - > - {{ - $t( - "household_members_editor.hide_household_suggestion", - ) - }} - </button> - <!-- disabled bootstrap behaviour: data-bs-target="#collapse_household_suggestions" aria-controls="collapse_household_suggestions" --> - </h2> - <div - class="accordion-collapse" - id="collapse_household_suggestions" - aria-labelledby="heading_household_suggestions" - data-bs-parent="#householdSuggestions" - > - <div v-if="showHouseholdSuggestion"> - <div class="flex-table householdSuggestionList"> - <div - v-for="(s, i) in getSuggestions" - class="item-bloc" - :key="`householdSuggestions-${i}`" - > - <household-render-box - :household="s.household" - /> - <ul class="record_actions"> - <li> - <button - class="btn btn-sm btn-choose" - @click=" - selectHousehold(s.household) - " - > - {{ - $t( - "household_members_editor.select_household", - ) - }} - </button> - </li> - </ul> - </div> - </div> - </div> - </div> + </li> + </ul> + </div> </div> + </div> </div> + </div> </div> + </div> - <ul class="record_actions"> - <li v-if="hasHousehold"> - <button @click="resetMode" class="btn btn-sm btn-misc"> - {{ $t("household_members_editor.household.reset_mode") }} - </button> - </li> - <li v-if="!hasHousehold" class="add-persons"> - <add-persons - modal-title="Chercher un ménage existant" - button-title="Chercher un ménage existant" - :key="addPersons.key" - :options="addPersons.options" - @add-new-persons="pickHouseholdFound" - ref="pickHousehold" - > - <!-- to cast child method --> - </add-persons> - </li> - <li v-if="!hasHousehold"> - <button @click="setModeNew" class="btn btn-sm btn-create"> - {{ $t("household_members_editor.household.create_household") }} - </button> - </li> - <li v-if="isModeLeaveAllowed && !hasHousehold"> - <button @click="setModeLeave" class="btn btn-sm btn-misc"> - <i class="fa fa-sign-out" /> - {{ $t("household_members_editor.household.leave") }} - </button> - </li> - </ul> + <ul class="record_actions"> + <li v-if="hasHousehold"> + <button @click="resetMode" class="btn btn-sm btn-misc"> + {{ $t("household_members_editor.household.reset_mode") }} + </button> + </li> + <li v-if="!hasHousehold" class="add-persons"> + <add-persons + modal-title="Chercher un ménage existant" + button-title="Chercher un ménage existant" + :key="addPersons.key" + :options="addPersons.options" + @add-new-persons="pickHouseholdFound" + ref="pickHousehold" + > + <!-- to cast child method --> + </add-persons> + </li> + <li v-if="!hasHousehold"> + <button @click="setModeNew" class="btn btn-sm btn-create"> + {{ $t("household_members_editor.household.create_household") }} + </button> + </li> + <li v-if="isModeLeaveAllowed && !hasHousehold"> + <button @click="setModeLeave" class="btn btn-sm btn-misc"> + <i class="fa fa-sign-out" /> + {{ $t("household_members_editor.household.leave") }} + </button> + </li> + </ul> </template> <script> @@ -134,156 +120,154 @@ import CurrentHousehold from "./CurrentHousehold"; import AddPersons from "ChillPersonAssets/vuejs/_components/AddPersons"; export default { - name: "Household", - components: { - AddPersons, - CurrentHousehold, - HouseholdRenderBox, - }, - emits: ["readyToGo"], - data() { - return { - addPersons: { - key: "household_find", - options: { - type: ["household"], - priority: null, - uniq: true, - button: { - size: "btn-sm", - type: "btn-search", - }, - }, + name: "Household", + components: { + AddPersons, + CurrentHousehold, + HouseholdRenderBox, + }, + emits: ["readyToGo"], + data() { + return { + addPersons: { + key: "household_find", + options: { + type: ["household"], + priority: null, + uniq: true, + button: { + size: "btn-sm", + type: "btn-search", + }, + }, + }, + addAddress: { + key: "household_new", + options: { + useDate: { + validFrom: false, + validTo: false, + }, + onlyButton: true, + button: { + text: { + create: "household_members_editor.household.set_address", + edit: "household_members_editor.household.update_address", }, - addAddress: { - key: "household_new", - options: { - useDate: { - validFrom: false, - validTo: false, - }, - onlyButton: true, - button: { - text: { - create: "household_members_editor.household.set_address", - edit: "household_members_editor.household.update_address", - }, - }, - title: { - create: "household_members_editor.household.create_new_address", - edit: "household_members_editor.household.update_address_title", - }, - }, - }, - }; + }, + title: { + create: "household_members_editor.household.create_new_address", + edit: "household_members_editor.household.update_address_title", + }, + }, + }, + }; + }, + computed: { + ...mapGetters([ + "isModeNewAllowed", + "isModeLeaveAllowed", + "getSuggestions", + "hasHousehold", + "isHouseholdNew", + "hasHouseholdSuggestion", + "countHouseholdSuggestion", + "filterHouseholdSuggestionByAccompanyingPeriod", + "hasAddressSuggestion", + "countAddressSuggestion", + "filterAddressesSuggestion", + "hasHouseholdAddress", + "isModeLeave", + "getAddressContext", + ]), + ...mapState([ + "household", + "showHouseholdSuggestion", + "showAddressSuggestion", + "mode", + ]), + household() { + return this.$store.state.household; }, - computed: { - ...mapGetters([ - "isModeNewAllowed", - "isModeLeaveAllowed", - "getSuggestions", - "hasHousehold", - "isHouseholdNew", - "hasHouseholdSuggestion", - "countHouseholdSuggestion", - "filterHouseholdSuggestionByAccompanyingPeriod", - "hasAddressSuggestion", - "countAddressSuggestion", - "filterAddressesSuggestion", - "hasHouseholdAddress", - "isModeLeave", - "getAddressContext", - ]), - ...mapState([ - "household", - "showHouseholdSuggestion", - "showAddressSuggestion", - "mode", - ]), - household() { - return this.$store.state.household; - }, - allowHouseholdSearch() { - return false; - return ( - this.$store.state.allowHouseholdSearch && - !this.$store.getters.hasHousehold - ); - }, - isHouseholdNewDesactivated() { - return ( - this.$store.state.mode !== null && - !this.$store.getters.isHouseholdNew - ); - }, - isHouseholdLeaveDesactivated() { - return ( - this.$store.state.mode !== null && - this.$store.state.mode !== "leave" - ); - }, + allowHouseholdSearch() { + return false; + return ( + this.$store.state.allowHouseholdSearch && + !this.$store.getters.hasHousehold + ); }, - methods: { - setModeNew() { - this.$store.dispatch("createHousehold"); - this.$emit("readyToGo"); - }, - setModeLeave() { - this.$store.dispatch("forceLeaveWithoutHousehold"); - this.$emit("readyToGo"); - }, - resetMode() { - this.$store.commit("resetMode"); - }, - addressChanged(payload) { - console.log("addressChanged", payload); - this.$store.dispatch("setHouseholdNewAddress", payload.address); - }, - selectHousehold(h) { - this.$store.dispatch("selectHousehold", h); - this.$emit("readyToGo"); - }, - pickHouseholdFound({ selected, modal }) { - selected.forEach(function (item) { - this.selectHousehold(item.result); - }, this); - this.$refs.pickHousehold.resetSearch(); // to cast child method - modal.showModal = false; - }, - removeHouseholdAddress() { - this.$store.commit("removeHouseholdAddress"); - }, - toggleHouseholdSuggestion() { - this.$store.commit("toggleHouseholdSuggestion"); - }, + isHouseholdNewDesactivated() { + return ( + this.$store.state.mode !== null && !this.$store.getters.isHouseholdNew + ); }, + isHouseholdLeaveDesactivated() { + return ( + this.$store.state.mode !== null && this.$store.state.mode !== "leave" + ); + }, + }, + methods: { + setModeNew() { + this.$store.dispatch("createHousehold"); + this.$emit("readyToGo"); + }, + setModeLeave() { + this.$store.dispatch("forceLeaveWithoutHousehold"); + this.$emit("readyToGo"); + }, + resetMode() { + this.$store.commit("resetMode"); + }, + addressChanged(payload) { + console.log("addressChanged", payload); + this.$store.dispatch("setHouseholdNewAddress", payload.address); + }, + selectHousehold(h) { + this.$store.dispatch("selectHousehold", h); + this.$emit("readyToGo"); + }, + pickHouseholdFound({ selected, modal }) { + selected.forEach(function (item) { + this.selectHousehold(item.result); + }, this); + this.$refs.pickHousehold.resetSearch(); // to cast child method + modal.showModal = false; + }, + removeHouseholdAddress() { + this.$store.commit("removeHouseholdAddress"); + }, + toggleHouseholdSuggestion() { + this.$store.commit("toggleHouseholdSuggestion"); + }, + }, }; </script> <style lang="scss" scoped> .filtered { - filter: grayscale(1) opacity(0.6); + filter: grayscale(1) opacity(0.6); } .filteredButActive { - filter: grayscale(1) opacity(0.6); - &:hover { - filter: unset; - } + filter: grayscale(1) opacity(0.6); + &:hover { + filter: unset; + } } div#household_members_editor div, div.householdSuggestionList { - &.flex-table { - margin: 0; - div.item-bloc div.item-row div.item-col { - &:first-child { - width: 25%; - } - &:last-child { - display: initial; - } - } + &.flex-table { + margin: 0; + div.item-bloc div.item-row div.item-col { + &:first-child { + width: 25%; + } + &:last-child { + display: initial; + } } + } } </style> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/HouseholdAddress.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/HouseholdAddress.vue index af6f00d84..c7d9d8aac 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/HouseholdAddress.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/HouseholdAddress.vue @@ -1,30 +1,26 @@ <template> - <current-household /> + <current-household /> - <ul class="record_actions"> - <!-- <li v-if="!hasHouseholdAddress && !isHouseholdForceAddress"> + <ul class="record_actions"> + <!-- <li v-if="!hasHouseholdAddress && !isHouseholdForceAddress"> <button class="btn btn-misc" @click="markNoAddress"> {{ $t('household_members_editor.household_address.mark_no_address') }} </button> </li> --> - <li v-if="!hasHouseholdAddress"> - <add-address - :context="getAddressContext" - :key="addAddress.key" - :options="addAddress.options" - :address-changed-callback="addressChanged" - /> - </li> - <li v-if="hasHouseholdAddress"> - <button class="btn btn-remove" @click="removeHouseholdAddress"> - {{ - $t( - "household_members_editor.household_address.remove_address", - ) - }} - </button> - </li> - </ul> + <li v-if="!hasHouseholdAddress"> + <add-address + :context="getAddressContext" + :key="addAddress.key" + :options="addAddress.options" + :address-changed-callback="addressChanged" + /> + </li> + <li v-if="hasHouseholdAddress"> + <button class="btn btn-remove" @click="removeHouseholdAddress"> + {{ $t("household_members_editor.household_address.remove_address") }} + </button> + </li> + </ul> </template> <script> @@ -33,55 +29,56 @@ import CurrentHousehold from "./CurrentHousehold"; import { mapGetters } from "vuex"; export default { - name: "HouseholdAddress.vue", - components: { - CurrentHousehold, - AddAddress, - }, - data() { - return { - addAddress: { - key: "household_new", - options: { - useDate: { - validFrom: false, - validTo: false, - }, - onlyButton: true, - button: { - text: { - create: "household_members_editor.household_address.set_address", - edit: "household_members_editor.household_address.update_address", - }, - }, - title: { - create: "household_members_editor.household_address.create_new_address", - edit: "household_members_editor.household_address.update_address_title", - }, - }, + name: "HouseholdAddress.vue", + components: { + CurrentHousehold, + AddAddress, + }, + data() { + return { + addAddress: { + key: "household_new", + options: { + useDate: { + validFrom: false, + validTo: false, + }, + onlyButton: true, + button: { + text: { + create: "household_members_editor.household_address.set_address", + edit: "household_members_editor.household_address.update_address", }, - }; - }, - computed: { - ...mapGetters([ - "isHouseholdNew", - "hasHouseholdAddress", - "getAddressContext", - "isHouseholdForceNoAddress", - ]), - }, - methods: { - addressChanged(payload) { - console.log("addressChanged", payload); - this.$store.dispatch("setHouseholdNewAddress", payload.address); - }, - markNoAddress() { - this.$store.commit("markHouseholdNoAddress"); - }, - removeHouseholdAddress() { - this.$store.commit("removeHouseholdAddress"); + }, + title: { + create: + "household_members_editor.household_address.create_new_address", + edit: "household_members_editor.household_address.update_address_title", + }, }, + }, + }; + }, + computed: { + ...mapGetters([ + "isHouseholdNew", + "hasHouseholdAddress", + "getAddressContext", + "isHouseholdForceNoAddress", + ]), + }, + methods: { + addressChanged(payload) { + console.log("addressChanged", payload); + this.$store.dispatch("setHouseholdNewAddress", payload.address); }, + markNoAddress() { + this.$store.commit("markHouseholdNoAddress"); + }, + removeHouseholdAddress() { + this.$store.commit("removeHouseholdAddress"); + }, + }, }; </script> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/MemberDetails.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/MemberDetails.vue index 13515efec..3bdfb9d03 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/MemberDetails.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/MemberDetails.vue @@ -1,104 +1,102 @@ <template> - <div class="item-bloc"> - <div class="item-row"> - <div class="item-col"> - <div> - <person-render-box - render="badge" - :options="{}" - :person="conc.person" - /> - <span v-if="isHolder" class="badge bg-primary holder"> - {{ $t("household_members_editor.holder") }} - </span> - </div> - <div v-if="conc.person.birthdate !== null"> - {{ - $t("person.born", { - gender: conc.person.gender.genderTranslation, - }) - }} - </div> - </div> - <div class="item-col"> - <ul class="list-content fa-ul"> - <li> - <i class="fa fa-li fa-map-marker" /> - <span class="chill-no-data-statement" - >Sans adresse</span - > - </li> - </ul> - </div> + <div class="item-bloc"> + <div class="item-row"> + <div class="item-col"> + <div> + <person-render-box + render="badge" + :options="{}" + :person="conc.person" + /> + <span v-if="isHolder" class="badge bg-primary holder"> + {{ $t("household_members_editor.holder") }} + </span> </div> - - <div class="item-row comment"> - <comment-editor v-model="comment" /> - </div> - - <div class="item-row participation-details"> - <div v-if="conc.position.allowHolder" class="action"> - <button - class="btn" - :class="{ - 'btn-primary': isHolder, - 'btn-secondary': !isHolder, - }" - @click="toggleHolder" - > - {{ - $t( - isHolder - ? "household_members_editor.is_holder" - : "household_members_editor.is_not_holder", - ) - }} - </button> - </div> - - <div> - <button @click="removePosition" class="btn btn-outline-primary"> - {{ - $t("household_members_editor.remove_position", { - position: conc.position.label.fr, - }) - }} - </button> - </div> - - <div> - <button - v-if="conc.allowRemove" - @click="removeConcerned" - class="btn btn-primary" - > - {{ $t("household_members_editor.remove_concerned") }} - </button> - </div> + <div v-if="conc.person.birthdate !== null"> + {{ + $t("person.born", { + gender: conc.person.gender.genderTranslation, + }) + }} </div> + </div> + <div class="item-col"> + <ul class="list-content fa-ul"> + <li> + <i class="fa fa-li fa-map-marker" /> + <span class="chill-no-data-statement">Sans adresse</span> + </li> + </ul> + </div> </div> + + <div class="item-row comment"> + <comment-editor v-model="comment" /> + </div> + + <div class="item-row participation-details"> + <div v-if="conc.position.allowHolder" class="action"> + <button + class="btn" + :class="{ + 'btn-primary': isHolder, + 'btn-secondary': !isHolder, + }" + @click="toggleHolder" + > + {{ + $t( + isHolder + ? "household_members_editor.is_holder" + : "household_members_editor.is_not_holder", + ) + }} + </button> + </div> + + <div> + <button @click="removePosition" class="btn btn-outline-primary"> + {{ + $t("household_members_editor.remove_position", { + position: conc.position.label.fr, + }) + }} + </button> + </div> + + <div> + <button + v-if="conc.allowRemove" + @click="removeConcerned" + class="btn btn-primary" + > + {{ $t("household_members_editor.remove_concerned") }} + </button> + </div> + </div> + </div> </template> <style scoped lang="scss"> .drag-icon { - height: 1.1em; - margin-right: 0.5em; + height: 1.1em; + margin-right: 0.5em; } div.participation-details { - display: flex; - flex-direction: row !important; - justify-content: flex-end; + display: flex; + flex-direction: row !important; + justify-content: flex-end; - .action { - align-self: flex-start; - margin-right: auto; - } + .action { + align-self: flex-start; + margin-right: auto; + } } .holder { - display: inline; - vertical-align: super; - font-size: 0.6em; + display: inline; + vertical-align: super; + font-size: 0.6em; } </style> @@ -108,44 +106,44 @@ import PersonRenderBox from "ChillPersonAssets/vuejs/_components/Entity/PersonRe import CommentEditor from "ChillMainAssets/vuejs/_components/CommentEditor/CommentEditor.vue"; export default { - name: "MemberDetails", - components: { - PersonRenderBox, - CommentEditor, + name: "MemberDetails", + components: { + PersonRenderBox, + CommentEditor, + }, + props: ["conc"], + computed: { + ...mapGetters(["concByPersonId"]), + classicEditor: () => ClassicEditor, + editorConfig: () => classicEditorConfig, + isHolder() { + return this.conc.holder; }, - props: ["conc"], - computed: { - ...mapGetters(["concByPersonId"]), - classicEditor: () => ClassicEditor, - editorConfig: () => classicEditorConfig, - isHolder() { - return this.conc.holder; - }, - comment: { - get() { - return this.conc.comment; - }, - set(text) { - console.log("set comment"); - console.log("comment", text); + comment: { + get() { + return this.conc.comment; + }, + set(text) { + console.log("set comment"); + console.log("comment", text); - this.$store.dispatch("setComment", { - conc: this.conc, - comment: text, - }); - }, - }, + this.$store.dispatch("setComment", { + conc: this.conc, + comment: text, + }); + }, }, - methods: { - toggleHolder() { - this.$store.dispatch("toggleHolder", this.conc); - }, - removePosition() { - this.$store.dispatch("removePosition", this.conc); - }, - removeConcerned() { - this.$store.dispatch("removeConcerned", this.conc); - }, + }, + methods: { + toggleHolder() { + this.$store.dispatch("toggleHolder", this.conc); }, + removePosition() { + this.$store.dispatch("removePosition", this.conc); + }, + removeConcerned() { + this.$store.dispatch("removeConcerned", this.conc); + }, + }, }; </script> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/PersonComment.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/PersonComment.vue index 6630a72f1..99fb3ebfa 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/PersonComment.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/PersonComment.vue @@ -1,30 +1,30 @@ <template> - <comment-editor v-model="content" /> + <comment-editor v-model="content" /> </template> <script> import CommentEditor from "ChillMainAssets/vuejs/_components/CommentEditor/CommentEditor.vue"; export default { - name: "PersonComment.vue", - components: { - CommentEditor, - }, - props: ["conc"], - computed: { - content: { - get() { - return this.$props.conc.comment || ""; - }, - set(value) { - console.log("set content", value); - this.$store.commit("setComment", { - conc: this.$props.conc, - comment: value, - }); - }, - }, + name: "PersonComment.vue", + components: { + CommentEditor, + }, + props: ["conc"], + computed: { + content: { + get() { + return this.$props.conc.comment || ""; + }, + set(value) { + console.log("set content", value); + this.$store.commit("setComment", { + conc: this.$props.conc, + comment: value, + }); + }, }, + }, }; </script> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/Positioning.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/Positioning.vue index a19d80c7a..26ab0058e 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/Positioning.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/HouseholdMembersEditor/components/Positioning.vue @@ -1,57 +1,57 @@ <template> - <current-household /> + <current-household /> - <h2> - {{ $t("household_members_editor.positioning.persons_to_positionnate") }} - </h2> + <h2> + {{ $t("household_members_editor.positioning.persons_to_positionnate") }} + </h2> - <div class="list-household-members flex-table"> - <div v-for="conc in concerned" class="item-bloc" :key="conc.person.id"> - <div class="pick-position item-row"> - <div class="person"> - <!-- <h3>{{ conc.person.text }}</h3> --> - <h3><person-text :person="conc.person" /></h3> - </div> - <div class="holder"> - <button - class="btn" - :disabled="!allowHolderForConcerned(conc)" - :class="{ - 'btn-outline-chill-green': !conc.holder, - 'btn-chill-green': conc.holder, - }" - @click="toggleHolder(conc)" - > - {{ $t("household_members_editor.positioning.holder") }} - </button> - </div> - <div - v-for="(position, i) in positions" - :key="`position-${i}`" - class="position" - > - <button - class="btn" - :class="{ - 'btn-primary': conc.position === position, - 'btn-outline-primary': conc.position !== position, - }" - @click="moveToPosition(conc.person.id, position.id)" - > - {{ localizeString(position.label) }} - </button> - </div> - </div> - <div class="item-row"> - <div class="col-12"> - <h6> - {{ $t("household_members_editor.positioning.comment") }} - </h6> - <person-comment :conc="conc" /> - </div> - </div> + <div class="list-household-members flex-table"> + <div v-for="conc in concerned" class="item-bloc" :key="conc.person.id"> + <div class="pick-position item-row"> + <div class="person"> + <!-- <h3>{{ conc.person.text }}</h3> --> + <h3><person-text :person="conc.person" /></h3> </div> + <div class="holder"> + <button + class="btn" + :disabled="!allowHolderForConcerned(conc)" + :class="{ + 'btn-outline-chill-green': !conc.holder, + 'btn-chill-green': conc.holder, + }" + @click="toggleHolder(conc)" + > + {{ $t("household_members_editor.positioning.holder") }} + </button> + </div> + <div + v-for="(position, i) in positions" + :key="`position-${i}`" + class="position" + > + <button + class="btn" + :class="{ + 'btn-primary': conc.position === position, + 'btn-outline-primary': conc.position !== position, + }" + @click="moveToPosition(conc.person.id, position.id)" + > + {{ localizeString(position.label) }} + </button> + </div> + </div> + <div class="item-row"> + <div class="col-12"> + <h6> + {{ $t("household_members_editor.positioning.comment") }} + </h6> + <person-comment :conc="conc" /> + </div> + </div> </div> + </div> </template> <script> @@ -62,61 +62,61 @@ import PersonText from "../../_components/Entity/PersonText.vue"; import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper"; export default { - name: "Positioning", - components: { - CurrentHousehold, - PersonComment, - PersonText, + name: "Positioning", + components: { + CurrentHousehold, + PersonComment, + PersonText, + }, + computed: { + ...mapState(["concerned"]), + ...mapGetters([ + "persons", + "concUnpositionned", + "positions", + "concByPosition", + ]), + allPersonsPositionnated() { + return ( + this.$store.getters.persons.length > 0 && + this.$store.getters.concUnpositionned.length === 0 + ); }, - computed: { - ...mapState(["concerned"]), - ...mapGetters([ - "persons", - "concUnpositionned", - "positions", - "concByPosition", - ]), - allPersonsPositionnated() { - return ( - this.$store.getters.persons.length > 0 && - this.$store.getters.concUnpositionned.length === 0 - ); - }, - allowHolderForConcerned: () => (conc) => { - console.log("allow holder for concerned", conc); - if (conc.position === null) { - return false; - } + allowHolderForConcerned: () => (conc) => { + console.log("allow holder for concerned", conc); + if (conc.position === null) { + return false; + } - return conc.position.allowHolder; - }, + return conc.position.allowHolder; }, - methods: { - localizeString, - moveToPosition(person_id, position_id) { - this.$store.dispatch("markPosition", { person_id, position_id }); - }, - toggleHolder(conc) { - console.log("toggle holder", conc); - this.$store.dispatch("toggleHolder", conc); - }, + }, + methods: { + localizeString, + moveToPosition(person_id, position_id) { + this.$store.dispatch("markPosition", { person_id, position_id }); }, + toggleHolder(conc) { + console.log("toggle holder", conc); + this.$store.dispatch("toggleHolder", conc); + }, + }, }; </script> <style lang="scss" scoped> .pick-position { - margin: 0; - padding: 0; - display: flex; - justify-content: flex-end; - align-items: center; + margin: 0; + padding: 0; + display: flex; + justify-content: flex-end; + align-items: center; - .person { - margin-right: auto; - } - .holder { - margin-right: 1.2rem; - } + .person { + margin-right: auto; + } + .holder { + margin-right: 1.2rem; + } } </style> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/VisGraph/App.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/VisGraph/App.vue index 401f41be5..e9ee85164 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/VisGraph/App.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/VisGraph/App.vue @@ -1,211 +1,181 @@ <template> - <div id="visgraph" /> + <div id="visgraph" /> - <teleport to="#visgraph-legend"> - <div class="post-menu"> - <div class="list-group mt-4"> - <button - type="button" - class="list-group-item list-group-item-action btn btn-misc" - @click="createRelationship" - > - <i class="fa fa-plus" /> {{ $t("visgraph.add_link") }} - </button> - <a - type="button" - class="list-group-item list-group-item-action btn btn-misc" - id="exportCanvasBtn" - @click="exportCanvasAsImage" - > - <i class="fa fa-camera fa-fw" /> - {{ $t("visgraph.screenshot") }} - </a> - </div> - - <div v-if="displayHelpMessage" class="alert alert-info mt-3"> - {{ $t("visgraph.create_link_help") }} - </div> - - <div class="my-4 legend"> - <h3>{{ $t("visgraph.Legend") }}</h3> - <div class="list-group"> - <label - class="list-group-item" - v-for="(layer, i) in legendLayers" - :key="`layer-${i}`" - > - <input - class="form-check-input me-1" - type="checkbox" - :value="layer.id" - v-model="checkedLayers" - @change="toggleLayer" - /> - {{ layer.label }} - </label> - </div> - </div> - </div> - </teleport> - - <teleport to="body"> - <modal - v-if="modal.showModal" - :modal-dialog-class="modal.modalDialogClass" - @close="modal.showModal = false" + <teleport to="#visgraph-legend"> + <div class="post-menu"> + <div class="list-group mt-4"> + <button + type="button" + class="list-group-item list-group-item-action btn btn-misc" + @click="createRelationship" > - <template #header> - <h2 class="modal-title"> - {{ $t(modal.title) }} - </h2> - <!-- {{ modal.data.id }} --> - </template> - <template #body> - <div v-if="modal.action === 'delete'"> - <p>{{ $t("visgraph.delete_confirmation_text") }}</p> - </div> - <div v-else> - <form> - <div class="row"> - <div class="col-12 text-center"> - {{ $t("visgraph.between") }}<br />{{ - $t("visgraph.and") - }} - </div> - <div class="col"> - <small>{{ - getPersonAge(modal.data.from) - }}</small> - <h4>{{ getPerson(modal.data.from).text }}</h4> - <p - class="text-start" - v-if="relation && relation.title" - > - <span v-if="reverse"> - {{ - $t( - "visgraph.relation_from_to_like", - [ - getPerson(modal.data.from) - .text, - getPerson(modal.data.to) - .text, - relation.reverseTitle.fr.toLowerCase(), - ], - ) - }} - </span> - <span v-else> - {{ - $t( - "visgraph.relation_from_to_like", - [ - getPerson(modal.data.from) - .text, - getPerson(modal.data.to) - .text, - relation.title.fr.toLowerCase(), - ], - ) - }} - </span> - </p> - </div> - <div class="col text-end"> - <small>{{ getPersonAge(modal.data.to) }}</small> - <h4>{{ getPerson(modal.data.to).text }}</h4> - <p - class="text-end" - v-if="relation && relation.title" - > - <span v-if="reverse"> - {{ - $t( - "visgraph.relation_from_to_like", - [ - getPerson(modal.data.to) - .text, - getPerson(modal.data.from) - .text, - relation.title.fr.toLowerCase(), - ], - ) - }} - </span> - <span v-else> - {{ - $t( - "visgraph.relation_from_to_like", - [ - getPerson(modal.data.to) - .text, - getPerson(modal.data.from) - .text, - relation.reverseTitle.fr.toLowerCase(), - ], - ) - }} - </span> - </p> - </div> - </div> - <div class="my-3"> - <VueMultiselect - id="relation" - label="title" - track-by="id" - :custom-label="customLabel" - :placeholder="$t('visgraph.choose_relation')" - :close-on-select="true" - :multiple="false" - :searchable="true" - :options="relations" - v-model="relation" - :value="relation" - /> - </div> - <div class="form-check form-switch"> - <input - class="form-check-input" - type="checkbox" - id="reverse" - v-model="reverse" - /> - <label class="form-check-label" for="reverse">{{ - $t("visgraph.reverse_relation") - }}</label> - </div> - </form> - </div> - </template> - <template #footer> - <button - class="btn" - :class="modal.button.class" - @click="submitRelationship" - > - {{ $t(modal.button.text) }} - </button> - <button - class="btn btn-delete" - v-if="modal.action === 'edit'" - @click="dropRelationship" - /> - </template> - </modal> - </teleport> - <ul class="record_actions sticky-form-buttons"> - <li> - <add-persons - button-title="visgraph.add_person" - modal-title="visgraph.add_person" - :key="addPersons.key" - :options="addPersons.options" - @add-new-persons="addNewPersons" - ref="addPersons" + <i class="fa fa-plus" /> {{ $t("visgraph.add_link") }} + </button> + <a + type="button" + class="list-group-item list-group-item-action btn btn-misc" + id="exportCanvasBtn" + @click="exportCanvasAsImage" + > + <i class="fa fa-camera fa-fw" /> + {{ $t("visgraph.screenshot") }} + </a> + </div> + + <div v-if="displayHelpMessage" class="alert alert-info mt-3"> + {{ $t("visgraph.create_link_help") }} + </div> + + <div class="my-4 legend"> + <h3>{{ $t("visgraph.Legend") }}</h3> + <div class="list-group"> + <label + class="list-group-item" + v-for="(layer, i) in legendLayers" + :key="`layer-${i}`" + > + <input + class="form-check-input me-1" + type="checkbox" + :value="layer.id" + v-model="checkedLayers" + @change="toggleLayer" /> - </li> - </ul> + {{ layer.label }} + </label> + </div> + </div> + </div> + </teleport> + + <teleport to="body"> + <modal + v-if="modal.showModal" + :modal-dialog-class="modal.modalDialogClass" + @close="modal.showModal = false" + > + <template #header> + <h2 class="modal-title"> + {{ $t(modal.title) }} + </h2> + <!-- {{ modal.data.id }} --> + </template> + <template #body> + <div v-if="modal.action === 'delete'"> + <p>{{ $t("visgraph.delete_confirmation_text") }}</p> + </div> + <div v-else> + <form> + <div class="row"> + <div class="col-12 text-center"> + {{ $t("visgraph.between") }}<br />{{ $t("visgraph.and") }} + </div> + <div class="col"> + <small>{{ getPersonAge(modal.data.from) }}</small> + <h4>{{ getPerson(modal.data.from).text }}</h4> + <p class="text-start" v-if="relation && relation.title"> + <span v-if="reverse"> + {{ + $t("visgraph.relation_from_to_like", [ + getPerson(modal.data.from).text, + getPerson(modal.data.to).text, + relation.reverseTitle.fr.toLowerCase(), + ]) + }} + </span> + <span v-else> + {{ + $t("visgraph.relation_from_to_like", [ + getPerson(modal.data.from).text, + getPerson(modal.data.to).text, + relation.title.fr.toLowerCase(), + ]) + }} + </span> + </p> + </div> + <div class="col text-end"> + <small>{{ getPersonAge(modal.data.to) }}</small> + <h4>{{ getPerson(modal.data.to).text }}</h4> + <p class="text-end" v-if="relation && relation.title"> + <span v-if="reverse"> + {{ + $t("visgraph.relation_from_to_like", [ + getPerson(modal.data.to).text, + getPerson(modal.data.from).text, + relation.title.fr.toLowerCase(), + ]) + }} + </span> + <span v-else> + {{ + $t("visgraph.relation_from_to_like", [ + getPerson(modal.data.to).text, + getPerson(modal.data.from).text, + relation.reverseTitle.fr.toLowerCase(), + ]) + }} + </span> + </p> + </div> + </div> + <div class="my-3"> + <VueMultiselect + id="relation" + label="title" + track-by="id" + :custom-label="customLabel" + :placeholder="$t('visgraph.choose_relation')" + :close-on-select="true" + :multiple="false" + :searchable="true" + :options="relations" + v-model="relation" + :value="relation" + /> + </div> + <div class="form-check form-switch"> + <input + class="form-check-input" + type="checkbox" + id="reverse" + v-model="reverse" + /> + <label class="form-check-label" for="reverse">{{ + $t("visgraph.reverse_relation") + }}</label> + </div> + </form> + </div> + </template> + <template #footer> + <button + class="btn" + :class="modal.button.class" + @click="submitRelationship" + > + {{ $t(modal.button.text) }} + </button> + <button + class="btn btn-delete" + v-if="modal.action === 'edit'" + @click="dropRelationship" + /> + </template> + </modal> + </teleport> + <ul class="record_actions sticky-form-buttons"> + <li> + <add-persons + button-title="visgraph.add_person" + modal-title="visgraph.add_person" + :key="addPersons.key" + :options="addPersons.options" + @add-new-persons="addNewPersons" + ref="addPersons" + /> + </li> + </ul> </template> <script> @@ -214,478 +184,459 @@ import { mapState, mapGetters } from "vuex"; import Modal from "ChillMainAssets/vuejs/_components/Modal"; import VueMultiselect from "vue-multiselect"; import { - getRelationsList, - postRelationship, - patchRelationship, - deleteRelationship, + getRelationsList, + postRelationship, + patchRelationship, + deleteRelationship, } from "./api"; import { splitId, getAge } from "./vis-network"; import { visMessages } from "./i18n"; import AddPersons from "ChillPersonAssets/vuejs/_components/AddPersons.vue"; export default { - name: "App", - components: { - Modal, - VueMultiselect, - AddPersons, + name: "App", + components: { + Modal, + VueMultiselect, + AddPersons, + }, + props: ["household_id"], + data() { + return { + container: "", + checkedLayers: [], + relations: [], + displayHelpMessage: false, + listenPersonFlag: "normal", + newEdgeData: {}, + modal: { + showModal: false, + modalDialogClass: "modal-md", + title: null, + action: null, + data: { + type: "relationship", + from: null, + to: null, + relation: null, + reverse: false, + }, + button: { + class: null, + text: null, + }, + }, + canvas: null, + link: null, + addPersons: { + key: "filiation", + options: { + type: ["person"], + priority: null, + uniq: false, + }, + }, + }; + }, + computed: { + ...mapGetters([ + "nodes", + "edges", + // not used 'isInWhitelist', 'isHouseholdLoading', 'isCourseLoaded', 'isRelationshipLoaded', 'isPersonLoaded', 'isExcludedNode', 'countLinksByNode', 'getParticipationsByCourse', 'getMembersByHousehold', 'getPersonsGroup', + ]), + ...mapState([ + "persons", + "households", + "courses", + "excludedNodesIds", + "updateHack", + // not used 'links', 'relationships', 'whitelistIds', 'personLoadedIds', 'householdLoadingIds', 'courseLoadedIds', 'relationshipLoadedIds', + ]), + + visgraph_data() { + //console.log('::: visgraph_data :::', this.nodes.length, 'nodes,', this.edges.length, 'edges') + return { + nodes: this.nodes, + edges: this.edges, + }; }, - props: ["household_id"], - data() { - return { - container: "", - checkedLayers: [], - relations: [], - displayHelpMessage: false, - listenPersonFlag: "normal", - newEdgeData: {}, - modal: { - showModal: false, - modalDialogClass: "modal-md", - title: null, - action: null, - data: { - type: "relationship", - from: null, - to: null, - relation: null, - reverse: false, - }, - button: { - class: null, - text: null, - }, - }, - canvas: null, - link: null, - addPersons: { - key: "filiation", - options: { - type: ["person"], - priority: null, - uniq: false, - }, - }, - }; + + refreshNetwork() { + //console.log('--- refresh network') + window.network.setData(this.visgraph_data); + + return 1; }, - computed: { - ...mapGetters([ - "nodes", - "edges", - // not used 'isInWhitelist', 'isHouseholdLoading', 'isCourseLoaded', 'isRelationshipLoaded', 'isPersonLoaded', 'isExcludedNode', 'countLinksByNode', 'getParticipationsByCourse', 'getMembersByHousehold', 'getPersonsGroup', - ]), - ...mapState([ - "persons", - "households", - "courses", - "excludedNodesIds", - "updateHack", - // not used 'links', 'relationships', 'whitelistIds', 'personLoadedIds', 'householdLoadingIds', 'courseLoadedIds', 'relationshipLoadedIds', - ]), - visgraph_data() { - //console.log('::: visgraph_data :::', this.nodes.length, 'nodes,', this.edges.length, 'edges') - return { - nodes: this.nodes, - edges: this.edges, - }; - }, - - refreshNetwork() { - //console.log('--- refresh network') - window.network.setData(this.visgraph_data); - - return 1; - }, - - legendLayers() { - //console.log('--- refresh legend and rebuild checked Layers') - // eslint-disable-next-line vue/no-side-effects-in-computed-properties - this.checkedLayers = []; - let layersDisplayed = [ - ...this.nodes.filter((n) => n.id.startsWith("household")), - ...this.nodes.filter((n) => n.id.startsWith("accompanying")), - ]; - layersDisplayed.forEach((layer) => { - this.checkedLayers.push(layer.id); - }); - return [...this.households, ...this.courses]; - }, - - // eslint-disable-next-line vue/no-dupe-keys - checkedLayers() { - // required to refresh data checkedLayers - //console.log('--- checkedLayers') - return this.checkedLayers; - }, - - relation: { - get() { - return this.modal.data.relation; - }, - set(value) { - this.modal.data.relation = value; - }, - }, - - reverse: { - get() { - return this.modal.data.reverse; - }, - set(value) { - this.modal.data.reverse = value; - }, - }, + legendLayers() { + //console.log('--- refresh legend and rebuild checked Layers') + // eslint-disable-next-line vue/no-side-effects-in-computed-properties + this.checkedLayers = []; + let layersDisplayed = [ + ...this.nodes.filter((n) => n.id.startsWith("household")), + ...this.nodes.filter((n) => n.id.startsWith("accompanying")), + ]; + layersDisplayed.forEach((layer) => { + this.checkedLayers.push(layer.id); + }); + return [...this.households, ...this.courses]; }, - watch: { - updateHack(newValue, oldValue) { - //console.log(`--- updateHack ${oldValue} <> ${newValue}`) - if (oldValue !== newValue) { - this.forceUpdateComponent(); - } - }, + + // eslint-disable-next-line vue/no-dupe-keys + checkedLayers() { + // required to refresh data checkedLayers + //console.log('--- checkedLayers') + return this.checkedLayers; }, - mounted() { - //console.log('=== mounted: init graph') - this.initGraph(); - this.listenOnGraph(); - this.getRelationsList(); - console.log(this.persons); - this.canvas = document - .getElementById("visgraph") - .querySelector("canvas"); - this.link = document.getElementById("exportCanvasBtn"); + relation: { + get() { + return this.modal.data.relation; + }, + set(value) { + this.modal.data.relation = value; + }, }, - methods: { - addNewPersons({ selected, modal }) { - // console.log('@@@ CLICK button addNewPersons', selected); - selected.forEach(function (item) { - this.$store - .dispatch("addMorePerson", item.result) - .catch(({ name, violations }) => { - if ( - name === "ValidationException" || - name === "AccessException" - ) { - violations.forEach((violation) => - this.$toast.open({ message: violation }), - ); - } else { - this.$toast.open({ message: violations }); - } - }); - }, this); - this.$refs.addPersons.resetSearch(); // to cast child method - modal.showModal = false; - }, - initGraph() { - this.container = document.getElementById("visgraph"); - // Instanciate vis objects in separate window variables, see vis-network.js - window.network = new vis.Network( - this.container, - this.visgraph_data, - window.options, - ); - }, - forceUpdateComponent() { - //console.log('!! forceUpdateComponent !!') - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - this.refreshNetwork; - this.$forceUpdate(); - }, - // events - listenOnGraph() { - window.network.on("selectNode", (data) => { - if (data.nodes.length > 1) { - throw "Multi selection is not allowed. Disable it in options.interaction !"; - } - let node = data.nodes[0]; - let nodeType = splitId(node, "type"); - switch (nodeType) { - case "person": - let person = this.nodes.filter((n) => n.id === node)[0]; - //console.log('@@@@@@ event on selected Node', person.id) - if (this.listenPersonFlag === "normal") { - if (person.folded === true) { - //console.log(' @@> expand mode event') - this.$store.commit("unfoldPerson", person); - this.$store.dispatch( - "fetchInfoForPerson", - person, - ); - } - } else { - //console.log(' @@> create link mode event') - this.listenStepsToAddRelationship(person); - } - break; + reverse: { + get() { + return this.modal.data.reverse; + }, + set(value) { + this.modal.data.reverse = value; + }, + }, + }, + watch: { + updateHack(newValue, oldValue) { + //console.log(`--- updateHack ${oldValue} <> ${newValue}`) + if (oldValue !== newValue) { + this.forceUpdateComponent(); + } + }, + }, + mounted() { + //console.log('=== mounted: init graph') + this.initGraph(); + this.listenOnGraph(); + this.getRelationsList(); + console.log(this.persons); - case "household": - let household = this.nodes.filter( - (n) => n.id === node, - )[0]; - //console.log('@@@@@@ event on selected Node', household.id) - this.$store.dispatch( - "unfoldPersonsByHousehold", - household, - ); - break; - - case "accompanying_period": - let course = this.nodes.filter((n) => n.id === node)[0]; - //console.log('@@@@@@ event on selected Node', course.id) - this.$store.dispatch("unfoldPersonsByCourse", course); - break; - - default: - throw "event is undefined for this type of node"; - } - this.forceUpdateComponent(); - }); - window.network.on("selectEdge", (data) => { - if (data.nodes.length !== 0 || data.edges.length !== 1) { - return false; //we don't want to trigger nodeEdge or multiselect ! - } - let link = data.edges[0]; - let linkType = splitId(link, "link"); - //console.log('@@@@@ event on selected Edge', data.edges.length, linkType, data) - - if (linkType.startsWith("relationship")) { - //console.log('linkType relationship') - - let relationships = this.edges.filter((l) => l.id === link); - if (relationships.length > 1) { - throw "error: only one link is allowed between two person!"; - } - - let relationship = relationships[0]; - //console.log(relationship) - - this.editRelationshipModal({ - from: relationship.from, - to: relationship.to, - id: relationship.id, - relation: relationship.relation, - reverse: relationship.reverse, - }); - } - }); - }, - listenStepsToAddRelationship(person) { - //console.log(' @@> listenStep', this.listenPersonFlag) - if (this.listenPersonFlag === "step2") { - //console.log(' @@> person 2', person) - this.newEdgeData.to = person.id; - this.addRelationshipModal(this.newEdgeData); - this.displayHelpMessage = false; - this.listenPersonFlag = "normal"; - this.newEdgeData = {}; - } - if (this.listenPersonFlag === "step1") { - //console.log(' @@> person 1', person) - this.newEdgeData.from = person.id; - this.listenPersonFlag = "step2"; - } - }, - - /// control Layers - toggleLayer(value) { - let id = value.target.value; - //console.log('@@@@@@ toggle Layer', id) - this.forceUpdateComponent(); - if (this.checkedLayers.includes(id)) { - this.removeLayer(id); + this.canvas = document.getElementById("visgraph").querySelector("canvas"); + this.link = document.getElementById("exportCanvasBtn"); + }, + methods: { + addNewPersons({ selected, modal }) { + // console.log('@@@ CLICK button addNewPersons', selected); + selected.forEach(function (item) { + this.$store + .dispatch("addMorePerson", item.result) + .catch(({ name, violations }) => { + if (name === "ValidationException" || name === "AccessException") { + violations.forEach((violation) => + this.$toast.open({ message: violation }), + ); } else { - this.addLayer(id); + this.$toast.open({ message: violations }); } - }, - addLayer(id) { - //console.log('+ addLayer', id) - this.checkedLayers.push(id); - this.$store.dispatch("excludedNode", ["remove", id]); - }, - removeLayer(id) { - //console.log('- removeLayer', id) - this.checkedLayers = this.checkedLayers.filter((i) => i !== id); - this.$store.dispatch("excludedNode", ["add", id]); - }, - - /// control Modal - addRelationshipModal(edgeData) { - //console.log('==- addRelationshipModal', edgeData) - this.modal = { - data: { from: edgeData.from, to: edgeData.to, reverse: false }, - action: "create", - showModal: true, - title: "visgraph.add_relationship_link", - button: { class: "btn-create", text: "action.create" }, - }; - }, - editRelationshipModal(edgeData) { - //console.log('==- editRelationshipModal', edgeData) - this.modal = { - data: edgeData, - action: "edit", - showModal: true, - title: "visgraph.edit_relationship_link", - button: { class: "btn-edit", text: "action.edit" }, - }; - }, - - // form - resetForm() { - this.modal = { - data: { - type: "relationship", - from: null, - to: null, - relation: null, - reverse: false, - }, - action: null, - title: null, - button: { class: null, text: null }, - }; - //console.log('==- reset Form', this.modal.data) - }, - getRelationsList() { - //console.log('fetch relationsList') - return getRelationsList() - .then( - (relations) => - new Promise((resolve) => { - //console.log('+ relations list', relations.results.length) - this.relations = relations.results.filter( - (r) => r.isActive === true, - ); - resolve(); - }), - ) - .catch(); - }, - customLabel(value) { - //console.log('customLabel', value) - return value.title && value.reverseTitle - ? `${value.title.fr} ↔ ${value.reverseTitle.fr}` - : ""; - }, - getPerson(id) { - let person = this.persons.filter((p) => p.id === id); - return person[0]; - }, - getPersonAge(id) { - let person = this.getPerson(id); - return getAge(person); - }, - - // actions - createRelationship() { - this.displayHelpMessage = true; - this.listenPersonFlag = "step1"; // toggle listener in create link mode - //console.log(' @@> switch listener to create link mode:', this.listenPersonFlag) - }, - dropRelationship() { - //console.log('delete', this.modal.data) - deleteRelationship(this.modal.data).catch(); - this.$store.commit("removeLink", this.modal.data.id); - this.modal.showModal = false; - this.resetForm(); - this.forceUpdateComponent(); - }, - submitRelationship() { - //console.log('submitRelationship', this.modal.action) - switch (this.modal.action) { - case "create": - return postRelationship(this.modal.data) - .then( - (relationship) => - new Promise((resolve) => { - //console.log('post relationship response', relationship) - this.$store.dispatch( - "addLinkFromRelationship", - relationship, - ); - this.modal.showModal = false; - this.resetForm(); - this.forceUpdateComponent(); - resolve(); - }), - ) - .catch((error) => { - if (error.name === "ValidationException") { - for (let v of error.violations) { - this.$toast.open({ message: v }); - console.log(v); - } - } else { - this.$toast.open({ - message: "An error occurred", - }); - } - }); - - case "edit": - return patchRelationship(this.modal.data) - .then( - (relationship) => - new Promise((resolve) => { - //console.log('patch relationship response', relationship) - this.$store.commit( - "updateLink", - relationship, - ); - this.modal.showModal = false; - this.resetForm(); - this.forceUpdateComponent(); - resolve(); - }), - ) - .catch(); - - default: - throw "uncaught action"; - } - }, - - // export image - async exportCanvasAsImage() { - let filename = `filiation_${this.household_id}.jpg`, - mime = "image/jpeg", - quality = 0.85, - footer = `© Chill ${new Date().getFullYear()}`, - timestamp = `${visMessages.fr.visgraph.relationship_household} n° ${this.household_id} — ${new Date().toLocaleString()}`; - - // resolve toBlob in a Promise - const getCanvasBlob = (canvas) => - new Promise((resolve) => { - canvas.toBlob((blob) => resolve(blob), mime, quality); - }); - - // build image from new temporary canvas - let tmpCanvas = document.createElement("canvas"); - tmpCanvas.width = this.canvas.width; - tmpCanvas.height = this.canvas.height; - - let ctx = tmpCanvas.getContext("2d"); - ctx.beginPath(); - ctx.fillStyle = "#fff"; - ctx.fillRect(0, 0, tmpCanvas.width, tmpCanvas.height); - ctx.fillStyle = "#9d4600"; - ctx.fillText(footer + " — " + timestamp, 5, tmpCanvas.height - 10); - ctx.drawImage(this.canvas, 0, 0); - - return await getCanvasBlob(tmpCanvas).then((blob) => { - let url = document.createElement("a"); - url.download = filename; - url.href = window.URL.createObjectURL(blob); - url.click(); - console.log("url", url.href); - URL.revokeObjectURL(url.href); - }); - }, + }); + }, this); + this.$refs.addPersons.resetSearch(); // to cast child method + modal.showModal = false; }, + initGraph() { + this.container = document.getElementById("visgraph"); + // Instanciate vis objects in separate window variables, see vis-network.js + window.network = new vis.Network( + this.container, + this.visgraph_data, + window.options, + ); + }, + forceUpdateComponent() { + //console.log('!! forceUpdateComponent !!') + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + this.refreshNetwork; + this.$forceUpdate(); + }, + + // events + listenOnGraph() { + window.network.on("selectNode", (data) => { + if (data.nodes.length > 1) { + throw "Multi selection is not allowed. Disable it in options.interaction !"; + } + let node = data.nodes[0]; + let nodeType = splitId(node, "type"); + switch (nodeType) { + case "person": + let person = this.nodes.filter((n) => n.id === node)[0]; + //console.log('@@@@@@ event on selected Node', person.id) + if (this.listenPersonFlag === "normal") { + if (person.folded === true) { + //console.log(' @@> expand mode event') + this.$store.commit("unfoldPerson", person); + this.$store.dispatch("fetchInfoForPerson", person); + } + } else { + //console.log(' @@> create link mode event') + this.listenStepsToAddRelationship(person); + } + break; + + case "household": + let household = this.nodes.filter((n) => n.id === node)[0]; + //console.log('@@@@@@ event on selected Node', household.id) + this.$store.dispatch("unfoldPersonsByHousehold", household); + break; + + case "accompanying_period": + let course = this.nodes.filter((n) => n.id === node)[0]; + //console.log('@@@@@@ event on selected Node', course.id) + this.$store.dispatch("unfoldPersonsByCourse", course); + break; + + default: + throw "event is undefined for this type of node"; + } + this.forceUpdateComponent(); + }); + window.network.on("selectEdge", (data) => { + if (data.nodes.length !== 0 || data.edges.length !== 1) { + return false; //we don't want to trigger nodeEdge or multiselect ! + } + let link = data.edges[0]; + let linkType = splitId(link, "link"); + //console.log('@@@@@ event on selected Edge', data.edges.length, linkType, data) + + if (linkType.startsWith("relationship")) { + //console.log('linkType relationship') + + let relationships = this.edges.filter((l) => l.id === link); + if (relationships.length > 1) { + throw "error: only one link is allowed between two person!"; + } + + let relationship = relationships[0]; + //console.log(relationship) + + this.editRelationshipModal({ + from: relationship.from, + to: relationship.to, + id: relationship.id, + relation: relationship.relation, + reverse: relationship.reverse, + }); + } + }); + }, + listenStepsToAddRelationship(person) { + //console.log(' @@> listenStep', this.listenPersonFlag) + if (this.listenPersonFlag === "step2") { + //console.log(' @@> person 2', person) + this.newEdgeData.to = person.id; + this.addRelationshipModal(this.newEdgeData); + this.displayHelpMessage = false; + this.listenPersonFlag = "normal"; + this.newEdgeData = {}; + } + if (this.listenPersonFlag === "step1") { + //console.log(' @@> person 1', person) + this.newEdgeData.from = person.id; + this.listenPersonFlag = "step2"; + } + }, + + /// control Layers + toggleLayer(value) { + let id = value.target.value; + //console.log('@@@@@@ toggle Layer', id) + this.forceUpdateComponent(); + if (this.checkedLayers.includes(id)) { + this.removeLayer(id); + } else { + this.addLayer(id); + } + }, + addLayer(id) { + //console.log('+ addLayer', id) + this.checkedLayers.push(id); + this.$store.dispatch("excludedNode", ["remove", id]); + }, + removeLayer(id) { + //console.log('- removeLayer', id) + this.checkedLayers = this.checkedLayers.filter((i) => i !== id); + this.$store.dispatch("excludedNode", ["add", id]); + }, + + /// control Modal + addRelationshipModal(edgeData) { + //console.log('==- addRelationshipModal', edgeData) + this.modal = { + data: { from: edgeData.from, to: edgeData.to, reverse: false }, + action: "create", + showModal: true, + title: "visgraph.add_relationship_link", + button: { class: "btn-create", text: "action.create" }, + }; + }, + editRelationshipModal(edgeData) { + //console.log('==- editRelationshipModal', edgeData) + this.modal = { + data: edgeData, + action: "edit", + showModal: true, + title: "visgraph.edit_relationship_link", + button: { class: "btn-edit", text: "action.edit" }, + }; + }, + + // form + resetForm() { + this.modal = { + data: { + type: "relationship", + from: null, + to: null, + relation: null, + reverse: false, + }, + action: null, + title: null, + button: { class: null, text: null }, + }; + //console.log('==- reset Form', this.modal.data) + }, + getRelationsList() { + //console.log('fetch relationsList') + return getRelationsList() + .then( + (relations) => + new Promise((resolve) => { + //console.log('+ relations list', relations.results.length) + this.relations = relations.results.filter( + (r) => r.isActive === true, + ); + resolve(); + }), + ) + .catch(); + }, + customLabel(value) { + //console.log('customLabel', value) + return value.title && value.reverseTitle + ? `${value.title.fr} ↔ ${value.reverseTitle.fr}` + : ""; + }, + getPerson(id) { + let person = this.persons.filter((p) => p.id === id); + return person[0]; + }, + getPersonAge(id) { + let person = this.getPerson(id); + return getAge(person); + }, + + // actions + createRelationship() { + this.displayHelpMessage = true; + this.listenPersonFlag = "step1"; // toggle listener in create link mode + //console.log(' @@> switch listener to create link mode:', this.listenPersonFlag) + }, + dropRelationship() { + //console.log('delete', this.modal.data) + deleteRelationship(this.modal.data).catch(); + this.$store.commit("removeLink", this.modal.data.id); + this.modal.showModal = false; + this.resetForm(); + this.forceUpdateComponent(); + }, + submitRelationship() { + //console.log('submitRelationship', this.modal.action) + switch (this.modal.action) { + case "create": + return postRelationship(this.modal.data) + .then( + (relationship) => + new Promise((resolve) => { + //console.log('post relationship response', relationship) + this.$store.dispatch("addLinkFromRelationship", relationship); + this.modal.showModal = false; + this.resetForm(); + this.forceUpdateComponent(); + resolve(); + }), + ) + .catch((error) => { + if (error.name === "ValidationException") { + for (let v of error.violations) { + this.$toast.open({ message: v }); + console.log(v); + } + } else { + this.$toast.open({ + message: "An error occurred", + }); + } + }); + + case "edit": + return patchRelationship(this.modal.data) + .then( + (relationship) => + new Promise((resolve) => { + //console.log('patch relationship response', relationship) + this.$store.commit("updateLink", relationship); + this.modal.showModal = false; + this.resetForm(); + this.forceUpdateComponent(); + resolve(); + }), + ) + .catch(); + + default: + throw "uncaught action"; + } + }, + + // export image + async exportCanvasAsImage() { + let filename = `filiation_${this.household_id}.jpg`, + mime = "image/jpeg", + quality = 0.85, + footer = `© Chill ${new Date().getFullYear()}`, + timestamp = `${visMessages.fr.visgraph.relationship_household} n° ${this.household_id} — ${new Date().toLocaleString()}`; + + // resolve toBlob in a Promise + const getCanvasBlob = (canvas) => + new Promise((resolve) => { + canvas.toBlob((blob) => resolve(blob), mime, quality); + }); + + // build image from new temporary canvas + let tmpCanvas = document.createElement("canvas"); + tmpCanvas.width = this.canvas.width; + tmpCanvas.height = this.canvas.height; + + let ctx = tmpCanvas.getContext("2d"); + ctx.beginPath(); + ctx.fillStyle = "#fff"; + ctx.fillRect(0, 0, tmpCanvas.width, tmpCanvas.height); + ctx.fillStyle = "#9d4600"; + ctx.fillText(footer + " — " + timestamp, 5, tmpCanvas.height - 10); + ctx.drawImage(this.canvas, 0, 0); + + return await getCanvasBlob(tmpCanvas).then((blob) => { + let url = document.createElement("a"); + url.download = filename; + url.href = window.URL.createObjectURL(blob); + url.click(); + console.log("url", url.href); + URL.revokeObjectURL(url.href); + }); + }, + }, }; </script> @@ -694,21 +645,21 @@ export default { <style lang="scss" scoped> div#visgraph { - height: 700px; - margin: auto; + height: 700px; + margin: auto; } div#visgraph-legend { - div.post-menu.legend { - } + div.post-menu.legend { + } } .modal-mask { - background-color: rgba(0, 0, 0, 0.25); + background-color: rgba(0, 0, 0, 0.25); } .debug { - margin: 1em; - padding: 1em; - color: dimgray; - font-style: italic; - font-size: 80%; + margin: 1em; + padding: 1em; + color: dimgray; + font-style: italic; + font-size: 80%; } </style> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/VisGraph/i18n.js b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/VisGraph/i18n.js index 289a68a34..a24e63015 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/VisGraph/i18n.js +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/VisGraph/i18n.js @@ -50,8 +50,8 @@ const visMessages = { return "Né·e le"; } }, - center_id: "Identifiant du centre", - center_type: "Type de centre", + center_id: "Identifiant du territoire", + center_type: "Type de territoire", center_name: "Territoire", // vendée phonenumber: "Téléphone", mobilenumber: "Mobile", diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_api/AddPersons.js b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_api/AddPersons.ts similarity index 57% rename from src/Bundle/ChillPersonBundle/Resources/public/vuejs/_api/AddPersons.js rename to src/Bundle/ChillPersonBundle/Resources/public/vuejs/_api/AddPersons.ts index 1d0a607ed..519a3d3e7 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_api/AddPersons.js +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_api/AddPersons.ts @@ -1,7 +1,15 @@ +import { Search, SearchOptions } from "ChillPersonAssets/types"; + /* * Build query string with query and options */ -const parametersToString = ({ query, options }) => { +const parametersToString = ({ + query, + options, +}: { + query: string; + options: SearchOptions; +}) => { let types = ""; options.type.forEach(function (type) { types += "&type[]=" + type; @@ -16,11 +24,13 @@ const parametersToString = ({ query, options }) => { * @query string - the query to search for * @deprecated */ -const searchPersons = ({ query, options }, signal) => { - console.err("deprecated"); - let queryStr = parametersToString({ query, options }); - let url = `/fr/search.json?name=person_regular&${queryStr}`; - let fetchOpts = { +export function searchPersons( + { query, options }: { query: string; options: SearchOptions }, + signal: AbortSignal, +): Promise<Search> { + const queryStr = parametersToString({ query, options }); + const url = `/fr/search.json?name=person_regular&${queryStr}`; + const fetchOpts = { method: "GET", headers: { "Content-Type": "application/json;charset=utf-8", @@ -34,7 +44,7 @@ const searchPersons = ({ query, options }, signal) => { } throw Error("Error with request resource response"); }); -}; +} /* * Endpoint v.2 chill_main_search_global @@ -43,15 +53,16 @@ const searchPersons = ({ query, options }, signal) => { * @param query string - the query to search for * */ -const searchEntities = ({ query, options }, signal) => { - let queryStr = parametersToString({ query, options }); - let url = `/api/1.0/search.json?${queryStr}`; +export function searchEntities( + { query, options }: { query: string; options: SearchOptions }, + signal: AbortSignal, +): Promise<Search> { + const queryStr = parametersToString({ query, options }); + const url = `/api/1.0/search.json?${queryStr}`; return fetch(url, { signal }).then((response) => { if (response.ok) { return response.json(); } throw Error("Error with request resource response"); }); -}; - -export { searchPersons, searchEntities }; +} diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_api/OnTheFly.js b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_api/OnTheFly.js deleted file mode 100644 index 51dedec98..000000000 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_api/OnTheFly.js +++ /dev/null @@ -1,88 +0,0 @@ -import { makeFetch } from "ChillMainAssets/lib/api/apiMethods"; - -/* - * GET a person by id - */ -const getPerson = (id) => { - const url = `/api/1.0/person/person/${id}.json`; - return fetch(url).then((response) => { - if (response.ok) { - return response.json(); - } - throw Error("Error with request resource response"); - }); -}; - -const getPersonAltNames = () => - fetch("/api/1.0/person/config/alt_names.json").then((response) => { - if (response.ok) { - return response.json(); - } - throw Error("Error with request resource response"); - }); - -const getCivilities = () => - fetch("/api/1.0/main/civility.json").then((response) => { - if (response.ok) { - return response.json(); - } - throw Error("Error with request resource response"); - }); - -const getGenders = () => makeFetch("GET", "/api/1.0/main/gender.json"); -// .then(response => { -// console.log(response) -// if (response.ok) { return response.json(); } -// throw Error('Error with request resource response'); -// }); - -const getCentersForPersonCreation = () => - makeFetch("GET", "/api/1.0/person/creation/authorized-centers", null); - -/* - * POST a new person - */ -const postPerson = (body) => { - const url = `/api/1.0/person/person.json`; - return fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json;charset=utf-8", - }, - body: JSON.stringify(body), - }).then((response) => { - if (response.ok) { - return response.json(); - } - throw Error("Error with request resource response"); - }); -}; - -/* - * PATCH an existing person - */ -const patchPerson = (id, body) => { - const url = `/api/1.0/person/person/${id}.json`; - return fetch(url, { - method: "PATCH", - headers: { - "Content-Type": "application/json;charset=utf-8", - }, - body: JSON.stringify(body), - }).then((response) => { - if (response.ok) { - return response.json(); - } - throw Error("Error with request resource response"); - }); -}; - -export { - getCentersForPersonCreation, - getPerson, - getPersonAltNames, - getCivilities, - getGenders, - postPerson, - patchPerson, -}; diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_api/OnTheFly.ts b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_api/OnTheFly.ts new file mode 100644 index 000000000..742f7ef54 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_api/OnTheFly.ts @@ -0,0 +1,144 @@ +import { fetchResults, makeFetch } from "ChillMainAssets/lib/api/apiMethods"; +import { Center, Civility, Gender, SetCenter } from "ChillMainAssets/types"; +import { + AltName, + Person, + PersonIdentifier, + PersonIdentifierWorker, + PersonWrite, +} from "ChillPersonAssets/types"; +import person from "ChillPersonAssets/vuejs/_components/OnTheFly/Person.vue"; + +/* + * GET a person by id + */ +export const getPerson = async (id: number): Promise<Person> => { + const url = `/api/1.0/person/person/${id}.json`; + return fetch(url).then((response) => { + if (response.ok) { + return response.json(); + } + throw Error("Error with request resource response"); + }); +}; + +export const personToWritePerson = (person: Person): PersonWrite => { + return { + type: "person", + firstName: person.firstName, + lastName: person.lastName, + altNames: person.altNames.map((altName) => ({ + key: altName.key, + value: altName.label, + })), + addressId: null, + birthdate: + null === person.birthdate + ? null + : { datetime: person.birthdate.datetime8601 }, + deathdate: + null === person.deathdate + ? null + : { datetime: person.deathdate.datetime8601 }, + phonenumber: person.phonenumber, + mobilenumber: person.mobilenumber, + center: + null === person.centers + ? null + : person.centers + .map((center): SetCenter => ({ id: center.id, type: "center" })) + .find(() => true) || null, + email: person.email, + civility: + null === person.civility + ? null + : { id: person.civility.id, type: "chill_main_civility" }, + gender: + null === person.gender + ? null + : { id: person.gender.id, type: "chill_main_gender" }, + identifiers: person.identifiers.map((identifier: PersonIdentifier) => ({ + type: "person_identifier", + definition_id: identifier.definition.id, + value: identifier.value, + })), + }; +}; + +export const getPersonAltNames = async (): Promise<AltName[]> => + fetch("/api/1.0/person/config/alt_names.json").then((response) => { + if (response.ok) { + return response.json(); + } + throw Error("Error with request resource response"); + }); + +export const getCivilities = async (): Promise<Civility[]> => + fetchResults("/api/1.0/main/civility.json"); + +export const getGenders = async (): Promise<Gender[]> => + fetchResults("/api/1.0/main/gender.json"); + +export const getCentersForPersonCreation = async (): Promise<{ + showCenters: boolean; + centers: Center[]; +}> => makeFetch("GET", "/api/1.0/person/creation/authorized-centers", null); + +export const getPersonIdentifiers = async (): Promise< + PersonIdentifierWorker[] +> => fetchResults("/api/1.0/person/identifiers/workers"); + +export interface WritePersonViolationMap extends Record< + string, + Record<string, string> +> { + firstName: { + "{{ value }}": string; + }; + lastName: { + "{{ value }}": string; + }; + gender: { + "{{ value }}": string; + }; + mobilenumber: { + "{{ types }}": string; // ex: "mobile number" + "{{ value }}": string; // ex: "+33 1 02 03 04 05" + }; + phonenumber: { + "{{ types }}": string; // ex: "mobile number" + "{{ value }}": string; // ex: "+33 1 02 03 04 05" + }; + email: { + "{{ value }}": string; + }; + center: { + "{{ value }}": string; + }; + civility: { + "{{ value }}": string; + }; + birthdate: {}; + identifiers: { + "{{ value }}": string; + definition_id: string; + }; +} +export const createPerson = async (person: PersonWrite): Promise<Person> => { + return makeFetch<PersonWrite, Person, WritePersonViolationMap>( + "POST", + "/api/1.0/person/person.json", + person, + ); +}; + +export const editPerson = async ( + person: PersonWrite, + personId: number, +): Promise<Person> => { + return makeFetch<PersonWrite, Person, WritePersonViolationMap>( + "PATCH", + `/api/1.0/person/person/${personId}.json`, + person, + ); +}; diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_api/accompanyingCourseWorkEvaluationDocument.ts b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_api/accompanyingCourseWorkEvaluationDocument.ts index a5dcd4f2c..955c27b3b 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_api/accompanyingCourseWorkEvaluationDocument.ts +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_api/accompanyingCourseWorkEvaluationDocument.ts @@ -2,30 +2,30 @@ import { AccompanyingPeriodWorkEvaluationDocument } from "../../types"; import { makeFetch } from "../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods"; export const duplicate = async ( - id: number, + id: number, ): Promise<AccompanyingPeriodWorkEvaluationDocument> => { - return makeFetch<null, AccompanyingPeriodWorkEvaluationDocument>( - "POST", - `/api/1.0/person/accompanying-course-work-evaluation-document/${id}/duplicate`, - ); + return makeFetch<null, AccompanyingPeriodWorkEvaluationDocument>( + "POST", + `/api/1.0/person/accompanying-course-work-evaluation-document/${id}/duplicate`, + ); }; export const duplicateDocumentToEvaluation = async ( - document_id: number, - evaluation_id: number, + document_id: number, + evaluation_id: number, ): Promise<AccompanyingPeriodWorkEvaluationDocument> => { - return makeFetch<null, AccompanyingPeriodWorkEvaluationDocument>( - "POST", - `/api/1.0/person/accompanying-course-work-evaluation-document/${document_id}/evaluation/${evaluation_id}/duplicate`, - ); + return makeFetch<null, AccompanyingPeriodWorkEvaluationDocument>( + "POST", + `/api/1.0/person/accompanying-course-work-evaluation-document/${document_id}/evaluation/${evaluation_id}/duplicate`, + ); }; export const moveDocumentToEvaluation = async ( - document_id: number, - evaluation_id: number, + document_id: number, + evaluation_id: number, ): Promise<AccompanyingPeriodWorkEvaluationDocument> => { - return makeFetch<null, AccompanyingPeriodWorkEvaluationDocument>( - "POST", - `/api/1.0/person/accompanying-course-work-evaluation-document/${document_id}/evaluation/${evaluation_id}/move`, - ); + return makeFetch<null, AccompanyingPeriodWorkEvaluationDocument>( + "POST", + `/api/1.0/person/accompanying-course-work-evaluation-document/${document_id}/evaluation/${evaluation_id}/move`, + ); }; diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AccompanyingPeriod/SetReferrer.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AccompanyingPeriod/SetReferrer.vue index 8391fbbb6..0a8d07f30 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AccompanyingPeriod/SetReferrer.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AccompanyingPeriod/SetReferrer.vue @@ -1,53 +1,44 @@ <template> - <ul class="list-suggest add-items" v-if="suggested.length > 0"> - <li v-for="(r, i) in suggested" @click="setReferrer(r)" :key="i"> - <span>{{ r.text }}</span> - </li> - </ul> + <ul class="list-suggest add-items" v-if="suggested.length > 0"> + <li v-for="(r, i) in suggested" @click="setReferrer(r)" :key="i"> + <span>{{ r.text }}</span> + </li> + </ul> </template> -<script> +<script setup> import { makeFetch } from "ChillMainAssets/lib/api/apiMethods.ts"; +import { defineProps, defineEmits } from "vue"; -export default { - name: "SetReferrer", - props: { - suggested: { - type: Array, - required: false, - //default: [], - }, - periodId: { - type: Number, - required: true, - }, - }, - data() { - return { - /*suggested: [ - {id: 5, text: 'Robert'}, {id: 8, text: 'Monique'}, - ]*/ - }; - }, - emits: ["referrerSet"], - methods: { - setReferrer: function (ref) { - const url = `/api/1.0/person/accompanying-course/${this.periodId}.json`; - const body = { - type: "accompanying_period", - user: { id: ref.id, type: ref.type }, - }; +const props = defineProps({ + suggested: { + type: Array, + required: false, + // default: [], + }, + periodId: { + type: Number, + required: true, + }, +}); - return makeFetch("PATCH", url, body) - .then(() => { - this.$emit("referrerSet", ref); - }) - .catch((error) => { - throw error; - }); - }, - }, -}; +const emit = defineEmits(["referrerSet"]); + +function setReferrer(ref) { + const url = `/api/1.0/person/accompanying-course/${props.periodId}.json`; + const body = { + type: "accompanying_period", + user: { id: ref.id, type: ref.type }, + }; + + return makeFetch("PATCH", url, body) + .then(() => { + emit("referrerSet", ref); + }) + .catch((error) => { + throw error; + }); +} </script> <style scoped></style> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AccompanyingPeriodWorkSelector/AccompanyingPeriodWorkEvaluationItem.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AccompanyingPeriodWorkSelector/AccompanyingPeriodWorkEvaluationItem.vue index fc641a0a2..1beae1b90 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AccompanyingPeriodWorkSelector/AccompanyingPeriodWorkEvaluationItem.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AccompanyingPeriodWorkSelector/AccompanyingPeriodWorkEvaluationItem.vue @@ -1,53 +1,47 @@ <template> - <div class="container"> - <div class="item-bloc"> - <div class="item-row"> - <h2 class="badge-title"> - <span class="title_label"></span> - <span class="title_action"> - <span> - {{ trans(EVALUATION) }}: - <span class="badge bg-light text-dark"> - {{ eval?.evaluation?.title.fr }} - </span> - </span> + <div class="container"> + <div class="item-bloc"> + <div class="item-row"> + <h2 class="badge-title"> + <span class="title_label"></span> + <span class="title_action"> + <span> + {{ trans(EVALUATION) }}: + <span class="badge bg-light text-dark"> + {{ eval?.evaluation?.title.fr }} + </span> + </span> - <ul class="small_in_title columns mt-1"> - <li> - <span class="item-key"> - {{ - trans( - ACCOMPANYING_COURSE_WORK_START_DATE, - ) - }} - : - </span> - <b>{{ formatDate(eval.startDate) }}</b> - </li> + <ul class="small_in_title columns mt-1"> + <li> + <span class="item-key"> + {{ trans(ACCOMPANYING_COURSE_WORK_START_DATE) }} + : + </span> + <b>{{ formatDate(eval.startDate) }}</b> + </li> - <li v-if="eval.endDate"> - <span class="item-key"> - {{ - trans(ACCOMPANYING_COURSE_WORK_END_DATE) - }} - : - </span> - <b>{{ formatDate(eval.endDate) }}</b> - </li> - </ul> - </span> - </h2> - </div> - </div> + <li v-if="eval.endDate"> + <span class="item-key"> + {{ trans(ACCOMPANYING_COURSE_WORK_END_DATE) }} + : + </span> + <b>{{ formatDate(eval.endDate) }}</b> + </li> + </ul> + </span> + </h2> + </div> </div> + </div> </template> <script setup lang="ts"> import { - ACCOMPANYING_COURSE_WORK_END_DATE, - ACCOMPANYING_COURSE_WORK_START_DATE, - EVALUATION, - trans, + ACCOMPANYING_COURSE_WORK_END_DATE, + ACCOMPANYING_COURSE_WORK_START_DATE, + EVALUATION, + trans, } from "translator"; import { ISOToDate } from "ChillMainAssets/chill/js/date"; import { DateTime } from "ChillMainAssets/types"; @@ -56,15 +50,15 @@ import { AccompanyingPeriodWorkEvaluation } from "../../../types"; // eslint-disable-next-line @typescript-eslint/no-unused-vars const props = defineProps<{ eval: AccompanyingPeriodWorkEvaluation }>(); const formatDate = (dateObject: DateTime) => { - if (dateObject) { - const parsedDate = ISOToDate(dateObject.datetime); - if (parsedDate) { - return new Intl.DateTimeFormat("default", { - dateStyle: "short", - }).format(parsedDate); - } else { - return ""; - } + if (dateObject) { + const parsedDate = ISOToDate(dateObject.datetime); + if (parsedDate) { + return new Intl.DateTimeFormat("default", { + dateStyle: "short", + }).format(parsedDate); + } else { + return ""; } + } }; </script> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AccompanyingPeriodWorkSelector/AccompanyingPeriodWorkEvaluationList.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AccompanyingPeriodWorkSelector/AccompanyingPeriodWorkEvaluationList.vue index 8581546f1..b85c87ec4 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AccompanyingPeriodWorkSelector/AccompanyingPeriodWorkEvaluationList.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AccompanyingPeriodWorkSelector/AccompanyingPeriodWorkEvaluationList.vue @@ -1,24 +1,24 @@ <template> - <div class="results"> - <div - v-for="evaluation in evaluations" - :key="evaluation.id" - class="list-item" - > - <label class="acpw-item"> - <div> - <input - type="radio" - :value="evaluation" - v-model="selectedEvaluation" - name="item" - /> - </div> - - <accompanying-period-work-evaluation-item :eval="evaluation" /> - </label> + <div class="results"> + <div + v-for="evaluation in evaluations" + :key="evaluation.id" + class="list-item" + > + <label class="acpw-item"> + <div> + <input + type="radio" + :value="evaluation" + v-model="selectedEvaluation" + name="item" + /> </div> + + <accompanying-period-work-evaluation-item :eval="evaluation" /> + </label> </div> + </div> </template> <script setup lang="ts"> @@ -28,7 +28,7 @@ import AccompanyingPeriodWorkEvaluationItem from "ChillPersonAssets/vuejs/_compo // eslint-disable-next-line @typescript-eslint/no-unused-vars const props = defineProps<{ - evaluations: AccompanyingPeriodWorkEvaluation[]; + evaluations: AccompanyingPeriodWorkEvaluation[]; }>(); const selectedEvaluation = ref<AccompanyingPeriodWorkEvaluation | null>(null); @@ -36,12 +36,12 @@ const selectedEvaluation = ref<AccompanyingPeriodWorkEvaluation | null>(null); const emit = defineEmits(); watch(selectedEvaluation, (newValue) => { - emit("update:selectedEvaluation", newValue); + emit("update:selectedEvaluation", newValue); }); </script> <style> .acpw-item { - display: flex; + display: flex; } </style> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AccompanyingPeriodWorkSelector/AccompanyingPeriodWorkItem.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AccompanyingPeriodWorkSelector/AccompanyingPeriodWorkItem.vue index 330423a20..98668a65b 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AccompanyingPeriodWorkSelector/AccompanyingPeriodWorkItem.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AccompanyingPeriodWorkSelector/AccompanyingPeriodWorkItem.vue @@ -1,51 +1,45 @@ <template> - <div class="container"> - <div class="item-bloc"> - <div class="item-row"> - <h2 class="badge-title"> - <span class="title_label"></span> - <span class="title_action"> - <span class="chill-entity entity-social-action"> - <span class="badge bg-light text-dark"> - {{ acpw?.socialAction?.title.fr }} - </span> - </span> + <div class="container"> + <div class="item-bloc"> + <div class="item-row"> + <h2 class="badge-title"> + <span class="title_label"></span> + <span class="title_action"> + <span class="chill-entity entity-social-action"> + <span class="badge bg-light text-dark"> + {{ acpw?.socialAction?.title.fr }} + </span> + </span> - <ul class="small_in_title columns mt-1"> - <li> - <span class="item-key"> - {{ - trans( - ACCOMPANYING_COURSE_WORK_START_DATE, - ) - }} - : - </span> - <b>{{ formatDate(acpw.startDate) }}</b> - </li> + <ul class="small_in_title columns mt-1"> + <li> + <span class="item-key"> + {{ trans(ACCOMPANYING_COURSE_WORK_START_DATE) }} + : + </span> + <b>{{ formatDate(acpw.startDate) }}</b> + </li> - <li v-if="acpw.endDate"> - <span class="item-key"> - {{ - trans(ACCOMPANYING_COURSE_WORK_END_DATE) - }} - : - </span> - <b>{{ formatDate(acpw.endDate) }}</b> - </li> - </ul> - </span> - </h2> - </div> - </div> + <li v-if="acpw.endDate"> + <span class="item-key"> + {{ trans(ACCOMPANYING_COURSE_WORK_END_DATE) }} + : + </span> + <b>{{ formatDate(acpw.endDate) }}</b> + </li> + </ul> + </span> + </h2> + </div> </div> + </div> </template> <script setup lang="ts"> import { - ACCOMPANYING_COURSE_WORK_END_DATE, - ACCOMPANYING_COURSE_WORK_START_DATE, - trans, + ACCOMPANYING_COURSE_WORK_END_DATE, + ACCOMPANYING_COURSE_WORK_START_DATE, + trans, } from "translator"; import { ISOToDate } from "ChillMainAssets/chill/js/date"; import { DateTime } from "ChillMainAssets/types"; @@ -54,15 +48,15 @@ import { AccompanyingPeriodWork } from "../../../types"; // eslint-disable-next-line @typescript-eslint/no-unused-vars const props = defineProps<{ acpw: AccompanyingPeriodWork }>(); const formatDate = (dateObject: DateTime) => { - if (dateObject) { - const parsedDate = ISOToDate(dateObject.datetime); - if (parsedDate) { - return new Intl.DateTimeFormat("default", { - dateStyle: "short", - }).format(parsedDate); - } else { - return ""; - } + if (dateObject) { + const parsedDate = ISOToDate(dateObject.datetime); + if (parsedDate) { + return new Intl.DateTimeFormat("default", { + dateStyle: "short", + }).format(parsedDate); + } else { + return ""; } + } }; </script> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AccompanyingPeriodWorkSelector/AccompanyingPeriodWorkList.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AccompanyingPeriodWorkSelector/AccompanyingPeriodWorkList.vue index 02f26656e..03d513b13 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AccompanyingPeriodWorkSelector/AccompanyingPeriodWorkList.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AccompanyingPeriodWorkSelector/AccompanyingPeriodWorkList.vue @@ -1,24 +1,24 @@ <template> - <div class="results"> - <div - v-for="acpw in accompanyingPeriodWorks" - :key="acpw.id" - class="list-item" - > - <label class="acpw-item"> - <div> - <input - type="radio" - :value="acpw" - v-model="selectedAcpw" - name="item" - /> - </div> - - <accompanying-period-work-item :acpw="acpw" /> - </label> + <div class="results"> + <div + v-for="acpw in accompanyingPeriodWorks" + :key="acpw.id" + class="list-item" + > + <label class="acpw-item"> + <div> + <input + type="radio" + :value="acpw" + v-model="selectedAcpw" + name="item" + /> </div> + + <accompanying-period-work-item :acpw="acpw" /> + </label> </div> + </div> </template> <script setup lang="ts"> @@ -27,31 +27,31 @@ import { AccompanyingPeriodWork } from "../../../types"; import { defineProps, ref, watch } from "vue"; const props = defineProps<{ - accompanyingPeriodWorks: AccompanyingPeriodWork[]; - selectedAcpw?: AccompanyingPeriodWork | null; + accompanyingPeriodWorks: AccompanyingPeriodWork[]; + selectedAcpw?: AccompanyingPeriodWork | null; }>(); const selectedAcpw = ref<AccompanyingPeriodWork | null>( - props.selectedAcpw ?? null, + props.selectedAcpw ?? null, ); const emit = defineEmits<{ - "update:selectedAcpw": [value: AccompanyingPeriodWork | null]; + "update:selectedAcpw": [value: AccompanyingPeriodWork | null]; }>(); watch( - () => props.selectedAcpw, - (val) => { - selectedAcpw.value = val ?? null; - }, + () => props.selectedAcpw, + (val) => { + selectedAcpw.value = val ?? null; + }, ); watch(selectedAcpw, (newValue) => { - emit("update:selectedAcpw", newValue); + emit("update:selectedAcpw", newValue); }); </script> <style> .acpw-item { - display: flex; + display: flex; } </style> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AccompanyingPeriodWorkSelector/AccompanyingPeriodWorkSelectorModal.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AccompanyingPeriodWorkSelector/AccompanyingPeriodWorkSelectorModal.vue index 007486bc2..5ba7c1283 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AccompanyingPeriodWorkSelector/AccompanyingPeriodWorkSelectorModal.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AccompanyingPeriodWorkSelector/AccompanyingPeriodWorkSelectorModal.vue @@ -1,64 +1,58 @@ <template> - <div> - <div class="row justify-content-end" v-if="!isEvaluationSelector"> - <div class="col-md-6 col-sm-10" v-if="selectedAcpw"> - <ul class="list-suggest remove-items"> - <li> - <span - @click="selectedAcpw = null" - class="chill-denomination" - >{{ selectedAcpw?.socialAction?.title.fr }}</span - > - </li> - </ul> - </div> - </div> - - <ul v-if="!showModal" class="record_actions"> - <li> - <a class="btn btn-sm btn-create mt-3" @click="openModal"> - {{ trans(ACPW_DUPLICATE_SELECT_ACCOMPANYING_PERIOD_WORK) }} - </a> - </li> + <div> + <div class="row justify-content-end" v-if="!isEvaluationSelector"> + <div class="col-md-6 col-sm-10" v-if="selectedAcpw"> + <ul class="list-suggest remove-items"> + <li> + <span @click="selectedAcpw = null" class="chill-denomination">{{ + selectedAcpw?.socialAction?.title.fr + }}</span> + </li> </ul> - - <teleport to="body"> - <modal - v-if="showModal" - @close="closeModal" - modal-dialog-class="modal-dialog-scrollable modal-xl" - > - <template #header> - <h3> - {{ getModalTitle() }} - </h3> - </template> - - <template #body> - <accompanying-period-work-list - v-if="evaluations.length === 0" - :accompanying-period-works="accompanyingPeriodWorks" - v-model:selectedAcpw="selectedAcpw" - /> - <accompanying-period-work-evaluation-list - v-if="evaluations.length > 0" - :evaluations="evaluations" - v-model:selectedEvaluation="selectedEvaluation" - /> - </template> - - <template #footer> - <button - type="button" - class="btn btn-save" - @click="confirmSelection" - > - {{ trans(CONFIRM) }} - </button> - </template> - </modal> - </teleport> + </div> </div> + + <ul v-if="!showModal" class="record_actions"> + <li> + <a class="btn btn-sm btn-create mt-3" @click="openModal"> + {{ trans(ACPW_DUPLICATE_SELECT_ACCOMPANYING_PERIOD_WORK) }} + </a> + </li> + </ul> + + <teleport to="body"> + <modal + v-if="showModal" + @close="closeModal" + modal-dialog-class="modal-dialog-scrollable modal-xl" + > + <template #header> + <h3> + {{ getModalTitle() }} + </h3> + </template> + + <template #body> + <accompanying-period-work-list + v-if="evaluations.length === 0" + :accompanying-period-works="accompanyingPeriodWorks" + v-model:selectedAcpw="selectedAcpw" + /> + <accompanying-period-work-evaluation-list + v-if="evaluations.length > 0" + :evaluations="evaluations" + v-model:selectedEvaluation="selectedEvaluation" + /> + </template> + + <template #footer> + <button type="button" class="btn btn-save" @click="confirmSelection"> + {{ trans(CONFIRM) }} + </button> + </template> + </modal> + </teleport> + </div> </template> <script setup lang="ts"> @@ -67,10 +61,10 @@ import Modal from "ChillMainAssets/vuejs/_components/Modal.vue"; import AccompanyingPeriodWorkList from "./AccompanyingPeriodWorkList.vue"; import { AccompanyingPeriodWork } from "../../../types"; import { - trans, - ACPW_DUPLICATE_SELECT_ACCOMPANYING_PERIOD_WORK, - ACPW_DUPLICATE_SELECT_AN_EVALUATION, - CONFIRM, + trans, + ACPW_DUPLICATE_SELECT_ACCOMPANYING_PERIOD_WORK, + ACPW_DUPLICATE_SELECT_AN_EVALUATION, + CONFIRM, } from "translator"; import { fetchResults } from "ChillMainAssets/lib/api/apiMethods"; import AccompanyingPeriodWorkEvaluationList from "ChillPersonAssets/vuejs/_components/AccompanyingPeriodWorkSelector/AccompanyingPeriodWorkEvaluationList.vue"; @@ -83,95 +77,93 @@ const accompanyingPeriodWorks = ref<AccompanyingPeriodWork[]>([]); const evaluations = ref<AccompanyingPeriodWorkEvaluation[]>([]); const props = defineProps<{ - accompanyingPeriodId: string; - isEvaluationSelector: boolean; - ignoreAccompanyingPeriodWorkIds: number[]; + accompanyingPeriodId: string; + isEvaluationSelector: boolean; + ignoreAccompanyingPeriodWorkIds: number[]; }>(); const emit = defineEmits<{ - pickWork: [payload: { work: AccompanyingPeriodWork | null }]; - closeModal: []; - "update:selectedEvaluation": [evaluation: AccompanyingPeriodWorkEvaluation]; + pickWork: [payload: { work: AccompanyingPeriodWork | null }]; + closeModal: []; + "update:selectedEvaluation": [evaluation: AccompanyingPeriodWorkEvaluation]; }>(); const getModalTitle = () => - evaluations.value.length > 0 - ? trans(ACPW_DUPLICATE_SELECT_AN_EVALUATION) - : trans(ACPW_DUPLICATE_SELECT_ACCOMPANYING_PERIOD_WORK); + evaluations.value.length > 0 + ? trans(ACPW_DUPLICATE_SELECT_AN_EVALUATION) + : trans(ACPW_DUPLICATE_SELECT_ACCOMPANYING_PERIOD_WORK); onMounted(() => { - if (props.accompanyingPeriodId) { - getAccompanyingPeriodWorks(parseInt(props.accompanyingPeriodId)); - } else { - console.error("No accompanyingperiod id was given"); - } + if (props.accompanyingPeriodId) { + getAccompanyingPeriodWorks(parseInt(props.accompanyingPeriodId)); + } else { + console.error("No accompanyingperiod id was given"); + } - showModal.value = true; + showModal.value = true; }); const getAccompanyingPeriodWorks = async (periodId: number) => { - const url = `/api/1.0/person/accompanying-course/${periodId}/works.json`; + const url = `/api/1.0/person/accompanying-course/${periodId}/works.json`; - const accompanyingPeriodWorksFetched = - await fetchResults<AccompanyingPeriodWork>(url); - if (props.isEvaluationSelector) { - accompanyingPeriodWorks.value = accompanyingPeriodWorksFetched.filter( - (acpw: AccompanyingPeriodWork) => - acpw.accompanyingPeriodWorkEvaluations.length > 0 && - typeof acpw.id !== "undefined" && - !props.ignoreAccompanyingPeriodWorkIds.includes(acpw.id), - ); - } else { - accompanyingPeriodWorks.value = accompanyingPeriodWorksFetched; - } + const accompanyingPeriodWorksFetched = + await fetchResults<AccompanyingPeriodWork>(url); + if (props.isEvaluationSelector) { + accompanyingPeriodWorks.value = accompanyingPeriodWorksFetched.filter( + (acpw: AccompanyingPeriodWork) => + acpw.accompanyingPeriodWorkEvaluations.length > 0 && + typeof acpw.id !== "undefined" && + !props.ignoreAccompanyingPeriodWorkIds.includes(acpw.id), + ); + } else { + accompanyingPeriodWorks.value = accompanyingPeriodWorksFetched; + } }; watch(selectedAcpw, (newValue) => { - const inputField = document.getElementById( - "find_accompanying_period_work_acpw", - ) as HTMLInputElement; - if (inputField) { - inputField.value = String(newValue?.id || ""); - } + const inputField = document.getElementById( + "find_accompanying_period_work_acpw", + ) as HTMLInputElement; + if (inputField) { + inputField.value = String(newValue?.id || ""); + } - /* if (!props.isEvaluationSelector) { + /* if (!props.isEvaluationSelector) { console.log("Emitting from watch:", { work: newValue }); emit("pickWork", { work: newValue }); }*/ }); const openModal = () => { - showModal.value = true; + showModal.value = true; }; const closeModal = () => { - showModal.value = false; - selectedEvaluation.value = null; - // selectedAcpw.value = null; - emit("closeModal"); + showModal.value = false; + selectedEvaluation.value = null; + // selectedAcpw.value = null; + emit("closeModal"); }; const confirmSelection = () => { - selectedAcpw.value = selectedAcpw.value; - console.log("selectedAcpw", selectedAcpw.value); + selectedAcpw.value = selectedAcpw.value; + console.log("selectedAcpw", selectedAcpw.value); - if (!props.isEvaluationSelector) { - if (selectedAcpw.value) { - // only emit if something is actually selected! - emit("pickWork", { work: selectedAcpw.value }); - closeModal(); - } - // optionally show some error or warning if not selected - return; + if (!props.isEvaluationSelector) { + if (selectedAcpw.value) { + // only emit if something is actually selected!emit("pickWork", { work: selectedAcpw.value }); + closeModal(); } + // optionally show some error or warning if not selected + return; + } - if (selectedAcpw.value && props.isEvaluationSelector) { - evaluations.value = - selectedAcpw.value.accompanyingPeriodWorkEvaluations; - } + if (selectedAcpw.value && props.isEvaluationSelector) { + evaluations.value = selectedAcpw.value.accompanyingPeriodWorkEvaluations; + } - if (selectedEvaluation.value && props.isEvaluationSelector) { - // console.log('evaluation log in modal', selectedEvaluation.value) - emit("update:selectedEvaluation", selectedEvaluation.value); - closeModal(); - } + if (selectedEvaluation.value && props.isEvaluationSelector) { + // console.log('evaluation log in modal', selectedEvaluation.value) + emit("update:selectedEvaluation", selectedEvaluation.value); + closeModal(); + } }; </script> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons.vue index 5e81b8241..ea4090452 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons.vue @@ -1,517 +1,254 @@ <template> - <a - class="btn" - :class="getClassButton" - :title="$t(buttonTitle || '')" - @click="openModal" - > - <span v-if="displayTextButton">{{ $t(buttonTitle || "") }}</span> - </a> + <a + class="btn" + :class="getClassButton" + :title="buttonTitle" + @click="openModalChoose" + > + <span v-if="displayTextButton">{{ buttonTitle }}</span> + </a> - <teleport to="body"> - <modal - v-if="modal.showModal" - :modal-dialog-class="modal.modalDialogClass" - @close="modal.showModal = false" - > - <template #header> - <h3 class="modal-title"> - {{ $t(modalTitle) }} - </h3> - </template> + <person-choose-modal + v-if="showModalChoose" + ref="personChooseModal" + :modal-title="modalTitle" + :options="options" + :selected="selected" + :modal-dialog-class="'modal-dialog-scrollable modal-xl'" + :allow-create="props.allowCreate" + @close="closeModalChoose" + @onPickEntities="onPickEntities" + @onAskForCreate="onAskForCreate" + @triggerAddContact="triggerAddContact" + @updateSelected="updateSelected" + @cleanSelected="emptySelected" + /> - <template #body-head> - <div class="modal-body"> - <div class="search"> - <label class="col-form-label" style="float: right"> - {{ - $tc( - "add_persons.suggested_counter", - suggestedCounter, - ) - }} - </label> + <CreateModal + v-if=" + creatableEntityTypes.length > 0 && + showModalCreate && + null == thirdPartyParentAddContact + " + action="create" + :allowed-types="creatableEntityTypes" + :query="query" + :parent="null" + modalTitle="test" + @close="closeModalCreate" + @onPersonCreated="onPersonCreated" + @onThirdPartyCreated="onThirdPartyCreated" + ></CreateModal> - <input - id="search-persons" - name="query" - v-model="query" - :placeholder="$t('add_persons.search_some_persons')" - ref="search" - /> - <i class="fa fa-search fa-lg" /> - <i - class="fa fa-times" - v-if="queryLength >= 3" - @click="resetSuggestion" - /> - </div> - </div> - <div class="modal-body" v-if="checkUniq === 'checkbox'"> - <div class="count"> - <span> - <a v-if="suggestedCounter > 2" @click="selectAll"> - {{ $t("action.check_all") }} - </a> - <a - v-if="selectedCounter > 0" - @click="resetSelection" - > - <i v-if="suggestedCounter > 2"> • </i> - {{ $t("action.reset") }} - </a> - </span> - <span v-if="selectedCounter > 0"> - {{ - $tc( - "add_persons.selected_counter", - selectedCounter, - ) - }} - </span> - </div> - </div> - </template> - - <template #body> - <div class="results"> - <person-suggestion - v-for="item in this.selectedAndSuggested - .slice() - .reverse()" - :key="itemKey(item)" - :item="item" - :search="search" - :type="checkUniq" - @save-form-on-the-fly="saveFormOnTheFly" - @new-prior-suggestion="newPriorSuggestion" - @update-selected="updateSelected" - /> - - <div class="create-button"> - <on-the-fly - v-if=" - queryLength >= 3 && - (options.type.includes('person') || - options.type.includes('thirdparty')) - " - :button-text=" - $t('onthefly.create.button', { q: query }) - " - :allowed-types="options.type" - :query="query" - action="create" - @save-form-on-the-fly="saveFormOnTheFly" - ref="onTheFly" - /> - </div> - </div> - </template> - - <template #footer> - <button - class="btn btn-create" - @click.prevent="$emit('addNewPersons', { selected, modal })" - > - {{ $t("action.add") }} - </button> - </template> - </modal> - </teleport> + <CreateModal + v-if="showModalCreate && thirdPartyParentAddContact !== null" + :allowed-types="['thirdparty']" + action="addContact" + modalTitle="test" + :parent="thirdPartyParentAddContact" + :query="''" + @close="closeModalCreate" + @onPersonCreated="onPersonCreated" + @onThirdPartyCreated="onThirdPartyCreated" + ></CreateModal> </template> -<script> -import Modal from "ChillMainAssets/vuejs/_components/Modal"; -import OnTheFly from "ChillMainAssets/vuejs/OnTheFly/components/OnTheFly.vue"; -import PersonSuggestion from "./AddPersons/PersonSuggestion"; -import { searchEntities } from "ChillPersonAssets/vuejs/_api/AddPersons"; -import { makeFetch } from "ChillMainAssets/lib/api/apiMethods"; +<script setup lang="ts"> +import { ref, computed, nextTick, useTemplateRef } from "vue"; +import PersonChooseModal from "./AddPersons/PersonChooseModal.vue"; +import type { + Suggestion, + SearchOptions, + CreatableEntityType, + EntityType, + Person, +} from "ChillPersonAssets/types"; +import { marked } from "marked"; +import options = marked.options; +import CreateModal from "ChillMainAssets/vuejs/OnTheFly/components/CreateModal.vue"; +import { + Thirdparty, + ThirdpartyCompany, +} from "../../../../../ChillThirdPartyBundle/Resources/public/types"; -export default { - name: "AddPersons", - components: { - Modal, - PersonSuggestion, - OnTheFly, - }, - props: ["buttonTitle", "modalTitle", "options"], - emits: ["addNewPersons"], - data() { - return { - modal: { - showModal: false, - modalDialogClass: "modal-dialog-scrollable modal-xl", - }, - search: { - query: "", - previousQuery: "", - currentSearchQueryController: null, - suggested: [], - selected: [], - priorSuggestion: {}, - }, - }; - }, - computed: { - query: { - set(query) { - return this.setQuery(query); - }, - get() { - return this.search.query; - }, - }, - queryLength() { - return this.search.query.length; - }, - suggested() { - return this.search.suggested; - }, - suggestedCounter() { - return this.search.suggested.length; - }, - selected() { - return this.search.selected; - }, - selectedCounter() { - return this.search.selected.length; - }, - selectedAndSuggested() { - this.addPriorSuggestion(); - const uniqBy = (a, key) => [ - ...new Map(a.map((x) => [key(x), x])).values(), - ]; - let union = [ - ...new Set([ - ...this.suggested.slice().reverse(), - ...this.selected.slice().reverse(), - ]), - ]; - return uniqBy(union, (k) => k.key); - }, - getClassButton() { - let size = - typeof this.options.button !== "undefined" && - typeof this.options.button.size !== "undefined" - ? this.options.button.size - : ""; - let type = - typeof this.options.button !== "undefined" && - typeof this.options.button.type !== "undefined" - ? this.options.button.type - : "btn-create"; - return size ? size + " " + type : type; - }, - displayTextButton() { - return typeof this.options.button !== "undefined" && - typeof this.options.button.display !== "undefined" - ? this.options.button.display - : true; - }, - checkUniq() { - if (this.options.uniq === true) { - return "radio"; - } - return "checkbox"; - }, - priorSuggestion() { - return this.search.priorSuggestion; - }, - hasPriorSuggestion() { - return this.search.priorSuggestion.key ? true : false; - }, - }, - methods: { - openModal() { - this.modal.showModal = true; - this.$nextTick(function () { - this.$refs.search.focus(); - }); - }, - setQuery(query) { - this.search.query = query; +interface AddPersonsConfig { + suggested?: Suggestion[]; + buttonTitle: string; + modalTitle: string; + options: SearchOptions; + allowCreate?: boolean; + types?: EntityType[] | undefined; +} - setTimeout( - function () { - if (query === "") { - this.loadSuggestions([]); - return; - } - if (query === this.search.query) { - if (this.search.currentSearchQueryController !== null) { - this.search.currentSearchQueryController.abort(); - } - this.search.currentSearchQueryController = - new AbortController(); - searchEntities( - { query, options: this.options }, - this.search.currentSearchQueryController.signal, - ) - .then( - (suggested) => - new Promise((resolve) => { - this.loadSuggestions(suggested.results); - resolve(); - }), - ) - .catch((error) => { - if (error instanceof DOMException) { - if (error.name === "AbortError") { - console.log( - "request aborted due to user continue typing", - ); - return; - } - } +const props = withDefaults(defineProps<AddPersonsConfig>(), { + suggested: () => [], + allowCreate: () => true, + types: () => ["person"], +}); - throw error; - }); - } - }.bind(this), - query.length > 3 ? 300 : 700, - ); - }, - loadSuggestions(suggested) { - this.search.suggested = suggested; - this.search.suggested.forEach(function (item) { - item.key = this.itemKey(item); - }, this); - }, - updateSelected(value) { - //console.log('value', value); - this.search.selected = value; - }, - resetSearch() { - this.resetSelection(); - this.resetSuggestion(); - }, - resetSuggestion() { - this.search.query = ""; - this.search.suggested = []; - }, - resetSelection() { - this.search.selected = []; - }, - selectAll() { - this.search.suggested.forEach(function (item) { - this.search.selected.push(item); - }, this); - }, - itemKey(item) { - return item.result.type + item.result.id; - }, - addPriorSuggestion() { - // console.log('prior suggestion', this.priorSuggestion); - if (this.hasPriorSuggestion) { - // console.log('addPriorSuggestion',); - this.suggested.unshift(this.priorSuggestion); - this.selected.unshift(this.priorSuggestion); +const emit = + defineEmits< + (e: "addNewPersons", payload: { selected: Suggestion[] }) => void + >(); - this.newPriorSuggestion(null); - } - }, - newPriorSuggestion(entity) { - // console.log('newPriorSuggestion', entity); - if (entity !== null) { - let suggestion = { - key: entity.type + entity.id, - relevance: 0.5, - result: entity, - }; - this.search.priorSuggestion = suggestion; - // console.log('search priorSuggestion', this.search.priorSuggestion); - this.addPriorSuggestion(suggestion); - } else { - this.search.priorSuggestion = {}; - } - }, - saveFormOnTheFly({ type, data }) { - console.log( - "saveFormOnTheFly from addPersons, type", - type, - ", data", - data, - ); - if (type === "person") { - makeFetch("POST", "/api/1.0/person/person.json", data) - .then((responsePerson) => { - this.newPriorSuggestion(responsePerson); - this.$refs.onTheFly.closeModal(); +type PersonChooseModalType = InstanceType<typeof PersonChooseModal>; +const personChooseModal = + useTemplateRef<PersonChooseModalType>("personChooseModal"); - if (null !== data.addressId) { - const household = { - type: "household", - }; - const address = { - id: data.addressId, - }; - makeFetch( - "POST", - "/api/1.0/person/household.json", - household, - ) - .then((responseHousehold) => { - const member = { - concerned: [ - { - person: { - type: "person", - id: responsePerson.id, - }, - start_date: { - // TODO: use date.ts methods (low priority) - datetime: `${new Date().toISOString().split("T")[0]}T00:00:00+02:00`, - }, - holder: false, - comment: null, - }, - ], - destination: { - type: "household", - id: responseHousehold.id, - }, - composition: null, - }; - return makeFetch( - "POST", - "/api/1.0/person/household/members/move.json", - member, - ) - .then(() => { - makeFetch( - "POST", - `/api/1.0/person/household/${responseHousehold.id}/address.json`, - address, - ) - .then((_response) => { - console.log(_response); - }) - .catch((error) => { - if ( - error.name === - "ValidationException" - ) { - for (let v of error.violations) { - this.$toast.open({ - message: v, - }); - } - } else { - this.$toast.open({ - message: - "An error occurred", - }); - } - }); - }) - .catch((error) => { - if ( - error.name === - "ValidationException" - ) { - for (let v of error.violations) { - this.$toast.open({ - message: v, - }); - } - } else { - this.$toast.open({ - message: - "An error occurred", - }); - } - }); - }) - .catch((error) => { - if (error.name === "ValidationException") { - for (let v of error.violations) { - this.$toast.open({ message: v }); - } - } else { - this.$toast.open({ - message: "An error occurred", - }); - } - }); - } - }) - .catch((error) => { - if (error.name === "ValidationException") { - for (let v of error.violations) { - this.$toast.open({ message: v }); - } - } else { - this.$toast.open({ message: "An error occurred" }); - } - }); - } else if (type === "thirdparty") { - makeFetch("POST", "/api/1.0/thirdparty/thirdparty.json", data) - .then((response) => { - this.newPriorSuggestion(response); - this.$refs.onTheFly.closeModal(); - }) - .catch((error) => { - if (error.name === "ValidationException") { - for (let v of error.violations) { - this.$toast.open({ message: v }); - } - } else { - this.$toast.open({ message: "An error occurred" }); - } - }); - } - }, - }, -}; +/** + * Flag to show/hide the modal "choose". + */ +const showModalChoose = ref(false); + +/** + * Flag to show/hide the modal "create". + */ +const showModalCreate = ref(false); + +/** + * Store the previous search query, stored while going from "search" state to "create" + */ +const query = ref(""); + +/** + * Temporarily store the thirdparty company when calling "addContact" + */ +const thirdPartyParentAddContact = ref<ThirdpartyCompany | null>(null); + +/** + * Contains the selected elements. + * + * If the property option.uniq is true, this will contains only one element. + * + * Suggestion must be added/removed using the @link{addSuggestionToSelected} and @link{removeSuggestionFromSelected} + * methods. + */ +const selected = ref<Map<string, Suggestion>>(new Map()); + +const getClassButton = computed(() => { + const size = props.options?.button?.size ?? ""; + const type = props.options?.button?.type ?? "btn-create"; + return size ? `${size} ${type}` : type; +}); + +const displayTextButton = computed(() => + props.options?.button?.display !== undefined + ? props.options.button.display + : true, +); + +const creatableEntityTypes = computed<CreatableEntityType[]>(() => { + if (typeof props.options.type !== "undefined") { + return props.options.type.filter( + (e: EntityType) => e === "thirdparty" || e === "person", + ); + } + return props.types.filter( + (e: EntityType) => e === "thirdparty" || e === "person", + ); +}); + +function onAskForCreate(payload: { query: string }): void { + query.value = payload.query; + closeModalChoose(); + showModalCreate.value = true; +} + +function openModalChoose(): void { + showModalChoose.value = true; +} + +function closeModalChoose(): void { + showModalChoose.value = false; +} + +function closeModalCreate(): void { + if (null !== thirdPartyParentAddContact.value) { + thirdPartyParentAddContact.value = null; + } + showModalCreate.value = false; +} + +/** + * Called by PersonSuggestion's updateSelection event, when an element is checked/unchecked + */ +function updateSelected(payload: { + suggestion: Suggestion; + isSelected: boolean; +}): void { + if (payload.isSelected) { + addSuggestionToSelected(payload.suggestion); + } else { + removeSuggestionFromSelected(payload.suggestion); + } +} + +function addSuggestionToSelected(suggestion: Suggestion): void { + if (props.options.uniq) { + selected.value.clear(); + } + selected.value.set(suggestion.key, suggestion); +} + +function removeSuggestionFromSelected(suggestion: Suggestion): void { + selected.value.delete(suggestion.key); +} + +function emptySelected(): void { + selected.value = new Map(); +} + +function onPickEntities(): void { + const alls = Array.from(selected.value.values()); + emit("addNewPersons", { selected: alls }); + closeModalChoose(); +} + +function triggerAddContact({ parent }: { parent: ThirdpartyCompany }): void { + closeModalChoose(); + openModalChoose(); + thirdPartyParentAddContact.value = parent; + showModalCreate.value = true; +} + +function onPersonCreated(payload: { person: Person }): void { + showModalCreate.value = false; + const suggestion = { + result: payload.person, + relevance: 999999, + key: "person", + }; + addSuggestionToSelected(suggestion); + if (props.options.uniq) { + emit("addNewPersons", { selected: [suggestion] }); + } else { + openModalChoose(); + } +} + +function onThirdPartyCreated(payload: { thirdParty: Thirdparty }): void { + showModalCreate.value = false; + const suggestion = { + result: payload.thirdParty, + relevance: 999999, + key: "thirdparty", + }; + addSuggestionToSelected(suggestion); + if (props.options.uniq) { + emit("addNewPersons", { selected: [suggestion] }); + } else { + openModalChoose(); + } +} + +function resetSearch(): void { + selected.value = new Map(); + personChooseModal.value?.resetSearch(); +} + +defineExpose({ resetSearch }); </script> -<style lang="scss"> -li.add-persons { - a { - cursor: pointer; - } -} -div.body-head { - overflow-y: unset; - div.modal-body:first-child { - margin: auto 4em; - div.search { - position: relative; - input { - width: 100%; - padding: 1.2em 1.5em 1.2em 2.5em; - //margin: 1em 0; - } - i { - position: absolute; - opacity: 0.5; - padding: 0.65em 0; - top: 50%; - } - i.fa-search { - left: 0.5em; - } - i.fa-times { - right: 1em; - padding: 0.75em 0; - cursor: pointer; - } - } - } - div.modal-body:last-child { - padding-bottom: 0; - } - div.count { - margin: -0.5em 0 0.7em; - display: flex; - justify-content: space-between; - a { - cursor: pointer; - } - } -} -.create-button > a { - margin-top: 0.5em; - margin-left: 2.6em; -} +<style lang="scss" scoped> +/* Button styles can remain here if needed */ </style> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/PersonChooseModal.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/PersonChooseModal.vue new file mode 100644 index 000000000..966d7a364 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/PersonChooseModal.vue @@ -0,0 +1,358 @@ +<template> + <teleport to="body"> + <modal + @close="() => emit('close')" + :modal-dialog-class="modalDialogClass" + :hide-footer="false" + > + <template #header> + <h3 class="modal-title">{{ modalTitle }}</h3> + </template> + + <template #body-head> + <div class="modal-body"> + <div class="search"> + <label class="col-form-label" style="float: right"> + {{ + trans(ADD_PERSONS_SUGGESTED_COUNTER, { + count: suggestedCounter, + }) + }} + </label> + + <input + id="search-persons" + name="query" + v-model="query" + :placeholder="trans(ADD_PERSONS_SEARCH_SOME_PERSONS)" + ref="searchRef" + /> + <i class="fa fa-search fa-lg" /> + <i + class="fa fa-times" + v-if="queryLength >= 3" + @click="resetSuggestion" + /> + </div> + </div> + + <div class="modal-body" v-if="checkUniq === 'checkbox'"> + <div class="count"> + <span> + <a v-if="suggestedCounter > 2" @click="selectAll"> + {{ trans(ACTION_CHECK_ALL) }} + </a> + <a v-if="selectedCounter > 0" @click="resetSelection"> + <i v-if="suggestedCounter > 2"> • </i> + {{ trans(ACTION_RESET) }} + </a> + </span> + <span v-if="selectedCounter > 0"> + {{ + trans(ADD_PERSONS_SELECTED_COUNTER, { count: selectedCounter }) + }} + </span> + </div> + </div> + </template> + + <template #body> + <div class="results"> + <person-suggestion + v-for="item in selectedAndSuggested" + :key="item.key" + :item="item" + :isSelected="item.isSelected" + :type="checkUniq" + @update-selected="(payload) => emit('updateSelected', payload)" + @trigger-add-contact="triggerAddContact" + /> + + <div v-if="hasNoResult"> + <div class="noResult chill-no-data-statement"> + {{ + trans(ADD_PERSONS_SUGGESTED_COUNTER, { + count: suggestedCounter, + }) + }} + </div> + </div> + + <div + v-if="props.allowCreate && query.length > 0" + class="create-button" + > + <button + type="button" + class="btn btn-submit" + @click="emit('onAskForCreate', { query })" + > + {{ trans(ONTHEFLY_CREATE_BUTTON, { q: query }) }} + </button> + </div> + </div> + </template> + + <template #footer> + <button + type="button" + class="btn btn-create" + @click.prevent="pickEntities" + > + {{ trans(ACTION_ADD) }} + </button> + </template> + </modal> + </teleport> +</template> + +<script setup lang="ts"> +import { ref, reactive, computed, nextTick, watch, onMounted } from "vue"; +import Modal from "ChillMainAssets/vuejs/_components/Modal.vue"; +import PersonSuggestion from "./PersonSuggestion.vue"; +import { searchEntities } from "ChillPersonAssets/vuejs/_api/AddPersons"; + +import { + trans, + ADD_PERSONS_SUGGESTED_COUNTER, + ADD_PERSONS_SEARCH_SOME_PERSONS, + ADD_PERSONS_SELECTED_COUNTER, + ONTHEFLY_CREATE_BUTTON, + ACTION_CHECK_ALL, + ACTION_RESET, + ACTION_ADD, +} from "translator"; + +import type { + Suggestion, + Search, + SearchOptions, + Entities, +} from "ChillPersonAssets/types"; +import { ThirdpartyCompany } from "../../../../../../ChillThirdPartyBundle/Resources/public/types"; + +interface Props { + modalTitle: string; + options: SearchOptions; + suggested?: Suggestion[]; + selected: Map<string, Suggestion>; + modalDialogClass?: string; + allowCreate?: boolean; +} + +const props = withDefaults(defineProps<Props>(), { + suggested: () => [], + modalDialogClass: "modal-dialog-scrollable modal-xl", + allowCreate: () => true, +}); + +const emit = defineEmits<{ + (e: "close"): void; + (e: "onPickEntities"): void; + (e: "onAskForCreate", payload: { query: string }): void; + (e: "triggerAddContact", payload: { parent: ThirdpartyCompany }): void; + ( + e: "updateSelected", + payload: { suggestion: Suggestion; isSelected: boolean }, + ): void; + (e: "cleanSelected"): void; +}>(); + +const searchRef = ref<HTMLInputElement | null>(null); + +onMounted(() => { + // give the focus on the search bar + searchRef.value?.focus(); +}); + +const search = reactive({ + query: "" as string, + previousQuery: "" as string, + currentSearchQueryController: null as AbortController | null, + priorSuggestion: {} as Partial<Suggestion>, + hasPreviousQuery: false, +}); + +/** + * Contains the suggested entities from the search results. + * + * In other words, those entities are displayed and selectable by the user + */ +const suggested = ref<Suggestion[]>([]); + +const query = computed({ + get: () => search.query, + set: (val: string) => setQuery(val), +}); +const queryLength = computed(() => search.query.length); +const suggestedCounter = computed(() => suggested.value.length); +const selectedCounter = computed(() => props.selected.size); + +const checkUniq = computed(() => (props.options.uniq ? "radio" : "checkbox")); + +const selectedAndSuggested = computed<(Suggestion & { isSelected: boolean })[]>( + () => { + const selectedAndSuggested = []; + + // add selected that are not in the search results + for (const selected of props.selected.values()) { + if (!suggested.value.some((s: Suggestion) => s.key === selected.key)) { + selectedAndSuggested.push({ ...selected, isSelected: false }); + } + } + for (const suggestion of suggested.value) { + selectedAndSuggested.push({ + ...suggestion, + isSelected: props.selected.has(suggestion.key), + }); + } + + return selectedAndSuggested; + }, +); + +const hasNoResult = computed( + () => search.hasPreviousQuery && suggested.value.length === 0, +); + +function setQuery(q: string) { + search.query = q; + + if (search.currentSearchQueryController) { + search.currentSearchQueryController.abort(); + search.currentSearchQueryController = null; + } + + if (q === "") { + loadSuggestions([]); + return; + } + + const delay = q.length > 3 ? 300 : 700; + + setTimeout(() => { + if (q !== search.query) return; + + search.currentSearchQueryController = new AbortController(); + + searchEntities( + { query: q, options: props.options }, + search.currentSearchQueryController.signal, + ) + .then((suggested: Search) => { + loadSuggestions(suggested.results); + search.hasPreviousQuery = true; + }) + .catch((error: DOMException) => { + if (error instanceof DOMException && error.name === "AbortError") { + return; + } + throw error; + }); + }, delay); +} + +function loadSuggestions( + suggestedArr: { relevance: number; result: Entities }[], +): void { + suggested.value = suggestedArr.map((item) => { + return { + key: item.result.type + item.result.id, + relevance: item.relevance, + result: item.result, + }; + }); +} + +function resetSuggestion() { + search.query = ""; + suggested.value = []; +} + +function resetSelection() { + emit("cleanSelected"); +} + +function resetSearch() { + resetSelection(); + resetSuggestion(); +} + +function selectAll() { + suggested.value.forEach((suggestion: Suggestion) => { + emit("updateSelected", { suggestion, isSelected: true }); + }); +} + +function triggerAddContact(payload: { parent: ThirdpartyCompany }) { + emit("triggerAddContact", payload); +} + +/** + * Triggered when the user clicks on the "add" button. + */ +function pickEntities(): void { + emit("onPickEntities"); + search.query = ""; + emit("close"); +} + +defineExpose({ resetSearch }); +</script> + +<style lang="scss" scoped> +li.add-persons { + a { + cursor: pointer; + } +} + +div.body-head { + overflow-y: unset; + div.modal-body:first-child { + margin: auto 4em; + div.search { + position: relative; + input { + width: 100%; + padding: 1.2em 1.5em 1.2em 2.5em; + } + i { + position: absolute; + opacity: 0.5; + padding: 0.65em 0; + top: 50%; + } + i.fa-search { + left: 0.5em; + } + i.fa-times { + right: 1em; + padding: 0.75em 0; + cursor: pointer; + } + } + } + div.modal-body:last-child { + padding-bottom: 0; + } + div.count { + margin: -0.5em 0 0.7em; + display: flex; + justify-content: space-between; + a { + cursor: pointer; + } + } +} + +.create-button > button { + margin-top: 0.5em; + margin-left: 0.6em; +} +.noResult { + text-align: center; + margin: 2em; + font-size: large; +} +</style> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/PersonSuggestion.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/PersonSuggestion.vue index bb90557b7..bb528b0a8 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/PersonSuggestion.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/PersonSuggestion.vue @@ -1,136 +1,137 @@ <template> - <div class="list-item" :class="{ checked: isChecked }"> - <label> - <div> - <input - v-bind:type="type" - v-model="selected" - name="item" - v-bind:id="item" - v-bind:value="setValueByType(item, type)" - /> - </div> + <div class="list-item" :class="{ checked: props.isSelected }"> + <label> + <input + :type="type" + :value="props.item.key" + name="item" + :id="props.item.key" + :checked="props.isSelected" + @click="onUpdateValue" + /> - <suggestion-person - v-if="item.result.type === 'person'" - v-bind:item="item" - > - </suggestion-person> + <suggestion-person + v-if="isSuggestionForPerson(item)" + :item="item" + ></suggestion-person> - <suggestion-third-party - v-if="item.result.type === 'thirdparty'" - @newPriorSuggestion="newPriorSuggestion" - v-bind:item="item" - > - </suggestion-third-party> + <suggestion-third-party + v-if="isSuggestionForThirdParty(item)" + @trigger-add-contact="triggerAddContact" + :item="item" + ></suggestion-third-party> - <suggestion-user - v-if="item.result.type === 'user'" - v-bind:item="item" - > - </suggestion-user> + <suggestion-user + v-if="isSuggestionForUser(item)" + :item="item" + ></suggestion-user> - <suggestion-user-group - v-if="item.result.type === 'user_group'" - v-bind:item="item" - > - ></suggestion-user-group - > + <suggestion-user-group + v-if="isSuggestionForUserGroup(item)" + :item="item" + ></suggestion-user-group> - <suggestion-household - v-if="item.result.type === 'household'" - v-bind:item="item" - > - </suggestion-household> - </label> - </div> + <suggestion-household + v-if="isSuggestionForHousehold(item)" + :item="item" + ></suggestion-household> + </label> + </div> </template> -<script> -import SuggestionPerson from "./TypePerson"; -import SuggestionThirdParty from "./TypeThirdParty"; -import SuggestionUser from "./TypeUser"; -import SuggestionHousehold from "./TypeHousehold"; -import SuggestionUserGroup from "./TypeUserGroup"; +<script setup lang="ts"> +import { computed } from "vue"; -export default { - name: "PersonSuggestion", - components: { - SuggestionPerson, - SuggestionThirdParty, - SuggestionUser, - SuggestionHousehold, - SuggestionUserGroup, - }, - props: ["item", "search", "type"], - emits: ["updateSelected", "newPriorSuggestion"], - computed: { - selected: { - set(value) { - //console.log('value', value); - this.$emit("updateSelected", value); - }, - get() { - return this.search.selected; - }, - }, - isChecked() { - return this.search.selected.indexOf(this.item) === -1 - ? false - : true; - }, - }, - methods: { - setValueByType(value, type) { - return type === "radio" ? [value] : value; - }, - newPriorSuggestion(response) { - this.$emit("newPriorSuggestion", response); - }, - }, +// Components +import SuggestionPerson from "./TypePerson.vue"; +import SuggestionThirdParty from "./TypeThirdParty.vue"; +import SuggestionUser from "./TypeUser.vue"; +import SuggestionHousehold from "./TypeHousehold.vue"; +import SuggestionUserGroup from "./TypeUserGroup.vue"; + +// Types +import { + isSuggestionForHousehold, + isSuggestionForPerson, + isSuggestionForThirdParty, + isSuggestionForUser, + isSuggestionForUserGroup, + Suggestion, +} from "ChillPersonAssets/types"; +import { ThirdpartyCompany } from "../../../../../../ChillThirdPartyBundle/Resources/public/types"; + +const props = defineProps<{ + item: Suggestion; + isSelected: boolean; + type: "radio" | "checkbox"; +}>(); +const emit = defineEmits<{ + ( + e: "updateSelected", + payload: { suggestion: Suggestion; isSelected: boolean }, + ): void; + (e: "triggerAddContact", payload: { parent: ThirdpartyCompany }): void; +}>(); + +const isChecked = computed<boolean>(() => props.isSelected); + +const onUpdateValue = (event: Event) => { + const target = event?.target; + if (!(target instanceof HTMLInputElement)) { + console.error("the value of checked is not an HTMLInputElement"); + return; + } + emit("updateSelected", { + suggestion: props.item, + isSelected: props.type === "radio" ? true : target.checked, + }); }; + +function triggerAddContact(payload: { parent: ThirdpartyCompany }) { + emit("triggerAddContact", payload); +} </script> <style lang="scss"> div.results { - div.list-item { - padding: 0.4em 0.8em; - display: flex; - flex-direction: row; - &.checked { - background-color: #ececec; - border-bottom: 1px dotted #8b8b8b; - } - label { - display: inline-flex; - width: 100%; - div.container:not(.household) { - & > input { - margin-right: 0.8em; - } - > span:not(.name) { - margin-left: 0.5em; - opacity: 0.5; - font-size: 90%; - font-style: italic; - } - } - div.right_actions { - margin: 0 0 0 auto; - display: flex; - align-items: flex-end; - & > * { - margin-left: 0.5em; - align-self: baseline; - } - - a.btn { - border: 1px solid lightgrey; - font-size: 70%; - padding: 4px; - } - } - } + div.list-item { + padding: 0.4em 0.8em; + display: flex; + flex-direction: row; + &.checked { + background-color: #ececec; + border-bottom: 1px dotted #8b8b8b; } + label { + display: inline-flex; + width: 100%; + div.container:not(.household) { + & > input { + margin-right: 0.8em; + } + > span:not(.name) { + margin-left: 0.5em; + opacity: 0.5; + font-size: 90%; + font-style: italic; + } + } + div.right_actions { + margin: 0 0 0 auto; + display: flex; + align-items: flex-end; + & > * { + margin-left: 0.5em; + align-self: baseline; + } + + a.btn { + border: 1px solid lightgrey; + font-size: 70%; + padding: 4px; + } + } + } + } } </style> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/TypeHousehold.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/TypeHousehold.vue index 86a58737a..f31b8308e 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/TypeHousehold.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/TypeHousehold.vue @@ -1,26 +1,26 @@ <template> - <div class="container household"> - <household-render-box - :household="item.result" - :is-address-multiline="false" - /> - </div> + <div class="container household"> + <HouseholdRenderBox + :household="item.result" + :is-address-multiline="false" + /> + </div> - <div class="right_actions"> - <badge-entity :entity="item.result" :options="{ displayLong: true }" /> - </div> + <div class="right_actions"> + <BadgeEntity :entity="item.result" :options="{ displayLong: true }" /> + </div> </template> -<script> +<script setup lang="ts"> +import { defineProps } from "vue"; import BadgeEntity from "ChillMainAssets/vuejs/_components/BadgeEntity.vue"; import HouseholdRenderBox from "ChillPersonAssets/vuejs/_components/Entity/HouseholdRenderBox.vue"; +import { Suggestion } from "ChillPersonAssets/types"; +import { Household } from "ChillMainAssets/types"; -export default { - name: "SuggestionHousehold", - components: { - BadgeEntity, - HouseholdRenderBox, - }, - props: ["item"], -}; +interface TypeHouseholdProps { + item: Suggestion & { result: Household }; +} + +defineProps<TypeHouseholdProps>(); </script> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/TypePerson.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/TypePerson.vue index 259029f73..f088b7060 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/TypePerson.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/TypePerson.vue @@ -1,43 +1,46 @@ <template> - <div class="container"> - <span class="name"> - <person-text :person="item.result" /> - </span> - <span class="birthday" v-if="hasBirthdate"> - {{ $d(item.result.birthdate.datetime, "short") }} - </span> - <span class="location" v-if="hasAddress"> - {{ item.result.current_household_address.text }} - - {{ item.result.current_household_address.postcode.name }} - </span> - </div> + <div class="container"> + <span class="name"> + <person-text :person="item.result" /> + </span> + <span class="birthday" v-if="hasBirthdate"> + {{ formatDate(item.result.birthdate?.datetime, "short") }} + </span> + <span class="location" v-if="hasAddress"> + {{ item.result.current_household_address?.text }} - + {{ item.result.current_household_address?.postcode?.name }} + </span> + </div> - <div class="right_actions"> - <badge-entity :entity="item.result" :options="{ displayLong: true }" /> - <on-the-fly type="person" :id="item.result.id" action="show" /> - </div> + <div class="right_actions"> + <badge-entity :entity="item.result" :options="{ displayLong: true }" /> + <on-the-fly type="person" :id="item.result.id" action="show" /> + </div> </template> -<script> +<script lang="ts" setup> +import { computed, defineProps } from "vue"; import OnTheFly from "ChillMainAssets/vuejs/OnTheFly/components/OnTheFly.vue"; import BadgeEntity from "ChillMainAssets/vuejs/_components/BadgeEntity.vue"; import PersonText from "ChillPersonAssets/vuejs/_components/Entity/PersonText.vue"; +import { Person, Suggestion } from "ChillPersonAssets/types"; -export default { - name: "SuggestionPerson", - components: { - OnTheFly, - BadgeEntity, - PersonText, - }, - props: ["item"], - computed: { - hasBirthdate() { - return this.item.result.birthdate !== null; - }, - hasAddress() { - return this.item.result.current_household_address !== null; - }, - }, -}; +function formatDate(dateString: string | undefined, format: string) { + if (!dateString) return ""; + // Use Intl.DateTimeFormat or your preferred formatting here + const date = new Date(dateString); + if (format === "short") { + return date.toLocaleDateString(); + } + return date.toString(); +} + +const props = defineProps<{ + item: Suggestion & { result: Person }; +}>(); + +const hasBirthdate = computed(() => props.item.result.birthdate !== null); +const hasAddress = computed( + () => props.item.result.current_household_address !== null, +); </script> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/TypeThirdParty.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/TypeThirdParty.vue index 8c97d2e29..e861f11d6 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/TypeThirdParty.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/TypeThirdParty.vue @@ -1,126 +1,136 @@ <template> - <div class="container tpartycontainer"> - <div class="tparty-identification"> - <span v-if="item.result.profession" class="profession">{{ - item.result.profession - }}</span> - <span class="name"> {{ item.result.text }}  </span> - <span class="location"> - <template v-if="hasAddress"> - {{ getAddress.text }} - - {{ getAddress.postcode.name }} - </template> - </span> - </div> - <div class="tpartyparent" v-if="hasParent"> - <span class="name"> > {{ item.result.parent.text }} </span> - </div> + <div class="container tpartycontainer"> + <div class="tparty-identification"> + <span + v-if=" + (isThirdpartyChild(item.result) || + isThirdpartyContact(item.result)) && + item.result.profession + " + class="profession" + >{{ item.result.profession }}</span + > + <span class="name"> {{ item.result.text }}  </span> + <span class="location"> + <template v-if="hasAddress"> + {{ getAddress?.text }} - + {{ getAddress?.postcode?.name }} + </template> + </span> </div> + <div + class="tpartyparent" + v-if="isThirdpartyChild(item.result) && null !== item.result.parent" + > + <span class="name"> > {{ item.result.parent.text }} </span> + </div> + </div> - <div class="right_actions"> - <badge-entity :entity="item.result" :options="{ displayLong: true }" /> - <on-the-fly - v-if="item.result.kind === 'company'" - :parent="item.result" - @save-form-on-the-fly="saveFormOnTheFly" - action="addContact" - ref="onTheFly" - /> - <on-the-fly type="thirdparty" :id="item.result.id" action="show" /> - </div> + <div class="right_actions"> + <badge-entity :entity="item.result" :options="{ displayLong: true }" /> + <a + v-if="item.result.type === 'thirdparty' && item.result.kind === 'company'" + class="btn btn-tpchild" + @click="emit('triggerAddContact', { parent: item.result })" + ><i class="bi bi-person-fill-add"></i + ></a> + <on-the-fly type="thirdparty" :id="item.result.id" action="show" /> + </div> </template> -<script> +<script lang="ts" setup> +import { computed, ref } from "vue"; import OnTheFly from "ChillMainAssets/vuejs/OnTheFly/components/OnTheFly.vue"; import BadgeEntity from "ChillMainAssets/vuejs/_components/BadgeEntity.vue"; import { makeFetch } from "ChillMainAssets/lib/api/apiMethods"; +import { useToast } from "vue-toast-notification"; +import { Result, Suggestion } from "ChillPersonAssets/types"; +import { + isThirdpartyChild, + isThirdpartyContact, + Thirdparty, + ThirdpartyCompany, +} from "./../../../../../../ChillThirdPartyBundle/Resources/public/types"; +interface TypeThirdPartyProps { + item: Suggestion & { result: Thirdparty }; +} + +const props = defineProps<TypeThirdPartyProps>(); + +const emit = + defineEmits< + (e: "triggerAddContact", payload: { parent: ThirdpartyCompany }) => void + >(); + +const onTheFly = ref<InstanceType<typeof OnTheFly> | null>(null); +const toast = useToast(); + +const hasAddress = computed(() => { + if (props.item.result.address !== null) { + return true; + } + if ( + isThirdpartyChild(props.item.result) && + props.item.result.parent !== null + ) { + return props.item.result.parent.address !== null; + } + + return false; +}); + +const getAddress = computed(() => { + if (props.item.result.address !== null) { + return props.item.result.address; + } + if ( + isThirdpartyChild(props.item.result) && + props.item.result.parent !== null && + props.item.result.parent.address !== null + ) { + return props.item.result.parent.address; + } + return null; +}); + +// i18n config (if needed elsewhere) const i18n = { - messages: { - fr: { - thirdparty: { - contact: "Personne physique", - company: "Personne morale", - child: "Personne de contact", - }, - }, - }, -}; - -export default { - name: "SuggestionThirdParty", - components: { - OnTheFly, - BadgeEntity, - }, - props: ["item"], - emits: ["newPriorSuggestion"], - i18n, - computed: { - hasAddress() { - if (this.$props.item.result.address !== null) { - return true; - } - if (this.$props.item.result.parent !== null) { - return this.$props.item.result.parent.address !== null; - } - return false; - }, - hasParent() { - return this.$props.item.result.parent !== null; - }, - getAddress() { - if (this.$props.item.result.address !== null) { - return this.$props.item.result.address; - } - if (this.$props.item.result.parent.address !== null) { - return this.$props.item.result.parent.address; - } - - return null; - }, - }, - methods: { - saveFormOnTheFly({ data }) { - makeFetch("POST", "/api/1.0/thirdparty/thirdparty.json", data) - .then((response) => { - this.$emit("newPriorSuggestion", response); - this.$refs.onTheFly.closeModal(); - }) - .catch((error) => { - if (error.name === "ValidationException") { - for (let v of error.violations) { - this.$toast.open({ message: v }); - } - } else { - this.$toast.open({ message: "An error occurred" }); - } - }); - }, + messages: { + fr: { + thirdparty: { + contact: "Personne physique", + company: "Personne morale", + child: "Personne de contact", + }, }, + }, }; +defineExpose({ + i18n, +}); </script> <style lang="scss" scoped> .tpartycontainer { - .tpartyparent { - .name { - font-weight: bold; - font-variant: all-small-caps; - } + .tpartyparent { + .name { + font-weight: bold; + font-variant: all-small-caps; } - .tparty-identification { - span:not(.name) { - margin-left: 0.5em; - opacity: 0.5; - font-size: 90%; - font-style: italic; - } - .profession { - font-weight: 800; - color: black; - font-style: normal !important; - } + } + .tparty-identification { + span:not(.name) { + margin-left: 0.5em; + opacity: 0.5; + font-size: 90%; + font-style: italic; } + .profession { + font-weight: 800; + color: black; + font-style: normal !important; + } + } } </style> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/TypeUser.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/TypeUser.vue index 56b3afe17..81864e4f8 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/TypeUser.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/TypeUser.vue @@ -1,40 +1,35 @@ <template> - <div class="container usercontainer"> - <div class="user-identification"> - <user-render-box-badge :user="item.result" /> - </div> - </div> - <div class="right_actions"> - <badge-entity :entity="item.result" :options="{ displayLong: true }" /> + <div class="container usercontainer"> + <div class="user-identification"> + <UserRenderBoxBadge :user="props.item.result" /> </div> + </div> + <div class="right_actions"> + <BadgeEntity :entity="props.item.result" :options="{ displayLong: true }" /> + </div> </template> -<script> +<script lang="ts" setup> +import { computed, defineProps } from "vue"; import BadgeEntity from "ChillMainAssets/vuejs/_components/BadgeEntity.vue"; import UserRenderBoxBadge from "ChillMainAssets/vuejs/_components/Entity/UserRenderBoxBadge.vue"; +import { Suggestion } from "ChillPersonAssets/types"; +import { User } from "ChillMainAssets/types"; -export default { - name: "SuggestionUser", - components: { - UserRenderBoxBadge, - BadgeEntity, - }, - props: ["item"], - computed: { - hasParent() { - return this.$props.item.result.parent !== null; - }, - }, -}; +interface TypeUserProps { + item: Suggestion & { result: User }; +} + +const props = defineProps<TypeUserProps>(); </script> <style lang="scss" scoped> .usercontainer { - .userparent { - .name { - font-weight: bold; - font-variant: all-small-caps; - } + .userparent { + .name { + font-weight: bold; + font-variant: all-small-caps; } + } } </style> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/TypeUserGroup.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/TypeUserGroup.vue index 1daf921e1..e818f9567 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/TypeUserGroup.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/AddPersons/TypeUserGroup.vue @@ -1,32 +1,24 @@ +<template> + <div class="container user-group-container"> + <div class="user-group-identification"> + <user-group-render-box :user-group="props.item.result as UserGroup" /> + </div> + </div> + <div class="right_actions"> + <span class="badge rounded-pill bg-user-group"> Groupe d'utilisateur </span> + </div> +</template> + <script setup lang="ts"> -import { - ResultItem, - UserGroup, -} from "../../../../../../ChillMainBundle/Resources/public/types"; -import BadgeEntity from "ChillMainAssets/vuejs/_components/BadgeEntity.vue"; -import UserRenderBoxBadge from "ChillMainAssets/vuejs/_components/Entity/UserRenderBoxBadge.vue"; +import { UserGroup } from "../../../../../../ChillMainBundle/Resources/public/types"; import UserGroupRenderBox from "ChillMainAssets/vuejs/_components/Entity/UserGroupRenderBox.vue"; +import { Suggestion } from "ChillPersonAssets/types"; interface TypeUserGroupProps { - item: ResultItem<UserGroup>; + item: Suggestion & { result: UserGroup }; } const props = defineProps<TypeUserGroupProps>(); </script> -<template> - <div class="container user-group-container"> - <div class="user-group-identification"> - <user-group-render-box - :user-group="props.item.result" - ></user-group-render-box> - </div> - </div> - <div class="right_actions"> - <span class="badge rounded-pill bg-user-group"> - Groupe d'utilisateur - </span> - </div> -</template> - <style scoped lang="scss"></style> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/Entity/HouseholdRenderBox.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/Entity/HouseholdRenderBox.vue index ad996a375..e6f50708b 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/Entity/HouseholdRenderBox.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/Entity/HouseholdRenderBox.vue @@ -1,168 +1,161 @@ <template> - <section class="chill-entity entity-household"> - <div class="item-row"> - <div class="item-col"> - <!-- identifier --> - <div v-if="isHouseholdNew()" class="h4"> - <i class="fa fa-home" /> - {{ $t("new_household") }} - </div> - <div v-else class="h4"> - <i class="fa fa-home" /> - {{ $t("household_number", { number: household.id }) }} - </div> - </div> - <div class="item-col"> - <ul class="list-content"> - <!-- member part --> - <li - v-if="hasCurrentMembers" - class="members" - :title="$t('current_members')" - > - <span - v-for="m in currentMembers()" - :key="m.id" - class="m" - :class="{ is_new: m.is_new === true }" - > - <person-render-box - render="badge" - :person="m.person" - :options="{ - isHolder: m.holder, - addLink: true, - }" - > - <template #post-badge v-if="m.is_new === true"> - <span class="post-badge is_new" - ><i class="fa fa-sign-in" - /></span> - </template> - </person-render-box> - </span> - </li> - <li v-else class="members" :title="$t('current_members')"> - <p class="chill-no-data-statement"> - {{ $t("no_members_yet") }} - </p> - </li> - - <!-- address part --> - <li v-if="hasAddress()"> - <address-render-box - :address="household.current_address" - :is-multiline="isMultiline" - /> - </li> - <li v-else> - <span class="chill-no-data-statement">{{ - $t("no_current_address") - }}</span> - </li> - </ul> - </div> + <section class="chill-entity entity-household"> + <div class="item-row"> + <div class="item-col"> + <!-- identifier --> + <div v-if="isHouseholdNew()" class="h4"> + <i class="fa fa-home" /> + {{ trans(RENDERBOX_NEW_HOUSEHOLD) }} </div> - </section> -</template> + <div v-else class="h4"> + <i class="fa fa-home" /> + {{ trans(RENDERBOX_HOUSEHOLD_NUMBER, { number: household.id }) }} + </div> + </div> + <div class="item-col"> + <ul class="list-content"> + <!-- member part --> + <li + v-if="hasCurrentMembers" + class="members" + :title="trans(RENDERBOX_CURRENT_MEMBERS)" + > + <span + v-for="m in currentMembers()" + :key="m.id" + class="m" + :class="{ is_new: m.is_new === true }" + > + <person-render-box + render="badge" + :person="m.person" + :options="{ + isHolder: m.holder, + addLink: true, + }" + > + <template #post-badge v-if="m.is_new === true"> + <span class="post-badge is_new" + ><i class="fa fa-sign-in" + /></span> + </template> + </person-render-box> + </span> + </li> + <li v-else class="members" :title="trans(RENDERBOX_CURRENT_MEMBERS)"> + <p class="chill-no-data-statement"> + {{ trans(RENDERBOX_NO_MEMBERS_YET) }} + </p> + </li> -<script> + <!-- address part --> + <li v-if="hasAddress()"> + <address-render-box + :address="household.current_address" + :is-multiline="isMultiline" + /> + </li> + <li v-else> + <span class="chill-no-data-statement">{{ + trans(RENDERBOX_NO_CURRENT_ADDRESS) + }}</span> + </li> + </ul> + </div> + </div> + </section> +</template> +<script setup> +import { computed } from "vue"; import PersonRenderBox from "ChillPersonAssets/vuejs/_components/Entity/PersonRenderBox.vue"; import AddressRenderBox from "ChillMainAssets/vuejs/_components/Entity/AddressRenderBox.vue"; +import { + trans, + RENDERBOX_NEW_HOUSEHOLD, + RENDERBOX_HOUSEHOLD_NUMBER, + RENDERBOX_CURRENT_MEMBERS, + RENDERBOX_NO_MEMBERS_YET, + RENDERBOX_NO_CURRENT_ADDRESS, +} from "translator"; -const i18n = { - messages: { - fr: { - household_number: "Ménage n°{number}", - current_members: "Membres actuels", - no_current_address: "Sans adresse actuellement", - new_household: "Nouveau ménage", - no_members_yet: "Aucun membre actuellement", - holder: "titulaire", - }, - }, -}; +const props = defineProps({ + household: { type: Object, required: true }, + isAddressMultiline: { type: Boolean, default: false }, +}); -export default { - name: "HouseholdRenderBox", - props: ["household", "isAddressMultiline"], - components: { - PersonRenderBox, - AddressRenderBox, - }, - i18n, - computed: { - isMultiline() { - return typeof this.isAddressMultiline !== "undefined" - ? this.isAddressMultiline - : false; - }, - }, - methods: { - hasCurrentMembers() { - return this.household.current_members_id.length > 0; - }, - currentMembers() { - let members = this.household.members - .filter((m) => this.household.current_members_id.includes(m.id)) - .sort((a, b) => { - const orderA = a.position ? a.position.ordering : 0; - const orderB = b.position ? b.position.ordering : 0; +const isMultiline = computed(() => + typeof props.isAddressMultiline !== "undefined" + ? props.isAddressMultiline + : false, +); - if (orderA < orderB) { - return -1; - } - if (orderA > orderB) { - return 1; - } - if (a.holder && !b.holder) { - return -1; - } - if (!a.holder && b.holder) { - return 1; - } - return 0; - }); +function hasCurrentMembers() { + return props.household.current_members_id.length > 0; +} - if (this.household.new_members !== undefined) { - this.household.new_members - .map((m) => { - m.is_new = true; - return m; - }) - .forEach((m) => { - members.push(m); - }); - } +function currentMembers() { + let members = props.household.members + .filter((m) => props.household.current_members_id.includes(m.id)) + .sort((a, b) => { + const orderA = a.position ? a.position.ordering : 0; + const orderB = b.position ? b.position.ordering : 0; - return members; - }, - currentMembersLength() { - return this.household.current_members_id.length; - }, - isHouseholdNew() { - return !Number.isInteger(this.household.id); - }, - hasAddress() { - return this.household.current_address !== null; - }, - }, -}; + if (orderA < orderB) { + return -1; + } + if (orderA > orderB) { + return 1; + } + if (a.holder && !b.holder) { + return -1; + } + if (!a.holder && b.holder) { + return 1; + } + return 0; + }); + + if (props.household.new_members !== undefined) { + props.household.new_members + .map((m) => { + m.is_new = true; + return m; + }) + .forEach((m) => { + members.push(m); + }); + } + + return members; +} + +function currentMembersLength() { + return props.household.current_members_id.length; +} + +function isHouseholdNew() { + return !Number.isInteger(props.household.id); +} + +function hasAddress() { + return props.household.current_address !== null; +} +defineExpose(currentMembersLength); </script> <style lang="scss"> section.chill-entity { - &.entity-household { - ul.list-content li::marker { - content: ""; - } - - .members { - .post-badge.is_new { - margin-left: 0.5rem; - color: var(--bs-chill-green); - } - } + &.entity-household { + ul.list-content li::marker { + content: ""; } + + .members { + .post-badge.is_new { + margin-left: 0.5rem; + color: var(--bs-chill-green); + } + } + } } </style> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/Entity/PersonRenderBox.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/Entity/PersonRenderBox.vue index cdc143f76..c44841c00 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/Entity/PersonRenderBox.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/Entity/PersonRenderBox.vue @@ -1,493 +1,325 @@ <template> - <div v-if="render === 'bloc'" class="item-bloc"> - <section class="chill-entity entity-person"> - <div class="item-row entity-bloc"> - <div class="item-col"> - <div class="entity-label"> - <div :class="'denomination h' + options.hLevel"> - <a v-if="options.addLink === true" :href="getUrl"> - <!-- use person-text here to avoid code duplication ? TODO --> - <span class="firstname">{{ - person.firstName - }}</span> - <span class="lastname">{{ - person.lastName - }}</span> - <span - v-if="person.suffixText" - class="suffixtext" - > {{ person.suffixText }}</span - > - <span - v-if=" - person.altNames && - options.addAltNames == true - " - class="altnames" - > - <span - :class="'altname altname-' + altNameKey" - >{{ altNameLabel }}</span - > - </span> - </a> - - <!-- use person-text here to avoid code duplication ? TODO --> - <span class="firstname">{{ - person.firstName - }}</span> - <span class="lastname">{{ person.lastName }}</span> - <span v-if="person.suffixText" class="suffixtext" - > {{ person.suffixText }}</span - > - <span v-if="person.deathdate" class="deathdate"> - (‡)</span - > - <span - v-if=" - person.altNames && - options.addAltNames == true - " - class="altnames" - > - <span - :class="'altname altname-' + altNameKey" - >{{ altNameLabel }}</span - > - </span> - - <span - v-if="options.addId == true" - class="id-number" - :title="'n° ' + person.id" - >{{ person.id }}</span - > - - <badge-entity - v-if="options.addEntity === true" - :entity="person" - :options="{ - displayLong: options.entityDisplayLong, - }" - /> - </div> - - <p v-if="options.addInfo === true" class="moreinfo"> - <gender-icon-render-box - v-if="person.gender" - :gender="person.gender" - /> - <time - v-if="person.birthdate && !person.deathdate" - :datetime="person.birthdate" - :title="birthdate" - > - {{ - $t( - person.gender - ? `renderbox.birthday.${person.gender.genderTranslation}` - : "renderbox.birthday.neutral", - ) + - " " + - $d(birthdate, "text") - }} - </time> - - <time - v-else-if="person.birthdate && person.deathdate" - :datetime="person.deathdate" - :title="person.deathdate" - > - {{ $d(birthdate) }} - {{ $d(deathdate) }} - </time> - - <time - v-else-if="person.deathdate" - :datetime="person.deathdate" - :title="person.deathdate" - > - {{ - $t("renderbox.deathdate") + " " + deathdate - }} - </time> - - <span - v-if="options.addAge && person.birthdate" - class="age" - >{{ - $tc("renderbox.years_old", person.age) - }}</span - > - </p> - </div> - </div> - - <div class="item-col"> - <div class="float-button bottom"> - <div class="box"> - <div class="action"> - <slot name="record-actions" /> - </div> - <ul class="list-content fa-ul"> - <li v-if="person.current_household_id"> - <i class="fa fa-li fa-map-marker" /> - <address-render-box - v-if="person.current_household_address" - :address=" - person.current_household_address - " - :is-multiline="isMultiline" - /> - <p v-else class="chill-no-data-statement"> - {{ - $t( - "renderbox.household_without_address", - ) - }} - </p> - <a - v-if="options.addHouseholdLink === true" - :href="getCurrentHouseholdUrl" - :title=" - $t( - 'persons_associated.show_household_number', - { - id: person.current_household_id, - }, - ) - " - > - <span - class="badge rounded-pill bg-chill-beige" - > - <i - class="fa fa-fw fa-home" - /><!--{{ $t('persons_associated.show_household') }}--> - </span> - </a> - </li> - <li v-else-if="options.addNoData"> - <i class="fa fa-li fa-map-marker" /> - <p class="chill-no-data-statement"> - {{ $t("renderbox.no_data") }} - </p> - </li> - - <template - v-if=" - this.showResidentialAddresses && - ( - person.current_residential_addresses || - [] - ).length > 0 - " - > - <li - v-for="( - addr, i - ) in person.current_residential_addresses" - :key="i" - > - <i class="fa fa-li fa-map-marker" /> - <div v-if="addr.address"> - <span class="item-key" - >{{ - $t( - "renderbox.residential_address", - ) - }}:</span - > - <div style="margin-top: -1em"> - <address-render-box - :address="addr.address" - :is-multiline="isMultiline" - /> - </div> - </div> - <div - v-else-if="addr.hostPerson" - class="mt-3" - > - <p> - {{ - $t("renderbox.located_at") - }}: - </p> - <span - class="chill-entity entity-person badge-person" - > - <person-text - v-if="addr.hostPerson" - :person="addr.hostPerson" - /> - </span> - <address-render-box - v-if="addr.hostPerson.address" - :address=" - addr.hostPerson.address - " - :is-multiline="isMultiline" - /> - </div> - <div - v-else-if="addr.hostThirdParty" - class="mt-3" - > - <p> - {{ - $t("renderbox.located_at") - }}: - </p> - <span - class="chill-entity entity-person badge-thirdparty" - > - <third-party-text - v-if="addr.hostThirdParty" - :thirdparty=" - addr.hostThirdParty - " - /> - </span> - <address-render-box - v-if=" - addr.hostThirdParty.address - " - :address=" - addr.hostThirdParty.address - " - :is-multiline="isMultiline" - /> - </div> - </li> - </template> - - <li v-if="person.email"> - <i class="fa fa-li fa-envelope-o" /> - <a :href="'mailto: ' + person.email">{{ - person.email - }}</a> - </li> - <li v-else-if="options.addNoData"> - <i class="fa fa-li fa-envelope-o" /> - <p class="chill-no-data-statement"> - {{ $t("renderbox.no_data") }} - </p> - </li> - - <li v-if="person.mobilenumber"> - <i class="fa fa-li fa-mobile" /> - <a :href="'tel: ' + person.mobilenumber">{{ - person.mobilenumber - }}</a> - </li> - <li v-else-if="options.addNoData"> - <i class="fa fa-li fa-mobile" /> - <p class="chill-no-data-statement"> - {{ $t("renderbox.no_data") }} - </p> - </li> - <li v-if="person.phonenumber"> - <i class="fa fa-li fa-phone" /> - <a :href="'tel: ' + person.phonenumber">{{ - person.phonenumber - }}</a> - </li> - <li v-else-if="options.addNoData"> - <i class="fa fa-li fa-phone" /> - <p class="chill-no-data-statement"> - {{ $t("renderbox.no_data") }} - </p> - </li> - - <li - v-if=" - person.centers !== undefined && - person.centers.length > 0 && - options.addCenter - " - > - <i class="fa fa-li fa-long-arrow-right" /> - <template v-for="c in person.centers"> - {{ c.name }} - </template> - </li> - <li v-else-if="options.addNoData"> - <i class="fa fa-li fa-long-arrow-right" /> - <p class="chill-no-data-statement"> - {{ $t("renderbox.no_data") }} - </p> - </li> - <slot name="custom-zone" /> - </ul> - </div> - </div> - </div> + <div v-if="render === 'bloc'" class="item-bloc"> + <section class="chill-entity entity-person"> + <div class="item-row entity-bloc"> + <div class="item-col"> + <div class="entity-label"> + <div :class="'denomination h' + options.hLevel"> + <template v-if="options.addLink === true"> + <a v-if="options.addLink === true" :href="getUrl"> + <span>{{ person.text }}</span> + <span v-if="person.deathdate" class="deathdate"> (‡)</span> + </a> + </template> + <template v-else> + <span>{{ person.text }}</span> + <span v-if="person.deathdate" class="deathdate"> (‡)</span> + </template> + <badge-entity + v-if="options.addEntity === true" + :entity="person" + :options="{ displayLong: options.entityDisplayLong }" + /> </div> - <slot name="end-bloc" /> - </section> - </div> + <p> + <span v-if="options.addId == true" :title="person.personId" + ><i class="bi bi-info-circle"></i> {{ person.personId }}</span + > + </p> - <span - v-if="render === 'badge'" - class="chill-entity entity-person badge-person" - > - <a v-if="options.addLink === true" :href="getUrl"> - <span - v-if="options.isHolder" - class="fa-stack fa-holder" - :title="$t('renderbox.holder')" - > - <i class="fa fa-circle fa-stack-1x text-success" /> - <i class="fa fa-stack-1x">T</i> - </span> + <p v-if="options.addInfo === true" class="moreinfo"> + <gender-icon-render-box + v-if="person.gender" + :gender="person.gender" + /> + <span v-if="person.birthdate"> + {{ + trans(RENDERBOX_BIRTHDAY_STATEMENT, { + gender: toGenderTranslation(person.gender), + birthdate: ISOToDate(person.birthdate?.datetime), + }) + }} + </span> + <span v-if="options.addAge && person.birthdate" class="age"> + ({{ trans(RENDERBOX_YEARS_OLD, { n: person.age }) }}) + </span> + </p> - <person-text :person="person" /> - </a> - <span v-else> - <span - v-if="options.isHolder" - class="fa-stack fa-holder" - :title="$t('renderbox.holder')" - > - <i class="fa fa-circle fa-stack-1x text-success" /> - <i class="fa fa-stack-1x">T</i> - </span> - <person-text :person="person" /> - </span> - <slot name="post-badge" /> + <p> + <span v-if="person.deathdate"> + {{ + trans(RENDERBOX_DEATHDATE_STATEMENT, { + gender: toGenderTranslation(person.gender), + deathdate: ISOToDate(person.deathdate?.datetime), + }) + }} + </span> + </p> + </div> + </div> + + <div class="item-col"> + <div class="float-button bottom"> + <div class="box"> + <div class="action"> + <slot name="record-actions" /> + </div> + <ul class="list-content fa-ul"> + <li v-if="person.current_household_id"> + <i class="fa fa-li fa-map-marker" /> + <address-render-box + v-if="person.current_household_address" + :address="person.current_household_address" + :is-multiline="isMultiline" + /> + <p v-else class="chill-no-data-statement"> + {{ trans(RENDERBOX_HOUSEHOLD_WITHOUT_ADDRESS) }} + </p> + <a + v-if="options.addHouseholdLink === true" + :href="getCurrentHouseholdUrl" + > + <span class="badge rounded-pill bg-chill-beige"> + <i + class="fa fa-fw fa-home" + /><!--{{ trans(PERSONS_ASSOCIATED_SHOW_HOUSEHOLD) }}--> + </span> + </a> + </li> + <li v-else-if="options.addNoData"> + <i class="fa fa-li fa-map-marker" /> + <p class="chill-no-data-statement"> + {{ trans(RENDERBOX_NO_DATA) }} + </p> + </li> + + <template + v-if=" + showResidentialAddresses && + (person.current_residential_addresses || []).length > 0 + " + > + <li + v-for="(addr, i) in person.current_residential_addresses" + :key="i" + > + <i class="fa fa-li fa-map-marker" /> + <div v-if="addr.address"> + <span class="item-key"> + {{ trans(RENDERBOX_RESIDENTIAL_ADDRESS) }}: + </span> + <div style="margin-top: -1em"> + <address-render-box + :address="addr.address" + :is-multiline="isMultiline" + /> + </div> + </div> + <div v-else-if="addr.hostPerson" class="mt-3"> + <p>{{ trans(RENDERBOX_LOCATED_AT) }}:</p> + <span class="chill-entity entity-person badge-person"> + <person-text + v-if="addr.hostPerson" + :person="addr.hostPerson" + /> + </span> + + <address-render-box + v-if="addr.hostPerson?.current_household_address" + :address="addr.hostPerson.current_household_address" + :is-multiline="isMultiline" + /> + </div> + <div v-else-if="addr.hostThirdParty" class="mt-3"> + <p>{{ trans(RENDERBOX_LOCATED_AT) }}:</p> + <span class="chill-entity entity-person badge-thirdparty"> + <third-party-text + v-if="addr.hostThirdParty" + :thirdparty="addr.hostThirdParty" + /> + </span> + <address-render-box + v-if="addr.hostThirdParty.address" + :address="addr.hostThirdParty.address" + :is-multiline="isMultiline" + /> + </div> + </li> + </template> + + <li v-if="person.email"> + <i class="fa fa-li fa-envelope-o" /> + <a :href="'mailto: ' + person.email">{{ person.email }}</a> + </li> + <li v-else-if="options.addNoData"> + <i class="fa fa-li fa-envelope-o" /> + <p class="chill-no-data-statement"> + {{ trans(RENDERBOX_NO_DATA) }} + </p> + </li> + + <li v-if="person.mobilenumber"> + <i class="fa fa-li fa-mobile" /> + <a :href="'tel: ' + person.mobilenumber"> + {{ person.mobilenumber }} + </a> + </li> + <li v-else-if="options.addNoData"> + <i class="fa fa-li fa-mobile" /> + <p class="chill-no-data-statement"> + {{ trans(RENDERBOX_NO_DATA) }} + </p> + </li> + <li v-if="person.phonenumber"> + <i class="fa fa-li fa-phone" /> + <a :href="'tel: ' + person.phonenumber"> + {{ person.phonenumber }} + </a> + </li> + <li v-else-if="options.addNoData"> + <i class="fa fa-li fa-phone" /> + <p class="chill-no-data-statement"> + {{ trans(RENDERBOX_NO_DATA) }} + </p> + </li> + + <li + v-if=" + person.centers !== undefined && + person.centers.length > 0 && + options.addCenter + " + > + <i class="fa fa-li fa-long-arrow-right" /> + <template v-for="c in person.centers"> + {{ c.name }} + </template> + </li> + <li v-else-if="options.addNoData"> + <i class="fa fa-li fa-long-arrow-right" /> + <p class="chill-no-data-statement"> + {{ trans(RENDERBOX_NO_DATA) }} + </p> + </li> + <slot name="custom-zone" /> + </ul> + </div> + </div> + </div> + </div> + + <slot name="end-bloc" /> + </section> + </div> + + <span + v-if="render === 'badge'" + class="chill-entity entity-person badge-person" + > + <a v-if="options.addLink === true" :href="getUrl"> + <span + v-if="options.isHolder" + class="fa-stack fa-holder" + :title="trans(RENDERBOX_HOLDER)" + > + <i class="fa fa-circle fa-stack-1x text-success" /> + <i class="fa fa-stack-1x">T</i> + </span> + + <person-text :person="person" /> + </a> + <span v-else> + <span + v-if="options.isHolder" + class="fa-stack fa-holder" + :title="trans(RENDERBOX_HOLDER)" + > + <i class="fa fa-circle fa-stack-1x text-success" /> + <i class="fa fa-stack-1x">T</i> + </span> + <person-text :person="person" /> </span> + <slot name="post-badge" /> + </span> </template> -<script> +<script setup lang="ts"> +import { computed } from "vue"; import { ISOToDate } from "ChillMainAssets/chill/js/date"; import AddressRenderBox from "ChillMainAssets/vuejs/_components/Entity/AddressRenderBox.vue"; import GenderIconRenderBox from "ChillMainAssets/vuejs/_components/Entity/GenderIconRenderBox.vue"; import BadgeEntity from "ChillMainAssets/vuejs/_components/BadgeEntity.vue"; import PersonText from "ChillPersonAssets/vuejs/_components/Entity/PersonText.vue"; import ThirdPartyText from "ChillThirdPartyAssets/vuejs/_components/Entity/ThirdPartyText.vue"; +import { + trans, + RENDERBOX_HOLDER, + RENDERBOX_NO_DATA, + RENDERBOX_DEATHDATE_STATEMENT, + RENDERBOX_HOUSEHOLD_WITHOUT_ADDRESS, + RENDERBOX_RESIDENTIAL_ADDRESS, + RENDERBOX_LOCATED_AT, + RENDERBOX_BIRTHDAY_STATEMENT, + // PERSONS_ASSOCIATED_SHOW_HOUSEHOLD_NUMBER, + RENDERBOX_YEARS_OLD, +} from "translator"; +import { Person } from "ChillPersonAssets/types"; +import { toGenderTranslation } from "ChillMainAssets/lib/api/genderHelper"; -export default { - name: "PersonRenderBox", - components: { - AddressRenderBox, - GenderIconRenderBox, - BadgeEntity, - PersonText, - ThirdPartyText, - }, - props: { - person: { - required: true, - }, - options: { - type: Object, - required: false, - }, - render: { - type: String, - }, - returnPath: { - type: String, - }, - showResidentialAddresses: { - type: Boolean, - default: false, - }, - }, - computed: { - isMultiline: function () { - if (this.options.isMultiline) { - return this.options.isMultiline; - } else { - return false; - } - }, - birthdate: function () { - if ( - this.person.birthdate !== null || - this.person.birthdate === "undefined" - ) { - return ISOToDate(this.person.birthdate.datetime); - } else { - return ""; - } - }, - deathdate: function () { - if ( - this.person.deathdate !== null || - this.person.birthdate === "undefined" - ) { - return new Date(this.person.deathdate.datetime); - } else { - return ""; - } - }, - altNameLabel: function () { - let altNameLabel = ""; - this.person.altNames.forEach( - (altName) => (altNameLabel += altName.label), - ); - return altNameLabel; - }, - altNameKey: function () { - let altNameKey = ""; - this.person.altNames.forEach( - (altName) => (altNameKey += altName.key), - ); - return altNameKey; - }, - getUrl: function () { - return `/fr/person/${this.person.id}/general`; - }, - getCurrentHouseholdUrl: function () { - let returnPath = this.returnPath - ? `?returnPath=${this.returnPath}` - : ``; - return `/fr/person/household/${this.person.current_household_id}/summary${returnPath}`; - }, - }, -}; +interface RenderOptions { + addInfo?: boolean; + addEntity?: boolean; + addAltNames?: boolean; + addAge?: boolean; + addId?: boolean; + addLink?: boolean; + hLevel?: number; + entityDisplayLong?: boolean; + addCenter?: boolean; + addNoData?: boolean; + isMultiline?: boolean; + isHolder?: boolean; + addHouseholdLink?: boolean; +} + +interface Props { + person: Person; + options?: RenderOptions; + render?: "bloc" | "badge"; + returnPath?: string; + showResidentialAddresses?: boolean; +} + +const props = withDefaults(defineProps<Props>(), { + render: "bloc", + options: () => ({ + addInfo: true, + addEntity: false, + addAltNames: true, + addAge: true, + addId: true, + addLink: false, + hLevel: 3, + entityDisplayLong: true, + addCenter: true, + addNoData: true, + isMultiline: true, + isHolder: false, + addHouseholdLink: true, + }), +}); + +const isMultiline = computed<boolean>(() => { + return props.options?.isMultiline || false; +}); + +const getUrl = computed<string>(() => { + return `/fr/person/${props.person.id}/general`; +}); + +const getCurrentHouseholdUrl = computed<string>(() => { + const returnPath = props.returnPath ? `?returnPath=${props.returnPath}` : ``; + return `/fr/person/household/${props.person.current_household_id}/summary${returnPath}`; +}); </script> - -<style lang="scss" scoped> -@import "ChillMainAssets/module/bootstrap/shared"; -@import "ChillPersonAssets/chill/scss/mixins"; -@import "ChillMainAssets/chill/scss/chill_variables"; - -.lastname:before { - content: " "; -} - -div.flex-table { - div.item-bloc { - div.item-row { - div.item-col:first-child { - width: 33%; - } - - @include media-breakpoint-down(sm) { - div.item-col:first-child { - width: unset; - } - } - - div.item-col:last-child { - justify-content: flex-start; - } - } - } -} - -.age { - margin-left: 0.5em; - - &:before { - content: "("; - } - - &:after { - content: ")"; - } -} -</style> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/Entity/PersonText.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/Entity/PersonText.vue index f157fac6a..69cbba082 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/Entity/PersonText.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/Entity/PersonText.vue @@ -1,75 +1,35 @@ <template> - <span v-if="isCut">{{ cutText }}</span> - <span v-else class="person-text"> - <span class="firstname">{{ person.firstName }}</span> - <!-- display: inline --> - <span class="lastname"> {{ person.lastName }}</span> - <span - v-if="person.altNames && person.altNames.length > 0" - class="altnames" - > - <!-- display: inline --> - <span :class="'altname altname-' + altNameKey" - > ({{ altNameLabel }})</span - > - </span> - <!-- display: inline --> - <span v-if="person.suffixText" class="suffixtext" - > {{ person.suffixText }}</span - > - <!-- display: inline --> - <span - class="age" - v-if=" - this.addAge && - person.birthdate !== null && - person.deathdate === null - " - > {{ $tc("renderbox.years_old", person.age) }}</span - > - <span v-else-if="this.addAge && person.deathdate !== null" - > (‡)</span - > - </span> + <span v-if="isCut">{{ cutText }}</span> + <span v-else class="person-text"> + <span>{{ person.text }}</span> + <span v-if="person.suffixText" class="suffixtext" + > {{ person.suffixText }}</span + > + <span + class="age" + v-if="addAge && person.birthdate !== null && person.deathdate === null" + > {{ trans(RENDERBOX_YEARS_OLD, person.age) }}</span + > + <span v-else-if="addAge && person.deathdate !== null"> (‡)</span> + </span> </template> -<script> -export default { - name: "PersonText", - props: { - person: { - required: true, - }, - isCut: { - type: Boolean, - required: false, - default: false, - }, - addAge: { - type: Boolean, - required: false, - default: true, - }, - }, - computed: { - altNameLabel: function () { - let altNameLabel = ""; - this.person.altNames.forEach( - (altName) => (altNameLabel += altName.label), - ); - return altNameLabel; - }, - altNameKey: function () { - let altNameKey = ""; - this.person.altNames.forEach( - (altName) => (altNameKey += altName.key), - ); - return altNameKey; - }, - cutText: function () { - let more = this.person.text.length > 15 ? "…" : ""; - return this.person.text.slice(0, 15) + more; - }, - }, -}; +<script lang="ts" setup> +import { computed, toRefs } from "vue"; +import { trans, RENDERBOX_YEARS_OLD } from "translator"; +import { AltName, Person } from "ChillPersonAssets/types"; + +const props = defineProps<{ + person: Person; + isCut?: boolean; + addAge?: boolean; +}>(); + +const { person, isCut = false, addAge = true } = toRefs(props); + +const cutText = computed(() => { + if (!person.value.text) return ""; + const more = person.value.text.length > 15 ? "…" : ""; + return person.value.text.slice(0, 15) + more; +}); </script> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/OnTheFly/Person.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/OnTheFly/Person.vue index ff4b312f1..88e7c19b3 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/OnTheFly/Person.vue +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/OnTheFly/Person.vue @@ -1,530 +1,69 @@ <template> - <div v-if="action === 'show'"> - <div class="flex-table"> - <person-render-box - render="bloc" - :person="person" - :options="{ - addInfo: true, - addEntity: false, - addAltNames: true, - addAge: true, - addId: true, - addLink: false, - hLevel: 3, - addCenter: true, - addNoData: true, - isMultiline: true, - }" - :show-residential-addresses="true" - ></person-render-box> - </div> + <div v-if="action === 'show' && person !== null"> + <div class="flex-table"> + <person-render-box + render="bloc" + :person="person" + :options="{ + addInfo: true, + addEntity: false, + addAltNames: true, + addAge: true, + addId: true, + addLink: false, + hLevel: 3, + addCenter: true, + addNoData: true, + isMultiline: true, + }" + :show-residential-addresses="true" + /> </div> + </div> - <div v-else-if="action === 'edit' || action === 'create'"> - <div class="form-floating mb-3"> - <input - class="form-control form-control-lg" - id="lastname" - v-model="lastName" - :placeholder="$t('person.lastname')" - @change="checkErrors" - /> - <label for="lastname">{{ $t("person.lastname") }}</label> - </div> - - <div v-if="queryItems"> - <ul class="list-suggest add-items inline"> - <li - v-for="(qi, i) in queryItems" - :key="i" - @click="addQueryItem('lastName', qi)" - > - <span class="person-text">{{ qi }}</span> - </li> - </ul> - </div> - - <div class="form-floating mb-3"> - <input - class="form-control form-control-lg" - id="firstname" - v-model="firstName" - :placeholder="$t('person.firstname')" - @change="checkErrors" - /> - <label for="firstname">{{ $t("person.firstname") }}</label> - </div> - - <div v-if="queryItems"> - <ul class="list-suggest add-items inline"> - <li - v-for="(qi, i) in queryItems" - :key="i" - @click="addQueryItem('firstName', qi)" - > - <span class="person-text">{{ qi }}</span> - </li> - </ul> - </div> - - <div - v-for="(a, i) in config.altNames" - :key="a.key" - class="form-floating mb-3" - > - <input - class="form-control form-control-lg" - :id="a.key" - :value="personAltNamesLabels[i]" - @input="onAltNameInput" - /> - <label :for="a.key">{{ localizeString(a.labels) }}</label> - </div> - - <!-- TODO fix placeholder if undefined - --> - <div class="form-floating mb-3"> - <select - class="form-select form-select-lg" - id="gender" - v-model="gender" - > - <option selected disabled> - {{ $t("person.gender.placeholder") }} - </option> - <option v-for="g in config.genders" :value="g.id" :key="g.id"> - {{ g.label }} - </option> - </select> - <label>{{ $t("person.gender.title") }}</label> - </div> - - <div - class="form-floating mb-3" - v-if="showCenters && config.centers.length > 1" - > - <select - class="form-select form-select-lg" - id="center" - v-model="center" - > - <option selected disabled> - {{ $t("person.center.placeholder") }} - </option> - <option v-for="c in config.centers" :value="c" :key="c.id"> - {{ c.name }} - </option> - </select> - <label>{{ $t("person.center.title") }}</label> - </div> - - <div class="form-floating mb-3"> - <select - class="form-select form-select-lg" - id="civility" - v-model="civility" - > - <option selected disabled> - {{ $t("person.civility.placeholder") }} - </option> - <option - v-for="c in config.civilities" - :value="c.id" - :key="c.id" - > - {{ localizeString(c.name) }} - </option> - </select> - <label>{{ $t("person.civility.title") }}</label> - </div> - - <div class="input-group mb-3"> - <span class="input-group-text" id="birthdate" - ><i class="fa fa-fw fa-birthday-cake"></i - ></span> - <input - type="date" - class="form-control form-control-lg" - id="chill_personbundle_person_birthdate" - name="chill_personbundle_person[birthdate]" - v-model="birthDate" - aria-describedby="birthdate" - /> - </div> - - <div class="input-group mb-3"> - <span class="input-group-text" id="phonenumber" - ><i class="fa fa-fw fa-phone"></i - ></span> - <input - class="form-control form-control-lg" - v-model="phonenumber" - :placeholder="$t('person.phonenumber')" - :aria-label="$t('person.phonenumber')" - aria-describedby="phonenumber" - /> - </div> - - <div class="input-group mb-3"> - <span class="input-group-text" id="mobilenumber" - ><i class="fa fa-fw fa-mobile"></i - ></span> - <input - class="form-control form-control-lg" - v-model="mobilenumber" - :placeholder="$t('person.mobilenumber')" - :aria-label="$t('person.mobilenumber')" - aria-describedby="mobilenumber" - /> - </div> - - <div class="input-group mb-3"> - <span class="input-group-text" id="email" - ><i class="fa fa-fw fa-at"></i - ></span> - <input - class="form-control form-control-lg" - v-model="email" - :placeholder="$t('person.email')" - :aria-label="$t('person.email')" - aria-describedby="email" - /> - </div> - - <div v-if="action === 'create'" class="input-group mb-3 form-check"> - <input - class="form-check-input" - type="checkbox" - v-model="showAddressForm" - name="showAddressForm" - /> - <label class="form-check-label">{{ - $t("person.address.show_address_form") - }}</label> - </div> - <div - v-if="action === 'create' && showAddressFormValue" - class="form-floating mb-3" - > - <p>{{ $t("person.address.warning") }}</p> - <add-address - :context="addAddress.context" - :options="addAddress.options" - :addressChangedCallback="submitNewAddress" - ref="addAddress" - > - </add-address> - </div> - - <div class="alert alert-warning" v-if="errors.length"> - <ul> - <li v-for="(e, i) in errors" :key="i">{{ e }}</li> - </ul> - </div> - </div> + <div v-else-if="props.action === 'edit' || props.action === 'create'"> + <PersonEdit + :id="props.id" + :type="props.type" + :action="props.action" + :query="props.query" + /> + </div> </template> - -<script> -import { - getCentersForPersonCreation, - getCivilities, - getGenders, - getPerson, - getPersonAltNames, -} from "../../_api/OnTheFly"; +<script setup lang="ts"> +import { ref, onMounted } from "vue"; +import { getPerson } from "../../_api/OnTheFly"; import PersonRenderBox from "../Entity/PersonRenderBox.vue"; -import AddAddress from "ChillMainAssets/vuejs/Address/components/AddAddress.vue"; -import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper"; +import PersonEdit from "./PersonEdit.vue"; +import type { Person } from "ChillPersonAssets/types"; -export default { - name: "OnTheFlyPerson", - props: ["id", "type", "action", "query"], - //emits: ['createAction'], - components: { - PersonRenderBox, - AddAddress, - }, - data() { - return { - person: { - type: "person", - lastName: "", - firstName: "", - altNames: [], - addressId: null, - center: null, - }, - config: { - altNames: [], - civilities: [], - centers: [], - genders: [], - }, - showCenters: false, // NOTE: must remains false if the form is not in create mode - showAddressFormValue: false, - addAddress: { - options: { - button: { - text: { create: "person.address.create_address" }, - size: "btn-sm", - }, - title: { create: "person.address.create_address" }, - }, - context: { - target: {}, // boilerplate for getting the address id - edit: false, - addressId: null, - defaults: window.addaddress, - }, - }, - errors: [], - }; - }, - computed: { - firstName: { - set(value) { - this.person.firstName = value; - }, - get() { - return this.person.firstName; - }, - }, - lastName: { - set(value) { - this.person.lastName = value; - }, - get() { - return this.person.lastName; - }, - }, - gender: { - set(value) { - this.person.gender = { id: value, type: "chill_main_gender" }; - }, - get() { - return this.person.gender ? this.person.gender.id : null; - }, - }, - civility: { - set(value) { - this.person.civility = { - id: value, - type: "chill_main_civility", - }; - }, - get() { - return this.person.civility ? this.person.civility.id : null; - }, - }, - birthDate: { - set(value) { - if (this.person.birthdate) { - this.person.birthdate.datetime = value + "T00:00:00+0100"; - } else { - this.person.birthdate = { - datetime: value + "T00:00:00+0100", - }; - } - }, - get() { - return this.person.birthdate - ? this.person.birthdate.datetime.split("T")[0] - : ""; - }, - }, - phonenumber: { - set(value) { - this.person.phonenumber = value; - }, - get() { - return this.person.phonenumber; - }, - }, - mobilenumber: { - set(value) { - this.person.mobilenumber = value; - }, - get() { - return this.person.mobilenumber; - }, - }, - email: { - set(value) { - this.person.email = value; - }, - get() { - return this.person.email; - }, - }, - showAddressForm: { - set(value) { - this.showAddressFormValue = value; - }, - get() { - return this.showAddressFormValue; - }, - }, - center: { - set(value) { - console.log("will set center", value); - this.person.center = { id: value.id, type: value.type }; - }, - get() { - const center = this.config.centers.find( - (c) => - this.person.center !== null && - this.person.center.id === c.id, - ); +interface Props { + id: number; + type?: string; + action: "show" | "edit" | "create"; + query?: string; +} - console.log("center get", center); +const props = withDefaults(defineProps<Props>(), { query: "" }); - return typeof center === "undefined" ? null : center; - }, - }, - genderClass() { - switch (this.person.gender) { - case "woman": - return "fa-venus"; - case "man": - return "fa-mars"; - case "both": - return "fa-neuter"; - case "unknown": - return "fa-genderless"; - default: - return "fa-genderless"; - } - }, - genderTranslation() { - switch (this.person.gender.genderTranslation) { - case "woman": - return "person.gender.woman"; - case "man": - return "person.gender.man"; - case "neutral": - return "person.gender.neutral"; - case "unknown": - return "person.gender.unknown"; - default: - return "person.gender.unknown"; - } - }, - feminized() { - return this.person.gender === "woman" ? "e" : ""; - }, - personAltNamesLabels() { - return this.person.altNames.map((a) => (a ? a.label : "")); - }, - queryItems() { - return this.query ? this.query.split(" ") : null; - }, - }, - mounted() { - getPersonAltNames().then((altNames) => { - this.config.altNames = altNames; - }); - getCivilities().then((civilities) => { - if ("results" in civilities) { - this.config.civilities = civilities.results; - } - }); - getGenders().then((genders) => { - if ("results" in genders) { - console.log("genders", genders.results); - this.config.genders = genders.results; - } - }); - if (this.action !== "create") { - this.loadData(); - } else { - // console.log('show centers', this.showCenters); - getCentersForPersonCreation().then((params) => { - this.config.centers = params.centers.filter((c) => c.isActive); - this.showCenters = params.showCenters; - // console.log('centers', this.config.centers) - // console.log('show centers inside', this.showCenters); - if (this.showCenters && this.config.centers.length === 1) { - this.person.center = this.config.centers[0]; - } - }); - } - }, - methods: { - localizeString, - checkErrors() { - this.errors = []; - if (this.person.lastName === "") { - this.errors.push("Le nom ne doit pas être vide."); - } - if (this.person.firstName === "") { - this.errors.push("Le prénom ne doit pas être vide."); - } - if (!this.person.gender) { - this.errors.push("Le genre doit être renseigné"); - } - if (this.showCenters && this.person.center === null) { - this.errors.push("Le centre doit être renseigné"); - } - }, - loadData() { - getPerson(this.id).then( - (person) => - new Promise((resolve) => { - this.person = person; - //console.log('get person', this.person); - resolve(); - }), - ); - }, - onAltNameInput(event) { - const key = event.target.id; - const label = event.target.value; - let updateAltNames = this.person.altNames.filter( - (a) => a.key !== key, - ); - updateAltNames.push({ key: key, label: label }); - this.person.altNames = updateAltNames; - }, - addQueryItem(field, queryItem) { - switch (field) { - case "lastName": - this.person.lastName = this.person.lastName - ? (this.person.lastName += ` ${queryItem}`) - : queryItem; - break; - case "firstName": - this.person.firstName = this.person.firstName - ? (this.person.firstName += ` ${queryItem}`) - : queryItem; - break; - } - }, - submitNewAddress(payload) { - this.person.addressId = payload.addressId; - }, - }, -}; +const person = ref<Person | null>(null); + +function loadData(): void { + if (props.id === undefined || props.id === null) { + return; + } + const idNum = props.id; + if (!Number.isFinite(idNum)) { + return; + } + getPerson(idNum as number).then((p) => { + person.value = p; + }); +} + +onMounted(() => { + if (props.action !== "create") { + loadData(); + } +}); </script> - -<style lang="scss" scoped> -div.flex-table { - div.item-bloc { - div.item-row { - div.item-col:last-child { - justify-content: flex-start; - } - } - } -} -dl { - dd { - margin-left: 1em; - } -} -div.form-check { - label { - margin-left: 0.5em !important; - } -} -</style> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/OnTheFly/PersonEdit.vue b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/OnTheFly/PersonEdit.vue new file mode 100644 index 000000000..c8aeaa6bb --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_components/OnTheFly/PersonEdit.vue @@ -0,0 +1,714 @@ +<template> + <div v-if="action === 'create' || (action === 'edit' && dataLoaded)"> + <div class="mb-3"> + <div class="input-group has-validation"> + <div class="form-floating"> + <input + class="form-control form-control-lg" + :class="{ 'is-invalid': violations.hasViolation('lastName') }" + id="lastname" + v-model="lastName" + :placeholder="trans(PERSON_MESSAGES_PERSON_LASTNAME)" + /> + <label for="lastname" class="required">{{ + trans(PERSON_MESSAGES_PERSON_LASTNAME) + }}</label> + </div> + </div> + <div + v-for="err in violations.violationTitles('lastName')" + class="invalid-feedback was-validated-force" + > + {{ err }} + </div> + </div> + + <div v-if="queryItems"> + <ul class="list-suggest add-items inline"> + <li + v-for="(qi, i) in queryItems" + :key="i" + @click="addQueryItem('lastName', qi)" + > + <span class="person-text">{{ qi }}</span> + </li> + </ul> + </div> + + <div class="mb-3"> + <div class="input-group has-validation"> + <div class="form-floating"> + <input + class="form-control form-control-lg" + :class="{ 'is-invalid': violations.hasViolation('firstName') }" + id="firstname" + v-model="firstName" + :placeholder="trans(PERSON_MESSAGES_PERSON_FIRSTNAME)" + /> + <label for="firstname" class="required">{{ + trans(PERSON_MESSAGES_PERSON_FIRSTNAME) + }}</label> + </div> + </div> + <div + v-for="err in violations.violationTitles('firstName')" + class="invalid-feedback was-validated-force" + > + {{ err }} + </div> + </div> + + <div v-if="queryItems"> + <ul class="list-suggest add-items inline"> + <li + v-for="(qi, i) in queryItems" + :key="i" + @click="addQueryItem('firstName', qi)" + > + <span class="person-text">{{ qi }}</span> + </li> + </ul> + </div> + + <template v-if="action === 'create'"> + <div v-for="(a, i) in config.altNames" :key="a.key" class="mb-3"> + <div class="input-group has-validation"> + <div class="form-floating"> + <input + class="form-control form-control-lg" + :id="a.key" + :name="'label_' + a.key" + value="" + @input="onAltNameInput($event, a.key)" + /> + <label :for="'label_' + a.key">{{ + localizeString(a.labels) + }}</label> + </div> + </div> + </div> + </template> + + <template v-if="action === 'create'"> + <div + v-for="worker in config.identifiers" + :key="worker.definition_id" + class="mb-3" + > + <div class="input-group has-validation"> + <div class="form-floating"> + <input + class="form-control form-control-lg" + :class="{ + 'is-invalid': violations.hasViolationWithParameter( + 'identifiers', + 'definition_id', + worker.definition_id.toString(), + ), + }" + type="text" + :name="'worker_' + worker.definition_id" + :placeholder="localizeString(worker.label)" + @input="onIdentifierInput($event, worker.definition_id)" + /> + <label + :for="'worker_' + worker.definition_id" + :class="{ required: worker.presence == 'REQUIRED' }" + >{{ localizeString(worker.label) }}</label + > + </div> + <div + v-for="err in violations.violationTitlesWithParameter( + 'identifiers', + 'definition_id', + worker.definition_id.toString(), + )" + class="invalid-feedback was-validated-force" + > + {{ err }} + </div> + </div> + </div> + </template> + + <div class="mb-3"> + <div class="input-group has-validation"> + <div class="form-floating"> + <select + class="form-select form-select-lg" + :class="{ 'is-invalid': violations.hasViolation('gender') }" + id="gender" + v-model="gender" + > + <option selected disabled> + {{ trans(PERSON_MESSAGES_PERSON_GENDER_PLACEHOLDER) }} + </option> + <option v-for="g in config.genders" :value="g.id" :key="g.id"> + {{ g.label }} + </option> + </select> + <label for="gender" class="required">{{ + trans(PERSON_MESSAGES_PERSON_GENDER_TITLE) + }}</label> + </div> + <div + v-for="err in violations.violationTitles('gender')" + class="invalid-feedback was-validated-force" + > + {{ err }} + </div> + </div> + </div> + + <div class="mb-3" v-if="showCenters && config.centers.length > 1"> + <div class="input-group"> + <div class="form-floating"> + <select + class="form-select form-select-lg" + :class="{ 'is-invalid': violations.hasViolation('center') }" + id="center" + v-model="center" + > + <option selected disabled> + {{ trans(PERSON_MESSAGES_PERSON_CENTER_PLACEHOLDER) }} + </option> + <option v-for="c in config.centers" :value="c" :key="c.id"> + {{ c.name }} + </option> + </select> + <label for="center" class="required">{{ + trans(PERSON_MESSAGES_PERSON_CENTER_TITLE) + }}</label> + </div> + <div + v-for="err in violations.violationTitles('center')" + class="invalid-feedback was-validated-force" + > + {{ err }} + </div> + </div> + </div> + + <div class="mb-3"> + <div class="input-group has-validation"> + <div class="form-floating"> + <select + class="form-select form-select-lg" + :class="{ 'is-invalid': violations.hasViolation('civility') }" + id="civility" + v-model="civility" + > + <option selected disabled> + {{ trans(PERSON_MESSAGES_PERSON_CIVILITY_PLACEHOLDER) }} + </option> + <option v-for="c in config.civilities" :value="c.id" :key="c.id"> + {{ localizeString(c.name) }} + </option> + </select> + <label for="civility">{{ + trans(PERSON_MESSAGES_PERSON_CIVILITY_TITLE) + }}</label> + </div> + <div + v-for="err in violations.violationTitles('civility')" + class="invalid-feedback was-validated-force" + > + {{ err }} + </div> + </div> + </div> + + <div class="mb-3"> + <div class="input-group has-validation"> + <span class="input-group-text"> + <i class="bi bi-cake2-fill"></i> + </span> + <div class="form-floating"> + <input + class="form-control form-control-lg" + :class="{ 'is-invalid': violations.hasViolation('birthdate') }" + name="birthdate" + type="date" + v-model="birthDate" + :placeholder="trans(BIRTHDATE)" + :aria-label="trans(BIRTHDATE)" + /> + <label for="birthdate">{{ trans(BIRTHDATE) }}</label> + </div> + <div + v-for="err in violations.violationTitles('birthdate')" + class="invalid-feedback was-validated-force" + > + {{ err }} + </div> + </div> + </div> + + <div class="mb-3"> + <div class="input-group has-validation"> + <span class="input-group-text" id="phonenumber"> + <i class="fa fa-fw fa-phone"></i> + </span> + <div class="form-floating"> + <input + class="form-control form-control-lg" + :class="{ 'is-invalid': violations.hasViolation('phonenumber') }" + v-model="phonenumber" + :placeholder="trans(PERSON_MESSAGES_PERSON_PHONENUMBER)" + :aria-label="trans(PERSON_MESSAGES_PERSON_PHONENUMBER)" + aria-describedby="phonenumber" + /> + <label for="phonenumber">{{ + trans(PERSON_MESSAGES_PERSON_PHONENUMBER) + }}</label> + </div> + <div + v-for="err in violations.violationTitles('phonenumber')" + class="invalid-feedback was-validated-force" + > + {{ err }} + </div> + </div> + </div> + + <div class="mb-3"> + <div class="input-group has-validation"> + <span class="input-group-text" id="mobilenumber"> + <i class="fa fa-fw fa-mobile"></i> + </span> + <div class="form-floating"> + <input + class="form-control form-control-lg" + :class="{ 'is-invalid': violations.hasViolation('mobilenumber') }" + v-model="mobilenumber" + :placeholder="trans(PERSON_MESSAGES_PERSON_MOBILENUMBER)" + :aria-label="trans(PERSON_MESSAGES_PERSON_MOBILENUMBER)" + aria-describedby="mobilenumber" + /> + <label for="mobilenumber">{{ + trans(PERSON_MESSAGES_PERSON_MOBILENUMBER) + }}</label> + </div> + <div + v-for="err in violations.violationTitles('mobilenumber')" + class="invalid-feedback was-validated-force" + > + {{ err }} + </div> + </div> + </div> + + <div class="mb-3"> + <div class="input-group has-validation"> + <span class="input-group-text" id="email"> + <i class="fa fa-fw fa-at"></i> + </span> + <div class="form-floating"> + <input + class="form-control form-control-lg" + :class="{ 'is-invalid': violations.hasViolation('email') }" + v-model="email" + :placeholder="trans(PERSON_MESSAGES_PERSON_EMAIL)" + :aria-label="trans(PERSON_MESSAGES_PERSON_EMAIL)" + aria-describedby="email" + /> + <label for="email">{{ trans(PERSON_MESSAGES_PERSON_EMAIL) }}</label> + </div> + <div + v-for="err in violations.violationTitles('email')" + class="invalid-feedback was-validated-force" + > + {{ err }} + </div> + </div> + </div> + + <div v-if="action === 'create'" class="input-group mb-3 form-check"> + <input + class="form-check-input" + type="checkbox" + v-model="showAddressForm" + name="showAddressForm" + /> + <label class="form-check-label"> + {{ trans(PERSON_MESSAGES_PERSON_ADDRESS_SHOW_ADDRESS_FORM) }} + </label> + </div> + + <div + v-if="action === 'create' && showAddressFormValue" + class="form-floating mb-3" + > + <p>{{ trans(PERSON_MESSAGES_PERSON_ADDRESS_WARNING) }}</p> + <AddAddress + :context="addAddress.context" + :options="addAddress.options" + :addressChangedCallback="submitNewAddress" + ref="addAddress" + /> + </div> + </div> + <div v-else></div> +</template> + +<script setup lang="ts"> +import { ref, reactive, computed, onMounted } from "vue"; +import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper"; +import AddAddress from "ChillMainAssets/vuejs/Address/components/AddAddress.vue"; +import { + createPerson, + editPerson, + getCentersForPersonCreation, + getCivilities, + getGenders, + getPerson, + getPersonAltNames, + getPersonIdentifiers, + personToWritePerson, + WritePersonViolationMap, +} from "../../_api/OnTheFly"; +import { + trans, + BIRTHDATE, + PERSON_EDIT_ERROR_WHILE_SAVING, + PERSON_MESSAGES_PERSON_LASTNAME, + PERSON_MESSAGES_PERSON_FIRSTNAME, + PERSON_MESSAGES_PERSON_GENDER_PLACEHOLDER, + PERSON_MESSAGES_PERSON_GENDER_TITLE, + PERSON_MESSAGES_PERSON_CENTER_PLACEHOLDER, + PERSON_MESSAGES_PERSON_CENTER_TITLE, + PERSON_MESSAGES_PERSON_CIVILITY_PLACEHOLDER, + PERSON_MESSAGES_PERSON_CIVILITY_TITLE, + PERSON_MESSAGES_PERSON_PHONENUMBER, + PERSON_MESSAGES_PERSON_MOBILENUMBER, + PERSON_MESSAGES_PERSON_EMAIL, + PERSON_MESSAGES_PERSON_ADDRESS_SHOW_ADDRESS_FORM, + PERSON_MESSAGES_PERSON_ADDRESS_WARNING, +} from "translator"; +import { Center, Civility, Gender } from "ChillMainAssets/types"; +import { + AltName, + Person, + PersonWrite, + PersonIdentifierWorker, +} from "ChillPersonAssets/types"; +import { isValidationException } from "ChillMainAssets/lib/api/apiMethods"; +import { useToast } from "vue-toast-notification"; +import { + getTimezoneOffsetString, + ISOToDate, +} from "ChillMainAssets/chill/js/date"; +import { useViolationList } from "ChillMainAssets/vuejs/_composables/violationList"; + +interface PersonEditComponentConfig { + id?: number | null; + action: "edit" | "create"; + query: string; +} + +const props = withDefaults(defineProps<PersonEditComponentConfig>(), { + id: null, +}); + +const emit = + defineEmits<(e: "onPersonCreated", payload: { person: Person }) => void>(); + +defineExpose({ postPerson }); + +const toast = useToast(); + +const person = reactive<PersonWrite>({ + type: "person", + firstName: "", + lastName: "", + altNames: [], + addressId: null, + birthdate: null, + deathdate: null, + phonenumber: "", + mobilenumber: "", + email: "", + gender: null, + center: null, + civility: null, + identifiers: [], +}); + +const config = reactive<{ + altNames: AltName[]; + civilities: Civility[]; + centers: Center[]; + genders: Gender[]; + identifiers: PersonIdentifierWorker[]; +}>({ + altNames: [], + civilities: [], + centers: [], + genders: [], + identifiers: [], +}); + +const showCenters = ref(false); +const showAddressFormValue = ref(false); + +const addAddress = reactive({ + options: { + button: { + text: { create: "person.address.create_address" }, + size: "btn-sm", + }, + title: { create: "person.address.create_address" }, + }, + context: { + target: {}, + edit: false, + addressId: null as number | null, + defaults: (window as any).addaddress, + }, +}); + +const firstName = computed({ + get: () => person.firstName, + set: (value: string) => { + person.firstName = value; + }, +}); +const lastName = computed({ + get: () => person.lastName, + set: (value: string) => { + person.lastName = value; + }, +}); +const gender = computed({ + get: () => (person.gender ? person.gender.id : null), + set: (value: string | null) => { + person.gender = value + ? { id: Number.parseInt(value), type: "chill_main_gender" } + : null; + }, +}); +const civility = computed({ + get: () => (person.civility ? person.civility.id : null), + set: (value: number | null) => { + person.civility = + value !== null ? { id: value, type: "chill_main_civility" } : null; + }, +}); +const birthDate = computed({ + get: () => (person.birthdate ? person.birthdate.datetime.split("T")[0] : ""), + set: (value: string) => { + const date = ISOToDate(value); + if (null === date) { + person.birthdate = null; + return; + } + const offset = getTimezoneOffsetString( + date, + Intl.DateTimeFormat().resolvedOptions().timeZone, + ); + if (person.birthdate) { + person.birthdate.datetime = value + "T00:00:00" + offset; + } else { + person.birthdate = { datetime: value + "T00:00:00" + offset }; + } + }, +}); +const phonenumber = computed({ + get: () => person.phonenumber, + set: (value: string) => { + person.phonenumber = value; + }, +}); +const mobilenumber = computed({ + get: () => person.mobilenumber, + set: (value: string) => { + person.mobilenumber = value; + }, +}); +const email = computed({ + get: () => person.email, + set: (value: string) => { + person.email = value; + }, +}); +const showAddressForm = computed({ + get: () => showAddressFormValue.value, + set: (value: boolean) => { + showAddressFormValue.value = value; + }, +}); +const center = computed({ + get: () => { + const c = config.centers.find( + (c) => person.center !== null && person.center.id === c.id, + ); + return typeof c === "undefined" ? null : c; + }, + set: (value: Center | null) => { + if (null !== value) { + person.center = { + id: value.id, + type: value.type, + }; + } else { + person.center = null; + } + }, +}); + +/** + * Find the query items to display for suggestion + */ +const queryItems = computed(() => { + const words: null | string[] = props.query ? props.query.split(" ") : null; + + if (null === words) { + return null; + } + + const firstNameWords = (person.firstName || "") + .trim() + .toLowerCase() + .split(" "); + const lastNameWords = (person.lastName || "").trim().toLowerCase().split(" "); + + return words + .filter((word) => !firstNameWords.includes(word.toLowerCase())) + .filter((word) => !lastNameWords.includes(word.toLowerCase())); +}); + +const dataLoaded = ref<boolean>(false); + +async function loadData() { + if (props.id !== undefined && props.id !== null) { + const p = await getPerson(props.id); + const w = personToWritePerson(p); + person.firstName = w.firstName; + person.lastName = w.lastName; + person.altNames.push(...w.altNames); + person.civility = w.civility; + person.addressId = w.addressId; + person.birthdate = w.birthdate; + person.deathdate = w.deathdate; + person.phonenumber = w.phonenumber; + person.mobilenumber = w.mobilenumber; + person.email = w.email; + person.gender = w.gender; + person.center = w.center; + person.civility = w.civility; + person.identifiers.push(...w.identifiers); + dataLoaded.value = true; + } +} + +function onAltNameInput(event: Event, key: string): void { + const target = event.target as HTMLInputElement; + const value = target.value; + const updateAltNamesKey = person.altNames.findIndex((a) => a.key === key); + if (-1 === updateAltNamesKey) { + person.altNames.push({ key, value }); + } else { + person.altNames[updateAltNamesKey].value = value; + } +} + +function onIdentifierInput(event: Event, definition_id: number): void { + const target = event.target as HTMLInputElement; + const value = target.value; + const updateIdentifierKey = person.identifiers.findIndex( + (w) => w.definition_id === definition_id, + ); + if (-1 === updateIdentifierKey) { + person.identifiers.push({ + type: "person_identifier", + definition_id, + value: { content: value }, + }); + } else { + person.identifiers[updateIdentifierKey].value = { content: value }; + } +} + +function addQueryItem(field: "lastName" | "firstName", queryItem: string) { + switch (field) { + case "lastName": + person.lastName = person.lastName + ? (person.lastName += ` ${queryItem}`) + : queryItem; + break; + case "firstName": + person.firstName = person.firstName + ? (person.firstName += ` ${queryItem}`) + : queryItem; + break; + } +} + +const violations = useViolationList<WritePersonViolationMap>(); + +function submitNewAddress(payload: { addressId: number }) { + person.addressId = payload.addressId; +} + +async function postPerson(): Promise<Person> { + try { + if (props.action === "create") { + const createdPerson = await createPerson(person); + emit("onPersonCreated", { person: createdPerson }); + + return Promise.resolve(createdPerson); + } else if (props.id !== null) { + const updatedPerson = await editPerson(person, props.id); + emit("onPersonCreated", { person: updatedPerson }); + + return Promise.resolve(updatedPerson); + } + } catch (e: unknown) { + if (isValidationException<WritePersonViolationMap>(e)) { + violations.setValidationException(e); + } else { + toast.error(trans(PERSON_EDIT_ERROR_WHILE_SAVING)); + } + } + throw "'action' is not create, or edit with a not-null id"; +} + +onMounted(() => { + getPersonAltNames().then((altNames) => { + config.altNames = altNames; + }); + getCivilities().then((civilities) => { + config.civilities = civilities; + }); + getGenders().then((genders) => { + config.genders = genders; + }); + getPersonIdentifiers().then((identifiers) => { + config.identifiers = identifiers.filter( + (w: PersonIdentifierWorker) => + w.presence === "ON_CREATION" || w.presence === "REQUIRED", + ); + }); + if (props.action !== "create") { + loadData(); + } else { + getCentersForPersonCreation().then((params) => { + config.centers = params.centers.filter((c: Center) => c.isActive); + showCenters.value = params.showCenters; + if (showCenters.value && config.centers.length === 1) { + // if there is only one center, preselect it + person.center = { + id: config.centers[0].id, + type: config.centers[0].type ?? "center", + }; + } + }); + } +}); +</script> + +<style lang="scss" scoped> +.was-validated-force { + display: block; +} +</style> diff --git a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_js/i18n.ts b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_js/i18n.ts index ba7637544..d1a2a3b84 100644 --- a/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_js/i18n.ts +++ b/src/Bundle/ChillPersonBundle/Resources/public/vuejs/_js/i18n.ts @@ -1,64 +1,63 @@ const personMessages = { - fr: { - add_persons: { - title: "Ajouter des usagers", - suggested_counter: - "Pas de résultats | 1 résultat | {count} résultats", - selected_counter: " 1 sélectionné | {count} sélectionnés", - search_some_persons: "Rechercher des personnes..", - }, - item: { - type_person: "Usager", - type_user: "TMS", - type_thirdparty: "Tiers professionnel", - type_household: "Ménage", - }, - person: { - firstname: "Prénom", - lastname: "Nom", - born: (ctx: { gender: "man" | "woman" | "neutral" }) => { - if (ctx.gender === "man") { - return "Né le"; - } else if (ctx.gender === "woman") { - return "Née le"; - } else { - return "Né·e le"; - } - }, - center_id: "Identifiant du centre", - center_type: "Type de centre", - center_name: "Territoire", // vendée - phonenumber: "Téléphone", - mobilenumber: "Mobile", - altnames: "Autres noms", - email: "Courriel", - gender: { - title: "Genre", - placeholder: "Choisissez le genre de l'usager", - woman: "Féminin", - man: "Masculin", - neutral: "Neutre, non binaire", - unknown: "Non renseigné", - undefined: "Non renseigné", - }, - civility: { - title: "Civilité", - placeholder: "Choisissez la civilité", - }, - address: { - create_address: "Ajouter une adresse", - show_address_form: - "Ajouter une adresse pour un usager non suivi et seul dans un ménage", - warning: - "Un nouveau ménage va être créé. L'usager sera membre de ce ménage.", - }, - center: { - placeholder: "Choisissez un centre", - title: "Centre", - }, - }, - error_only_one_person: "Une seule personne peut être sélectionnée !", + fr: { + add_persons: { + title: "Ajouter des usagers", + suggested_counter: "Pas de résultats | 1 résultat | {count} résultats", + selected_counter: " 1 sélectionné | {count} sélectionnés", + search_some_persons: "Rechercher des personnes..", }, + item: { + type_person: "Usager", + type_user: "TMS", + type_thirdparty: "Tiers professionnel", + type_household: "Ménage", + }, + person: { + firstname: "Prénom", + lastname: "Nom", + born: (ctx: { gender: "man" | "woman" | "neutral" }) => { + if (ctx.gender === "man") { + return "Né le"; + } else if (ctx.gender === "woman") { + return "Née le"; + } else { + return "Né·e le"; + } + }, + center_id: "Identifiant du territoire", + center_type: "Type de territoire", + center_name: "Territoire", // vendée + phonenumber: "Téléphone", + mobilenumber: "Mobile", + altnames: "Autres noms", + email: "Courriel", + gender: { + title: "Genre", + placeholder: "Choisissez le genre de l'usager", + woman: "Féminin", + man: "Masculin", + neutral: "Neutre, non binaire", + unknown: "Non renseigné", + undefined: "Non renseigné", + }, + civility: { + title: "Civilité", + placeholder: "Choisissez la civilité", + }, + address: { + create_address: "Ajouter une adresse", + show_address_form: + "Ajouter une adresse pour un usager non suivi et seul dans un ménage", + warning: + "Un nouveau ménage va être créé. L'usager sera membre de ce ménage.", + }, + center: { + placeholder: "Choisissez un territoire", + title: "territoire", + }, + }, + error_only_one_person: "Une seule personne peut être sélectionnée !", + }, }; export { personMessages }; diff --git a/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingCourseWork/_objectifs_results_evaluations.html.twig b/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingCourseWork/_objectifs_results_evaluations.html.twig index f35310503..9adba79ff 100644 --- a/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingCourseWork/_objectifs_results_evaluations.html.twig +++ b/src/Bundle/ChillPersonBundle/Resources/views/AccompanyingCourseWork/_objectifs_results_evaluations.html.twig @@ -2,30 +2,6 @@ # OPTIONS # - displayContent: [short|long] default: short #} -{% if w.results|length > 0 %} - - <table class="obj-res-eval"> - <thead> - <th class="obj"><h4 class="title_label">{{ 'accompanying_course_work.goal'|trans }}</h4></th> - <th class="res"><h4 class="title_label">{{ 'accompanying_course_work.results'|trans }}</h4></th> - </thead> - <tbody> - <tr> - <td class="obj"> - <p class="chill-no-data-statement">{{ 'accompanying_course_work.results without objective'|trans }}</p> - </td> - <td class="res"> - <ul class="result_list"> - {% for r in w.results %} - <li>{{ r.title|localize_translatable_string }}</li> - {% endfor %} - </ul> - </td> - </tr> - </tbody> - </table> -{% endif %} - {% if w.goals|length > 0 %} <table class="obj-res-eval"> <thead> @@ -57,6 +33,31 @@ </table> {% endif %} +{% if w.results|length > 0 %} + + <table class="obj-res-eval"> + <thead> + <th class="obj"><h4 class="title_label">{{ 'accompanying_course_work.goal'|trans }}</h4></th> + <th class="res"><h4 class="title_label">{{ 'accompanying_course_work.results'|trans }}</h4></th> + </thead> + <tbody> + <tr> + <td class="obj"> + <p class="chill-no-data-statement">{{ 'accompanying_course_work.results without objective'|trans }}</p> + </td> + <td class="res"> + <ul class="result_list"> + {% for r in w.results %} + <li>{{ r.title|localize_translatable_string }}</li> + {% endfor %} + </ul> + </td> + </tr> + </tbody> + </table> +{% endif %} + + {% if w.accompanyingPeriodWorkEvaluations|length > 0 %} <table class="obj-res-eval"> <thead> @@ -216,9 +217,29 @@ {% if e.timeSpent is not null and e.timeSpent > 0 %} <li> - {% set minutes = (e.timeSpent / 60) %} - <span - class="item-key">{{ 'accompanying_course_work.timeSpent'|trans ~ ' : ' }}</span> {{ 'duration.minute'|trans({ '{m}' : minutes }) }} + {% set totalHours = (e.timeSpent / 3600)|round(0, 'floor') %} + {% set totalMinutes = ((e.timeSpent % 3600) / 60)|round(0, 'floor') %} + + <span class="item-key">{{ 'accompanying_course_work.timeSpent'|trans ~ ' : ' }}</span> + + {% if totalHours >= 8 %} + {% set days = (totalHours / 8)|round(0, 'floor') %} + {% set remainingHours = totalHours % 8 %} + + {% if days > 0 %} + {{ 'duration.day'|trans({ '{d}' : days }) }} + {% endif %} + {% if remainingHours > 0 %} + {{ 'duration.hour'|trans({ '{h}' : remainingHours }) }} + {% endif %} + {% else %} + {% if totalHours > 0 %} + {{ 'duration.hour'|trans({ '{h}' : totalHours }) }} + {% endif %} + {% if totalMinutes > 0 %} + {{ 'duration.minute'|trans({ '{m}' : totalMinutes }) }} + {% endif %} + {% endif %} </li> {% elseif displayContent is defined and displayContent == 'long' %} <li> diff --git a/src/Bundle/ChillPersonBundle/Resources/views/Entity/person.html.twig b/src/Bundle/ChillPersonBundle/Resources/views/Entity/person.html.twig index d8a930b40..c373fd58b 100644 --- a/src/Bundle/ChillPersonBundle/Resources/views/Entity/person.html.twig +++ b/src/Bundle/ChillPersonBundle/Resources/views/Entity/person.html.twig @@ -81,25 +81,25 @@ </div> {%- if options['addInfo'] -%} <p class="moreinfo"> - {% if person.gender is not null %}{{ person.gender.icon|chill_entity_render_box }}{% endif %} + {% if person.gender is not null %}{{ person.gender.icon|chill_entity_render_box }} {% endif %} {%- if person.deathdate is not null -%} {%- if person.birthdate is not null -%} {# must be on one line to avoid spaces with dash #} <time datetime="{{ person.birthdate|date('Y-m-d') }}" title="{{ 'birthdate'|trans|e('html_attr') }}">{{ person.birthdate|format_date("medium") }}</time>– {%- else -%} - {{ 'Date of death'|trans }}: + <span>{{ 'Date of death'|trans }}: </span> {%- endif -%} {#- must be on one line to avoid spaces with dash -#} <time datetime="{{ person.deathdate|date('Y-m-d') }}" title="{{ 'deathdate'|trans }}">{{ person.deathdate|format_date("medium") }}</time> {%- if options['addAge'] -%} <span class="age"> {{ 'years_old'|trans({ 'age': person.age }) }}</span> {%- endif -%} - {%- if options['addId'] -%} - {%- set personId = person|chill_person_id_render_text %} - <span class="id-number" title="{{ 'Person'|trans ~ ' ' ~ personId }}"> - ({{ personId }}) - </span> - {%- endif -%} +{# {%- if options['addId'] -%}#} +{# {%- set personId = person|chill_person_id_render_text %}#} +{# <span class="id-number" title="{{ 'Person'|trans ~ ' ' ~ personId }}">#} +{# ({{ personId }})#} +{# </span>#} +{# {%- endif -%}#} {%- elseif person.birthdate is not null -%} <time datetime="{{ person.birthdate|date('Y-m-d') }}" title="{{ 'Birthdate'|trans }}"> {{ 'Born the date'|trans({'gender': person.gender ? person.gender.genderTranslation.value : 'neutral', @@ -212,4 +212,3 @@ </div> </div> {%- endif -%} - diff --git a/src/Bundle/ChillPersonBundle/Resources/views/Person/create.html.twig b/src/Bundle/ChillPersonBundle/Resources/views/Person/create.html.twig index 1ceb84395..5a17acf18 100644 --- a/src/Bundle/ChillPersonBundle/Resources/views/Person/create.html.twig +++ b/src/Bundle/ChillPersonBundle/Resources/views/Person/create.html.twig @@ -88,6 +88,15 @@ <div data-suggest-container="{{ altName.vars.full_name|e('html_attr') }}" class="col-sm-8" style="margin-left: auto;"></div> {% endfor %} {% endif %} + {% if form.identifiers|length > 0 %} + {% for f in form.identifiers %} + <div class="row mb-1" style="display:flex;"> + {{ form_row(f) }} + </div> + {% endfor %} + {% else %} + {{ form_widget(form.identifiers) }} + {% endif %} {{ form_row(form.gender, { 'label' : 'Gender'|trans }) }} diff --git a/src/Bundle/ChillPersonBundle/Resources/views/Person/edit.html.twig b/src/Bundle/ChillPersonBundle/Resources/views/Person/edit.html.twig index fe5dde242..169479854 100644 --- a/src/Bundle/ChillPersonBundle/Resources/views/Person/edit.html.twig +++ b/src/Bundle/ChillPersonBundle/Resources/views/Person/edit.html.twig @@ -140,9 +140,7 @@ <fieldset> <legend><h2>{{ 'person.Identifiers'|trans }}</h2></legend> <div> - {% for f in form.identifiers %} - {{ form_row(f) }} - {% endfor %} + {{ form_widget(form.identifiers) }} </div> </fieldset> {% else %} diff --git a/src/Bundle/ChillPersonBundle/Resources/views/Person/list_with_period.html.twig b/src/Bundle/ChillPersonBundle/Resources/views/Person/list_with_period.html.twig index 0840cecb6..cf7fb1466 100644 --- a/src/Bundle/ChillPersonBundle/Resources/views/Person/list_with_period.html.twig +++ b/src/Bundle/ChillPersonBundle/Resources/views/Person/list_with_period.html.twig @@ -301,6 +301,47 @@ 'customButtons': { 'after': _self.button_person_after(person), 'before': _self.button_person_before((person)) } }) }} + {% set calendars = [] %} + {% for c in person.getNextCalendarsForPerson(10) %} + {% if is_granted('CHILL_CALENDAR_CALENDAR_SEE', c) %} + {% set calendars = calendars|merge([c]) %} + {% endif %} + {% endfor %} + + {% if calendars|length > 0 %} + <div class="wrap-list periods-list"> + <div class="wl-row"> + <div class="wl-col title"> + <h3>{{ 'chill_calendar.Next calendars'|trans }}</h3> + </div> + <div class="wl-col list"> + <div class="calendar-list"> + <ul class="calendar-list"> + {% for c in calendars %} + <li> + {% if is_granted('CHILL_CALENDAR_CALENDAR_EDIT', c) %} + <a href="{{ chill_path_add_return_path('chill_calendar_calendar_edit', { id: c.id }) }}"> + <span class="badge bg-secondary"> + {{ c.startDate|format_datetime('long', 'short') }} + </span> + </a> + {% else %} + <span class="badge bg-secondary"> + {{ c.startDate|format_datetime('long', 'short') }} + </span> + {% endif %} + </li> + {% endfor %} + </ul> + {% if is_granted('CHILL_CALENDAR_CALENDAR_SEE', person) %} + <a href="{{ chill_path_add_return_path('chill_calendar_calendar_list_by_person', {'id': person.id}) }}" class="calendar-list__global"><i class="fa fa-list"></i></a> + {% endif %} + </div> + </div> + </div> + </div> + {% endif %} + {#- 'acps' is for AcCompanyingPeriodS #} {%- set acps = [] %} {%- set acpsClosed = [] %} diff --git a/src/Bundle/ChillPersonBundle/Serializer/Normalizer/PersonJsonDenormalizer.php b/src/Bundle/ChillPersonBundle/Serializer/Normalizer/PersonJsonDenormalizer.php new file mode 100644 index 000000000..9f0464a71 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Serializer/Normalizer/PersonJsonDenormalizer.php @@ -0,0 +1,155 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\PersonBundle\Serializer\Normalizer; + +use Chill\MainBundle\Entity\Center; +use Chill\MainBundle\Entity\Civility; +use Chill\MainBundle\Entity\Gender; +use Chill\PersonBundle\Entity\Identifier\PersonIdentifier; +use Chill\PersonBundle\Entity\Person; +use Chill\PersonBundle\Entity\PersonAltName; +use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface; +use libphonenumber\PhoneNumber; +use Symfony\Component\Serializer\Exception\UnexpectedValueException; +use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface; +use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\ObjectToPopulateTrait; + +/** + * Denormalize a Person entity from a JSON-like array structure, creating or updating an existing instance. + * + * To find an existing instance by his id, see the @see{PersonJsonReadDenormalizer}. + */ +final class PersonJsonDenormalizer implements DenormalizerInterface, DenormalizerAwareInterface +{ + use DenormalizerAwareTrait; + use ObjectToPopulateTrait; + + public function __construct(private readonly PersonIdentifierManagerInterface $personIdentifierManager) {} + + public function denormalize($data, string $type, ?string $format = null, array $context = []): Person + { + $person = $this->extractObjectToPopulate($type, $context); + + if (null === $person) { + $person = new Person(); + } + + // Setters applied directly per known field for readability + if (\array_key_exists('firstName', $data)) { + $person->setFirstName($data['firstName']); + } + + if (\array_key_exists('lastName', $data)) { + $person->setLastName($data['lastName']); + } + + if (\array_key_exists('phonenumber', $data)) { + $person->setPhonenumber($this->denormalizer->denormalize($data['phonenumber'], PhoneNumber::class, $format, $context)); + } + + if (\array_key_exists('mobilenumber', $data)) { + $person->setMobilenumber($this->denormalizer->denormalize($data['mobilenumber'], PhoneNumber::class, $format, $context)); + } + + if (\array_key_exists('gender', $data) && null !== $data['gender']) { + $gender = $this->denormalizer->denormalize($data['gender'], Gender::class, $format, []); + $person->setGender($gender); + } + + if (\array_key_exists('birthdate', $data)) { + $object = $this->denormalizer->denormalize($data['birthdate'], \DateTime::class, $format, $context); + $person->setBirthdate($object); + } + + if (\array_key_exists('deathdate', $data)) { + $object = $this->denormalizer->denormalize($data['deathdate'], \DateTimeImmutable::class, $format, $context); + $person->setDeathdate($object); + } + + if (\array_key_exists('center', $data)) { + $object = $this->denormalizer->denormalize($data['center'], Center::class, $format, $context); + $person->setCenter($object); + } + + if (\array_key_exists('altNames', $data)) { + foreach ($data['altNames'] as $altNameData) { + if (!array_key_exists('key', $altNameData) + || !array_key_exists('value', $altNameData) + || '' === trim((string) $altNameData['key']) + ) { + throw new UnexpectedValueException('format for alt name is not correct'); + } + $altNameKey = $altNameData['key']; + $altNameValue = $altNameData['value']; + + $altName = $person->getAltNames()->findFirst(fn (int $key, PersonAltName $personAltName) => $personAltName->getKey() === $altNameKey); + if (null === $altName) { + $altName = new PersonAltName(); + $person->addAltName($altName); + } + $altName->setKey($altNameKey)->setLabel($altNameValue); + } + } + + if (\array_key_exists('identifiers', $data)) { + foreach ($data['identifiers'] as $identifierData) { + if (!array_key_exists('definition_id', $identifierData) + || !array_key_exists('value', $identifierData) + || !is_int($identifierData['definition_id']) + || !is_array($identifierData['value']) + ) { + throw new UnexpectedValueException('format for identifiers is not correct'); + } + + $definitionId = $identifierData['definition_id']; + $value = $identifierData['value']; + + $worker = $this->personIdentifierManager->buildWorkerByPersonIdentifierDefinition($definitionId); + + if (!$worker->getDefinition()->isEditableByUsers()) { + continue; + } + + $personIdentifier = $person->getIdentifiers()->findFirst(fn (int $key, PersonIdentifier $personIdentifier) => $personIdentifier->getDefinition()->getId() === $definitionId); + if (null === $personIdentifier) { + $personIdentifier = new PersonIdentifier($worker->getDefinition()); + $person->addIdentifier($personIdentifier); + } + + $personIdentifier->setValue($value); + $personIdentifier->setCanonical($worker->canonicalizeValue($value)); + + if ($worker->isEmpty($personIdentifier)) { + $person->removeIdentifier($personIdentifier); + } + } + } + + if (\array_key_exists('email', $data)) { + $person->setEmail($data['email']); + } + + if (\array_key_exists('civility', $data) && null !== $data['civility']) { + $civility = $this->denormalizer->denormalize($data['civility'], Civility::class, $format, []); + $person->setCivility($civility); + } + + return $person; + } + + public function supportsDenormalization($data, $type, $format = null): bool + { + return Person::class === $type && 'person' === ($data['type'] ?? null) && !isset($data['id']); + } +} diff --git a/src/Bundle/ChillPersonBundle/Serializer/Normalizer/PersonJsonNormalizer.php b/src/Bundle/ChillPersonBundle/Serializer/Normalizer/PersonJsonNormalizer.php index fbf1c832c..8467c1d56 100644 --- a/src/Bundle/ChillPersonBundle/Serializer/Normalizer/PersonJsonNormalizer.php +++ b/src/Bundle/ChillPersonBundle/Serializer/Normalizer/PersonJsonNormalizer.php @@ -11,174 +11,38 @@ declare(strict_types=1); namespace Chill\PersonBundle\Serializer\Normalizer; -use Chill\MainBundle\Entity\Center; -use Chill\MainBundle\Entity\Civility; -use Chill\MainBundle\Entity\Gender; use Chill\MainBundle\Phonenumber\PhoneNumberHelperInterface; use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface; use Chill\MainBundle\Templating\Entity\ChillEntityRenderExtension; use Chill\PersonBundle\Entity\Person; use Chill\PersonBundle\Entity\PersonAltName; -use Chill\PersonBundle\Repository\PersonRepository; use Chill\PersonBundle\Repository\ResidentialAddressRepository; use Doctrine\Common\Collections\Collection; -use libphonenumber\PhoneNumber; -use Symfony\Component\Serializer\Exception\UnexpectedValueException; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; -use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface; -use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait; use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; -use Symfony\Component\Serializer\Normalizer\ObjectToPopulateTrait; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; /** * Serialize a Person entity. */ -class PersonJsonNormalizer implements DenormalizerAwareInterface, NormalizerAwareInterface, PersonJsonNormalizerInterface +class PersonJsonNormalizer implements NormalizerAwareInterface, NormalizerInterface { - use DenormalizerAwareTrait; - use NormalizerAwareTrait; - use ObjectToPopulateTrait; - public function __construct( private readonly ChillEntityRenderExtension $render, - /* TODO: replace by PersonRenderInterface, as sthis is the only one required */ - private readonly PersonRepository $repository, private readonly CenterResolverManagerInterface $centerResolverManager, private readonly ResidentialAddressRepository $residentialAddressRepository, private readonly PhoneNumberHelperInterface $phoneNumberHelper, + private readonly \Chill\PersonBundle\PersonIdentifier\Rendering\PersonIdRenderingInterface $personIdRendering, ) {} - public function denormalize($data, $type, $format = null, array $context = []): mixed - { - $person = $this->extractObjectToPopulate($type, $context); - - if (\array_key_exists('id', $data) && null === $person) { - $person = $this->repository->find($data['id']); - - if (null === $person) { - throw new UnexpectedValueException("The person with id \"{$data['id']}\" does ".'not exists'); - } - - // currently, not allowed to update a person through api - // if instantiated with id - return $person; - } - - if (null === $person) { - $person = new Person(); - } - - $fields = [ - 'firstName', - 'lastName', - 'phonenumber', - 'mobilenumber', - 'gender', - 'birthdate', - 'deathdate', - 'center', - 'altNames', - 'email', - 'civility', - ]; - - $fields = array_filter( - $fields, - static fn (string $field): bool => \array_key_exists($field, $data) - ); - - foreach ($fields as $item) { - switch ($item) { - case 'firstName': - $person->setFirstName($data[$item]); - - break; - - case 'lastName': - $person->setLastName($data[$item]); - - break; - - case 'phonenumber': - $person->setPhonenumber($this->denormalizer->denormalize($data[$item], PhoneNumber::class, $format, $context)); - - break; - - case 'mobilenumber': - $person->setMobilenumber($this->denormalizer->denormalize($data[$item], PhoneNumber::class, $format, $context)); - - break; - - case 'gender': - $gender = $this->denormalizer->denormalize($data[$item], Gender::class, $format, []); - - $person->setGender($gender); - - break; - - case 'birthdate': - $object = $this->denormalizer->denormalize($data[$item], \DateTime::class, $format, $context); - - $person->setBirthdate($object); - - break; - - case 'deathdate': - $object = $this->denormalizer->denormalize($data[$item], \DateTimeImmutable::class, $format, $context); - - $person->setDeathdate($object); - - break; - - case 'center': - $object = $this->denormalizer->denormalize($data[$item], Center::class, $format, $context); - $person->setCenter($object); - - break; - - case 'altNames': - foreach ($data[$item] as $altName) { - $oldAltName = $person - ->getAltNames() - ->filter(static fn (PersonAltName $n): bool => $n->getKey() === $altName['key'])->first(); - - if (false === $oldAltName) { - $newAltName = new PersonAltName(); - $newAltName->setKey($altName['key']); - $newAltName->setLabel($altName['label']); - $person->addAltName($newAltName); - } else { - $oldAltName->setLabel($altName['label']); - } - } - - break; - - case 'email': - $person->setEmail($data[$item]); - - break; - - case 'civility': - $civility = $this->denormalizer->denormalize($data[$item], Civility::class, $format, []); - - $person->setCivility($civility); - - break; - } - } - - return $person; - } - /** * @param Person $person * @param string|null $format */ - public function normalize($person, $format = null, array $context = []): string|int|float|bool|\ArrayObject|array|null + public function normalize($person, $format = null, array $context = []) { $groups = $context[AbstractNormalizer::GROUPS] ?? []; @@ -204,35 +68,20 @@ class PersonJsonNormalizer implements DenormalizerAwareInterface, NormalizerAwar 'email' => $person->getEmail(), 'gender' => $this->normalizer->normalize($person->getGender(), $format, $context), 'civility' => $this->normalizer->normalize($person->getCivility(), $format, $context), + 'personId' => $this->personIdRendering->renderPersonId($person), + 'identifiers' => $this->normalizer->normalize($person->getIdentifiers(), $format, $context), ]; if (\in_array('minimal', $groups, true) && 1 === \count($groups)) { return $data; } - return [ - ...$data, - 'centers' => $this->normalizer->normalize( - $this->centerResolverManager->resolveCenters($person), - $format, - $context - ), - 'altNames' => $this->normalizeAltNames($person->getAltNames()), - 'current_household_id' => null !== $household ? - $this->normalizer->normalize($household->getId(), $format, $context) : - null, - 'current_residential_addresses' => [] !== $currentResidentialAddresses ? - $this->normalizer->normalize($currentResidentialAddresses, $format, $context) : - null, - ]; + return [...$data, 'centers' => $this->normalizer->normalize($this->centerResolverManager->resolveCenters($person), $format, $context), 'altNames' => $this->normalizeAltNames($person->getAltNames()), 'current_household_id' => $household ? $this->normalizer->normalize($household->getId(), $format, $context) : null, 'current_residential_addresses' => $currentResidentialAddresses ? + $this->normalizer->normalize($currentResidentialAddresses, $format, $context) : + null]; } - public function supportsDenormalization($data, $type, $format = null, array $context = []): bool - { - return Person::class === $type && 'person' === ($data['type'] ?? null); - } - - public function supportsNormalization($data, $format = null, array $context = []): bool + public function supportsNormalization($data, $format = null): bool { return $data instanceof Person && 'json' === $format; } diff --git a/src/Bundle/ChillPersonBundle/Serializer/Normalizer/PersonJsonReadDenormalizer.php b/src/Bundle/ChillPersonBundle/Serializer/Normalizer/PersonJsonReadDenormalizer.php new file mode 100644 index 000000000..ce8e5ce48 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Serializer/Normalizer/PersonJsonReadDenormalizer.php @@ -0,0 +1,51 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\PersonBundle\Serializer\Normalizer; + +use Chill\PersonBundle\Entity\Person; +use Chill\PersonBundle\Repository\PersonRepository; +use Symfony\Component\Serializer\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Exception\LogicException; +use Symfony\Component\Serializer\Exception\UnexpectedValueException; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; + +/** + * Find a Person entity by his id during the denormalization process. + */ +readonly class PersonJsonReadDenormalizer implements DenormalizerInterface +{ + public function __construct(private PersonRepository $repository) {} + + public function denormalize($data, string $type, ?string $format = null, array $context = []): Person + { + if (!is_array($data)) { + throw new InvalidArgumentException(); + } + + if (\array_key_exists('id', $data)) { + $person = $this->repository->find($data['id']); + + if (null === $person) { + throw new UnexpectedValueException("The person with id \"{$data['id']}\" does ".'not exists'); + } + + return $person; + } + + throw new LogicException(); + } + + public function supportsDenormalization($data, string $type, ?string $format = null) + { + return is_array($data) && Person::class === $type && 'person' === ($data['type'] ?? null) && isset($data['id']); + } +} diff --git a/src/Bundle/ChillPersonBundle/Service/DocGenerator/PersonContext.php b/src/Bundle/ChillPersonBundle/Service/DocGenerator/PersonContext.php index b17dba81f..e81dbf34e 100644 --- a/src/Bundle/ChillPersonBundle/Service/DocGenerator/PersonContext.php +++ b/src/Bundle/ChillPersonBundle/Service/DocGenerator/PersonContext.php @@ -168,9 +168,8 @@ final readonly class PersonContext implements PersonContextInterface if ($this->isScopeNecessary($entity)) { $builder->add('scope', ScopePickerType::class, [ - 'center' => $this->centerResolverManager->resolveCenters($entity), 'role' => PersonDocumentVoter::CREATE, - 'label' => 'Scope', + 'subject' => $entity, ]); } } diff --git a/src/Bundle/ChillPersonBundle/Service/SocialWork/SocialActionCSVExportService.php b/src/Bundle/ChillPersonBundle/Service/SocialWork/SocialActionCSVExportService.php index 09b4442b2..71c548dd1 100644 --- a/src/Bundle/ChillPersonBundle/Service/SocialWork/SocialActionCSVExportService.php +++ b/src/Bundle/ChillPersonBundle/Service/SocialWork/SocialActionCSVExportService.php @@ -42,28 +42,41 @@ final readonly class SocialActionCSVExportService $csv->insertOne($headers); foreach ($actions as $action) { - if ($action->getGoals()->isEmpty() && $action->getResults()->isEmpty() && $action->getEvaluations()->isEmpty()) { + $hasGoals = !$action->getGoals()->isEmpty(); + $hasResults = !$action->getResults()->isEmpty(); + $hasEvaluations = !$action->getEvaluations()->isEmpty(); + + // If action has no goals, results, or evaluations, insert a single row + if (!$hasGoals && !$hasResults && !$hasEvaluations) { $csv->insertOne($this->formatRow($action)); + continue; } - foreach ($action->getGoals() as $goal) { - if ($goal->getResults()->isEmpty()) { - $csv->insertOne($this->formatRow($action, $goal)); - } - - foreach ($goal->getResults() as $goalResult) { - $csv->insertOne($this->formatRow($action, $goal, $goalResult)); + // Process goals and their results + if ($hasGoals) { + foreach ($action->getGoals() as $goal) { + if ($goal->getResults()->isEmpty()) { + $csv->insertOne($this->formatRow($action, $goal)); + } else { + foreach ($goal->getResults() as $goalResult) { + $csv->insertOne($this->formatRow($action, $goal, $goalResult)); + } + } } } - foreach ($action->getResults() as $result) { - if ($result->getGoals()->isEmpty()) { + // Process results that are linked to this action (regardless of whether they have goals elsewhere) + if ($hasResults && !$hasGoals) { + foreach ($action->getResults() as $result) { $csv->insertOne($this->formatRow($action, null, null, $result)); } } - foreach ($action->getEvaluations() as $evaluation) { - $csv->insertOne($this->formatRow($action, evaluation: $evaluation)); + // Process evaluations + if ($hasEvaluations) { + foreach ($action->getEvaluations() as $evaluation) { + $csv->insertOne($this->formatRow($action, evaluation: $evaluation)); + } } } diff --git a/src/Bundle/ChillPersonBundle/Tests/Action/PersonEdit/Service/PersonEditDTOFactoryTest.php b/src/Bundle/ChillPersonBundle/Tests/Action/PersonEdit/Service/PersonEditDTOFactoryTest.php new file mode 100644 index 000000000..28241b14a --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Tests/Action/PersonEdit/Service/PersonEditDTOFactoryTest.php @@ -0,0 +1,146 @@ +<?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 Action\PersonEdit\Service; + +use Chill\MainBundle\Entity\Civility; +use Chill\MainBundle\Entity\Country; +use Chill\MainBundle\Entity\Embeddable\CommentEmbeddable; +use Chill\MainBundle\Entity\Gender; +use Chill\MainBundle\Entity\Language; +use Chill\PersonBundle\Actions\PersonEdit\PersonEditDTO; +use Chill\PersonBundle\Actions\PersonEdit\Service\PersonEditDTOFactory; +use Chill\PersonBundle\Config\ConfigPersonAltNamesHelper; +use Chill\PersonBundle\Entity\AdministrativeStatus; +use Chill\PersonBundle\Entity\EmploymentStatus; +use Chill\PersonBundle\Entity\MaritalStatus; +use Chill\PersonBundle\Entity\Person; +use Chill\PersonBundle\Entity\PersonAltName; +use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface; +use Doctrine\Common\Collections\ArrayCollection; +use libphonenumber\PhoneNumber; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; + +/** + * @internal + * + * @coversNothing + */ +class PersonEditDTOFactoryTest extends TestCase +{ + use ProphecyTrait; + + public function testMapPersonEditDTOtoPersonCopiesAllFields(): void + { + $configHelper = $this->createMock(ConfigPersonAltNamesHelper::class); + $identifierManager = $this->createMock(PersonIdentifierManagerInterface::class); + $factory = new PersonEditDTOFactory($configHelper, $identifierManager); + + $dto = new PersonEditDTO(); + $dto->firstName = 'John'; + $dto->lastName = 'Doe'; + $dto->birthdate = new \DateTime('1980-05-10'); + $dto->deathdate = new \DateTimeImmutable('2050-01-01'); + $dto->gender = new Gender(); + $dto->genderComment = new CommentEmbeddable('gender comment'); + $dto->numberOfChildren = 2; + $dto->memo = 'Some memo'; + $dto->employmentStatus = new EmploymentStatus(); + $dto->administrativeStatus = new AdministrativeStatus(); + $dto->placeOfBirth = 'Cityville'; + $dto->contactInfo = 'Some contact info'; + $phone = new PhoneNumber(); + $dto->phonenumber = $phone; + $mobile = new PhoneNumber(); + $dto->mobilenumber = $mobile; + $dto->acceptSms = true; + $dto->otherPhonenumbers = new ArrayCollection(); + $dto->email = 'john.doe@example.org'; + $dto->acceptEmail = true; + $dto->countryOfBirth = new Country(); + $dto->nationality = new Country(); + $dto->spokenLanguages = new ArrayCollection([new Language()]); + $dto->civility = new Civility(); + $dto->maritalStatus = new MaritalStatus(); + $dto->maritalStatusDate = new \DateTime('2010-01-01'); + $dto->maritalStatusComment = new CommentEmbeddable('married'); + $dto->cFData = ['foo' => 'bar']; + + $person = new Person(); + + $factory->mapPersonEditDTOtoPerson($dto, $person); + + self::assertSame('John', $person->getFirstName()); + self::assertSame('Doe', $person->getLastName()); + self::assertSame($dto->birthdate, $person->getBirthdate()); + self::assertSame($dto->deathdate, $person->getDeathdate()); + self::assertSame($dto->gender, $person->getGender()); + self::assertSame($dto->genderComment, $person->getGenderComment()); + self::assertSame($dto->numberOfChildren, $person->getNumberOfChildren()); + self::assertSame('Some memo', $person->getMemo()); + self::assertSame($dto->employmentStatus, $person->getEmploymentStatus()); + self::assertSame($dto->administrativeStatus, $person->getAdministrativeStatus()); + self::assertSame('Cityville', $person->getPlaceOfBirth()); + self::assertSame('Some contact info', $person->getcontactInfo()); + self::assertSame($phone, $person->getPhonenumber()); + self::assertSame($mobile, $person->getMobilenumber()); + self::assertTrue($person->getAcceptSMS()); + self::assertSame($dto->otherPhonenumbers, $person->getOtherPhoneNumbers()); + self::assertSame('john.doe@example.org', $person->getEmail()); + self::assertTrue($person->getAcceptEmail()); + self::assertSame($dto->countryOfBirth, $person->getCountryOfBirth()); + self::assertSame($dto->nationality, $person->getNationality()); + self::assertSame($dto->spokenLanguages, $person->getSpokenLanguages()); + self::assertSame($dto->civility, $person->getCivility()); + self::assertSame($dto->maritalStatus, $person->getMaritalStatus()); + self::assertSame($dto->maritalStatusDate, $person->getMaritalStatusDate()); + self::assertSame($dto->maritalStatusComment, $person->getMaritalStatusComment()); + self::assertSame($dto->cFData, $person->getCFData()); + } + + public function testAltNamesHandlingWithConfigHelper(): void + { + $configHelper = $this->createMock(ConfigPersonAltNamesHelper::class); + $configHelper->method('getChoices')->willReturn([ + 'aka' => ['en' => 'Also Known As'], + 'nickname' => ['en' => 'Nickname'], + ]); + + $identifierManager = $this->createMock(PersonIdentifierManagerInterface::class); + $identifierManager->method('getWorkers')->willReturn([]); + + $factory = new PersonEditDTOFactory($configHelper, $identifierManager); + $person = new Person(); + + $dto = $factory->createPersonEditDTO($person); + + // Assert DTO has two altNames keys from helper + self::assertCount(2, $dto->altNames); + self::assertContainsOnlyInstancesOf(PersonAltName::class, $dto->altNames); + self::assertSame(['aka', 'nickname'], array_keys($dto->altNames)); + self::assertSame(['aka' => 'aka', 'nickname' => 'nickname'], array_map(fn (PersonAltName $altName) => $altName->getKey(), $dto->altNames)); + + // Fill only one label and leave the other empty + $dto->altNames['aka']->setLabel('The Boss'); + // 'nickname' remains empty by default + + // Map DTO back to person + $factory->mapPersonEditDTOtoPerson($dto, $person); + + // Assert only the filled alt name is persisted on the person + $altNames = $person->getAltNames(); + self::assertCount(1, $altNames); + $altNameArray = $altNames->toArray(); + self::assertSame('aka', $altNameArray[0]->getKey()); + self::assertSame('The Boss', $altNameArray[0]->getLabel()); + } +} diff --git a/src/Bundle/ChillPersonBundle/Tests/Controller/PersonIdentifierListApiControllerTest.php b/src/Bundle/ChillPersonBundle/Tests/Controller/PersonIdentifierListApiControllerTest.php new file mode 100644 index 000000000..e4df5446a --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Tests/Controller/PersonIdentifierListApiControllerTest.php @@ -0,0 +1,157 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\PersonBundle\Tests\Controller; + +use Chill\MainBundle\Pagination\PaginatorFactoryInterface; +use Chill\MainBundle\Serializer\Normalizer\CollectionNormalizer; +use Chill\PersonBundle\Controller\PersonIdentifierListApiController; +use Chill\PersonBundle\Entity\Identifier\PersonIdentifier; +use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition; +use Chill\PersonBundle\PersonIdentifier\Normalizer\PersonIdentifierWorkerNormalizer; +use Chill\PersonBundle\PersonIdentifier\PersonIdentifierEngineInterface; +use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface; +use Chill\PersonBundle\PersonIdentifier\PersonIdentifierWorker; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\Serializer; + +/** + * @internal + * + * @coversNothing + */ +class PersonIdentifierListApiControllerTest extends TestCase +{ + use ProphecyTrait; + + public function testListAccessDenied(): void + { + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(false)->shouldBeCalledOnce(); + + $serializer = new Serializer([new PersonIdentifierWorkerNormalizer(), new CollectionNormalizer()], [new JsonEncoder()]); + + $personIdentifierManager = $this->prophesize(PersonIdentifierManagerInterface::class); + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + + $controller = new PersonIdentifierListApiController( + $security->reveal(), + $serializer, + $personIdentifierManager->reveal(), + $paginatorFactory->reveal(), + ); + + $this->expectException(AccessDeniedHttpException::class); + $controller->list(); + } + + public function testListSuccess(): void + { + // Build 3 workers + $engine = new class () implements PersonIdentifierEngineInterface { + public static function getName(): string + { + return 'dummy'; + } + + public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string + { + return null; + } + + public function buildForm(\Symfony\Component\Form\FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void {} + + public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string + { + return ''; + } + + public function isEmpty(PersonIdentifier $identifier): bool + { + return false; + } + + public function validate(PersonIdentifier $identifier, PersonIdentifierDefinition $definition): array + { + return []; + } + + public function getDefaultValue(PersonIdentifierDefinition $definition): array + { + return []; + } + }; + + $definition1 = new PersonIdentifierDefinition(['en' => 'Label 1'], 'dummy'); + $definition2 = new PersonIdentifierDefinition(['en' => 'Label 2'], 'dummy'); + $definition3 = new PersonIdentifierDefinition(['en' => 'Label 3'], 'dummy'); + // simulate persisted ids + $r = new \ReflectionProperty(PersonIdentifierDefinition::class, 'id'); + $r->setAccessible(true); + $r->setValue($definition1, 1); + $r->setValue($definition2, 2); + $r->setValue($definition3, 3); + + $workers = [ + new PersonIdentifierWorker($engine, $definition1), + new PersonIdentifierWorker($engine, $definition2), + new PersonIdentifierWorker($engine, $definition3), + ]; + + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true)->shouldBeCalledOnce(); + + $personIdentifierManager = $this->prophesize(PersonIdentifierManagerInterface::class); + $personIdentifierManager->getWorkers()->willReturn($workers)->shouldBeCalledOnce(); + + $paginator = $this->prophesize(\Chill\MainBundle\Pagination\PaginatorInterface::class); + $paginator->setItemsPerPage(3)->shouldBeCalledOnce(); + $paginator->getCurrentPageFirstItemNumber()->willReturn(0); + $paginator->getItemsPerPage()->willReturn(count($workers)); + $paginator->getTotalItems()->willReturn(count($workers)); + $paginator->hasNextPage()->willReturn(false); + $paginator->hasPreviousPage()->willReturn(false); + + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $paginatorFactory->create(3)->willReturn($paginator->reveal())->shouldBeCalledOnce(); + + $serializer = new Serializer([ + new PersonIdentifierWorkerNormalizer(), + new CollectionNormalizer(), + ], [new JsonEncoder()]); + + $controller = new PersonIdentifierListApiController( + $security->reveal(), + $serializer, + $personIdentifierManager->reveal(), + $paginatorFactory->reveal(), + ); + + $response = $controller->list(); + self::assertSame(200, $response->getStatusCode()); + $body = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR); + self::assertIsArray($body); + self::assertArrayHasKey('count', $body); + self::assertArrayHasKey('pagination', $body); + self::assertArrayHasKey('results', $body); + self::assertSame(3, $body['count']); + self::assertCount(3, $body['results']); + // spot check one item + self::assertSame('person_identifier_worker', $body['results'][0]['type']); + self::assertSame(1, $body['results'][0]['id']); + self::assertSame('dummy', $body['results'][0]['engine']); + self::assertSame(['en' => 'Label 1'], $body['results'][0]['label']); + } +} diff --git a/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Identifier/StringIdentifierValidationTest.php b/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Identifier/StringIdentifierValidationTest.php new file mode 100644 index 000000000..436c15589 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Identifier/StringIdentifierValidationTest.php @@ -0,0 +1,111 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\PersonBundle\Tests\PersonIdentifier\Identifier; + +use Chill\PersonBundle\Entity\Identifier\PersonIdentifier; +use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition; +use Chill\PersonBundle\PersonIdentifier\Identifier\StringIdentifier; +use PHPUnit\Framework\TestCase; + +/** + * @internal + * + * @coversNothing + */ +class StringIdentifierValidationTest extends TestCase +{ + private function makeDefinition(array $data = []): PersonIdentifierDefinition + { + $definition = new PersonIdentifierDefinition(label: ['en' => 'Test'], engine: StringIdentifier::NAME); + if ([] !== $data) { + $definition->setData($data); + } + + return $definition; + } + + private function makeIdentifier(PersonIdentifierDefinition $definition, ?string $content): PersonIdentifier + { + $identifier = new PersonIdentifier($definition); + $identifier->setValue(['content' => $content]); + + return $identifier; + } + + public function testValidateWithoutOptionsHasNoViolations(): void + { + $definition = $this->makeDefinition(); + $identifier = $this->makeIdentifier($definition, 'AB-123'); + + $engine = new StringIdentifier(); + $violations = $engine->validate($identifier, $definition); + + self::assertIsArray($violations); + self::assertCount(0, $violations); + } + + public function testValidateOnlyNumbersOption(): void + { + $definition = $this->makeDefinition(['only_numbers' => true]); + $engine = new StringIdentifier(); + + // valid numeric content + $identifierOk = $this->makeIdentifier($definition, '123456'); + $violationsOk = $engine->validate($identifierOk, $definition); + self::assertCount(0, $violationsOk); + + // invalid alphanumeric content + $identifierBad = $this->makeIdentifier($definition, '12AB'); + $violationsBad = $engine->validate($identifierBad, $definition); + self::assertCount(1, $violationsBad); + self::assertSame('person_identifier.only_number', $violationsBad[0]->message); + self::assertSame('2a3352c0-a2b9-11f0-a767-b7a3f80e52f1', $violationsBad[0]->code); + } + + public function testValidateFixedLengthOption(): void + { + $definition = $this->makeDefinition(['fixed_length' => 5]); + $engine = new StringIdentifier(); + + // valid exact length + $identifierOk = $this->makeIdentifier($definition, 'ABCDE'); + $violationsOk = $engine->validate($identifierOk, $definition); + self::assertCount(0, $violationsOk); + + // invalid length (too short) + $identifierBad = $this->makeIdentifier($definition, 'AB'); + $violationsBad = $engine->validate($identifierBad, $definition); + self::assertCount(1, $violationsBad); + self::assertSame('person_identifier.fixed_length', $violationsBad[0]->message); + self::assertSame('2b02a8fe-a2b9-11f0-bfe5-033300972783', $violationsBad[0]->code); + self::assertSame(['limit' => '5'], $violationsBad[0]->parameters); + } + + public function testValidateOnlyNumbersAndFixedLengthTogether(): void + { + $definition = $this->makeDefinition(['only_numbers' => true, 'fixed_length' => 4]); + $engine = new StringIdentifier(); + + // valid: numeric and correct length + $identifierOk = $this->makeIdentifier($definition, '1234'); + $violationsOk = $engine->validate($identifierOk, $definition); + self::assertCount(0, $violationsOk); + + // invalid: non-numeric and wrong length -> two violations expected + $identifierBad = $this->makeIdentifier($definition, 'AB'); + $violationsBad = $engine->validate($identifierBad, $definition); + self::assertCount(2, $violationsBad); + // Order is defined by implementation: numbers check first, then length + self::assertSame('person_identifier.only_number', $violationsBad[0]->message); + self::assertSame('person_identifier.fixed_length', $violationsBad[1]->message); + } +} diff --git a/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Normalizer/PersonIdentifierWorkerNormalizerTest.php b/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Normalizer/PersonIdentifierWorkerNormalizerTest.php new file mode 100644 index 000000000..c54d360d3 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Normalizer/PersonIdentifierWorkerNormalizerTest.php @@ -0,0 +1,133 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\PersonBundle\Tests\PersonIdentifier\Normalizer; + +use Chill\PersonBundle\Entity\Identifier\PersonIdentifier; +use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition; +use Chill\PersonBundle\PersonIdentifier\Normalizer\PersonIdentifierWorkerNormalizer; +use Chill\PersonBundle\PersonIdentifier\PersonIdentifierEngineInterface; +use Chill\PersonBundle\PersonIdentifier\PersonIdentifierWorker; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Exception\UnexpectedValueException; + +/** + * @internal + * + * @coversNothing + */ +class PersonIdentifierWorkerNormalizerTest extends TestCase +{ + public function testSupportsNormalization(): void + { + $engine = new class () implements PersonIdentifierEngineInterface { + public static function getName(): string + { + return 'dummy'; + } + + public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string + { + return null; + } + + public function buildForm(\Symfony\Component\Form\FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void {} + + public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string + { + return ''; + } + + public function isEmpty(PersonIdentifier $identifier): bool + { + return false; + } + + public function validate(PersonIdentifier $identifier, PersonIdentifierDefinition $definition): array + { + return []; + } + + public function getDefaultValue(PersonIdentifierDefinition $definition): array + { + return []; + } + }; + + $definition = new PersonIdentifierDefinition(label: ['en' => 'SSN'], engine: 'string'); + $worker = new PersonIdentifierWorker($engine, $definition); + + $normalizer = new PersonIdentifierWorkerNormalizer(); + + self::assertTrue($normalizer->supportsNormalization($worker)); + self::assertFalse($normalizer->supportsNormalization(new \stdClass())); + } + + public function testNormalizeReturnsExpectedArray(): void + { + $engine = new class () implements PersonIdentifierEngineInterface { + public static function getName(): string + { + return 'dummy'; + } + + public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string + { + return null; + } + + public function buildForm(\Symfony\Component\Form\FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void {} + + public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string + { + return ''; + } + + public function isEmpty(PersonIdentifier $identifier): bool + { + return false; + } + + public function getDefaultValue(PersonIdentifierDefinition $definition): array + { + return []; + } + + public function validate(PersonIdentifier $identifier, PersonIdentifierDefinition $definition): array + { + return []; + } + }; + + $definition = new PersonIdentifierDefinition(label: ['en' => 'SSN'], engine: 'string'); + $definition->setActive(false); + $worker = new PersonIdentifierWorker($engine, $definition); + + $normalizer = new PersonIdentifierWorkerNormalizer(); + $normalized = $normalizer->normalize($worker); + + self::assertSame([ + 'type' => 'person_identifier_worker', + 'definition_id' => null, + 'engine' => 'string', + 'label' => ['en' => 'SSN'], + 'isActive' => false, + 'presence' => 'ON_EDIT', + ], $normalized); + } + + public function testNormalizeThrowsOnInvalidObject(): void + { + $normalizer = new PersonIdentifierWorkerNormalizer(); + $this->expectException(UnexpectedValueException::class); + $normalizer->normalize(new \stdClass()); + } +} diff --git a/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Rendering/PersonIdRenderingTest.php b/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Rendering/PersonIdRenderingTest.php index c14f85be6..66e4ff3aa 100644 --- a/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Rendering/PersonIdRenderingTest.php +++ b/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Rendering/PersonIdRenderingTest.php @@ -71,6 +71,21 @@ class PersonIdRenderingTest extends TestCase // same behavior as StringIdentifier::renderAsString return $identifier?->getValue()['content'] ?? ''; } + + public function isEmpty(PersonIdentifier $identifier): bool + { + return false; + } + + public function validate(PersonIdentifier $identifier, PersonIdentifierDefinition $definition): array + { + return []; + } + + public function getDefaultValue(PersonIdentifierDefinition $definition): array + { + return []; + } }; return new PersonIdentifierWorker($engine, $definition); diff --git a/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Validator/RequiredIdentifierConstraintValidatorTest.php b/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Validator/RequiredIdentifierConstraintValidatorTest.php new file mode 100644 index 000000000..86201b6fb --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Validator/RequiredIdentifierConstraintValidatorTest.php @@ -0,0 +1,131 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\PersonBundle\Tests\PersonIdentifier\Validator; + +use Chill\PersonBundle\Entity\Identifier\IdentifierPresenceEnum; +use Chill\PersonBundle\Entity\Identifier\PersonIdentifier; +use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition; +use Chill\PersonBundle\PersonIdentifier\PersonIdentifierEngineInterface; +use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface; +use Chill\PersonBundle\PersonIdentifier\PersonIdentifierWorker; +use Chill\PersonBundle\PersonIdentifier\Validator\RequiredIdentifierConstraint; +use Chill\PersonBundle\PersonIdentifier\Validator\RequiredIdentifierConstraintValidator; +use Doctrine\Common\Collections\ArrayCollection; +use PHPUnit\Framework\Attributes\CoversClass; +use Symfony\Component\Validator\Constraints\NotBlank; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; +use Symfony\Component\Validator\Exception\UnexpectedValueException; +use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Prophecy\Argument; + +/** + * @internal + */ +#[CoversClass(RequiredIdentifierConstraintValidator::class)] +final class RequiredIdentifierConstraintValidatorTest extends ConstraintValidatorTestCase +{ + use ProphecyTrait; + + private PersonIdentifierDefinition $requiredDefinition; + + protected function createValidator(): RequiredIdentifierConstraintValidator + { + $this->requiredDefinition = new PersonIdentifierDefinition( + label: ['fr' => 'Identifiant requis'], + engine: 'test.engine', + ); + $this->requiredDefinition->setPresence(IdentifierPresenceEnum::REQUIRED); + $reflection = new \ReflectionClass($this->requiredDefinition); + $id = $reflection->getProperty('id'); + $id->setValue($this->requiredDefinition, 1); + + // Mock only the required methods of the engine used by the validator through the worker + $engineProphecy = $this->prophesize(PersonIdentifierEngineInterface::class); + $engineProphecy->isEmpty(Argument::type(PersonIdentifier::class)) + ->will(function (array $args): bool { + /** @var PersonIdentifier $identifier */ + $identifier = $args[0]; + + return '' === trim($identifier->getValue()['content'] ?? ''); + }); + $engineProphecy->renderAsString(Argument::any(), Argument::any()) + ->will(function (array $args): string { + /** @var PersonIdentifier|null $identifier */ + $identifier = $args[0] ?? null; + + return $identifier?->getValue()['content'] ?? ''; + }); + + $worker = new PersonIdentifierWorker($engineProphecy->reveal(), $this->requiredDefinition); + + // Mock only the required method used by the validator + $managerProphecy = $this->prophesize(PersonIdentifierManagerInterface::class); + $managerProphecy->getWorkers()->willReturn([$worker]); + + return new RequiredIdentifierConstraintValidator($managerProphecy->reveal()); + } + + public function testThrowsOnNonCollectionValue(): void + { + $this->expectException(UnexpectedValueException::class); + $this->validator->validate(new \stdClass(), new RequiredIdentifierConstraint()); + } + + public function testThrowsOnInvalidConstraintType(): void + { + $this->expectException(UnexpectedTypeException::class); + // Provide a valid Collection value so the type check reaches the constraint check + $this->validator->validate(new ArrayCollection(), new NotBlank()); + } + + public function testNoViolationWhenRequiredIdentifierPresentAndNotEmpty(): void + { + $identifier = new PersonIdentifier($this->requiredDefinition); + $identifier->setValue(['content' => 'ABC']); + + $collection = new ArrayCollection([$identifier]); + + $this->validator->validate($collection, new RequiredIdentifierConstraint()); + + $this->assertNoViolation(); + } + + public function testViolationWhenRequiredIdentifierMissing(): void + { + $collection = new ArrayCollection(); + + $this->validator->validate($collection, new RequiredIdentifierConstraint()); + + $this->buildViolation('person_identifier.This identifier must be set') + ->setParameter('{{ value }}', '') + ->setParameter('definition_id', '1') + ->setCode('c08b7b32-947f-11f0-8608-9b8560e9bf05') + ->assertRaised(); + } + + public function testViolationWhenRequiredIdentifierIsEmpty(): void + { + $identifier = new PersonIdentifier($this->requiredDefinition); + $identifier->setValue(['content' => ' ']); + + $collection = new ArrayCollection([$identifier]); + + $this->validator->validate($collection, new RequiredIdentifierConstraint()); + + $this->buildViolation('person_identifier.This identifier must be set') + ->setParameter('{{ value }}', ' ') + ->setParameter('definition_id', '1') + ->setCode('c08b7b32-947f-11f0-8608-9b8560e9bf05') + ->assertRaised(); + } +} diff --git a/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Validator/UniqueIdentifierConstraintValidatorTest.php b/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Validator/UniqueIdentifierConstraintValidatorTest.php new file mode 100644 index 000000000..d872bfc56 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Validator/UniqueIdentifierConstraintValidatorTest.php @@ -0,0 +1,158 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\PersonBundle\Tests\PersonIdentifier\Validator; + +use Chill\PersonBundle\Entity\Identifier\PersonIdentifier; +use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition; +use Chill\PersonBundle\Entity\Person; +use Chill\PersonBundle\PersonIdentifier\Validator\UniqueIdentifierConstraint; +use Chill\PersonBundle\PersonIdentifier\Validator\UniqueIdentifierConstraintValidator; +use Chill\PersonBundle\Repository\Identifier\PersonIdentifierRepository; +use Chill\PersonBundle\Templating\Entity\PersonRenderInterface; +use PHPUnit\Framework\Attributes\CoversClass; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Prophecy\Prophecy\ObjectProphecy; +use Symfony\Component\Validator\Constraints\NotBlank; +use Symfony\Component\Validator\Exception\UnexpectedTypeException; +use Symfony\Component\Validator\Exception\UnexpectedValueException; +use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; + +/** + * @internal + */ +#[CoversClass(UniqueIdentifierConstraintValidator::class)] +final class UniqueIdentifierConstraintValidatorTest extends ConstraintValidatorTestCase +{ + use ProphecyTrait; + + /** + * @var ObjectProphecy|PersonIdentifierRepository + */ + private ObjectProphecy $repository; + + /** + * @var ObjectProphecy|PersonRenderInterface + */ + private ObjectProphecy $personRender; + + protected function setUp(): void + { + $this->repository = $this->prophesize(PersonIdentifierRepository::class); + $this->personRender = $this->prophesize(PersonRenderInterface::class); + parent::setUp(); + } + + protected function createValidator(): UniqueIdentifierConstraintValidator + { + return new UniqueIdentifierConstraintValidator($this->repository->reveal(), $this->personRender->reveal()); + } + + public function testThrowsOnInvalidConstraintType(): void + { + $this->expectException(UnexpectedTypeException::class); + + // Provide a valid value so execution reaches the constraint type check + $definition = new PersonIdentifierDefinition(['en' => 'Test'], 'string'); + $identifier = new PersonIdentifier($definition); + $identifier->setValue(['value' => 'ABC']); + + $this->validator->validate($identifier, new NotBlank()); + } + + public function testThrowsOnInvalidValueType(): void + { + $this->expectException(UnexpectedValueException::class); + $this->validator->validate(new \stdClass(), new UniqueIdentifierConstraint()); + } + + public function testNoViolationWhenNoDuplicate(): void + { + $definition = new PersonIdentifierDefinition(['en' => 'Test'], 'string'); + $identifier = new PersonIdentifier($definition); + $identifier->setValue(['value' => 'UNIQ']); + + // Configure repository mock to return empty array + $this->repository->findByDefinitionAndCanonical($definition, ['value' => 'UNIQ'])->willReturn([]); + + $this->validator->validate($identifier, new UniqueIdentifierConstraint()); + $this->assertNoViolation(); + } + + public function testViolationWhenDuplicateFound(): void + { + $definition = new PersonIdentifierDefinition(['en' => 'SSN'], 'string'); + $reflectionClass = new \ReflectionClass($definition); + $reflectionId = $reflectionClass->getProperty('id'); + $reflectionId->setValue($definition, 1); + + $personA = new Person(); + $personA->setFirstName('Alice')->setLastName('Anderson'); + $personB = new Person(); + $personB->setFirstName('Bob')->setLastName('Brown'); + + $dup1 = new PersonIdentifier($definition); + $dup1->setPerson($personA); + $dup1->setValue(['value' => '123']); + $dup2 = new PersonIdentifier($definition); + $dup2->setPerson($personB); + $dup2->setValue(['value' => '123']); + + // Repository returns duplicates + $this->repository->findByDefinitionAndCanonical($definition, ['value' => '123'])->willReturn([$dup1, $dup2]); + + // Person renderer returns names + $this->personRender->renderString($personA, Argument::type('array'))->willReturn('Alice Anderson'); + $this->personRender->renderString($personB, Argument::type('array'))->willReturn('Bob Brown'); + + $identifier = new PersonIdentifier($definition); + $identifier->setPerson(new Person()); + $identifier->setValue(['value' => '123']); + + $constraint = new UniqueIdentifierConstraint(); + + $this->validator->validate($identifier, $constraint); + + $this->buildViolation($constraint->message) + ->setParameter('{{ persons }}', 'Alice Anderson, Bob Brown') + ->setParameter('definition_id', '1') + ->assertRaised(); + } + + public function testViolationWhenDuplicateFoundButForSamePerson(): void + { + $definition = new PersonIdentifierDefinition(['en' => 'SSN'], 'string'); + $reflectionClass = new \ReflectionClass($definition); + $reflectionId = $reflectionClass->getProperty('id'); + $reflectionId->setValue($definition, 1); + + $personA = new Person(); + $personA->setFirstName('Alice')->setLastName('Anderson'); + + $dup1 = new PersonIdentifier($definition); + $dup1->setPerson($personA); + $dup1->setValue(['value' => '123']); + + // Repository returns duplicates + $this->repository->findByDefinitionAndCanonical($definition, ['value' => '123'])->willReturn([$dup1]); + + $identifier = new PersonIdentifier($definition); + $identifier->setPerson($personA); + $identifier->setValue(['value' => '123']); + + $constraint = new UniqueIdentifierConstraint(); + + $this->validator->validate($identifier, $constraint); + + $this->assertNoViolation(); + } +} diff --git a/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Validator/ValidIdentifierConstraintValidatorTest.php b/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Validator/ValidIdentifierConstraintValidatorTest.php new file mode 100644 index 000000000..9e4abff3b --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Tests/PersonIdentifier/Validator/ValidIdentifierConstraintValidatorTest.php @@ -0,0 +1,115 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\PersonBundle\Tests\PersonIdentifier\Validator; + +use Chill\PersonBundle\Entity\Identifier\PersonIdentifier; +use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition; +use Chill\PersonBundle\PersonIdentifier\IdentifierViolationDTO; +use Chill\PersonBundle\PersonIdentifier\PersonIdentifierEngineInterface; +use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface; +use Chill\PersonBundle\PersonIdentifier\PersonIdentifierWorker; +use Chill\PersonBundle\PersonIdentifier\Validator\ValidIdentifierConstraint; +use Chill\PersonBundle\PersonIdentifier\Validator\ValidIdentifierConstraintValidator; +use PHPUnit\Framework\Attributes\CoversClass; +use Prophecy\PhpUnit\ProphecyTrait; +use Prophecy\Prophecy\ObjectProphecy; +use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; + +/** + * @internal + */ +#[CoversClass(ValidIdentifierConstraintValidator::class)] +final class ValidIdentifierConstraintValidatorTest extends ConstraintValidatorTestCase +{ + use ProphecyTrait; + + /** + * @var ObjectProphecy|PersonIdentifierManagerInterface + */ + private ObjectProphecy $manager; + + protected function setUp(): void + { + $this->manager = $this->prophesize(PersonIdentifierManagerInterface::class); + parent::setUp(); + } + + protected function createValidator(): ValidIdentifierConstraintValidator + { + return new ValidIdentifierConstraintValidator($this->manager->reveal()); + } + + public function testAddsViolationFromWorker(): void + { + $definition = new PersonIdentifierDefinition(['en' => 'SSN'], 'string'); + // set definition id via reflection for definition_id parameter + $ref = new \ReflectionClass($definition); + $prop = $ref->getProperty('id'); + $prop->setValue($definition, 1); + + $identifier = new PersonIdentifier($definition); + $identifier->setValue(['value' => 'bad']); + + $violation = new IdentifierViolationDTO('Invalid Identifier', '0000-1111-2222-3333', ['{{ foo }}' => 'bar']); + + // engine that returns one violation + $engine = new class ([$violation]) implements PersonIdentifierEngineInterface { + public function __construct(private readonly array $violations) {} + + public static function getName(): string + { + return 'dummy'; + } + + public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string + { + return null; + } + + public function buildForm(\Symfony\Component\Form\FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void {} + + public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string + { + return ''; + } + + public function isEmpty(PersonIdentifier $identifier): bool + { + return false; + } + + public function validate(PersonIdentifier $identifier, PersonIdentifierDefinition $definition): array + { + return $this->violations; + } + + public function getDefaultValue(PersonIdentifierDefinition $definition): array + { + return []; + } + }; + $worker = new PersonIdentifierWorker($engine, $definition); + + $this->manager + ->buildWorkerByPersonIdentifierDefinition($definition) + ->willReturn($worker); + + $constraint = new ValidIdentifierConstraint(); + $this->validator->validate($identifier, $constraint); + + $this->buildViolation('Invalid Identifier') + ->setParameters(['{{ foo }}' => 'bar']) + ->setParameter('{{ code }}', '0000-1111-2222-3333') + ->setParameter('definition_id', '1') + ->assertRaised(); + } +} diff --git a/src/Bundle/ChillPersonBundle/Tests/Repository/Identifier/PersonIdentifierRepositoryTest.php b/src/Bundle/ChillPersonBundle/Tests/Repository/Identifier/PersonIdentifierRepositoryTest.php new file mode 100644 index 000000000..ab36fd872 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Tests/Repository/Identifier/PersonIdentifierRepositoryTest.php @@ -0,0 +1,76 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\PersonBundle\Tests\Repository\Identifier; + +use Chill\PersonBundle\Entity\Identifier\PersonIdentifier; +use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition; +use Chill\PersonBundle\Entity\Person; +use Chill\PersonBundle\PersonIdentifier\Identifier\StringIdentifier; +use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface; +use Chill\PersonBundle\Repository\Identifier\PersonIdentifierRepository; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; + +/** + * @internal + * + * @coversNothing + */ +class PersonIdentifierRepositoryTest extends KernelTestCase +{ + public function testFindByDefinitionAndCanonical(): void + { + self::bootKernel(); + $container = self::getContainer(); + /** @var PersonIdentifierManagerInterface $personIdentifierManager */ + $personIdentifierManager = $container->get(PersonIdentifierManagerInterface::class); + + /** @var EntityManagerInterface $em */ + $em = $container->get(EntityManagerInterface::class); + + // Get a random existing person from fixtures + /** @var Person|null $person */ + $person = $em->getRepository(Person::class)->findOneBy([]); + self::assertNotNull($person, 'An existing Person is required for this integration test.'); + + // Create a definition + $definition = new PersonIdentifierDefinition(['en' => 'Test Identifier'], StringIdentifier::NAME); + $em->persist($definition); + $em->flush(); + + // Create an identifier attached to the person + $value = ['content' => 'ABC-'.bin2hex(random_bytes(4))]; + $identifier = new PersonIdentifier($definition); + $identifier->setPerson($person); + $identifier->setValue($value); + $identifier->setCanonical($personIdentifierManager->buildWorkerByPersonIdentifierDefinition($definition)->canonicalizeValue($identifier->getValue())); + $em->persist($identifier); + $em->flush(); + + // Use the repository to find by definition and value + /** @var PersonIdentifierRepository $repo */ + $repo = $container->get(PersonIdentifierRepository::class); + $results = $repo->findByDefinitionAndCanonical($definition, $value); + + self::assertNotEmpty($results, 'Repository should return at least one result.'); + self::assertContainsOnlyInstancesOf(PersonIdentifier::class, $results); + self::assertTrue(in_array($identifier->getId(), array_map(static fn (PersonIdentifier $pi) => $pi->getId(), $results), true)); + + // Cleanup + foreach ($results as $res) { + $em->remove($res); + } + $em->flush(); + $em->remove($definition); + $em->flush(); + } +} diff --git a/src/Bundle/ChillPersonBundle/Tests/Repository/PersonACLAwareRepositoryTest.php b/src/Bundle/ChillPersonBundle/Tests/Repository/PersonACLAwareRepositoryTest.php index 0d2ea6eb3..7cf12a2ae 100644 --- a/src/Bundle/ChillPersonBundle/Tests/Repository/PersonACLAwareRepositoryTest.php +++ b/src/Bundle/ChillPersonBundle/Tests/Repository/PersonACLAwareRepositoryTest.php @@ -11,14 +11,18 @@ declare(strict_types=1); namespace Chill\PersonBundle\Tests\Repository; +use Chill\MainBundle\Entity\Center; use Chill\MainBundle\Entity\User; use Chill\MainBundle\Repository\CenterRepositoryInterface; use Chill\MainBundle\Repository\CountryRepository; use Chill\MainBundle\Security\Authorization\AuthorizationHelperInterface; use Chill\PersonBundle\Entity\Person; +use Chill\PersonBundle\Entity\PersonPhone; +use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface; use Chill\PersonBundle\Repository\PersonACLAwareRepository; use Chill\PersonBundle\Security\Authorization\PersonVoter; use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\Attributes\DataProvider; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; @@ -39,6 +43,8 @@ final class PersonACLAwareRepositoryTest extends KernelTestCase private EntityManagerInterface $entityManager; + private PersonIdentifierManagerInterface $personIdentifierManager; + protected function setUp(): void { self::bootKernel(); @@ -46,6 +52,8 @@ final class PersonACLAwareRepositoryTest extends KernelTestCase $this->entityManager = self::getContainer()->get(EntityManagerInterface::class); $this->countryRepository = self::getContainer()->get(CountryRepository::class); $this->centerRepository = self::getContainer()->get(CenterRepositoryInterface::class); + $this->personIdentifierManager = self::getContainer()->get(PersonIdentifierManagerInterface::class); + } public function testCountByCriteria(): void @@ -63,7 +71,8 @@ final class PersonACLAwareRepositoryTest extends KernelTestCase $security->reveal(), $this->entityManager, $this->countryRepository, - $authorizationHelper->reveal() + $authorizationHelper->reveal(), + $this->personIdentifierManager, ); $number = $repository->countBySearchCriteria('diallo'); @@ -86,7 +95,8 @@ final class PersonACLAwareRepositoryTest extends KernelTestCase $security->reveal(), $this->entityManager, $this->countryRepository, - $authorizationHelper->reveal() + $authorizationHelper->reveal(), + $this->personIdentifierManager, ); $results = $repository->findBySearchCriteria(0, 5, false, 'diallo'); @@ -98,4 +108,68 @@ final class PersonACLAwareRepositoryTest extends KernelTestCase $this->assertStringContainsString('diallo', strtolower($person->getFirstName().' '.$person->getLastName())); } } + + /** + * @dataProvider providePersonsWithPhoneNumbers + */ + public function testFindByPhonenumber(\libphonenumber\PhoneNumber $phoneNumber, ?int $expectedId): void + { + $user = new User(); + + $authorizationHelper = $this->prophesize(AuthorizationHelperInterface::class); + $authorizationHelper->getReachableCenters(Argument::exact($user), Argument::exact(PersonVoter::SEE)) + ->willReturn($this->centerRepository->findAll()); + + $security = $this->prophesize(Security::class); + $security->getUser()->willReturn($user); + + $repository = new PersonACLAwareRepository( + $security->reveal(), + $this->entityManager, + $this->countryRepository, + $authorizationHelper->reveal(), + $this->personIdentifierManager, + ); + + $actual = $repository->findByPhone($phoneNumber, 0, 10); + + if (null === $expectedId) { + self::assertCount(0, $actual); + } else { + $actualIds = array_map(fn (Person $person) => $person->getId(), $actual); + + self::assertContains($expectedId, $actualIds); + } + } + + public static function providePersonsWithPhoneNumbers(): iterable + { + self::bootKernel(); + $em = self::getContainer()->get(EntityManagerInterface::class); + $center = $em->createQuery('SELECT c FROM '.Center::class.' c ')->setMaxResults(1) + ->getSingleResult(); + $util = \libphonenumber\PhoneNumberUtil::getInstance(); + + $mobile = $util->parse('+32486123456'); + $fixed = $util->parse('+3281136917'); + $anotherMobile = $util->parse('+32486123478'); + $person = (new Person())->setFirstName('diallo')->setLastName('diallo')->setCenter($center); + $person->setMobilenumber($mobile)->setPhonenumber($fixed); + $otherPhone = new PersonPhone(); + $otherPhone->setPerson($person); + $otherPhone->setPhonenumber($anotherMobile); + $otherPhone->setType('mobile'); + + $em->persist($person); + $em->persist($otherPhone); + + $em->flush(); + + self::ensureKernelShutdown(); + + yield [$mobile, $person->getId()]; + yield [$anotherMobile, $person->getId()]; + yield [$fixed, $person->getId()]; + yield [$util->parse('+331234567890'), null]; + } } diff --git a/src/Bundle/ChillPersonBundle/Tests/Serializer/Normalizer/PersonJsonDenormalizerTest.php b/src/Bundle/ChillPersonBundle/Tests/Serializer/Normalizer/PersonJsonDenormalizerTest.php new file mode 100644 index 000000000..aef5ee584 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Tests/Serializer/Normalizer/PersonJsonDenormalizerTest.php @@ -0,0 +1,312 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\PersonBundle\Tests\Serializer\Normalizer; + +use Chill\MainBundle\Entity\Center; +use Chill\MainBundle\Entity\Civility; +use Chill\MainBundle\Entity\Gender; +use Chill\PersonBundle\Entity\Identifier\PersonIdentifier; +use Chill\PersonBundle\Entity\Identifier\PersonIdentifierDefinition; +use Chill\PersonBundle\Entity\Person; +use Chill\PersonBundle\PersonIdentifier\PersonIdentifierEngineInterface; +use Chill\PersonBundle\PersonIdentifier\PersonIdentifierManagerInterface; +use Chill\PersonBundle\PersonIdentifier\PersonIdentifierWorker; +use Chill\PersonBundle\Serializer\Normalizer\PersonJsonDenormalizer; +use libphonenumber\PhoneNumber; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; + +/** + * @internal + * + * @covers \Chill\PersonBundle\Serializer\Normalizer\PersonJsonDenormalizer + */ +final class PersonJsonDenormalizerTest extends TestCase +{ + private function createIdentifierManager(): PersonIdentifierManagerInterface + { + return new class () implements PersonIdentifierManagerInterface { + public function getWorkers(): array + { + return []; + } + + public function buildWorkerByPersonIdentifierDefinition(int|PersonIdentifierDefinition $personIdentifierDefinition): PersonIdentifierWorker + { + if (is_int($personIdentifierDefinition)) { + $definition = new PersonIdentifierDefinition(['en' => 'Test'], 'dummy'); + // Force the id for testing purposes + $r = new \ReflectionProperty(PersonIdentifierDefinition::class, 'id'); + $r->setAccessible(true); + $r->setValue($definition, $personIdentifierDefinition); + } else { + $definition = $personIdentifierDefinition; + } + + $engine = new class () implements PersonIdentifierEngineInterface { + public static function getName(): string + { + return 'dummy'; + } + + public function canonicalizeValue(array $value, PersonIdentifierDefinition $definition): ?string + { + // trivial canonicalization for tests + return isset($value['content']) ? (string) $value['content'] : null; + } + + public function buildForm(\Symfony\Component\Form\FormBuilderInterface $builder, PersonIdentifierDefinition $personIdentifierDefinition): void {} + + public function renderAsString(?PersonIdentifier $identifier, PersonIdentifierDefinition $definition): string + { + return ''; + } + + public function isEmpty(PersonIdentifier $identifier): bool + { + $value = $identifier->getValue(); + $content = isset($value['content']) ? trim((string) $value['content']) : ''; + + return '' === $content; + } + + public function validate(PersonIdentifier $identifier, PersonIdentifierDefinition $definition): array + { + return []; + } + + public function getDefaultValue(PersonIdentifierDefinition $definition): array + { + return []; + } + }; + + return new PersonIdentifierWorker($engine, $definition); + } + }; + } + + public function testSupportsDenormalizationReturnsTrueForValidData(): void + { + $denormalizer = new PersonJsonDenormalizer($this->createIdentifierManager()); + + $data = [ + 'type' => 'person', + // important: new Person (creation) must not contain an id + ]; + + self::assertTrue($denormalizer->supportsDenormalization($data, Person::class)); + } + + public function testSupportsDenormalizationReturnsFalseForInvalidData(): void + { + $denormalizer = new PersonJsonDenormalizer($this->createIdentifierManager()); + + // not an array + self::assertFalse($denormalizer->supportsDenormalization('not-an-array', Person::class)); + + // missing type + self::assertFalse($denormalizer->supportsDenormalization([], Person::class)); + + // wrong type value + self::assertFalse($denormalizer->supportsDenormalization(['type' => 'not-person'], Person::class)); + + // id present means it's not a create payload for this denormalizer + self::assertFalse($denormalizer->supportsDenormalization(['type' => 'person', 'id' => 123], Person::class)); + + // wrong target class + self::assertFalse($denormalizer->supportsDenormalization(['type' => 'person'], \stdClass::class)); + } + + public function testDenormalizeMapsPayloadToPersonProperties(): void + { + $json = <<<'JSON' + { + "type": "person", + "firstName": "Jérome", + "lastName": "diallo", + "altNames": [ + { + "key": "jeune_fille", + "value": "FJ" + } + ], + "birthdate": null, + "deathdate": null, + "phonenumber": "", + "mobilenumber": "", + "email": "", + "gender": { + "id": 5, + "type": "chill_main_gender" + }, + "center": { + "id": 1, + "type": "center" + }, + "civility": null, + "identifiers": [ + { + "type": "person_identifier", + "value": { + "content": "789456" + }, + "definition_id": 5 + } + ] + } + JSON; + $data = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + + $inner = new class () implements DenormalizerInterface { + public ?Gender $gender = null; + public ?Center $center = null; + + public function denormalize($data, $type, $format = null, array $context = []) + { + if (PhoneNumber::class === $type) { + return '' === $data ? null : new PhoneNumber(); + } + if (\DateTime::class === $type || \DateTimeImmutable::class === $type) { + return null === $data ? null : new \DateTimeImmutable((string) $data); + } + if (Gender::class === $type) { + return $this->gender ??= new Gender(); + } + if (Center::class === $type) { + return $this->center ??= new Center(); + } + if (Civility::class === $type) { + return null; // input is null in our payload + } + + return null; + } + + public function supportsDenormalization($data, $type, $format = null) + { + return true; + } + }; + + $denormalizer = new PersonJsonDenormalizer($this->createIdentifierManager()); + $denormalizer->setDenormalizer($inner); + + $person = $denormalizer->denormalize($data, Person::class); + + self::assertInstanceOf(Person::class, $person); + self::assertSame('Jérome', $person->getFirstName()); + self::assertSame('diallo', $person->getLastName()); + + // phone numbers: empty strings map to null via the inner denormalizer stub + self::assertNull($person->getPhonenumber()); + self::assertNull($person->getMobilenumber()); + + // email passes through as is + self::assertSame('', $person->getEmail()); + + // nested objects are provided by our inner denormalizer and must be set back on the Person + self::assertSame($inner->gender, $person->getGender()); + self::assertSame($inner->center, $person->getCenter()); + + // dates are null in the provided payload + self::assertNull($person->getBirthdate()); + self::assertNull($person->getDeathdate()); + + // civility is null as provided + self::assertNull($person->getCivility()); + + // altNames: make sure the alt name with key jeune_fille has label FJ + $found = false; + foreach ($person->getAltNames() as $altName) { + if ('jeune_fille' === $altName->getKey()) { + $found = true; + self::assertSame('FJ', $altName->getLabel()); + } + } + self::assertTrue($found, 'Expected altNames to contain key "jeune_fille"'); + + $found = false; + foreach ($person->getIdentifiers() as $identifier) { + if (5 === $identifier->getDefinition()->getId()) { + $found = true; + self::assertSame(['content' => '789456'], $identifier->getValue()); + } + } + self::assertTrue($found, 'Expected identifiers with definition id 5'); + } + + public function testDenormalizeRemovesEmptyIdentifier(): void + { + $data = [ + 'type' => 'person', + 'firstName' => 'Alice', + 'lastName' => 'Smith', + 'identifiers' => [ + [ + 'type' => 'person_identifier', + 'value' => ['content' => ''], + 'definition_id' => 7, + ], + ], + ]; + + $denormalizer = new PersonJsonDenormalizer($this->createIdentifierManager()); + + $person = $denormalizer->denormalize($data, Person::class); + + // The identifier with empty content must be considered empty and removed + self::assertSame(0, $person->getIdentifiers()->count(), 'Expected no identifiers to remain on the person'); + } + + public function testDenormalizeRemovesPreviouslyExistingIdentifierWhenIncomingValueIsEmpty(): void + { + // Prepare an existing Person with a pre-existing identifier (definition id = 9) + $definition = new PersonIdentifierDefinition(['en' => 'Test'], 'dummy'); + $ref = new \ReflectionProperty(PersonIdentifierDefinition::class, 'id'); + $ref->setValue($definition, 9); + + $existingIdentifier = new PersonIdentifier($definition); + $existingIdentifier->setValue(['content' => 'ABC']); + + $person = new Person(); + $person->addIdentifier($existingIdentifier); + + // Also set the identifier's own id = 9 so that the denormalizer logic matches it + // (the current denormalizer matches by PersonIdentifier->getId() === definition_id) + $refId = new \ReflectionProperty(PersonIdentifier::class, 'id'); + $refId->setValue($existingIdentifier, 9); + + // Incoming payload sets the same definition id with an empty value + $data = [ + 'type' => 'person', + 'identifiers' => [ + [ + 'type' => 'person_identifier', + 'value' => ['content' => ''], + 'definition_id' => 9, + ], + ], + ]; + + $denormalizer = new PersonJsonDenormalizer($this->createIdentifierManager()); + + // Use AbstractNormalizer::OBJECT_TO_POPULATE to update the existing person + $result = $denormalizer->denormalize($data, Person::class, null, [ + AbstractNormalizer::OBJECT_TO_POPULATE => $person, + ]); + + self::assertSame($person, $result, 'Denormalizer should update and return the provided Person instance'); + self::assertSame(0, $person->getIdentifiers()->count(), 'The previously existing identifier should be removed when incoming value is empty'); + } +} diff --git a/src/Bundle/ChillPersonBundle/Tests/Serializer/Normalizer/PersonJsonNormalizerIntegrationTest.php b/src/Bundle/ChillPersonBundle/Tests/Serializer/Normalizer/PersonJsonNormalizerIntegrationTest.php new file mode 100644 index 000000000..3fff8aa97 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Tests/Serializer/Normalizer/PersonJsonNormalizerIntegrationTest.php @@ -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 Serializer\Normalizer; + +use Chill\PersonBundle\Entity\Person; +use Chill\PersonBundle\Repository\PersonRepository; +use PHPUnit\Framework\Assert; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\Serializer\SerializerInterface; + +/** + * @internal + * + * @coversNothing + */ +final class PersonJsonNormalizerIntegrationTest extends KernelTestCase +{ + public function testNormalizeExistingPersonFromDatabase(): void + { + self::bootKernel(); + $container = self::getContainer(); + + /** @var PersonRepository $repo */ + $repo = $container->get(PersonRepository::class); + $person = $repo->findOneBy([]); + + if (!$person instanceof Person) { + self::markTestSkipped('No person found in test database. Load fixtures to enable this test.'); + } + + /** @var SerializerInterface $serializer */ + $serializer = $container->get(SerializerInterface::class); + + // Should not throw + $data = $serializer->normalize($person, 'json'); + Assert::assertIsArray($data); + + // Spot check some expected keys exist + foreach ([ + 'type', 'id', 'text', 'textAge', 'firstName', 'lastName', 'birthdate', 'age', 'gender', 'civility', + ] as $key) { + Assert::assertArrayHasKey($key, $data, sprintf('Expected key %s in normalized payload', $key)); + } + + // Minimal group should also work + $minimal = $serializer->normalize($person, 'json', ['groups' => 'minimal']); + Assert::assertIsArray($minimal); + foreach ([ + 'type', 'id', 'text', 'textAge', 'firstName', 'lastName', + ] as $key) { + Assert::assertArrayHasKey($key, $minimal, sprintf('Expected key %s in minimal normalized payload', $key)); + } + } +} diff --git a/src/Bundle/ChillPersonBundle/Tests/Serializer/Normalizer/PersonJsonNormalizerTest.php b/src/Bundle/ChillPersonBundle/Tests/Serializer/Normalizer/PersonJsonNormalizerTest.php index 3463e3840..41aadf4fb 100644 --- a/src/Bundle/ChillPersonBundle/Tests/Serializer/Normalizer/PersonJsonNormalizerTest.php +++ b/src/Bundle/ChillPersonBundle/Tests/Serializer/Normalizer/PersonJsonNormalizerTest.php @@ -11,74 +11,186 @@ declare(strict_types=1); namespace Serializer\Normalizer; +use Chill\MainBundle\Entity\Center; use Chill\MainBundle\Phonenumber\PhoneNumberHelperInterface; use Chill\MainBundle\Security\Resolver\CenterResolverManagerInterface; use Chill\MainBundle\Templating\Entity\ChillEntityRenderExtension; use Chill\PersonBundle\Entity\Person; -use Chill\PersonBundle\Repository\PersonRepository; +use Chill\PersonBundle\Entity\PersonAltName; use Chill\PersonBundle\Repository\ResidentialAddressRepository; use Chill\PersonBundle\Serializer\Normalizer\PersonJsonNormalizer; +use Chill\PersonBundle\PersonIdentifier\Rendering\PersonIdRenderingInterface; +use Doctrine\Common\Collections\ArrayCollection; +use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; -use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; /** * @internal * - * @coversNothing + * @covers \Chill\PersonBundle\Serializer\Normalizer\PersonJsonNormalizer */ -final class PersonJsonNormalizerTest extends KernelTestCase +final class PersonJsonNormalizerTest extends TestCase { use ProphecyTrait; - private PersonJsonNormalizer $normalizer; - - protected function setUp(): void + public function testSupportsNormalization(): void { - self::bootKernel(); + $normalizer = $this->createNormalizer(); - $residentialAddressRepository = $this->prophesize(ResidentialAddressRepository::class); - $residentialAddressRepository - ->findCurrentResidentialAddressByPerson(Argument::type(Person::class), Argument::any()) - ->willReturn([]); - - $this->normalizer = $this->buildPersonJsonNormalizer( - self::getContainer()->get(ChillEntityRenderExtension::class), - self::getContainer()->get(PersonRepository::class), - self::getContainer()->get(CenterResolverManagerInterface::class), - $residentialAddressRepository->reveal(), - self::getContainer()->get(PhoneNumberHelperInterface::class), - self::getContainer()->get(NormalizerInterface::class) - ); + self::assertTrue($normalizer->supportsNormalization(new Person(), 'json')); + self::assertFalse($normalizer->supportsNormalization(new \stdClass(), 'json')); + self::assertFalse($normalizer->supportsNormalization(new Person(), 'xml')); } - public function testNormalization(): void + public function testNormalizeWithMinimalGroupReturnsOnlyBaseKeys(): void { - $person = new Person(); - $result = $this->normalizer->normalize($person, 'json', [AbstractNormalizer::GROUPS => ['read']]); + $person = $this->createSamplePerson(); - $this->assertIsArray($result); + $normalizer = $this->createNormalizer(); + $data = $normalizer->normalize($person, 'json', [AbstractNormalizer::GROUPS => 'minimal']); + + // Expected base keys + $expectedKeys = [ + 'type', + 'id', + 'text', + 'textAge', + 'firstName', + 'lastName', + 'current_household_address', + 'birthdate', + 'deathdate', + 'age', + 'phonenumber', + 'mobilenumber', + 'email', + 'gender', + 'civility', + 'personId', + ]; + + foreach ($expectedKeys as $key) { + self::assertArrayHasKey($key, $data, sprintf('Key %s should be present', $key)); + } + self::assertSame('PERSON-ID-RENDER', $data['personId']); + + // Ensure extended keys are not present in minimal mode + foreach (['centers', 'altNames', 'current_household_id', 'current_residential_addresses'] as $key) { + self::assertArrayNotHasKey($key, $data, sprintf('Key %s should NOT be present in minimal group', $key)); + } } - private function buildPersonJsonNormalizer( - ChillEntityRenderExtension $render, - PersonRepository $repository, - CenterResolverManagerInterface $centerResolverManager, - ResidentialAddressRepository $residentialAddressRepository, - PhoneNumberHelperInterface $phoneNumberHelper, - NormalizerInterface $normalizer, - ): PersonJsonNormalizer { - $personJsonNormalizer = new PersonJsonNormalizer( - $render, - $repository, - $centerResolverManager, - $residentialAddressRepository, - $phoneNumberHelper - ); - $personJsonNormalizer->setNormalizer($normalizer); + public function testNormalizeWithoutGroupsIncludesExtendedKeys(): void + { + $person = $this->createSamplePerson(withAltNames: true); - return $personJsonNormalizer; + $center1 = (new Center())->setName('c1'); + $center2 = (new Center())->setName('c2'); + + + $normalizer = $this->createNormalizer( + centers: [$center1, $center2], + currentResidentialAddresses: [['addr' => 1]], + ); + + $data = $normalizer->normalize($person, 'json'); + + // Base keys + $baseKeys = [ + 'type', 'id', 'text', 'textAge', 'firstName', 'lastName', 'current_household_address', 'birthdate', 'deathdate', 'age', 'phonenumber', 'mobilenumber', 'email', 'gender', 'civility', 'personId', + ]; + foreach ($baseKeys as $key) { + self::assertArrayHasKey($key, $data, sprintf('Key %s should be present', $key)); + } + + // Extended keys + foreach (['centers', 'altNames', 'current_household_id', 'current_residential_addresses'] as $key) { + self::assertArrayHasKey($key, $data, sprintf('Key %s should be present', $key)); + } + + self::assertSame(['c1', 'c2'], $data['centers']); + self::assertIsArray($data['altNames']); + self::assertSame([['key' => 'aka', 'label' => 'Johnny']], $data['altNames']); + self::assertNull($data['current_household_id'], 'No household set so id should be null'); + self::assertSame([['addr' => 1]], $data['current_residential_addresses']); + } + + private function createNormalizer(array $centers = [], array $currentResidentialAddresses = []): PersonJsonNormalizer + { + $render = $this->prophesize(ChillEntityRenderExtension::class); + $render->renderString(Argument::type(Person::class), ['addAge' => false])->willReturn('John Doe'); + $render->renderString(Argument::type(Person::class), ['addAge' => true])->willReturn('John Doe (25)'); + + $centerResolver = $this->prophesize(CenterResolverManagerInterface::class); + $centerResolver->resolveCenters(Argument::type(Person::class))->willReturn($centers); + + $raRepo = $this->prophesize(ResidentialAddressRepository::class); + $raRepo->findCurrentResidentialAddressByPerson(Argument::type(Person::class))->willReturn($currentResidentialAddresses); + + $phoneHelper = $this->prophesize(PhoneNumberHelperInterface::class); + + $personIdRendering = $this->prophesize(PersonIdRenderingInterface::class); + $personIdRendering->renderPersonId(Argument::type(Person::class))->willReturn('PERSON-ID-RENDER'); + + $normalizer = new PersonJsonNormalizer( + $render->reveal(), + $centerResolver->reveal(), + $raRepo->reveal(), + $phoneHelper->reveal(), + $personIdRendering->reveal(), + ); + + // Inner normalizer that echoes values or simple conversions + $inner = new class () implements NormalizerInterface { + public function supportsNormalization($data, $format = null): bool + { + return true; + } + + public function normalize($object, $format = null, array $context = []) + { + // For scalars and arrays, return as-is; for objects, return string or id when possible + if (\is_scalar($object) || null === $object) { + return $object; + } + if ($object instanceof \DateTimeInterface) { + return $object->format('Y-m-d'); + } + if ($object instanceof Center) { + return $object->getName(); + } + if (is_array($object)) { + return array_map(fn ($o) => $this->normalize($o, $format, $context), $object); + } + + // default stub + return (string) (method_exists($object, 'getId') ? $object->getId() : 'normalized'); + } + }; + + $normalizer->setNormalizer($inner); + + return $normalizer; + } + + private function createSamplePerson(bool $withAltNames = false): Person + { + $p = new Person(); + $p->setFirstName('John'); + $p->setLastName('Doe'); + $p->setBirthdate(new \DateTime('2000-01-01')); + $p->setEmail('john@example.test'); + + if ($withAltNames) { + $alt = new PersonAltName(); + $alt->setKey('aka'); + $alt->setLabel('Johnny'); + $p->setAltNames(new ArrayCollection([$alt])); + } + + return $p; } } diff --git a/src/Bundle/ChillPersonBundle/Tests/Serializer/Normalizer/PersonJsonReadDenormalizerTest.php b/src/Bundle/ChillPersonBundle/Tests/Serializer/Normalizer/PersonJsonReadDenormalizerTest.php new file mode 100644 index 000000000..e019a41c6 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/Tests/Serializer/Normalizer/PersonJsonReadDenormalizerTest.php @@ -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\PersonBundle\Tests\Serializer\Normalizer; + +use Chill\PersonBundle\Entity\Person; +use Chill\PersonBundle\Repository\PersonRepository; +use Chill\PersonBundle\Serializer\Normalizer\PersonJsonReadDenormalizer; +use PHPUnit\Framework\TestCase; + +/** + * @internal + * + * @covers \Chill\PersonBundle\Serializer\Normalizer\PersonJsonReadDenormalizer + */ +final class PersonJsonReadDenormalizerTest extends TestCase +{ + public function testSupportsDenormalizationReturnsTrueForValidData(): void + { + $repository = $this->getMockBuilder(PersonRepository::class) + ->disableOriginalConstructor() + ->getMock(); + + $denormalizer = new PersonJsonReadDenormalizer($repository); + + $data = [ + 'type' => 'person', + 'id' => 123, + ]; + + self::assertTrue($denormalizer->supportsDenormalization($data, Person::class)); + } + + public function testSupportsDenormalizationReturnsFalseForInvalidData(): void + { + $repository = $this->getMockBuilder(PersonRepository::class) + ->disableOriginalConstructor() + ->getMock(); + + $denormalizer = new PersonJsonReadDenormalizer($repository); + + // not an array + self::assertFalse($denormalizer->supportsDenormalization('not-an-array', Person::class)); + + // missing type + self::assertFalse($denormalizer->supportsDenormalization(['id' => 1], Person::class)); + + // wrong type value + self::assertFalse($denormalizer->supportsDenormalization(['type' => 'not-person', 'id' => 1], Person::class)); + + // missing id + self::assertFalse($denormalizer->supportsDenormalization(['type' => 'person'], Person::class)); + + // wrong target class + self::assertFalse($denormalizer->supportsDenormalization(['type' => 'person', 'id' => 1], \stdClass::class)); + } + + public function testDenormalizeReturnsPersonFromRepository(): void + { + $person = new Person(); + + $repository = $this->getMockBuilder(PersonRepository::class) + ->disableOriginalConstructor() + ->onlyMethods(['find']) + ->getMock(); + + $repository->expects(self::once()) + ->method('find') + ->with(123) + ->willReturn($person); + + $denormalizer = new PersonJsonReadDenormalizer($repository); + + $result = $denormalizer->denormalize(['id' => 123], Person::class); + + self::assertSame($person, $result); + } +} diff --git a/src/Bundle/ChillPersonBundle/chill.api.specs.yaml b/src/Bundle/ChillPersonBundle/chill.api.specs.yaml index c5f244525..1d1a2b1e2 100644 --- a/src/Bundle/ChillPersonBundle/chill.api.specs.yaml +++ b/src/Bundle/ChillPersonBundle/chill.api.specs.yaml @@ -1,1998 +1,1998 @@ components: - schemas: - # should go to main - Date: - type: object - properties: - datetime: - type: string - format: date-time - Scope: - type: object - properties: - id: - type: integer - type: - type: string - enum: - - scope - name: - type: object - additionalProperties: - type: string - example: - fr: Social - ScopeById: - type: object - properties: - id: - type: integer - type: - type: string - enum: - - scope - required: - - id - - scope + schemas: + # should go to main + Date: + type: object + properties: + datetime: + type: string + format: date-time + Scope: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - scope + name: + type: object + additionalProperties: + type: string + example: + fr: Social + ScopeById: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - scope + required: + - id + - scope - # ok to stay here - Person: - type: object - properties: - id: - type: integer - readOnly: true - type: - type: string - enum: - - "person" - firstName: - type: string - lastName: - type: string - text: - type: string - description: a canonical representation for the person name - readOnly: true - birthdate: - $ref: "#/components/schemas/Date" - deathdate: - $ref: "#/components/schemas/Date" - phonenumber: - type: string - mobilenumber: - type: string - gender: - type: string - enum: - - man - - woman - - both - gender_numeric: - type: integer - description: a numerical representation of gender - readOnly: true - PersonById: - type: object - properties: - id: - type: integer - type: - type: string - enum: - - "person" - required: - - id - - type - # should go to third party - ThirdParty: - type: object - properties: - text: - type: string - ThirdPartyById: - type: object - properties: - id: - type: integer - type: - type: string - enum: - - "thirdparty" - required: - - id - - type + # ok to stay here + Person: + type: object + properties: + id: + type: integer + readOnly: true + type: + type: string + enum: + - "person" + firstName: + type: string + lastName: + type: string + text: + type: string + description: a canonical representation for the person name + readOnly: true + birthdate: + $ref: "#/components/schemas/Date" + deathdate: + $ref: "#/components/schemas/Date" + phonenumber: + type: string + mobilenumber: + type: string + gender: + type: string + enum: + - man + - woman + - both + gender_numeric: + type: integer + description: a numerical representation of gender + readOnly: true + PersonById: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - "person" + required: + - id + - type + # should go to third party + ThirdParty: + type: object + properties: + text: + type: string + ThirdPartyById: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - "thirdparty" + required: + - id + - type - # ok to stay here - AccompanyingPeriod: - type: object - properties: - type: - type: string - enum: - - accompanying_period - id: - type: integer - requestorAnonymous: - type: boolean - Resource: - type: object - properties: - type: - type: string - enum: - - "accompanying_period_resource" - readOnly: true - id: - type: integer - readOnly: true - resource: - anyOf: - - $ref: "#/components/schemas/PersonById" - - $ref: "#/components/schemas/ThirdPartyById" - ResourceById: - type: object - properties: - id: - type: integer - type: - type: string - enum: - - "accompanying_period_resource" - required: - - id - - type - Comment: - type: object - properties: - type: - type: string - enum: - - "accompanying_period_comment" - readOnly: true - id: - type: integer - readOnly: true - content: - type: string - CommentById: - type: object - properties: - id: - type: integer - type: - type: string - enum: - - "accompanying_period_comment" - required: - - id - - type - SocialIssue: - type: object - properties: - id: - type: integer - type: - type: string - enum: - - "social_issue" - parent_id: - type: integer - readOnly: true - children_ids: - type: array - items: - type: integer - readOnly: true - title: - type: object - additionalProperties: - type: string - example: - fr: Accompagnement Social Adulte - readOnly: true - text: - type: string - readOnly: true - Household: - type: object - properties: - id: - type: integer - type: - type: string - enum: - - "household" - HouseholdPosition: - type: object - properties: - id: - type: integer - type: - type: string - enum: - - "household_position" - AccompanyingCourseWork: - type: object - properties: - id: - type: integer - type: - type: string - enum: - - "accompanying_period_work" - note: - type: string - privateComment: - type: string - startDate: - $ref: "#/components/schemas/Date" - endDate: - $ref: "#/components/schemas/Date" - handlingThirdParty: - $ref: "#/components/schemas/ThirdPartyById" - goals: - type: array - items: - $ref: "#/components/schemas/AccompanyingCourseWorkGoal" - results: - type: array - items: - $ref: "#/components/schemas/SocialWorkResultById" - AccompanyingCourseWorkGoal: - type: object - properties: - id: - type: integer - type: - type: string - enum: - - "accompanying_period_work_goal" - note: - type: string - goal: - $ref: "#/components/schemas/SocialWorkGoalById" - results: - type: array - items: - $ref: "#/components/schemas/SocialWorkGoalById" + # ok to stay here + AccompanyingPeriod: + type: object + properties: + type: + type: string + enum: + - accompanying_period + id: + type: integer + requestorAnonymous: + type: boolean + Resource: + type: object + properties: + type: + type: string + enum: + - "accompanying_period_resource" + readOnly: true + id: + type: integer + readOnly: true + resource: + anyOf: + - $ref: "#/components/schemas/PersonById" + - $ref: "#/components/schemas/ThirdPartyById" + ResourceById: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - "accompanying_period_resource" + required: + - id + - type + Comment: + type: object + properties: + type: + type: string + enum: + - "accompanying_period_comment" + readOnly: true + id: + type: integer + readOnly: true + content: + type: string + CommentById: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - "accompanying_period_comment" + required: + - id + - type + SocialIssue: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - "social_issue" + parent_id: + type: integer + readOnly: true + children_ids: + type: array + items: + type: integer + readOnly: true + title: + type: object + additionalProperties: + type: string + example: + fr: Accompagnement Social Adulte + readOnly: true + text: + type: string + readOnly: true + Household: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - "household" + HouseholdPosition: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - "household_position" + AccompanyingCourseWork: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - "accompanying_period_work" + note: + type: string + privateComment: + type: string + startDate: + $ref: "#/components/schemas/Date" + endDate: + $ref: "#/components/schemas/Date" + handlingThirdParty: + $ref: "#/components/schemas/ThirdPartyById" + goals: + type: array + items: + $ref: "#/components/schemas/AccompanyingCourseWorkGoal" + results: + type: array + items: + $ref: "#/components/schemas/SocialWorkResultById" + AccompanyingCourseWorkGoal: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - "accompanying_period_work_goal" + note: + type: string + goal: + $ref: "#/components/schemas/SocialWorkGoalById" + results: + type: array + items: + $ref: "#/components/schemas/SocialWorkGoalById" - SocialWorkResultById: - type: object - properties: - id: - type: integer - type: - type: string - enum: - - "social_work_result" - SocialWorkGoalById: - type: object - properties: - id: - type: integer - type: - type: string - enum: - - "social_work_goal" + SocialWorkResultById: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - "social_work_result" + SocialWorkGoalById: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - "social_work_goal" - RelationById: - type: object - properties: - id: - type: integer - type: - type: string - enum: - - "relation" - required: - - id - - type - Relationship: - type: object - properties: - type: - type: string - enum: - - "relationship" - id: - type: integer - readOnly: true - fromPerson: - anyOf: - - $ref: "#/components/schemas/PersonById" - toPerson: - anyOf: - - $ref: "#/components/schemas/PersonById" - relation: - anyOf: - - $ref: "#/components/schemas/RelationById" - reverse: - type: boolean + RelationById: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - "relation" + required: + - id + - type + Relationship: + type: object + properties: + type: + type: string + enum: + - "relationship" + id: + type: integer + readOnly: true + fromPerson: + anyOf: + - $ref: "#/components/schemas/PersonById" + toPerson: + anyOf: + - $ref: "#/components/schemas/PersonById" + relation: + anyOf: + - $ref: "#/components/schemas/RelationById" + reverse: + type: boolean paths: - /1.0/person/person/{id}.json: - get: - tags: - - person - summary: Get a single person - parameters: - - name: id - in: path - required: true - description: The person's id - schema: - type: integer - format: integer - minimum: 1 - responses: - 200: - description: "OK" - content: - application/json: - schema: - $ref: "#/components/schemas/Person" - 403: - description: "Unauthorized" - patch: - tags: - - person - summary: "Alter a person" - parameters: - - name: id - in: path - required: true - description: The person's id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - description: "A person" - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/Person" - examples: - Update a person: - value: - type: "person" - firstName: "string" - lastName: "string" - birthdate: - datetime: "2016-06-01T00:00:00+02:00" - deathdate: - datetime: "2021-06-01T00:00:00+02:00" - phonenumber: "string" - mobilenumber: "string" - gender: "male" - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "Object with validation errors" + /1.0/person/person/{id}.json: + get: + tags: + - person + summary: Get a single person + parameters: + - name: id + in: path + required: true + description: The person's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 200: + description: "OK" + content: + application/json: + schema: + $ref: "#/components/schemas/Person" + 403: + description: "Unauthorized" + patch: + tags: + - person + summary: "Alter a person" + parameters: + - name: id + in: path + required: true + description: The person's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + description: "A person" + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Person" + examples: + Update a person: + value: + type: "person" + firstName: "string" + lastName: "string" + birthdate: + datetime: "2016-06-01T00:00:00+02:00" + deathdate: + datetime: "2021-06-01T00:00:00+02:00" + phonenumber: "string" + mobilenumber: "string" + gender: "male" + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "Object with validation errors" - /1.0/person/person.json: - post: - tags: - - person - summary: Create a single person - requestBody: - description: "A person" - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/Person" - examples: - Create a new person: - value: - type: "person" - firstName: "string" - lastName: "string" - birthdate: - datetime: "2016-06-01T00:00:00+02:00" - deathdate: - datetime: "2021-06-01T00:00:00+02:00" - phonenumber: "string" - mobilenumber: "string" - gender: "male" - responses: - 200: - description: "OK" - content: - application/json: - schema: - $ref: "#/components/schemas/Person" - 403: - description: "Unauthorized" - 422: - description: "Invalid data: the data is a valid json, could be deserialized, but does not pass validation" + /1.0/person/person.json: + post: + tags: + - person + summary: Create a single person + requestBody: + description: "A person" + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Person" + examples: + Create a new person: + value: + type: "person" + firstName: "string" + lastName: "string" + birthdate: + datetime: "2016-06-01T00:00:00+02:00" + deathdate: + datetime: "2021-06-01T00:00:00+02:00" + phonenumber: "string" + mobilenumber: "string" + gender: "male" + responses: + 200: + description: "OK" + content: + application/json: + schema: + $ref: "#/components/schemas/Person" + 403: + description: "Unauthorized" + 422: + description: "Invalid data: the data is a valid json, could be deserialized, but does not pass validation" - /1.0/person/person/{id}/address.json: - post: - tags: - - person - summary: post an address to a person - parameters: - - name: id - in: path - required: true - description: The person id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - id: - type: integer - description: The address id to attach to the person - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "Unprocessable entity (validation errors)" + /1.0/person/person/{id}/address.json: + post: + tags: + - person + summary: post an address to a person + parameters: + - name: id + in: path + required: true + description: The person id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + id: + type: integer + description: The address id to attach to the person + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "Unprocessable entity (validation errors)" - /1.0/person/address/suggest/by-person/{id}.json: - get: - tags: - - address - summary: get a list of suggested address for a person - description: > - The address are computed from various source. Currently: + /1.0/person/address/suggest/by-person/{id}.json: + get: + tags: + - address + summary: get a list of suggested address for a person + description: > + The address are computed from various source. Currently: - - the address of course to which the person is participating + - the address of course to which the person is participating - The current person's address is always ignored. - parameters: - - name: id - in: path - required: true - description: The person id - schema: - type: integer - format: integer - minimum: 1 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" + The current person's address is always ignored. + parameters: + - name: id + in: path + required: true + description: The person id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" - /1.0/person/address/suggest/by-household/{id}.json: - get: - tags: - - address - summary: get a list of suggested address for a household - description: > - The address are computed from various source. Currently: + /1.0/person/address/suggest/by-household/{id}.json: + get: + tags: + - address + summary: get a list of suggested address for a household + description: > + The address are computed from various source. Currently: - - the address of course to which the members is participating + - the address of course to which the members is participating - The current household address is always ignored. - parameters: - - name: id - in: path - required: true - description: The household id - schema: - type: integer - format: integer - minimum: 1 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" + The current household address is always ignored. + parameters: + - name: id + in: path + required: true + description: The household id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" - /1.0/person/accompanying-course/{id}.json: - get: - tags: - - accompanying-course - summary: "Return the description for an accompanying course (accompanying period)" - parameters: - - name: id - in: path - required: true - description: The accompanying period's id - schema: - type: integer - format: integer - minimum: 1 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - patch: - tags: - - person - summary: "Alter an accompanying course (accompanying period)" - parameters: - - name: id - in: path - required: true - description: The accompanying period's id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - description: "An accompanying period" - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/AccompanyingPeriod" - examples: - Set the requestor as anonymous: - value: - type: accompanying_period - id: 12345 - requestorAnonymous: true - Adding an initial comment: - value: - type: accompanying_period - id: 2668, - pinnedComment: - type: accompanying_period_comment - content: > - This is my an initial comment. + /1.0/person/accompanying-course/{id}.json: + get: + tags: + - accompanying-course + summary: "Return the description for an accompanying course (accompanying period)" + parameters: + - name: id + in: path + required: true + description: The accompanying period's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + patch: + tags: + - person + summary: "Alter an accompanying course (accompanying period)" + parameters: + - name: id + in: path + required: true + description: The accompanying period's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + description: "An accompanying period" + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AccompanyingPeriod" + examples: + Set the requestor as anonymous: + value: + type: accompanying_period + id: 12345 + requestorAnonymous: true + Adding an initial comment: + value: + type: accompanying_period + id: 2668, + pinnedComment: + type: accompanying_period_comment + content: > + This is my an initial comment. - Say hello to the new "parcours"! - Setting person with id 8405 as locator: - value: - type: accompanying_period - id: 0 - personLocation: - type: person - id: 8405 - Removing person location for both person and address: - value: - type: accompanying_period - id: 0 - personLocation: null - addressLocation: null - Adding address with id 7960 as temporarily address: - value: - type: accompanying_period - id: 0 - personLocation: null - addressLocation: - id: 7960 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "object with validation errors" + Say hello to the new "parcours"! + Setting person with id 8405 as locator: + value: + type: accompanying_period + id: 0 + personLocation: + type: person + id: 8405 + Removing person location for both person and address: + value: + type: accompanying_period + id: 0 + personLocation: null + addressLocation: null + Adding address with id 7960 as temporarily address: + value: + type: accompanying_period + id: 0 + personLocation: null + addressLocation: + id: 7960 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" - /1.0/person/accompanying-course/{id}/requestor.json: - post: - tags: - - accompanying-course - summary: "Add a requestor to the accompanying course" - parameters: - - name: id - in: path - required: true - description: The accompanying period's id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - description: "A person or thirdparty" - required: true - content: - application/json: - schema: - oneOf: - - $ref: "#/components/schemas/PersonById" - - $ref: "#/components/schemas/ThirdPartyById" - examples: - add person with id 50: - summary: "a person with id 50" - value: - type: person - id: 50 - add thirdparty with id 100: - summary: "a third party with id 100" - value: - type: thirdparty - id: 100 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "object with validation errors" - delete: - tags: - - accompanying-course - summary: "Remove the requestor for the accompanying course" - parameters: - - name: id - in: path - required: true - description: The accompanying period's id - schema: - type: integer - format: integer - minimum: 1 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "object with validation errors" + /1.0/person/accompanying-course/{id}/requestor.json: + post: + tags: + - accompanying-course + summary: "Add a requestor to the accompanying course" + parameters: + - name: id + in: path + required: true + description: The accompanying period's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + description: "A person or thirdparty" + required: true + content: + application/json: + schema: + oneOf: + - $ref: "#/components/schemas/PersonById" + - $ref: "#/components/schemas/ThirdPartyById" + examples: + add person with id 50: + summary: "a person with id 50" + value: + type: person + id: 50 + add thirdparty with id 100: + summary: "a third party with id 100" + value: + type: thirdparty + id: 100 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" + delete: + tags: + - accompanying-course + summary: "Remove the requestor for the accompanying course" + parameters: + - name: id + in: path + required: true + description: The accompanying period's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" - /1.0/person/accompanying-course/{id}/participation.json: - post: - tags: - - accompanying-course - summary: "Add a participant to the accompanying course" - parameters: - - name: id - in: path - required: true - description: The accompanying period's id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - description: "A person" - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/PersonById" - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "object with validation errors" - delete: - tags: - - accompanying-course - summary: "Remove the participant for the accompanying course" - parameters: - - name: id - in: path - required: true - description: The accompanying period's id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - description: "A person" - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/PersonById" - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "object with validation errors" + /1.0/person/accompanying-course/{id}/participation.json: + post: + tags: + - accompanying-course + summary: "Add a participant to the accompanying course" + parameters: + - name: id + in: path + required: true + description: The accompanying period's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + description: "A person" + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PersonById" + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" + delete: + tags: + - accompanying-course + summary: "Remove the participant for the accompanying course" + parameters: + - name: id + in: path + required: true + description: The accompanying period's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + description: "A person" + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PersonById" + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" - /1.0/person/accompanying-course/{id}/resource.json: - post: - tags: - - accompanying-course - summary: "Add a resource to the accompanying course" - parameters: - - name: id - in: path - required: true - description: The accompanying period's id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - description: "A resource" - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/Resource" - examples: - add person with id 50: - summary: "a person with id 50" - value: - type: accompanying_period_resource - resource: - type: person - id: 50 - add thirdparty with id 100: - summary: "a third party with id 100" - value: - type: accompanying_period_resource - resource: - type: thirdparty - id: 100 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "object with validation errors" - delete: - tags: - - accompanying-course - summary: "Remove the resource" - parameters: - - name: id - in: path - required: true - description: The accompanying period's id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - description: "A resource" - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/ResourceById" - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "object with validation errors" + /1.0/person/accompanying-course/{id}/resource.json: + post: + tags: + - accompanying-course + summary: "Add a resource to the accompanying course" + parameters: + - name: id + in: path + required: true + description: The accompanying period's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + description: "A resource" + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Resource" + examples: + add person with id 50: + summary: "a person with id 50" + value: + type: accompanying_period_resource + resource: + type: person + id: 50 + add thirdparty with id 100: + summary: "a third party with id 100" + value: + type: accompanying_period_resource + resource: + type: thirdparty + id: 100 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" + delete: + tags: + - accompanying-course + summary: "Remove the resource" + parameters: + - name: id + in: path + required: true + description: The accompanying period's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + description: "A resource" + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ResourceById" + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" - /1.0/person/accompanying-course/{id}/comment.json: - post: - tags: - - accompanying-course - summary: "Add a comment to the accompanying course" - parameters: - - name: id - in: path - required: true - description: The accompanying period's id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - description: "A comment" - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/Comment" - examples: - a single comment: - summary: "a simple comment" - value: - type: accompanying_period_comment - content: | - This is a funny comment I would like to share with you. + /1.0/person/accompanying-course/{id}/comment.json: + post: + tags: + - accompanying-course + summary: "Add a comment to the accompanying course" + parameters: + - name: id + in: path + required: true + description: The accompanying period's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + description: "A comment" + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Comment" + examples: + a single comment: + summary: "a simple comment" + value: + type: accompanying_period_comment + content: | + This is a funny comment I would like to share with you. - Thank you for reading this ! - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "object with validation errors" - delete: - tags: - - accompanying-course - summary: "Remove the comment" - parameters: - - name: id - in: path - required: true - description: The accompanying period's id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - description: "A comment" - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/CommentById" - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "object with validation errors" + Thank you for reading this ! + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" + delete: + tags: + - accompanying-course + summary: "Remove the comment" + parameters: + - name: id + in: path + required: true + description: The accompanying period's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + description: "A comment" + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CommentById" + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" - /1.0/person/accompanying-course/{id}/scope.json: - post: - tags: - - accompanying-course - summary: "Add a scope to the accompanying course" - parameters: - - name: id - in: path - required: true - description: The accompanying period's id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - description: "A comment" - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/Scope" - examples: - add a scope: - value: - type: scope - id: 5 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "object with validation errors" - delete: - tags: - - accompanying-course - summary: "Remove the scope" - parameters: - - name: id - in: path - required: true - description: The accompanying period's id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - description: "A scope with his id" - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/ScopeById" - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "object with validation errors" + /1.0/person/accompanying-course/{id}/scope.json: + post: + tags: + - accompanying-course + summary: "Add a scope to the accompanying course" + parameters: + - name: id + in: path + required: true + description: The accompanying period's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + description: "A comment" + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Scope" + examples: + add a scope: + value: + type: scope + id: 5 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" + delete: + tags: + - accompanying-course + summary: "Remove the scope" + parameters: + - name: id + in: path + required: true + description: The accompanying period's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + description: "A scope with his id" + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ScopeById" + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" - /1.0/person/accompanying-course/{id}/socialissue.json: - post: - tags: - - accompanying-course - summary: "Add a social issue to the accompanying course" - parameters: - - name: id - in: path - required: true - description: The accompanying period's id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - description: "A social issue by id" - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/SocialIssue" - examples: - add a social issue: - value: - type: social_issue - id: 5 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "object with validation errors" - delete: - tags: - - accompanying-course - summary: "Remove the social issue" - parameters: - - name: id - in: path - required: true - description: The accompanying period's id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - description: "A social issue with his id" - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/SocialIssue" - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "object with validation errors" - /1.0/person/accompanying-course/{id}/referrers-suggested.json: - get: - tags: - - accompanying-course - summary: "get a list of available referral for a given accompanying cours" - parameters: - - name: id - in: path - required: true - description: The accompanying period's id - schema: - type: integer - format: integer - minimum: 1 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" + /1.0/person/accompanying-course/{id}/socialissue.json: + post: + tags: + - accompanying-course + summary: "Add a social issue to the accompanying course" + parameters: + - name: id + in: path + required: true + description: The accompanying period's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + description: "A social issue by id" + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SocialIssue" + examples: + add a social issue: + value: + type: social_issue + id: 5 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" + delete: + tags: + - accompanying-course + summary: "Remove the social issue" + parameters: + - name: id + in: path + required: true + description: The accompanying period's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + description: "A social issue with his id" + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SocialIssue" + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" + /1.0/person/accompanying-course/{id}/referrers-suggested.json: + get: + tags: + - accompanying-course + summary: "get a list of available referral for a given accompanying cours" + parameters: + - name: id + in: path + required: true + description: The accompanying period's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" - /1.0/person/accompanying-course/{id}/works.json: - get: - tags: - - accompanying-course - summary: List of accompanying period works for an accompanying period - description: Gets a list of accompanying period works for an accompanying period - parameters: - - name: id - in: path - required: true - description: The accompanying period id - schema: - type: integer - format: integer - minimum: 1 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" + /1.0/person/accompanying-course/{id}/works.json: + get: + tags: + - accompanying-course + summary: List of accompanying period works for an accompanying period + description: Gets a list of accompanying period works for an accompanying period + parameters: + - name: id + in: path + required: true + description: The accompanying period id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" - /1.0/person/accompanying-course/{id}/work.json: - post: - tags: - - accompanying-course-work - summary: "Add a work (AccompanyingPeriodwork) to the accompanying course" - parameters: - - name: id - in: path - required: true - description: The accompanying period's id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - description: "A new work" - required: true - content: - application/json: - schema: - type: object - properties: - type: - type: string - enum: - - "accompanying_period_work" - startDate: - $ref: "#/components/schemas/Date" - endDate: - $ref: "#/components/schemas/Date" - examples: - create a work: - value: - type: accompanying_period_work - social_action: - id: 0 - type: social_work_social_action - startDate: - datetime: 2021-06-20T15:00:00+0200 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "object with validation errors" + /1.0/person/accompanying-course/{id}/work.json: + post: + tags: + - accompanying-course-work + summary: "Add a work (AccompanyingPeriodwork) to the accompanying course" + parameters: + - name: id + in: path + required: true + description: The accompanying period's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + description: "A new work" + required: true + content: + application/json: + schema: + type: object + properties: + type: + type: string + enum: + - "accompanying_period_work" + startDate: + $ref: "#/components/schemas/Date" + endDate: + $ref: "#/components/schemas/Date" + examples: + create a work: + value: + type: accompanying_period_work + social_action: + id: 0 + type: social_work_social_action + startDate: + datetime: 2021-06-20T15:00:00+0200 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" - /1.0/person/accompanying-course/work/{id}.json: - get: - tags: - - accompanying-course-work - summary: edit an existing accompanying course work - parameters: - - name: id - in: path - required: true - description: The accompanying course social work's id - schema: - type: integer - format: integer - minimum: 1 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 400: - description: "Bad Request" + /1.0/person/accompanying-course/work/{id}.json: + get: + tags: + - accompanying-course-work + summary: edit an existing accompanying course work + parameters: + - name: id + in: path + required: true + description: The accompanying course social work's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 400: + description: "Bad Request" - put: - tags: - - accompanying-course-work - summary: edit an existing accompanying course work - parameters: - - name: id - in: path - required: true - description: The accompanying course social work's id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/AccompanyingCourseWork" - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "Unprocessable entity (validation errors)" - 400: - description: "Bad Request" + put: + tags: + - accompanying-course-work + summary: edit an existing accompanying course work + parameters: + - name: id + in: path + required: true + description: The accompanying course social work's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AccompanyingCourseWork" + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "Unprocessable entity (validation errors)" + 400: + description: "Bad Request" - /1.0/person/accompanying-course/{id}/confirm.json: - post: - tags: - - person - summary: confirm an accompanying course - parameters: - - name: id - in: path - required: true - description: The accompanying period's id - schema: - type: integer - format: integer - minimum: 1 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 400: - description: "transition cannot be applied" + /1.0/person/accompanying-course/{id}/confirm.json: + post: + tags: + - person + summary: confirm an accompanying course + parameters: + - name: id + in: path + required: true + description: The accompanying period's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 400: + description: "transition cannot be applied" - /1.0/person/accompanying-course/{id}/confidential.json: - post: - tags: - - person - summary: "Toggle confidentiality of accompanying course" - parameters: - - name: id - in: path - required: true - description: The accompanying period's id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - description: "Confidentiality toggle" - required: true - content: - application/json: - schema: - type: object - properties: - type: - type: string - enum: - - "accompanying_period" - confidential: - type: boolean - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "object with validation errors" + /1.0/person/accompanying-course/{id}/confidential.json: + post: + tags: + - person + summary: "Toggle confidentiality of accompanying course" + parameters: + - name: id + in: path + required: true + description: The accompanying period's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + description: "Confidentiality toggle" + required: true + content: + application/json: + schema: + type: object + properties: + type: + type: string + enum: + - "accompanying_period" + confidential: + type: boolean + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" - /1.0/person/accompanying-course/{id}/intensity.json: - post: - tags: - - person - summary: "Toggle intensity status of accompanying course" - parameters: - - name: id - in: path - required: true - description: The accompanying period's id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - description: "Intensity toggle" - required: true - content: - application/json: - schema: - type: object - properties: - type: - type: string - enum: - - "accompanying_period" - intensity: - type: string - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "object with validation errors" + /1.0/person/accompanying-course/{id}/intensity.json: + post: + tags: + - person + summary: "Toggle intensity status of accompanying course" + parameters: + - name: id + in: path + required: true + description: The accompanying period's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + description: "Intensity toggle" + required: true + content: + application/json: + schema: + type: object + properties: + type: + type: string + enum: + - "accompanying_period" + intensity: + type: string + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" - /1.0/person/accompanying-course/by-person/{person_id}.json: - get: - tags: - - accompanying period - summary: get a list of accompanying periods for a person - description: Returns a list of the current accompanying periods for a person - parameters: - - name: person_id - in: path - required: true - description: The person id - schema: - type: integer - format: integer - minimum: 1 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" + /1.0/person/accompanying-course/by-person/{person_id}.json: + get: + tags: + - accompanying period + summary: get a list of accompanying periods for a person + description: Returns a list of the current accompanying periods for a person + parameters: + - name: person_id + in: path + required: true + description: The person id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" - /1.0/person/accompanying-period/origin.json: - get: - tags: - - person - summary: Return a list of all origins - responses: - 200: - description: "ok" + /1.0/person/accompanying-period/origin.json: + get: + tags: + - person + summary: Return a list of all origins + responses: + 200: + description: "ok" - /1.0/person/accompanying-period/origin/{id}.json: - get: - tags: - - person - summary: Return an origin by id - parameters: - - name: id - in: path - required: true - description: The origin id - schema: - type: integer - format: integer - minimum: 1 - responses: - 200: - description: "ok" - 400: - description: "Bad Request" - 401: - description: "Unauthorized" - 404: - description: "Not found" + /1.0/person/accompanying-period/origin/{id}.json: + get: + tags: + - person + summary: Return an origin by id + parameters: + - name: id + in: path + required: true + description: The origin id + schema: + type: integer + format: integer + minimum: 1 + responses: + 200: + description: "ok" + 400: + description: "Bad Request" + 401: + description: "Unauthorized" + 404: + description: "Not found" - /1.0/person/accompanying-period/resource/{id}.json: - patch: - tags: - - accompanying-course-resource - summary: "Alter the resource" - parameters: - - name: id - in: path - required: true - description: The resource's id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - description: "A resource" - required: true - content: - application/json: - schema: - type: object - properties: - type: - type: string - enum: - - "accompanying_period_resource" - #id: - # type: integer - comment: - type: string - required: - - type - examples: - Set the resource comment: - value: - type: accompanying_period_resource - #id: 0 - comment: my judicious comment - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "object with validation errors" + /1.0/person/accompanying-period/resource/{id}.json: + patch: + tags: + - accompanying-course-resource + summary: "Alter the resource" + parameters: + - name: id + in: path + required: true + description: The resource's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + description: "A resource" + required: true + content: + application/json: + schema: + type: object + properties: + type: + type: string + enum: + - "accompanying_period_resource" + #id: + # type: integer + comment: + type: string + required: + - type + examples: + Set the resource comment: + value: + type: accompanying_period_resource + #id: 0 + comment: my judicious comment + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" - /1.0/person/household.json: - get: - tags: - - household - summary: Return a list of all household - responses: - 200: - description: "ok" - post: - tags: - - household - requestBody: - description: "A household" - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/Household" - summary: Post a new household - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "Unprocessable entity (validation errors)" - 400: - description: "transition cannot be applied" + /1.0/person/household.json: + get: + tags: + - household + summary: Return a list of all household + responses: + 200: + description: "ok" + post: + tags: + - household + requestBody: + description: "A household" + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Household" + summary: Post a new household + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "Unprocessable entity (validation errors)" + 400: + description: "transition cannot be applied" - /1.0/person/household/{id}.json: - get: - tags: - - household - summary: Return a household by id - parameters: - - name: id - in: path - required: true - description: The household id - schema: - type: integer - format: integer - minimum: 1 - responses: - 200: - description: "ok" - content: - application/json: - schema: - $ref: "#/components/schemas/Household" - 404: - description: "not found" - 401: - description: "Unauthorized" + /1.0/person/household/{id}.json: + get: + tags: + - household + summary: Return a household by id + parameters: + - name: id + in: path + required: true + description: The household id + schema: + type: integer + format: integer + minimum: 1 + responses: + 200: + description: "ok" + content: + application/json: + schema: + $ref: "#/components/schemas/Household" + 404: + description: "not found" + 401: + description: "Unauthorized" - /1.0/person/household/by-address-reference/{address_id}.json: - get: - tags: - - household - summary: Return a list of household which are sharing the same address reference - parameters: - - name: address_id - in: path - required: true - description: the address reference id - schema: - type: integer - format: integer - minimum: 1 - responses: - 200: - description: "ok" - content: - application/json: - schema: - $ref: "#/components/schemas/Household" - 404: - description: "not found" - 401: - description: "Unauthorized" + /1.0/person/household/by-address-reference/{address_id}.json: + get: + tags: + - household + summary: Return a list of household which are sharing the same address reference + parameters: + - name: address_id + in: path + required: true + description: the address reference id + schema: + type: integer + format: integer + minimum: 1 + responses: + 200: + description: "ok" + content: + application/json: + schema: + $ref: "#/components/schemas/Household" + 404: + description: "not found" + 401: + description: "Unauthorized" - /1.0/person/household/suggest/by-person/{person_id}/through-accompanying-period-participation.json: - get: - tags: - - household - summary: Return households associated with the given person through accompanying periods - description: | - Return households associated with the given person throught accompanying periods participation. + /1.0/person/household/suggest/by-person/{person_id}/through-accompanying-period-participation.json: + get: + tags: + - household + summary: Return households associated with the given person through accompanying periods + description: | + Return households associated with the given person throught accompanying periods participation. - The current household of the given person is excluded. - parameters: - - name: person_id - in: path - required: true - description: The person's id - schema: - type: integer - format: integer - minimum: 1 - responses: - 200: - description: "ok" - content: - application/json: - schema: - $ref: "#/components/schemas/Household" - 404: - description: "not found" - 401: - description: "Unauthorized" + The current household of the given person is excluded. + parameters: + - name: person_id + in: path + required: true + description: The person's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 200: + description: "ok" + content: + application/json: + schema: + $ref: "#/components/schemas/Household" + 404: + description: "not found" + 401: + description: "Unauthorized" - /1.0/person/household/members/move.json: - post: - tags: - - household - summary: move one or multiple person from a household to another - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - concerned: - type: array - items: - type: object - properties: - person: - $ref: "#/components/schemas/PersonById" - start_date: - $ref: "#/components/schemas/Date" - position: - $ref: "#/components/schemas/HouseholdPosition" - holder: - type: boolean - comment: - type: string - destination: - $ref: "#/components/schemas/Household" - examples: - Moving person to a new household: - value: - concerned: - - person: - id: 0 - type: person - position: - type: household_position - id: 1 - start_date: - datetime: "2021-06-01T00:00:00+02:00" - comment: "This is my comment for moving" - holder: false - destination: - type: household - Moving person to a new household and set an address to this household: - value: - concerned: - - person: - id: 0 - type: person - position: - type: household_position - id: 1 - start_date: - datetime: "2021-06-01T00:00:00+02:00" - comment: "This is my comment for moving" - holder: false - destination: - type: household - forceAddress: - id: 0 - Moving person to an existing household: - value: - concerned: - - person: - id: 0 - type: person - position: - type: household_position - id: 1 - start_date: - datetime: 2021-06-01T00:00:00+02:00 - comment: "This is my comment for moving" - holder: false - destination: - type: household - id: 54 - Removing a person from any household: - value: - concerned: - - person: - id: 0 - type: person - start_date: - datetime: 2021-06-01T00:00:00+02:00 - destination: null - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "Unprocessable entity (validation errors)" - 400: - description: "transition cannot be applied" + /1.0/person/household/members/move.json: + post: + tags: + - household + summary: move one or multiple person from a household to another + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + concerned: + type: array + items: + type: object + properties: + person: + $ref: "#/components/schemas/PersonById" + start_date: + $ref: "#/components/schemas/Date" + position: + $ref: "#/components/schemas/HouseholdPosition" + holder: + type: boolean + comment: + type: string + destination: + $ref: "#/components/schemas/Household" + examples: + Moving person to a new household: + value: + concerned: + - person: + id: 0 + type: person + position: + type: household_position + id: 1 + start_date: + datetime: "2021-06-01T00:00:00+02:00" + comment: "This is my comment for moving" + holder: false + destination: + type: household + Moving person to a new household and set an address to this household: + value: + concerned: + - person: + id: 0 + type: person + position: + type: household_position + id: 1 + start_date: + datetime: "2021-06-01T00:00:00+02:00" + comment: "This is my comment for moving" + holder: false + destination: + type: household + forceAddress: + id: 0 + Moving person to an existing household: + value: + concerned: + - person: + id: 0 + type: person + position: + type: household_position + id: 1 + start_date: + datetime: 2021-06-01T00:00:00+02:00 + comment: "This is my comment for moving" + holder: false + destination: + type: household + id: 54 + Removing a person from any household: + value: + concerned: + - person: + id: 0 + type: person + start_date: + datetime: 2021-06-01T00:00:00+02:00 + destination: null + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "Unprocessable entity (validation errors)" + 400: + description: "transition cannot be applied" - /1.0/person/household/{id}/address.json: - post: - tags: - - household - summary: post an address to a household - parameters: - - name: id - in: path - required: true - description: The household id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - id: - type: integer - description: The address id to attach to the household - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "Unprocessable entity (validation errors)" - 400: - description: "transition cannot be applied" + /1.0/person/household/{id}/address.json: + post: + tags: + - household + summary: post an address to a household + parameters: + - name: id + in: path + required: true + description: The household id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + id: + type: integer + description: The address id to attach to the household + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "Unprocessable entity (validation errors)" + 400: + description: "transition cannot be applied" - /1.0/person/social/social-action.json: - get: - tags: - - social-work-social-action - summary: get a list of social action - responses: - 401: - description: "Unauthorized" - 200: - description: "OK" + /1.0/person/social/social-action.json: + get: + tags: + - social-work-social-action + summary: get a list of social action + responses: + 401: + description: "Unauthorized" + 200: + description: "OK" - /1.0/person/social/social-action/{id}.json: - get: - tags: - - social-work-social-action - parameters: - - name: id - in: path - required: true - description: The social action's id - schema: - type: integer - format: integer - minimum: 1 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 400: - description: "Bad Request" + /1.0/person/social/social-action/{id}.json: + get: + tags: + - social-work-social-action + parameters: + - name: id + in: path + required: true + description: The social action's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 400: + description: "Bad Request" - /1.0/person/social/social-action/by-social-issue/{id}.json: - get: - tags: - - social-work-social-action - parameters: - - name: id - in: path - required: true - description: The social action's id - schema: - type: integer - format: integer - minimum: 1 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 400: - description: "Bad Request" + /1.0/person/social/social-action/by-social-issue/{id}.json: + get: + tags: + - social-work-social-action + parameters: + - name: id + in: path + required: true + description: The social action's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 400: + description: "Bad Request" - /1.0/person/social-work/evaluation/by-social-action/{social_action_id}.json: - get: - tags: - - social-work-evaluation - summary: return a list of evaluation which are available for a given social action - parameters: - - name: social_action_id - in: path - required: true - description: The social action's id - schema: - type: integer - format: integer - minimum: 1 - responses: - 200: - description: ok - 404: - description: not found + /1.0/person/social-work/evaluation/by-social-action/{social_action_id}.json: + get: + tags: + - social-work-evaluation + summary: return a list of evaluation which are available for a given social action + parameters: + - name: social_action_id + in: path + required: true + description: The social action's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 200: + description: ok + 404: + description: not found - /1.0/person/social-work/social-issue.json: - get: - tags: - - social-issue - summary: Return a list of social work - responses: - 200: - description: "ok" + /1.0/person/social-work/social-issue.json: + get: + tags: + - social-issue + summary: Return a list of social work + responses: + 200: + description: "ok" - /1.0/person/social-work/social-issue/{id}.json: - get: - tags: - - social-issue - summary: Return a social issue by id - parameters: - - name: id - in: path - required: true - description: The social issue's id - schema: - type: integer - format: integer - minimum: 1 - responses: - 200: - description: "ok" - content: - application/json: - schema: - $ref: "#/components/schemas/SocialIssue" - 404: - description: "not found" - 401: - description: "Unauthorized" + /1.0/person/social-work/social-issue/{id}.json: + get: + tags: + - social-issue + summary: Return a social issue by id + parameters: + - name: id + in: path + required: true + description: The social issue's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 200: + description: "ok" + content: + application/json: + schema: + $ref: "#/components/schemas/SocialIssue" + 404: + description: "not found" + 401: + description: "Unauthorized" - /1.0/person/social-work/result.json: - get: - tags: - - accompanying-course-work - summary: get a list of social work result - responses: - 401: - description: "Unauthorized" - 200: - description: "OK" + /1.0/person/social-work/result.json: + get: + tags: + - accompanying-course-work + summary: get a list of social work result + responses: + 401: + description: "Unauthorized" + 200: + description: "OK" - /1.0/person/social-work/result/{id}.json: - get: - tags: - - accompanying-course-work - parameters: - - name: id - in: path - required: true - description: The result's id - schema: - type: integer - format: integer - minimum: 1 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 400: - description: "Bad Request" + /1.0/person/social-work/result/{id}.json: + get: + tags: + - accompanying-course-work + parameters: + - name: id + in: path + required: true + description: The result's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 400: + description: "Bad Request" - /1.0/person/social-work/result/by-goal/{id}.json: - get: - tags: - - accompanying-course-work - parameters: - - name: id - in: path - required: true - description: The goal's id - schema: - type: integer - format: integer - minimum: 1 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 400: - description: "Bad Request" + /1.0/person/social-work/result/by-goal/{id}.json: + get: + tags: + - accompanying-course-work + parameters: + - name: id + in: path + required: true + description: The goal's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 400: + description: "Bad Request" - /1.0/person/social-work/result/by-social-action/{id}.json: - get: - tags: - - accompanying-course-work - parameters: - - name: id - in: path - required: true - description: The social action's id - schema: - type: integer - format: integer - minimum: 1 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 400: - description: "Bad Request" + /1.0/person/social-work/result/by-social-action/{id}.json: + get: + tags: + - accompanying-course-work + parameters: + - name: id + in: path + required: true + description: The social action's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 400: + description: "Bad Request" - /1.0/person/social-work/goal.json: - get: - tags: - - accompanying-course-work - summary: get a list of social work goal - responses: - 401: - description: "Unauthorized" - 200: - description: "OK" + /1.0/person/social-work/goal.json: + get: + tags: + - accompanying-course-work + summary: get a list of social work goal + responses: + 401: + description: "Unauthorized" + 200: + description: "OK" - /1.0/person/social-work/goal/{id}.json: - get: - tags: - - accompanying-course-work - parameters: - - name: id - in: path - required: true - description: The goal's id - schema: - type: integer - format: integer - minimum: 1 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 400: - description: "Bad Request" + /1.0/person/social-work/goal/{id}.json: + get: + tags: + - accompanying-course-work + parameters: + - name: id + in: path + required: true + description: The goal's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 400: + description: "Bad Request" - /1.0/person/social-work/goal/by-social-action/{id}.json: - get: - tags: - - accompanying-course-work - parameters: - - name: id - in: path - required: true - description: The social action's id - schema: - type: integer - format: integer - minimum: 1 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 400: - description: "Bad Request" + /1.0/person/social-work/goal/by-social-action/{id}.json: + get: + tags: + - accompanying-course-work + parameters: + - name: id + in: path + required: true + description: The social action's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 400: + description: "Bad Request" - /1.0/relations/relationship/by-person/{id}.json: - get: - tags: - - relationships - parameters: - - name: id - in: path - required: true - description: The person's id - schema: - type: integer - format: integer - minimum: 1 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 400: - description: "Bad Request" + /1.0/relations/relationship/by-person/{id}.json: + get: + tags: + - relationships + parameters: + - name: id + in: path + required: true + description: The person's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 400: + description: "Bad Request" - /1.0/relations/relationship.json: - post: - tags: - - relationships - summary: Create a new relationship - requestBody: - description: "A relationship" - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/Relationship" - responses: - 200: - description: "OK" - content: - application/json: - schema: - $ref: "#/components/schemas/Relationship" - 403: - description: "Unauthorized" - 422: - description: "Invalid data: the data is a valid json, could be deserialized, but does not pass validation" + /1.0/relations/relationship.json: + post: + tags: + - relationships + summary: Create a new relationship + requestBody: + description: "A relationship" + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Relationship" + responses: + 200: + description: "OK" + content: + application/json: + schema: + $ref: "#/components/schemas/Relationship" + 403: + description: "Unauthorized" + 422: + description: "Invalid data: the data is a valid json, could be deserialized, but does not pass validation" - /1.0/relations/relationship/{id}.json: - patch: - tags: - - relationships - summary: "Alter a relationship" - parameters: - - name: id - in: path - required: true - description: The relationship's id - schema: - type: integer - format: integer - minimum: 1 - requestBody: - description: "A relationship" - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/Relationship" - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "Object with validation errors" - delete: - tags: - - relationships - summary: "Remove the relationship" - parameters: - - name: id - in: path - required: true - description: The relationship's id - schema: - type: integer - format: integer - minimum: 1 - responses: - 401: - description: "Unauthorized" - 404: - description: "Not found" - 200: - description: "OK" - 422: - description: "object with validation errors" + /1.0/relations/relationship/{id}.json: + patch: + tags: + - relationships + summary: "Alter a relationship" + parameters: + - name: id + in: path + required: true + description: The relationship's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + description: "A relationship" + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Relationship" + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "Object with validation errors" + delete: + tags: + - relationships + summary: "Remove the relationship" + parameters: + - name: id + in: path + required: true + description: The relationship's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "Unauthorized" + 404: + description: "Not found" + 200: + description: "OK" + 422: + description: "object with validation errors" - /1.0/relations/relation.json: - get: - tags: - - relations - summary: get a list of relations - responses: - 401: - description: "Unauthorized" - 200: - description: "OK" + /1.0/relations/relation.json: + get: + tags: + - relations + summary: get a list of relations + responses: + 401: + description: "Unauthorized" + 200: + description: "OK" - /1.0/person/config/alt_names.json: - get: - tags: - - person - summary: Return a list of possible altNames that are defined in the config - responses: - 200: - description: "OK" + /1.0/person/config/alt_names.json: + get: + tags: + - person + summary: Return a list of possible altNames that are defined in the config + responses: + 200: + description: "OK" - /1.0/person/creation/authorized-centers: - get: - tags: - - person - - permissions - summary: Return a list of possible centers for person creation - responses: - 200: - description: "OK" + /1.0/person/creation/authorized-centers: + get: + tags: + - person + - permissions + summary: Return a list of possible centers for person creation + responses: + 200: + description: "OK" - /1.0/person/accompanying-course-work-evaluation-document/{id}/duplicate: - post: - tags: - - accompanying-course-work-evaluation-document - summary: Dupliate an an accompanying period work evaluation document - parameters: - - in: path - name: id - required: true - description: The document's id - schema: - type: integer - format: integer - minimum: 1 - responses: - 200: - description: "OK" - content: - application/json: - schema: - type: object + /1.0/person/accompanying-course-work-evaluation-document/{id}/duplicate: + post: + tags: + - accompanying-course-work-evaluation-document + summary: Dupliate an an accompanying period work evaluation document + parameters: + - in: path + name: id + required: true + description: The document's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 200: + description: "OK" + content: + application/json: + schema: + type: object /1.0/person/accompanying-course-work-evaluation-document/{document_id}/evaluation/{evaluation_id}/duplicate: post: @@ -2023,3 +2023,16 @@ paths: application/json: schema: type: object + + /1.0/person/identifiers/workers: + get: + tags: + - person + summary: List the person identifiers + responses: + 200: + description: "OK" + content: + application/json: + schema: + type: object diff --git a/src/Bundle/ChillPersonBundle/config/services.yaml b/src/Bundle/ChillPersonBundle/config/services.yaml index e8177171d..9aacfec08 100644 --- a/src/Bundle/ChillPersonBundle/config/services.yaml +++ b/src/Bundle/ChillPersonBundle/config/services.yaml @@ -108,3 +108,9 @@ services: Chill\PersonBundle\PersonIdentifier\Rendering\: resource: '../PersonIdentifier/Rendering' + + Chill\PersonBundle\PersonIdentifier\Normalizer\: + resource: '../PersonIdentifier/Normalizer' + + Chill\PersonBundle\PersonIdentifier\Validator\: + resource: '../PersonIdentifier/Validator' diff --git a/src/Bundle/ChillPersonBundle/config/services/actions.yaml b/src/Bundle/ChillPersonBundle/config/services/actions.yaml index d6e2c80a5..220a7483e 100644 --- a/src/Bundle/ChillPersonBundle/config/services/actions.yaml +++ b/src/Bundle/ChillPersonBundle/config/services/actions.yaml @@ -11,3 +11,9 @@ services: Chill\PersonBundle\Actions\Remove\Handler\: resource: '../../Actions/Remove/Handler' + + Chill\PersonBundle\Actions\PersonEdit\Service\: + resource: '../../Actions/PersonEdit/Service' + + Chill\PersonBundle\Actions\PersonCreate\Service\: + resource: '../../Actions/PersonCreate/Service' diff --git a/src/Bundle/ChillPersonBundle/config/services/serializer.yaml b/src/Bundle/ChillPersonBundle/config/services/serializer.yaml deleted file mode 100644 index 5a1e54400..000000000 --- a/src/Bundle/ChillPersonBundle/config/services/serializer.yaml +++ /dev/null @@ -1,11 +0,0 @@ ---- -services: - # note: normalizers are loaded from ../services.yaml - - Chill\PersonBundle\Serializer\Normalizer\: - autowire: true - autoconfigure: true - resource: '../../Serializer/Normalizer' - tags: - - { name: 'serializer.normalizer', priority: 64 } - diff --git a/src/Bundle/ChillPersonBundle/migrations/Version20250918095044.php b/src/Bundle/ChillPersonBundle/migrations/Version20250918095044.php new file mode 100644 index 000000000..9ec97e337 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/migrations/Version20250918095044.php @@ -0,0 +1,37 @@ +<?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\Person; + +use Doctrine\DBAL\Schema\Schema; +use Doctrine\Migrations\AbstractMigration; + +final class Version20250918095044 extends AbstractMigration +{ + public function getDescription(): string + { + return 'Add more details about presence in PersonIdentifier'; + } + + public function up(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_person_identifier_definition ADD presence VARCHAR(255) DEFAULT \'ON_EDIT\' NOT NULL'); + $this->addSql('UPDATE chill_person_identifier_definition SET presence = \'NOT_EDITABLE\' WHERE is_editable_by_users IS FALSE'); + $this->addSql('ALTER TABLE chill_person_identifier_definition DROP is_editable_by_users'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_person_identifier_definition ADD is_editable_by_users BOOLEAN DEFAULT false NOT NULL'); + $this->addSql('UPDATE chill_person_identifier_definition SET is_editable_by_users = true WHERE presence <> \'NOT_EDITABLE\' '); + $this->addSql('ALTER TABLE chill_person_identifier_definition DROP presence'); + } +} diff --git a/src/Bundle/ChillPersonBundle/migrations/Version20250922151020.php b/src/Bundle/ChillPersonBundle/migrations/Version20250922151020.php new file mode 100644 index 000000000..709ecbf54 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/migrations/Version20250922151020.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\Migrations\Person; + +use Doctrine\DBAL\Schema\Schema; +use Doctrine\Migrations\AbstractMigration; + +final class Version20250922151020 extends AbstractMigration +{ + public function getDescription(): string + { + return 'Add unique constraint for person identifiers'; + } + + public function up(Schema $schema): void + { + $this->addSql('CREATE UNIQUE INDEX chill_person_identifier_unique ON chill_person_identifier (definition_id, canonical)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP INDEX chill_person_identifier_unique'); + } +} diff --git a/src/Bundle/ChillPersonBundle/migrations/Version20250924101621.php b/src/Bundle/ChillPersonBundle/migrations/Version20250924101621.php new file mode 100644 index 000000000..e6f9fff54 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/migrations/Version20250924101621.php @@ -0,0 +1,53 @@ +<?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\Person; + +use Doctrine\DBAL\Schema\Schema; +use Doctrine\Migrations\AbstractMigration; + +/** + * Restrict the deletion of identifier_definition to avoid risk of error. + * + * An identifier definition can only be removed if there aren't any identifier defined. + */ +final class Version20250924101621 extends AbstractMigration +{ + public function getDescription(): string + { + return 'Restrict the deletion of identifier_definition'; + } + + public function up(Schema $schema): void + { + $this->addSql(<<<'SQL' + ALTER TABLE chill_person_identifier DROP CONSTRAINT fk_bca5a36bd11ea911 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_person_identifier ADD CONSTRAINT fk_bca5a36bd11ea911 + FOREIGN KEY (definition_id) REFERENCES chill_person_identifier_definition (id) + on delete restrict + SQL); + + } + + public function down(Schema $schema): void + { + $this->addSql(<<<'SQL' + ALTER TABLE chill_person_identifier DROP CONSTRAINT fk_bca5a36bd11ea911 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_person_identifier ADD CONSTRAINT fk_bca5a36bd11ea911 + FOREIGN KEY (definition_id) REFERENCES chill_person_identifier_definition (id) + on delete cascade + SQL); + } +} diff --git a/src/Bundle/ChillPersonBundle/migrations/Version20250926124024.php b/src/Bundle/ChillPersonBundle/migrations/Version20250926124024.php new file mode 100644 index 000000000..71630d0ad --- /dev/null +++ b/src/Bundle/ChillPersonBundle/migrations/Version20250926124024.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\Migrations\Person; + +use Doctrine\DBAL\Schema\Schema; +use Doctrine\Migrations\AbstractMigration; + +final class Version20250926124024 extends AbstractMigration +{ + public function getDescription(): string + { + return 'Add unique constraint on person_identifier: only one identifier of each kind by person'; + } + + public function up(Schema $schema): void + { + $this->addSql('CREATE UNIQUE INDEX chill_person_identifier_unique_person_definition ON chill_person_identifier (definition_id, person_id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP INDEX chill_person_identifier_unique_person_definition'); + } +} diff --git a/src/Bundle/ChillPersonBundle/translations/messages+intl-icu.fr.yaml b/src/Bundle/ChillPersonBundle/translations/messages+intl-icu.fr.yaml index 201157214..795d2ddf9 100644 --- a/src/Bundle/ChillPersonBundle/translations/messages+intl-icu.fr.yaml +++ b/src/Bundle/ChillPersonBundle/translations/messages+intl-icu.fr.yaml @@ -209,3 +209,94 @@ accompanying_course_evaluation_document: accompanying_period_work: title: Action d'accompagnement (n°{id}) - {action_title} + +add_persons: + title: "Ajouter des usagers" + suggested_counter: >- + {count, plural, + =0 {Pas de résultats} + =1 {1 résultat} + other {# résultats} + } + selected_counter: >- + {count, plural, + =1 {1 sélectionné} + other {# sélectionnés} + } + search_some_persons: "Rechercher des personnes.." + + item: + type_person: "Usager" + type_user: "TMS" + type_thirdparty: "Tiers professionnel" + type_household: "Ménage" + + person: + firstname: "Prénom" + lastname: "Nom" + born: + man: "Né le" + woman: "Née le" + neutral: "Né·e le" + center_id: "Identifiant du centre" + center_type: "Type de centre" + center_name: "Territoire" + phonenumber: "Téléphone" + mobilenumber: "Mobile" + altnames: "Autres noms" + email: "Courriel" + gender: + title: "Genre" + placeholder: "Choisissez le genre de l'usager" + woman: "Féminin" + man: "Masculin" + neutral: "Neutre, non binaire" + unknown: "Non renseigné" + undefined: "Non renseigné" + civility: + title: "Civilité" + placeholder: "Choisissez la civilité" + address: + create_address: "Ajouter une adresse" + show_address_form: "Ajouter une adresse pour un usager non suivi et seul dans un ménage" + warning: "Un nouveau ménage va être créé. L'usager sera membre de ce ménage." + center: + placeholder: "Choisissez un centre" + title: "Centre" + + error_only_one_person: "Une seule personne peut être sélectionnée !" + +renderbox: + person: "Usager" + birthday_statement: >- + {gender, select, + man {Né le {birthdate, date}} + woman {Née le {birthdate, date}} + other {Né·e le {birthdate, date}} + } + deathdate_statement: >- + {gender, select, + man {Décédé le {deathdate, date}} + woman {Décédée le {deathdate, date}} + other {Décédé·e le {deathdate, date}} + } + household_without_address: "Le ménage de l'usager est sans adresse" + no_data: "Aucune information renseignée" + type: + thirdparty: "Tiers" + person: "Usager" + holder: "Titulaire" + years_old: >- + {n, plural, + =0 {0 an} + one {1 an} + other {# ans} + } + residential_address: "Adresse de résidence" + located_at: "réside chez" + household_number: "Ménage n°{number}" + current_members: "Membres actuels" + no_current_address: "Sans adresse actuellement" + new_household: "Nouveau ménage" + no_members_yet: "Aucun membre actuellement" + diff --git a/src/Bundle/ChillPersonBundle/translations/messages+intl-icu.nl.yaml b/src/Bundle/ChillPersonBundle/translations/messages+intl-icu.nl.yaml new file mode 100644 index 000000000..fd85afb8c --- /dev/null +++ b/src/Bundle/ChillPersonBundle/translations/messages+intl-icu.nl.yaml @@ -0,0 +1,211 @@ +Born the date: >- + {gender, select, + man {Geboren op {birthdate}} + woman {Geboren op {birthdate}} + neutral {Geboren op {birthdate}} + other {Geboren op {birthdate}} + } + +Requestor: >- + {gender, select, + man {Aanvrager} + woman {Aanvraagster} + neutral {Aanvrager} + } + +accompanying_period: + Participants_without_count: >- + {count, plural, + =0 {Deelnemer} + =1 {Deelnemer} + other {Deelnemers} + } + + number: >- + nr. {id} + +person: + from_the: sinds + And himself: >- + {gender, select, + man {en hijzelf} + woman {en zijzelf} + neutral {en zijzelf} + other {en zijzelf} + } + +household: + Household: Huishouden + Household number: Huishouden {household_num} + Household members: Leden van het huishouden + Household editor: Lidmaatschap invullen + Members at same time: Gelijktijdige leden + Any simultaneous members: Geen gelijktijdige leden + Select people to move: Gebruikers kiezen + Show future or past memberships: >- + {length, plural, + one {Oud lidmaatschap tonen} + many {# oude of toekomstige lidmaatschappen tonen} + other {# oude of toekomstige lidmaatschappen tonen} + } + Show accompanying periods of past or future memberships: >- + {length, plural, + one {De trajecten van een oud lidmaatschap tonen} + many {# trajecten van oude of toekomstige lidmaatschappen tonen} + other {# trajecten van oude of toekomstige lidmaatschappen tonen} + } + Hide memberships: Verbergen + Those members does not share address: Deze gebruikers delen het adres van het huishouden niet. + Any persons into this position: Geen persoon behoort tot het huishouden in deze positie. + Leave household: Huishouden verlaten + Leave: Loskoppelen + person: + leave: De gebruiker verlaat het huishouden + Join: Huishouden aansluiten + Change position: Herpositioneren + Household file: Huishoudendossier + Add a member: Lid toevoegen + Update membership: Bewerken + successfully saved member: Lid succesvol opgeslagen + Start date: Startdatum van het lidmaatschap van het huishouden + End date: Einddatum van het lidmaatschap van het huishouden + Comment: Opmerking + is holder: Is houder + is not holder: Is geen houder + holder: Houder + Edit member household: Lidmaatschap van het huishouden invullen + Edit his household: Zijn lidmaatschap van het huishouden bewerken + Current household members: Huidige leden + Household summary: Overzicht van het huishouden + Accompanying period: Begeleidingstraject + Addresses: Adresgeschiedenis + Relationship: Verwantschap + Budget: Budget + Household relationships: Verwantschappen in het huishouden + Current address: Huidig adres + Household does not have any address currently: Het huishouden heeft momenteel geen adres ingevuld + Edit household members: Lidmaatschap van het huishouden invullen + and x other persons: >- + {x, plural, + one {en één andere persoon} + many {en # andere personen} + other {en # andere personen} + } + Expecting for birth on date: Geboorte verwacht op {date} + Expecting for birth: Geboorte verwacht (datum onbekend) + Any expecting birth: Geen aanstaande geboorte ingevuld. + New comment and expecting birth: Opmerking schrijven + Edit comment and expecting birth: Opmerking bijwerken + Edit member metadata: Aanvullende gegevens + comment_membership: Algemene opmerking over leden + expecting_birth: Geboorte verwacht? + date_expecting_birth: Datum van de verwachte geboorte + data_saved: Gegevens opgeslagen + Household history: Geschiedenis van de huishoudens + Household history for person: Geschiedenis van de huishoudens van de persoon + Household history for %name%: Geschiedenis van de huishoudens voor {name} + Household shared: Woonhuishouden + Household not shared: Huishoudens buiten woonplaats + Members without position: Leden zonder positie + Never in any household: Lid van geen enkel huishouden + Membership currently running: Lopend + from: Sinds + to: Tot + person history: Huishoudens + As member: Als + +household_composition: + Since: >- + Sinds {startDate, date, long} + Still active: Nog steeds actief + Until: >- + Tot {endDate, date, long} + numberOfChildren children in household: >- + {numberOfChildren, plural, + =0 {Geen kind in het huishouden} + one {1 kind in het huishouden} + few {# kinderen in het huishouden} + other {# kinderen in het huishouden} + } + numberOfDependents adult dependents: >- + {numberOfDependents, plural, + =0 {Geen meerderjarige ten laste} + one {1 meerderjarige ten laste} + few {# meerderjarigen ten laste} + other {# meerderjarigen ten laste} + } + numberOfDependentsWithDisabilities dependents with disabilities: >- + {numberOfDependentsWithDisabilities, plural, + =0 {Geen ten laste erkend als persoon met beperking} + one {1 ten laste erkend als persoon met beperking} + few {# ten laste erkend als persoon met beperking} + other {# ten laste erkend als persoon met beperking} + } + +periods: + title: Begeleidingstraject (nr. {id}) + show closed periods: >- + {nb_items, plural, + =0 {Geen afgesloten traject} + one {Eén afgesloten traject of oud traject tonen} + many {# afgesloten trajecten of oude trajecten tonen} + other {# afgesloten trajecten of oude trajecten tonen} + } + hide closed periods: >- + {nb_items, plural, + =0 {Geen afgesloten traject} + one {Eén afgesloten traject of oud traject verbergen} + many {# afgesloten trajecten of oude trajecten verbergen} + other {# afgesloten trajecten of oude trajecten verbergen} + } + +exports: + by_person: + Filtered by person\'s geographical unit (based on address) computed at date, only units: + "Gefilterd op geografische zone op basis van het adres, berekend op {datecalc, date, short}, alleen de volgende zones: {units}" + filter: + person: + without_participation_between_dates: + "Filtered by having no participations during period: between": "Alleen de gebruikers die door geen enkel traject zijn geraakt tussen {dateafter, date, short} en {datebefore, date, short}" + course: + not_having_address_reference: + describe: >- + Alleen de trajecten die niet gelokaliseerd zijn op een referentieadres, op de datum van {date_calc, date, medium} + by_referrer_between_dates: + description: >- + Gefilterd op referent van het traject, tussen twee data: vanaf {start_date, date, medium}, tot {end_date, date, medium}, alleen {agents} + by_user_job: + "Filtered by user job: only job": "Gefilterd op beroep van de referent tussen {startDate, date, short} en {endDate, date, short}: alleen {job}" + by_user_scope: + "Filtered by user main scope: only scopes": "Gefilterd op dienst van de referent tussen {startDate, date, short} en {endDate, date, short}: alleen {scopes}" + work: + by_treating_agent: + Filtered by treating agent at date: >- + De behandelende agenten op { agent_at, date, medium }, alleen {agents} + step_history: + by_date: + description: >- + Statuswijzigingen gefilterd op datum: na { start_date, date, medium } (inclusief), voor { end_date, date, medium } + +'total persons matching the search pattern': >- + { total, plural, + =0 {Geen gebruiker komt overeen met de zoektermen} + one {Eén gebruiker komt overeen met de zoektermen} + many {# gebruikers komen overeen met de zoektermen} + other {# gebruikers komen overeen met de zoektermen} + } + +'nb person with similar name. Please verify that this is a new person': >- + { nb, plural, + =0 {Geen gebruiker heeft een vergelijkbare naam} + one {Eén gebruiker heeft een vergelijkbare naam. Controleer dat het niet om hem/haar gaat.} + other {# gebruikers hebben een vergelijkbare naam. Controleer dat het niet om één van hen gaat} + } + +accompanying_course_evaluation_document: + title: Evaluatie (nr. {id}) - {doc_title} + duplicated_at: >- + Gedupliceerd op {at, date, long} om {at, time, short} + +accompanying_period_work: + title: Begeleidingsactie (nr. {id}) - {action_title} diff --git a/src/Bundle/ChillPersonBundle/translations/messages.fr.yml b/src/Bundle/ChillPersonBundle/translations/messages.fr.yml index cde9a6add..2159f32db 100644 --- a/src/Bundle/ChillPersonBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillPersonBundle/translations/messages.fr.yml @@ -105,6 +105,8 @@ Administrative status: Situation administrative person: Identifiers: Identifiants +person_edit: + Error while saving: Erreur lors de l'enregistrement # dédoublonnage Old person: Doublon @@ -376,7 +378,7 @@ Create a list of people according to various filters.: Crée une liste d'usagers Fields to include in export: Champs à inclure dans l'export Address valid at this date: Addresse valide à cette date Data valid at this date: Données valides à cette date -Data regarding center, addresses, and so on will be computed at this date: Les données concernant le centre, l'adresse, le ménage, sera calculé à cette date. +Data regarding center, addresses, and so on will be computed at this date: Les données concernant le territoire, l'adresse, le ménage, sera calculé à cette date. List duplicates: Liste des doublons Create a list of duplicate people: Créer la liste des usagers détectés comme doublons. Count people participating in an accompanying course: Nombre d'usagers concernés par un parcours @@ -885,6 +887,12 @@ accompanying_course: administrative_location: Localisation administrative comment is pinned: Le commentaire est épinglé comment is unpinned: Le commentaire est désépinglé + requestor: + add: Ajouter un demandeur + persons_associated: + add_person: Ajouter des usagers + resources: + add_resources: Ajouter des interlocuteurs show: Montrer hide: Masquer @@ -1110,9 +1118,9 @@ export: Group course by household composition: Grouper les usagers par composition familiale Calc date: Date de calcul de la composition du ménage by_center: - title: Grouper les usagers par centre - at_date: Date de calcul du centre - center: Centre de l'usager + title: Grouper les usagers par territoire + at_date: Date de calcul du territoire + center: Territoire de l'usager by_postal_code: title: Grouper les usagers par code postal de l'adresse at_date: Date de calcul de l'adresse @@ -1437,7 +1445,7 @@ export: acpParticipantPersons: Usagers concernés acpParticipantPersonsIds: Usagers concernés (identifiants) duration: Durée du parcours (en jours) - centers: Centres des usagers + centers: Territoires des usagers eval: List of evaluations: Liste des évaluations @@ -1562,5 +1570,50 @@ my_parcours_filters: is_open: Parcours ouverts is_closed: Parcours clôturés +person_messages: + add_persons: + title: "Ajouter des usagers" + suggested_counter: "Pas de résultats | 1 résultat | {count} résultats" + selected_counter: " 1 sélectionné | {count} sélectionnés" + search_some_persons: "Rechercher des personnes.." + item: + type_person: "Usager" + type_user: "TMS" + type_thirdparty: "Tiers professionnel" + type_household: "Ménage" + person: + firstname: "Prénom" + lastname: "Nom" + born: + man: "Né le" + woman: "Née le" + neutral: "Né·e le" + center_id: "Identifiant du centre" + center_type: "Type de centre" + center_name: "Territoire" + phonenumber: "Téléphone fixe" + mobilenumber: "Mobile" + altnames: "Autres noms" + email: "Courriel" + gender: + title: "Genre" + placeholder: "Choisissez le genre de l'usager" + woman: "Féminin" + man: "Masculin" + neutral: "Neutre, non binaire" + unknown: "Non renseigné" + undefined: "Non renseigné" + civility: + title: "Civilité" + placeholder: "Choisissez la civilité" + address: + create_address: "Ajouter une adresse" + show_address_form: "Ajouter une adresse pour un usager non suivi et seul dans un ménage" + warning: "Un nouveau ménage va être créé. L'usager sera membre de ce ménage." + center: + placeholder: "Choisissez un centre" + title: "Centre" + error_only_one_person: "Une seule personne peut être sélectionnée !" + document_duplicate: - to_evaluation_success: "Le document a été dupliquer" + to_evaluation_success: "Le document a été dupliqué" diff --git a/src/Bundle/ChillPersonBundle/translations/messages.nl.yml b/src/Bundle/ChillPersonBundle/translations/messages.nl.yml index 94ddd51b5..c4e48bbaa 100644 --- a/src/Bundle/ChillPersonBundle/translations/messages.nl.yml +++ b/src/Bundle/ChillPersonBundle/translations/messages.nl.yml @@ -1,4 +1,5 @@ Edit: Wijzigen +Select an option…: Kies een optie… 'First name': Voornaam firstname: voornaam firstName: voornaam @@ -61,6 +62,7 @@ Remove phone: Verwijderen 'Unknown spoken languages': 'Gesproken talen ongekend' Male: Man Female: Vrouw +neutral: Neutraal Both: Non-binair man: Man woman: Vrouw @@ -86,6 +88,22 @@ Civility: Aanspreektitel choose civility: -- All genders: alle genderkeuzes Any person selected: Geen persoon geselecteerd +Create a household and add an address: Een adres toevoegen voor een niet-opgevolgde persoon die alleen woont +A new household will be created. The person will be member of this household.: Er wordt een nieuw huishouden aangemaakt. De persoon wordt lid van dit huishouden. +Comment on the gender: Commentaar over het gender +genderComment: Commentaar over het gender +maritalStatus: Burgerlijke staat +maritalStatusComment: Commentaar over de burgerlijke staat +maritalStatusDate: Datum van de burgerlijke staat +memo: Commentaar +numberOfChildren: Aantal kinderen +contactInfo: Contactopmerkingen +spokenLanguages: Gesproken talen +Employment status: Beroepssituatie +Administrative status: Administratieve situatie + +person: + Identifiers: Identificatoren # dédoublonnage Old person: Dubbel @@ -112,6 +130,7 @@ not-duplicate: Vals-positief Switch to truefalse: Aanduiden als vals-positief Switch to duplicate: Aanduiden als een duplicaat No duplicate candidates: Er werden geen duplicaten of vals-positieve dossiers gedetecteerd +Person1 cannot be the same as Person2: De geselecteerde persoon mag niet dezelfde zijn als dit dossier. # addresses part address_street_address_1: Adres regel 1 @@ -200,6 +219,9 @@ Associated peoples: Betrokken personen Resources: Hulpverlening partners Any requestor to this accompanying course: Er is geen aanvrager voor dit hulpverleningstraject Social actions: Ondersteuningsmaatregelen +Social action: Ondersteuningsmaatregel +Pick a social action: Kies een ondersteuningsmaatregel +Pick a social issue: Kies een hulpvraag Last social actions: Laatste ondersteuningsmaatregelen Social issue: Hulpvraag Social issues: Hulpvragen @@ -214,13 +236,22 @@ No requestor: Geen aanvrager No resources: "Geen hulpverlening partners" Persons associated: Betrokken personen Referrer: Doorverwijzer +Referrer2: Behandelend medewerker Referrers: Doorverwijzers Some peoples does not belong to any household currently. Add them to an household soon: Sommige personen maken nog geen deel uit van een huishouden. Voeg ze zo snel mogelijk aan huishouden toe. Add to household now: Toevoegen aan een huishouden Any resource for this accompanying course: Geen enkele hulpverlening partner course.draft: Ontwerp course.closed: Afgesloten +No referrer: Geen doorverwijzer +course: + draft: Ontwerp + closed: Afgesloten + inactive_short: Buiten actieve lijst + inactive_long: Voor-archief + confirmed: Bevestigd Origin: Oorsprong van de aanvraag +This course is closed: Dit traject is afgesloten Delete accompanying period: Hulpverleningstraject verwijderen Are you sure you want to remove the accompanying period "%id%" ?: Bent u zeker het hulpverleningstraject %id% te willen verwijderen? The accompanying course has been successfully removed.: Het hulpverleningstraject werd verwijdert. @@ -239,6 +270,10 @@ List of resources: "Overzicht steunfiguren" There are no available resources: "Nog geen steunfiguur" no comment found: "Geen enkele opmerking" Select a type: "Kies een type" +person_resource: + person_non_prof: Gebruiker / niet-professionele derde + thirdparty_prof: Professionele derde + freetext: Vrije omschrijving Select a person: "Kies een persoon" Select a thirdparty: "Kies een externe partner" Contact person: "Kies een contactpersoon" @@ -302,10 +337,35 @@ CHILL_PERSON_STATS: Statistieken van personen CHILL_PERSON_LISTS: Overzicht van personen CHILL_PERSON_DUPLICATE: Duplicaten persoon behandelen CHILL_PERSON_ACCOMPANYING_PERIOD_SEE: Hulpverleningstrajecten bekijken +CHILL_PERSON_ACCOMPANYING_PERIOD_CONFIDENTIAL: Vertrouwelijke hulpverleningstrajecten bekijken en bewerken +CHILL_PERSON_ACCOMPANYING_PERIOD_DELETE: Een hulpverleningstraject verwijderen +CHILL_PERSON_ACCOMPANYING_PERIOD_RE_OPEN: Een afgesloten traject heropenen +CHILL_PERSON_ACCOMPANYING_PERIOD_TOGGLE_CONFIDENTIAL_ALL: Vertrouwelijkheid van alle trajecten aanpassen +CHILL_PERSON_ACCOMPANYING_PERIOD_CRUD_CONFIDENTIAL: Vertrouwelijke hulpverleningstrajecten bekijken +CHILL_PERSON_ACCOMPANYING_PERIOD_CREATE: Een hulpverleningstraject aanmaken +CHILL_PERSON_ACCOMPANYING_PERIOD_UPDATE: Een hulpverleningstraject bijwerken +CHILL_PERSON_ACCOMPANYING_PERIOD_FULL: Details bekijken, aanmaken, verwijderen en bijwerken van een hulpverleningstraject +CHILL_PERSON_ACCOMPANYING_COURSE_REASSIGN_BULK: Trajecten in bulk hertoewijzen +CHILL_PERSON_ACCOMPANYING_PERIOD_SEE_DETAILS: Details van een hulpverleningstraject bekijken +CHILL_PERSON_ACCOMPANYING_PERIOD_STATS: Statistieken over hulpverleningstrajecten +CHILL_PERSON_ACCOMPANYING_PERIOD_SEE_CONFIDENTIAL: Vertrouwelijke trajecten bekijken +CHILL_MAIN_ACCOMPANYING_PERIOD_WORK_CREATE: Een ondersteuningsmaatregel aanmaken +CHILL_MAIN_ACCOMPANYING_PERIOD_WORK_DELETE: Een ondersteuningsmaatregel verwijderen +CHILL_MAIN_ACCOMPANYING_PERIOD_WORK_SEE: Ondersteuningsmaatregelen bekijken +CHILL_MAIN_ACCOMPANYING_PERIOD_WORK_UPDATE: Ondersteuningsmaatregelen bijwerken +CHILL_MAIN_ACCOMPANYING_PERIOD_WORK_STATS: Statistieken over ondersteuningsmaatregelen +CHILL_MAIN_ACCOMPANYING_PERIOD_WORK_EVALUATION_SHOW: Evaluaties bekijken +CHILL_MAIN_ACCOMPANYING_PERIOD_WORK_EVALUATION_STATS: Statistieken over evaluaties +CHILL_PERSON_HOUSEHOLD_SEE: Huishoudens bekijken +CHILL_PERSON_HOUSEHOLD_EDIT: Huishoudens bewerken +CHILL_PERSON_HOUSEHOLD_STATS: Statistieken over huishoudens #period Period closed!: Hulpverleningstraject afgesloten! Pediod closing form is not valide: Het formulier voor het afsluiten van het hulpverleningstraject is niet geldig. +Consider canceled: Laat trajecten als geannuleerd tellen +Canceled parcours help: Indien aangevinkt worden trajecten met dit sluitingsmotief beschouwd als geannuleerd en niet meegeteld in statistieken. De wijziging geldt ook voor alle onderliggende motieven. +( Canceled period ): (geannuleerd) #widget @@ -313,12 +373,40 @@ Pediod closing form is not valide: Het formulier voor het afsluiten van het hulp Accompanyied people: Begeleide personen ## exports +Exports of persons: Exports van personen +Count people by various parameters.: Het aantal personen tellen op basis van diverse filters. +Count people: Aantal personen Count peoples by various parameters.: Aantal personen tellen die voldoen aan vershillende criteria. Count peoples: Aantal personen List peoples: Overzicht personen Create a list of people according to various filters.: Overzicht geven van personen die voldoen aan bepaalde criteria. Fields to include in export: Velden die toegevoegd moeten worden in de export van gegevens. Address valid at this date: Adres dat geldig is op deze datum. +Data valid at this date: Gegevens geldig op deze datum +Data regarding center, addresses, and so on will be computed at this date: Gegevens over territorium, adres en huishouden worden op deze datum berekend. +List duplicates: Lijst van dubbels +Create a list of duplicate people: Lijst aanmaken van als duplicaat gedetecteerde personen. +Count people participating in an accompanying course: Aantal personen die een traject volgen +Count people participating in an accompanying course by various parameters.: Het aantal trajectdeelnemers tellen op basis van diverse filters. + +Exports of accompanying courses: Exports van hulpverleningstrajecten +Count accompanying courses: Aantal trajecten +Count accompanying courses by various parameters: Het aantal trajecten tellen op basis van diverse filters. +Accompanying courses participation duration and number of participations: Duur en aantal deelnames aan trajecten +Create an average of accompanying courses duration of each person participation to accompanying course, according to filters on persons, accompanying course: Een rapport maken dat de gemiddelde duur van deelname van iedere persoon aan trajecten berekent, met filters op personen en trajecten. +Closingdate to apply: Einddatum te gebruiken wanneer het traject nog niet afgesloten is + +Exports of social work actions: Exports van ondersteuningsmaatregelen +Count social work actions: Aantal maatregelen +Count social work actions by various parameters: Het aantal ondersteuningsmaatregelen tellen op basis van diverse filters. +Average duration of social work actions: Gemiddelde duur van maatregelen +Calculate the average duration of social work actions: Gemiddelde duur van de ondersteuningsmaatregelen berekenen. + +Exports of evaluations: Exports van evaluaties +Count evaluations: Aantal evaluaties +Count evaluation by various parameters.: Het aantal evaluaties tellen op basis van diverse filters. + +Exports of households: Exports van huishoudens ## filters Filter by person gender: Filteren op basis van gender @@ -336,6 +424,43 @@ Born before this date: Geboren voor This field should not be empty: Dit veld mag niet leeg zijn This date should be after the date given in "born after" field: Deze datum moet vallen na de datum in het veld 'geboren na' "Filtered by person's birtdate: between %date_from% and %date_to%": "Filteren op basis van geboortedatum: enkel geboren tussen %date_from% en %date_to%" +"Filtered by person's birthdate: between %date_from% and %date_to%": "Filteren op basis van geboortedatum: enkel geboren tussen %date_from% en %date_to%" + +Filter by person's deathdate: Personen filteren op overlijdensdatum. +"Filtered by person's deathdate: between %date_from% and %date_to%": "Filteren op basis van overlijdensdatum: enkel overleden tussen %date_from% en %date_to%" +Death after this date: Overleden na deze datum +Deathdate before: Overleden voor deze datum +Alive: Levend +Deceased: Overleden +Filter in relation to this date: Filteren ten opzichte van deze datum +"Filtered by a state of %deadOrAlive%: at this date %date_calc%": "Gefilterd op personen die %deadOrAlive% zijn op datum %date_calc%" + +Filter by person's age: Personen filteren op leeftijd. +"Filtered by person's age: between %min_age% and %max_age%": "Gefilterd op leeftijd: tussen %min_age% en %max_age%" +Minimum age: Minimumleeftijd +Maximum age: Maximumleeftijd +The minimum age should be less than the maximum age.: De minimumleeftijd moet lager zijn dan de maximumleeftijd. + +Date during which residential address was valid: Datum waarop het domicilieadres geldig was + +Family composition: Gezins­samenstelling +Family composition at this time: Gezins­samenstelling op dit moment + +Filter by person's marital status: Personen filteren op burgerlijke staat +Filtered by person's marital status: Gefilterd op burgerlijke staat +Marital status at this time: Burgerlijke staat op dit moment + +Filter by entrusted child status: Personen filteren op "toevertrouwd kind" +Filtered by entrusted child status: Enkel personen die "toevertrouwd kind" zijn + +Filter by nomadic status: Personen filteren op "woonwagenbewoner" +Filtered by nomadic status: Enkel personen die "woonwagenbewoner" zijn + +"Filter by person's who have a residential address located at another user": Personen filteren die een domicilieadres hebben bij een andere gebruiker +"Filtered by person's who have a residential address located at another user": Enkel personen met een domicilieadres bij een andere gebruiker + +Filter by person's that are alive or have deceased at a certain date: Personen filteren op levend of overleden op een bepaalde datum +Filtered by person's that are alive or have deceased at a certain date: Enkel personen die levend of overleden zijn op een bepaalde datum "Filter by accompanying period: active period": "Filteren op basis van actieve hulpverleningstrajecten" Having an accompanying period opened after this date: Met een hulpverleningstraject dat geopend werd na deze datum @@ -351,7 +476,168 @@ Having an accompanying period closed after this date: Met een hulpverleningstraj "Having an accompanying period closed before this date": "Met een hulpverleningstraject afgesloten voor deze datum" "Filtered by accompanying period: persons having an accompanying period closed between the %date_from% and %date_to%": "Filteren op basis van hulpverleningstraject: met een traject dat afgesloten werd tussen %date_from% en %date_to%" +Filter by person having an activity in a period: Personen filteren die een uitwisseling hadden in de opgegeven periode +Filtered by person having an activity between %date_from% and %date_to% with reasons %reasons_name%: Enkel personen gelinkt aan een uitwisseling tussen %date_from% en %date_to% met de onderwerpen %reasons_name% + +Filter by social issue: Trajecten filteren op hulpvragen +Accepted socialissues: Hulpvragen +"Filtered by socialissues: only %socialissues%": "Gefilterd op hulpvraag: enkel %socialissues%" +Group by social issue: Trajecten groeperen op hulpvraag + +Accepted steps: Statussen +Step: Status +"Filtered by steps: only %step%": "Gefilterd op trajectstatus: enkel %step%" +Group by step: Trajecten groeperen op trajectstatus + +Filter by geographical unit: Trajecten filteren op geografische zone +Group by geographical unit: Trajecten groeperen op geografische zone +Compute geographical location at date: Datum waarop de geografische locatie wordt berekend +Geographical unit: Geografische zone +acp_geog_agg_unitname: Geografische zone +acp_geog_agg_unitrefid: Sleutel van de geografische zone +Geographical layer: Geografische laag +Select a geographical layer: Kies een geografische laag +Group people by geographical unit based on his address: Personen groeperen op geografische zone (op basis van het adres) +Filter by person's geographical unit (based on address): Personen filteren op geografische zone (op basis van het adres) + +Group by social action: Trajecten groeperen op ondersteuningsmaatregel + +Filter by type of action, goals and results: Ondersteuningsmaatregelen filteren op type, doel en resultaat +'Filtered actions by type, goals and results: %selected%': "Acties gefilterd op: %selected%" + +Filter by evaluation: Trajecten filteren op evaluatie +Accepted evaluations: Evaluaties +Evaluation: Evaluatie +"Filtered by evaluations: only %evals%": "Gefilterd op evaluatie: enkel %evals%" +Group by evaluation: Trajecten groeperen op evaluatie + +Filter accompanying course by activity type: Trajecten filteren op type uitwisseling +Accepted activitytypes: Types uitwisselingen +"Filtered by activity types: only %activitytypes%": "Gefilterd op uitwisselingstype: enkel %activitytypes%" + +Filter by origin: Trajecten filteren op oorsprong +Accepted origins: Oorsprongen +"Filtered by origins: only %origins%": "Gefilterd op oorsprong van het traject: enkel %origins%" +Group by origin: Trajecten groeperen op oorsprong + +Filter by closing motive: Trajecten filteren op sluitingsmotief +Accepted closingmotives: Sluitingsmotieven +"Filtered by closingmotive: only %closingmotives%": "Gefilterd op sluitingsmotief: enkel %closingmotives%" +Group by closing motive: Trajecten groeperen op sluitingsmotief + +Filter by administrative location: Trajecten filteren op administratieve locatie +Accepted locations: Administratieve locaties +Administrative location: Administratieve locatie +"Filtered by administratives locations: only %locations%": "Gefilterd op administratieve locatie: enkel %locations%" +Group by administrative location: Trajecten groeperen op administratieve locatie + +Filter by requestor: Trajecten filteren volgens de aard van de aanvrager +Accepted choices: '' +is person concerned: De aanvrager is een betrokken persoon +is other person: De aanvrager is een persoon maar niet betrokken +no requestor: Het traject heeft geen aanvrager +"Filtered by requestor: only %choice%": "Gefilterd op aanwezigheid van aanvrager onder de betrokken personen: enkel indien %choice%" +Group by requestor: Trajecten groeperen volgens de aard van de aanvrager + +Filter by confidential: Trajecten filteren op vertrouwelijkheid +Accepted confidentials: '' +is confidential: het traject is vertrouwelijk +is not confidential: het traject is niet vertrouwelijk +"Filtered by confidential: only %confidential%": "Gefilterd op vertrouwelijkheid: enkel indien %confidential%" +Confidentiality: Vertrouwelijkheid +Group by confidential: Trajecten groeperen op vertrouwelijkheid + +Filter by emergency: Trajecten filteren op urgentie +Accepted emergency: '' +is emergency: het traject is dringend +is not emergency: het traject is niet dringend +"Filtered by emergency: only %emergency%": "Gefilterd op urgentie: enkel indien %emergency%" +Emergency: Dringend +Group by emergency: Trajecten groeperen op urgentie + +Filter by intensity: Trajecten filteren op intensiteit +Accepted intensities: '' +is occasional: het traject is occasioneel +is regular: het traject is regelmatig +"Filtered by intensity: only %intensity%": "Gefilterd op intensiteit: enkel indien %intensity%" +Intensity: Intensiteit +Group by intensity: Trajecten groeperen op intensiteit + +Filter by active on date: Trajecten filteren die open zijn op een datum +On date: Open op deze datum +"Filtered by actives courses: active on %ondate%": "Trajecten gefilterd: actief op %ondate%" + +Filter by active at least one day between dates: Trajecten filteren die minstens één dag actief waren in de periode +"Filtered by actives courses: at least one day between %datefrom% and %dateto%": "Trajecten gefilterd: minstens één dag actief tussen %datefrom% en %dateto%" + +Filter by referrers: Trajecten filteren op referent +Accepted referrers: Referenten +"Filtered by referrer: only %referrers%": "Gefilterd op referent: enkel %referrers%" +Group by referrers: Trajecten groeperen op referent + +Filter by opened between dates: Trajecten filteren waarvan de openingsdatum tussen twee data ligt +Date from: Startdatum +Date to: Einddatum +"Filtered by opening dates: between %datefrom% and %dateto%": "Trajecten gefilterd op openingsdatum: tussen %datefrom% en %dateto%" + +Filter by temporary location: Trajecten filteren met een tijdelijke locatie +Filter by which has no referrer: Trajecten filteren zonder referent +"Filtered acp which has no referrer on date: %date%": "Gefilterd: trajecten zonder referent op datum %date%" +Has no referrer on this date: Heeft geen referent op deze datum +Filter by which has no action: Trajecten filteren zonder acties +Filtered acp which has no actions: "Gefilterd: enkel trajecten zonder acties" +Group by number of actions: Trajecten groeperen op aantal acties +Filter by creator: Trajecten filteren op aanmaker +'Filtered by creator: only %creators%': 'Gefilterd op aanmaker: enkel %creators%' + +Filter actions without end date: Acties filteren zonder einddatum (open) +Filtered actions without end date: "Gefilterd: enkel acties zonder einddatum (open)" +Filter by start date evaluations: Evaluaties filteren op startdatum +Filter by end date evaluations: Evaluaties filteren op einddatum +start period date: Startdatum van de periode +end period date: Einddatum van de periode +"Filtered by start date: between %start_date% and %end_date%": "Gefilterd op startdatum: tussen %start_date% en %end_date%" +"Filtered by end date: between %start_date% and %end_date%": "Gefilterd op einddatum: tussen %start_date% en %end_date%" +Filter by current evaluations: Evaluaties filteren die lopen +"Filtered by current evaluations": "Gefilterd: enkel lopende evaluaties" + +'Filtered by geographic unit: computed at %date%, only in %units%': 'Gefilterd op geografische eenheid: adres op %date%, enkel eenheden %units%' + +Filter by scope: Filteren op dienst + +Group social work actions by action type: Ondersteuningsmaatregelen groeperen op type +Group social work actions by goal: Ondersteuningsmaatregelen groeperen op doel +Group social work actions by result: Ondersteuningsmaatregelen groeperen op resultaat +Group social work actions by goal and result: Ondersteuningsmaatregelen groeperen op doel en resultaat +Goal Type: Doel +Result Type: Resultaat +Goal and result Type: Doel en resultaat + +Filter by evaluation type: Evaluaties filteren op type +Accepted evaluationtype: Evaluaties +"Filtered by evaluation type: only %evals%": "Gefilterd op evaluatietype: enkel %evals%" +Group by evaluation type: Evaluaties groeperen op type +Evaluation type: Evaluatietype + +Filter evaluations by maxdate mention: Evaluaties filteren met een vervaldatum +Maxdate: '' +maxdate is specified: de vervaldatum is opgegeven +maxdate is not specified: de vervaldatum is niet opgegeven +"Filtered by maxdate: only %choice%": "Gefilterd op vervaldatum: enkel indien %choice%" + +Filter by composition: Huishoudens filteren op gezins­samenstelling +Accepted composition: Gezins­samenstelling +"Filtered by composition: only %compositions% on %ondate%": "Gefilterd op gezins­samenstelling: enkel %compositions%, op datum %ondate%" +Group by composition: Huishoudens groeperen op gezins­samenstelling + +Group by number of children: Huishoudens groeperen op aantal kinderen + ## aggregators +Group by duration: Trajecten groeperen op duur +Rounded month duration: Duur in maanden (afgerond) +current duration: lopend +duration 0 month: 0 maanden (<15 dagen) +' months': ' maanden' Group people by nationality: Personen groeperen per nationaliteit Group by level: Groeperen per niveau Group by continents: Groeperen per continent @@ -359,13 +645,28 @@ Group by country: Groeperen per land Group people by gender: Groeperen op basis van gender -Aggregate by age: Groeperen op basis van -Calculate age in relation to this date: Leeftijd bereken relatief tot deze datum +Group people by their professional situation: Personen groeperen op beroepssituatie +Group people by marital status: Personen groeperen op burgerlijke staat +Group people by administrative status: Personen groeperen op administratieve situatie +Group people by employment status: Personen groeperen op beroepssituatie + +Aggregate by household position: Personen groeperen op positie in het huishouden +Household position in relation to this date: Positie in het huishouden op deze datum +Household position: Positie in het huishouden + +Aggregate by age: Personen groeperen op leeftijd +Calculate age in relation to this date: Leeftijd berekenen ten opzichte van deze datum Group people by country of birth: Personen groeperen op bais van geboorteland Similar persons: Gelijkaardige personen crud: + administrative_status: + index: + title: Administratieve situaties + add_new: Een nieuwe toevoegen + title_new: Nieuwe administratieve situatie toevoegen + title_edit: Deze administratieve situatie bijwerken closing_motive: index: title: Overzicht redenen tot afsluiten @@ -403,6 +704,122 @@ crud: add_new: Nieuw resultaat hulpverleningsmaatregel toevoegen title_new: Nieuw resultaat title_edit: Resultaat bijwerken + employment_status: + index: + title: Beroepssituaties + add_new: Een nieuwe toevoegen + title_new: Nieuwe beroepssituatie toevoegen + title_edit: Deze beroepssituatie bijwerken + origin: + index: + title: Overzicht herkomst van trajecten + add_new: Een nieuwe toevoegen + title_new: Nieuwe oorsprong + title_edit: Oorsprong bijwerken + person_marital-status: + index: + title: Overzicht burgerlijke staten + add_new: Een nieuwe toevoegen + title_new: Nieuwe burgerlijke staat + title_edit: Burgerlijke staat bijwerken + person_resource-kind: + index: + title: Types steunfiguren + add_new: Een nieuw type toevoegen + title_new: Nieuw type steunfiguur + title_edit: Type steunfiguur bijwerken + person_household_position: + index: + title: Posities + add_new: Een nieuwe toevoegen + title_new: Nieuwe positie + title_edit: Positie bijwerken + person_household_composition_type: + index: + title: Gezins­samenstellingen + add_new: Een nieuwe toevoegen + title_new: Nieuwe gezins­samenstelling + title_edit: Gezins­samenstelling bijwerken + person_relation: + index: + title: Verwantschapsrelaties + add_new: Een nieuwe toevoegen + title_new: Nieuwe verwantschapsrelatie + title_edit: Verwantschapsrelatie bijwerken + social_evaluation: + index: + title: Overzicht evaluaties + add_new: Een nieuwe evaluatie toevoegen + title_new: Nieuwe evaluatie + title_edit: Evaluatie bijwerken + +origin: + noActiveAfter: gedeactiveerd na + +evaluation: + deleted: Evaluatie verwijderd + delay: Vertraging + notificationDelay: Notificatietermijn + url: Internetlink + title: Een evaluatie schrijven + status: Status + choose_a_status: Kies een status + startdate: Startdatum + enddate: Einddatum + maxdate: Vervaldatum + warning_interval: Herinnering (dagen) + public_comment: Publieke nota + comment_placeholder: Begin te schrijven ... + generate_a_document: Een document genereren + choose_a_template: Kies een sjabloon + add_a_document: Een document toevoegen + add: Een evaluatie toevoegen + time_spent: Schrijftijd + select_time_spent: Geef de schrijftijd aan + Documents: Documenten + document_add: Een document genereren of uploaden + document_upload: Een document uploaden + document_title: Titel van het document + template_title: Naam van het sjabloon + browse: Een document toevoegen + replace: Vervangen + download: Het bestaande bestand downloaden + notification_notify_referrer: De referent verwittigen + notification_notify_any: Andere gebruikers verwittigen + notification_send: Een notificatie verzenden + document: + edit: Bijwerken + delete: Verwijderen + move: Verplaatsen + duplicate: Dupliceren + duplicate_here: Hier dupliceren + duplicate_to_other_evaluation: Dupliceren naar een andere evaluatie + duplicate_success: Het evaluatiedocument werd gedupliceerd + move_success: Het evaluatiedocument werd verplaatst + +goal: + desactivationDate: Deactiveringsdatum + results: Resultaten + +socialAction: + defaultNotificationDelay: Standaard notificatietermijn + socialIssue: Hulpvraag + +socialIssue: + isParent?: Ouder? + Parent id: ID van de ouder + +household_id: Identificatie van het huishouden +household: + allowHolder: Kan titularis zijn + shareHousehold: Lid van het huishouden + +relation: + title: Eerste lid + reverseTitle: Tweede lid + +days: dagen +years: jaren # specific to closing motive @@ -410,31 +827,40 @@ closing_motive: any parent: Geen ouder new child: Nieuw kind +Person configuration: Configuratie module "Persoon" Configuration of person bundle: Configuratie module "Persoon" person_admin: + accompanying_period: Hulpverleningstrajecten What would you like to configure ?: Wat wil u graag configureren ? closing motives: Redenen tot afsluiten closing motives list: Overzicht redenen tot afsluiten closing motive explanation: > De redenen tot afsluiten geven een indicatie over waarom een hulpverleningstraject werd afgesloten. + origin: Oorsprongen marital status: Burgerlijke staat marital status list: Overzicht burgerlijke staat marital status explanation: > Configureer lijst voor het aanduiden van de burgerlijke staat + person_resource_kind: Type steunfiguur social_issue: Hulpverleningsvragen social_action: Hulpverleningsmaatregelen social_goal: Doelen social_result: Resultaten social_evaluation: Evaluaties + social_work: Sociale begeleiding + relation: Verwantschapsrelaties # specific to accompanying period accompanying_period: + deleted: Hulpverleningstraject verwijderd dates: Periode dates_from_%opening_date%: Actief sinds %opening_date% dates_from_%opening_date%_to_%closing_date%: Actief van %opening_date% tot %closing_date% DRAFT: Ontwerp CONFIRMED: Bevestigd CLOSED: Afgesloten + CONFIRMED_INACTIVE_SHORT: Buiten actieve lijst + CONFIRMED_INACTIVE_LONG: Voor-archief emergency: Dringend occasional: éénmalig regular: regelmatig @@ -464,12 +890,21 @@ fix it: Aanvullen accompanying_course: administrative_location: Administratieve locatie comment is pinned: Deze opmerking is gepinned + comment is unpinned: De opmerking is losgemaakt + +show: Tonen +hide: Verbergen +closed periods: afgesloten trajecten +Accompanying course configuration: Beheer van hulpverleningstrajecten +Social work configuration: Beheer van ondersteuningsmaatregelen # Accompanying Course comments Accompanying Course Comment: Opmerking +Accompanying Course Comments: Opmerkingen Accompanying Course Comment list: Opmerkingen hulpverleningstraject pinned: pinnen Pin comment: Pinnen +Unpin comment: Ontpinnen Post a new comment: Een nieuwe opmerking posten Write a new comment: Een nieuwe opmerking schrijven Edit a comment: Opmerking bijwerken @@ -497,6 +932,9 @@ Concerns household n°%id%: Betrokken huishouden n°%id% Composition: huishoudenssamenstelling Budget: Budget The composition has been successfully removed.: De huishoudenssamenstelling werd verwijdert. +edit address valid from: Datum van de verhuis aanpassen +Edit household address valid from: Datum van de verhuis van het huishouden aanpassen +Household configuration: Beheer van huishoudens # accompanying course work Accompanying Course Actions: Hulpverleningsmaatregelen @@ -504,9 +942,11 @@ Accompanying Course Action: Hulpverleningsmaatregel Are you sure you want to remove this work of the accompanying period %name% ?: Bent u zeker de hulpverleningsmaatregel voor het traject %name% te willen verwijderen? The accompanying period work has been successfully removed.: De hulpverleningsmaatregel werd verwijdert accompanying_course_work: + deleted: Hulpverleningsmaatregel verwijderd create: Voeg een hulpverleningsmaatregel toe Create accompanying course work: Voeg een hulpverleningsmaatregel toe Edit accompanying course work: Hulpverleningsmaatregel bijwerken + Show accompanying course work: Hulpverleningsmaatregel List accompanying course work: Overzicht hulpverleningsmaatregelen action: Hulpverleningsmaatregel create_date: Aanmaakdatum @@ -523,6 +963,13 @@ accompanying_course_work: Any work: Geen enkele hulpverleningsmaatregel remove: Hulpverleningsmaatregel verwijderen social_evaluation: Evaluatie + private_comment: Privéopmerking + timeSpent: Schrijftijd + date_filter: Filteren op datum + types_filter: Filteren op actietype + user_filter: Filteren op intervenant + On-going works over total: Lopende acties / totaal acties van het traject + my_actions_filter: Mijn acties (waar ik optreed) # Person addresses: Verblijfsadres @@ -546,6 +993,8 @@ docgen: A context for accompanying period work evaluation: Context voor de evaluatie van de hulpverleningsmaatregel Person basic: Persoon (basis) A basic context for person: Context voor de personen + Label for third party: Label dat aan gebruikers wordt getoond + Document title: Titel van het gegenereerde document period_notification: period_designated_subject: U bent de doorverwijzer voor dit huplverleningstraject @@ -570,6 +1019,8 @@ household_composition: Add a composition: Een huishoudenssamenstelling toevoegen Update composition: huishoudenssamenstelling bijwerken Create: Een nieuwe huishoudenssamenstelling toewijzen + numberOfDependents: Aantal personen ten laste + numberOfDependentsWithDisabilities: Aantal personen met een erkende handicap ten laste # docgen Linked evaluations: Gerelateerde evaluaties @@ -577,8 +1028,544 @@ Linked evaluations: Gerelateerde evaluaties # Accompanying period per user My accompanying periods: Mijn hulpverleningstrajecten My accompanying periods in draft: Mijn hulpverleningstrajecten in ontwerp +Display draft periods created by me: Toon de trajecten in ontwerp die ik heb gemaakt. Ze zijn enkel zichtbaar voor mij en worden 15 dagen na aanmaak automatisch verwijderd. +Number of periods: Aantal trajecten period_by_user_list: Period by user: Hulpverleningstrajecten per gebruiker Pick a user: Selecteer een gebruiker om zijn/haar hulpverleningstrajecten te zien Any course or no authorization to see them: Deze gebruiker heeft geen hulpverleningstrajecten of geen waartoe u toegang heeft. + +workflow: + doc for evaluation deleted: Document verwijderd in een evaluatie + SocialAction deleted: Ondersteuningsmaatregel verwijderd + signature_list: + title: Handtekening in afwachting + menu: Handtekening in afwachting + no_signatures: Geen aangevraagde handtekeningen + +reassign: + Bulk reassign: Trajecten herverdelen + Current user: Trajecten per referent + Next user: Nieuwe referent + Choose a user and click on "Filter" to apply: Kies een gebruiker en klik op "Filteren" om de trajecten te tonen + All periods on this list will be reassigned to this user, excepted the one you manually reassigned before: Alle trajecten op deze pagina worden aan deze gebruiker toegewezen, behalve die u manueel anders toewijst. + Reassign: Referent toewijzen + List periods to be able to reassign them: Kies een gebruiker en klik op "Filteren" om zijn/haar trajecten te tonen. Daarna kunt u ze herverdelen. + Filter by postal code: Filteren op postcode + Filter course which are located inside a postal code: Alleen trajecten tonen die bij deze postcode gelokaliseerd zijn (een gemeente kan meerdere postcodes hebben). + +notification: + Notify referrer: Referent verwittigen + Notify any: Andere gebruikers verwittigen + +personId: Identificatie van de persoon + +export: + enum: + frequency: + YYYY-IW: per week + YYYY-MM: per maand + YYYY: per jaar + export: + acp_closing: + title: Aantal statuswijzigingen van trajecten + description: Telt het aantal statuswijzigingen van trajecten. Deze export is geschikt om het aantal trajecten te kennen die binnen een periode geopend of gesloten werden (een traject kan binnen de periode gesloten en opnieuw geopend worden). + acp_stats: + avg_duration: Gemiddelde duur van deelname van elke betrokken persoon + count_participations: Aantal afzonderlijke deelnames + count_persons: Aantal verschillende betrokken personen + count_acps: Aantal verschillende trajecten + + nb_household_with_course: + Count households with accompanying course: Aantal huishoudens betrokken bij een traject + Count households: Aantal huishoudens + Count accompanying periods: Aantal trajecten + Count household with accompanying course by various parameters.: Telt het aantal huishoudens met een traject volgens diverse filters. + Date of calculation of household members: Datum waarop de leden van het huishouden worden berekend + + count_accompanying_period_work_associate_person: + title: Aantal acties, filters en groeperingen op de personen van het traject + description: Telt het aantal ondersteuningsmaatregelen met filters en groeperingen op personen, trajecten en acties. De filters en groeperingen op personen werken op de betrokken personen van het traject van de actie. + header: Aantal acties + + count_accompanying_period_work_associate_work: + title: Aantal acties, filters en groeperingen op de personen van de actie + description: Telt het aantal ondersteuningsmaatregelen met filters en groeperingen op personen, trajecten en acties. De filters en groeperingen op personen werken op de betrokken personen van de actie. + header: Aantal acties + count_person_on_acpw_associate_person_on_work: + title: Aantal personen betrokken bij een ondersteuningsmaatregel, filters en groeperingen op de personen van de actie + description: Telt het aantal personen betrokken bij een ondersteuningsmaatregel met filters en groeperingen op personen, trajecten en acties. De filters en groeperingen op personen werken op de betrokken personen van de actie. Als iemand bij meerdere acties betrokken is, wordt hij slechts eenmaal geteld. + header: Aantal personen betrokken bij een actie + + avg_duration_acpw_associate_on_period: + title: Gemiddelde duur van ondersteuningsmaatregelen, filters en groeperingen op de personen van het traject + header: Gemiddelde duur van ondersteuningsmaatregelen (in dagen) + description: Berekent de gemiddelde duur van ondersteuningsmaatregelen met filters en groeperingen op personen, trajecten en acties. De filters en groeperingen werken op de betrokken personen van het traject van de actie. + + avg_duration_acpw_associate_on_work: + title: Gemiddelde duur van ondersteuningsmaatregelen, filters en groeperingen op de personen van de actie + header: Gemiddelde duur van ondersteuningsmaatregelen (in dagen) + description: Berekent de gemiddelde duur van ondersteuningsmaatregelen met filters en groeperingen op personen, trajecten en acties. De filters en groeperingen werken op de betrokken personen van de actie. + + aggregator: + person: + by_household_composition: + Household composition: Huishoudsamenstelling + Group course by household composition: Trajecten groeperen op huishoudsamenstelling + Calc date: Datum voor berekening van de huishoudsamenstelling + by_center: + title: Personen groeperen op territorium + at_date: Datum voor berekening van het territorium + center: Territorium van de persoon + by_postal_code: + title: Personen groeperen op postcode van het adres + at_date: Datum voor berekening van het adres + header: Postcode + + step_history: + by_step: + title: Statuswijzigingen van traject groeperen per stap + header: Nieuwe trajectstatus + by_date: + title: Statuswijzigingen van traject groeperen per datum + header: Datum van statuswijziging van traject + date_grouping_label: Groeperen per + by_closing_motive: + title: Statuswijzigingen van traject groeperen per sluitingsmotief + header: Sluitingsmotief + + course: + by-user: + title: Trajecten groeperen per betrokken persoon + header: Betrokken persoon + by_referrer: + Referrer after: Referent vanaf + Until: Tot + by_referrer_scope: + Referrer and scope after: Referent en dienst vanaf + Until: Tot + by_referrer_job: + Referrer and job after: Referent en beroep vanaf + Until: Tot + by_user_scope: + Group course by referrer's scope: Trajecten groeperen per dienst van de referent + Referrer's scope: Dienst van de trajectreferent + by_user_job: + Group by user job: Trajecten groeperen per beroep van de referent + duration: + day: Duur van het traject in dagen + week: Duur van het traject in weken + month: Duur van het traject in maanden + Precision: Eenheid van de duur + by_number_of_action: + Number of actions: Aantal acties + by_creator_job: + Creator's job: Beroep van de aanmaker + Group by creator job: Trajecten groeperen per beroep van de aanmaker + by_user_working: + title: Trajecten groeperen per intervenant + user: Intervenant + by_job_working: + title: Trajecten groeperen per beroep van de intervenant + job: Beroep van de intervenant + Calc date: Datum voor berekening van het beroep van de intervenant + by_scope_working: + title: Trajecten groeperen per dienst van de intervenant + scope: Dienst van de intervenant + Calc date: Datum voor berekening van de dienst van de intervenant + by_scope: + Group course by scope: Trajecten groeperen per dienst + by_opening_date: + title: Trajecten groeperen per openingsdatum + frequency: Groeperingsinterval + header: Openingsdatum van trajecten (periode) + by_closing_date: + title: Trajecten groeperen per sluitingsdatum + frequency: Groeperingsinterval + header: Sluitingsdatum van trajecten (periode) + + course_work: + by_treating_agent: + Calc date: Referent op datum + Group by treating agent: Acties groeperen per behandelend medewerker + by_current_action: + Current action ?: Lopende actie? + Group by current actions: Acties groeperen op lopend + Current action: Lopende actie + Not current action: Afgeronde actie + by_agent_scope: + Group by treating agent scope: Acties groeperen per dienst van de behandelaar + Calc date: Datum voor berekening van de dienst van de behandelaar + by_agent_job: + Group by treating agent job: Acties groeperen per beroep van de behandelaar + Calc date: Datum voor berekening van het beroep van de behandelaar + by_handling_third_party: + title: Acties groeperen per behandelende derde + header: Behandelende derde + by_creator: + title: Acties groeperen per aanmaker + Creator: Aanmaker van de actie + by_creator_job: + title: Acties groeperen per beroep van de aanmaker + Creator's job: Beroep van de aanmaker + by_creator_scope: + title: Acties groeperen per dienst van de aanmaker + Creator's scope: Dienst van de aanmaker + + eval: + by_end_date: + Has end date ?: Evaluatie in uitvoering? + Group evaluations having end date: Evaluaties groeperen (met of zonder einddatum) + enddate is specified: de einddatum is opgegeven + enddate is not specified: de einddatum is niet opgegeven + Group by end date evaluations: Evaluaties groeperen per week/maand/jaar van de einddatum + End date period: Einde (per periode) + by_start_date_period: + Start date period: Begin (per periode) + Group by start date evaluations: Evaluaties groeperen per week/maand/jaar van de startdatum + by_max_date: + Group by max date evaluations: Evaluaties groeperen per week/maand/jaar van de vervaldatum + Max date: Vervaldatum + filter: + by_geog_unit: + Filtered by person's geographical unit (based on address) computed at %datecalc%, only %units%: Gefilterd op geografische eenheid (op basis van adres), berekend op %datecalc%, enkel %units% + + step_history: + by_step: + title: Statuswijzigingen van traject filteren per stap + pick_steps: Nieuwe stappen + description: "Gefilterd op stap: enkel %steps%" + by_date: + title: Statuswijzigingen van traject filteren per datum + start_date_label: Wijzigingen na + end_date_label: Wijzigingen voor + + person: + by_composition: + Filter by household composition: Personen filteren op huishoudsamenstelling + Accepted compositions: Huishoudsamenstellingen + Date calc: Berekeningsdatum + 'Filtered by composition at %date%: only %compositions%': 'Gefilterd op huishoudsamenstelling, op %date%, enkel %compositions%' + by_no_composition: + Filter persons without household composition: Personen filteren zonder huishoudsamenstelling (noch huishouden) + Persons filtered by no composition at %date%: Enkel personen zonder huishoudsamenstelling op %date% + Date calc: Berekeningsdatum + by_address_ref_status: + Filter by person's address ref status: Personen filteren via vergelijking met referentieadres + to_review: Verschilt van referentieadres + reviewed: Verschilt van referentieadres maar behouden door gebruiker + match: Identiek aan het referentieadres + "Filtered by person\\'s address status computed at %datecalc%, only %statuses%": Gefilterd op vergelijking met referentieadres, berekend op %datecalc%, enkel %statuses% + Filtered by person's address status computed at %datecalc%, only %statuses%: Gefilterd op vergelijking met referentieadres, berekend op %datecalc%, enkel %statuses% + Status: Status + Address at date: Adres op datum + with_participation_between_dates: + date_after: Betrokken bij een traject na + date_before: Betrokken bij een traject voor + title: Personen filteren die minstens één dag aan een traject verbonden waren in de opgegeven periode + 'Filtered by participations during period: between %dateafter% and %datebefore%': 'Gefilterd op personen met een traject tussen %dateafter% en %datebefore%' + without_participation_between_dates: + date_after: Na + date_before: Voor + title: Personen filteren die aan geen enkel traject gekoppeld waren + gender: + no_gender: gender niet gespecificeerd + + course: + not_having_address_reference: + title: Trajecten filteren zonder locatie op een referentieadres + adress_at: Adres op datum + having_info_within_interval: + title: Trajecten filteren die tussen twee data een interventie kregen + start_date: Begin van de periode + end_date: Einde van de periode + Only course with events between %startDate% and %endDate%: Alleen trajecten met een interventie tussen %startDate% en %endDate% + by_user_working: + title: Trajecten filteren per intervenant, tussen twee data + 'Filtered by user working on course: only %users%, between %start_date% and %end_date%': 'Gefilterd op intervenanten van het traject: enkel %users%, tussen %start_date% en %end_date%' + User working after: Interventie na + User working before: Interventie voor + by_job_working: + title: Trajecten filteren per beroep van de intervenant, tussen twee data + 'Filtered by job working on course: only %jobs%, between %start_date% and %end_date%': 'Gefilterd op beroep van intervenanten op het traject: enkel %jobs%, tussen %start_date% en %end_date%' + Job working after: Interventie na + Job working before: Interventie voor + Calc date: Berekeningsdatum + by_scope_working: + title: Trajecten filteren per dienst van de intervenant, tussen twee data + 'Filtered by scope working on course: only %scopes%, between %start_date% and %end_date%': 'Gefilterd op dienst van intervenanten op het traject: enkel %scopes%, tussen %start_date% en %end_date%' + Scope working after: Interventie na + Scope working before: Interventie voor + Calc date: Berekeningsdatum + by_step: + Filter by step: Trajecten filteren op trajectstatus + Filter by step between dates: Trajecten filteren op trajectstatus tussen twee data + steps: Geselecteerde statussen + date_calc: Datum waarop de status geldt + date_from: Status behaald na deze datum + date_to: Status behaald voor deze datum + 'Filtered by steps: only %step% and between %date_from% and %date_to%': 'Gefilterd op status: enkel %step%, tussen %date_from% en %date_to%' + by_user_scope: + Filter by user scope: Trajecten filteren op dienst van de referent + Start from: Referent en dienst vanaf + Until: Tot + by_referrer: + Computation date for referrer: Datum waarop de referent actief was + by_referrer_between_dates: + title: Trajecten filteren op referent (tussen twee data) + start date: De referent was actief na + end date: De referent was actief voor + having_temporarily: + label: Kwaliteit van de locatie + Having a temporarily location: Met een tijdelijke locatie + Having a person's location: Gelokaliseerd bij een persoon + Calculation date: Datum van de locatie + creator_job: + Filter by creator job: Trajecten filteren op beroep van de aanmaker + 'Filtered by creator job: only %jobs%': 'Gefilterd op beroep van de aanmaker: enkel %jobs%' + by_user_job: + Filter by user job: Trajecten filteren op beroep van de referent + Start from: Referent en beroep vanaf + Until: Tot + by_social_action: + title: Trajecten filteren op ondersteuningsmaatregel + Accepted socialactions: Ondersteuningsmaatregelen + accepted socialations help: Indien leeg, worden alle actietypes meegenomen + "Filtered by socialactions: only %socialactions%": "Gefilterd op ondersteuningsmaatregel: enkel %socialactions%, startdatum na %start_date_after% en voor %start_date_before%, einddatum na %end_date_after% en voor %end_date_before%" + start date after: Startdatum van de actie na + start date after help: Wordt genegeerd indien leeg + start date before: Startdatum van de actie voor + start date before help: Wordt genegeerd indien leeg + end date after: Einddatum van de actie na + end date after help: Wordt genegeerd indien leeg. Acties zonder einddatum worden altijd meegenomen. + end date before: Einddatum van de actie voor + end date before help: Wordt genegeerd indien leeg. Acties zonder einddatum worden altijd meegenomen. + date ignored: datumclausule genegeerd + + work: + start_between_dates: + title: Acties filteren waarvan de startdatum tussen twee data ligt + start_date: Startdatum + end_date: Einddatum + keep_null: Acties zonder startdatum behouden + keep_null_help: Indien aangevinkt worden acties zonder startdatum meegeteld. Indien niet, worden ze niet meegeteld. + Only where start date is between %startDate% and %endDate%: Enkel acties waarvan de startdatum tussen %startDate% en %endDate% ligt + end_between_dates: + title: Acties filteren waarvan de einddatum tussen twee data ligt (of actie nog loopt) + start_date: Startdatum + end_date: Einddatum + keep_null: Acties zonder einddatum behouden (lopende acties) + keep_null_help: Indien aangevinkt worden acties zonder einddatum meegeteld. Indien niet, worden ze niet meegeteld. + Only where start date is between %startDate% and %endDate%: Enkel acties waarvan de einddatum tussen %startDate% en %endDate% ligt + by_user_job: + Filter by treating agent job: Acties filteren op beroep van de behandelaar + "Filtered by treating agent job: only %jobs%": "Gefilterd op beroep van de behandelaar: enkel %jobs%" + Calc date: Datum voor berekening van het beroep van de behandelaar + by_user_scope: + Filter by treating agent scope: Acties filteren op dienst van de behandelaar + "Filtered by treating agent scope: only %scopes%": "Gefilterd op dienst van de behandelaar: enkel %scopes%" + Calc date: Datum voor berekening van de dienst van de behandelaar + by_treating_agent: + Filter by treating agent: Acties filteren op behandelend medewerker + Accepted agents: Behandelend medewerker + Calc date: Datum waarop de medewerker als behandelaar actief is + calc_date_help: Dit is de datum waarop de medewerker actief is als behandelaar, niet de datum van de toewijzing. + "Filtered by treating agent: only %agents%": "Gefilterd op behandelend medewerker: enkel %agents%" + by_handling3party: + title: Acties filteren op behandelende derde + Only 3 parties %3parties%: "Alleen acties met als behandelende derde: %3parties%" + pick_3parties: Behandelende derden van de acties + by_creator: + title: Acties filteren op aanmaker + Creators: Aanmaker van de actie + "Filtered by creator: only %creators%": "Gefilterd op aanmaker van de actie: enkel %creators%" + by_creator_job: + title: Acties filteren op beroep van de aanmaker + "Filtered by creator job: only %jobs%": "Gefilterd op beroep van de aanmaker: enkel %jobs%" + by_creator_scope: + title: Acties filteren op dienst van de aanmaker + "Filtered by creator scope: only %scopes%": "Gefilterd op dienst van de aanmaker: enkel %scopes%" + evaluation_between_dates: + title: Acties filteren die gekoppeld zijn aan een evaluatie tussen twee data + description: Enkel acties gekoppeld aan een evaluatie aangemaakt tussen %startDate% en %endDate% + start_date: Startdatum + end_date: Einddatum + + list: + person_with_acp: + List peoples having an accompanying period: Lijst van personen met een hulpverleningstraject + List peoples having an accompanying period with period details: Lijst van betrokken personen met details per traject + Create a list of people having an accompaying periods, according to various filters.: Maak een lijst van personen met een traject, volgens diverse criteria over traject of persoon + Create a list of people having an accompaying periods with details of period, according to various filters.: Maak een lijst van personen met een traject, volgens diverse criteria, met trajectdetails toegevoegd. + + acp: + List of accompanying periods: Lijst van hulpverleningstrajecten + Generate a list of accompanying periods, filtered on different parameters.: Genereer een lijst van trajecten, gefilterd op verschillende parameters. + Date of calculation for associated elements: Datum voor berekening van gekoppelde elementen + The associated referree, localisation, and other elements will be valid at this date: De gekoppelde referent, locatie en andere elementen zijn geldig op deze datum + acpId: Traject-ID + openingDate: Openingsdatum van het traject + closingDate: Sluitingsdatum van het traject + closingMotive: Sluitingsmotief + job: Beroep + confidential: Vertrouwelijk + emergency: Dringend + intensity: Intensiteit + acpCreatedAt: Aangemaakt op + acpUpdatedAt: Laatste update op + acpOrigin: Oorsprong van het traject + origin: Oorsprong van het traject + acpClosingMotive: Sluitingsmotief + acpJob: Beroep van het traject + acpCreatedBy_id: Aangemaakt door (ID) + acpCreatedBy: Aangemaakt door + acpUpdatedBy_id: Laatste wijziging door (ID) + acpUpdatedBy: Laatste wijziging door + administrativeLocation: Administratieve locatie + step: Stap + stepSince: Laatste wijziging van de stap + referrer: Referent + referrerSince: Referent sinds + locationIsPerson: Traject gelokaliseerd bij een betrokken persoon + locationIsTemp: Traject met tijdelijke locatie of gelokaliseerd bij een persoon + locationPersonName: Persoon bij wie het traject gelokaliseerd is + locationPersonId: ID van de persoon bij wie het traject gelokaliseerd is + acpaddress_fieldscountry: Land van het adres + isRequestorPerson: Is de aanvrager een persoon? + requestorPersonId: ID van de aanvrager (persoon) + acprequestorPerson: Naam van de aanvrager (persoon) + scopes: Diensten + socialIssues: Hulpvragen + requestorPerson: Aanvrager (persoon) + requestorThirdParty: Aanvrager (derde) + acpParticipantPersons: Betrokken personen + acpParticipantPersonsIds: Betrokken personen (ID's) + duration: Duur van het traject (in dagen) + centers: Territoria van de personen + + eval: + List of evaluations: Lijst van evaluaties + Generate a list of evaluations, filtered on different parameters: Genereer een lijst van evaluaties, gefilterd op verschillende parameters. + Date of calculation for associated elements: Datum voor berekening van gekoppelde elementen + help_description: De gekoppelde elementen, zoals referent en adres, worden op deze datum geëvalueerd + id: Evaluatie-ID + startDate: Startdatum + endDate: Einddatum + maxDate: Vervaldatum + warningInterval: Herinnering + acpw_id: Actie-ID + acpw_startDate: Start van de actie + acpw_endDate: Einde van de actie + acpw_socialaction_id: Actie-ID + acpw_socialaction: Titel van de actie + acpw_socialissue: Hulpvraag + acpw_note: Opmerking over de actie + acpw_acp_id: Traject-ID + acpw_acp_user: Referent van het traject + acpw_referrers: Behandelend medewerker op de opgegeven datum + acpw_persons_id: ID van de betrokken personen + acpw_persons: Betrokken personen van de actie + comment: Opmerking bij de evaluatie + eval_title: Titel van de evaluatie + createdAt: Aanmaakdatum + updatedAt: Wijzigingsdatum + createdBy: Aangemaakt door + updatedBy: Gewijzigd door + timeSpent: Schrijftijd (minuten) + + acpw_associate_work: + List of accompanying period works: Lijst van acties, filters op de personen van de actie + List description: Telt het aantal ondersteuningsmaatregelen met mogelijke filters op personen, trajecten en acties. De filters en groeperingen hebben betrekking op de betrokken personen van de actie. + + acpw_associate_period: + List of accompanying period works: Lijst van acties, filters op de personen van het traject + List description: Genereer een lijst van ondersteuningsmaatregelen met mogelijke filters op personen, trajecten en acties. De personenfilters werken op de betrokken personen van het traject van de actie. + + acpw: + List of accompanying period works: Lijst van acties + List description: Genereer een lijst van ondersteuningsmaatregelen, gefilterd op verschillende parameters. + Date of calculation for associated elements: Datum voor berekening van gekoppelde elementen + help_description: De behandelend medewerker van de actie is geldig op deze datum + id: Actie-ID + startDate: Startdatum + endDate: Einddatum + note: Opmerking over de actie + createdAt: Aanmaakdatum + updatedAt: Wijzigingsdatum + socialActionId: Actie-ID + socialAction: Titel van de actie + socialIssue: Hulpvraag + createdBy: Aangemaakt door + updatedBy: Gewijzigd door + acp_id: Traject-ID + acp_user: Referent van het traject + acpwReferrers: Behandelende medewerkers + referrer: Referent van het traject + personsId: ID's van de betrokken personen + personsName: Betrokken personen van de actie + goalsId: Doel-ID's + goalsTitle: Doelen + goalResultsId: Resultaat-ID's van doelen + goalResultsTitle: Resultaten van doelen + resultsId: Resultaat-ID's + resultsTitle: Resultaten + evaluationsId: Evaluatie-ID's + evaluationsTitle: Evaluaties + + household: + List household associated with accompanying period title: Lijst van huishoudens betrokken bij een traject + List description: Genereer de lijst van huishoudens, gefilterd volgens diverse parameters. + Date of calculation for associated elements: Datum voor berekening van gekoppelde elementen + help_description: De gekoppelde elementen, zoals adres, leden en samenstelling van het huishouden, zijn geldig op deze datum + id: Huishouden-ID + address: Adres van het huishouden + membersId: ID's van de leden van het huishouden + membersName: Leden van het huishouden + membersCount: Aantal leden + compositionNumberOfChildren: Aantal kinderen in de samenstelling + compositionComment: Opmerking over de samenstelling + compositionType: Type samenstelling + + acpaddress_fieldscountry: Land + +social_action: + and children: en afgeleiden + +social_issue: + and children: en afgeleiden + +generic_doc: + filter: + keys: + accompanying_period_work_evaluation_document: Document van ondersteuningsmaatregelen + +entity_display_title: + Evaluation (n°%eval%): Evaluatie (n°%eval%) + Work (n°%w%): Ondersteuningsmaatregel (n°%w%) + Accompanying Course (n°%w%): Hulpverleningstraject (n°%w%) + +acpw_duplicate: + title: Ondersteuningsmaatregelen samenvoegen + description: Deze samenvoeging bewaart de oudste startdatum, de meest recente einddatum, alle evaluaties, documenten en workflows. Behandelaars en derde partijen worden samengevoegd. Opmerkingen worden achter elkaar geplaatst. + Select accompanying period work: Een ondersteuningsmaatregel selecteren + Select an evaluation: Een evaluatie selecteren + Assign duplicate: Een dubbele ondersteuningsmaatregel aanduiden + Accompanying period work to delete: Ondersteuningsmaatregel te verwijderen + Accompanying period work to delete explanation: Deze ondersteuningsmaatregel wordt verwijderd. + Accompanying period work to keep: Ondersteuningsmaatregel te behouden + to keep: Te behouden ondersteuningsmaatregel + to delete: Te verwijderen ondersteuningsmaatregel + Successfully merged: Ondersteuningsmaatregel succesvol samengevoegd. + You cannot merge a accompanying period work with itself. Please choose a different one: U kunt een ondersteuningsmaatregel niet met zichzelf samenvoegen. Kies een andere. + +my_parcours_filters: + referrer_parcours_and_acpw: Behandelend medewerker of referent + referrer_acpw: Behandelend medewerker van een actie + referrer_parcours: Referent + parcours_intervening: Intervenant + is_open: Open trajecten + is_closed: Afgesloten trajecten + +document_duplicate: + to_evaluation_success: Het document werd gedupliceerd diff --git a/src/Bundle/ChillPersonBundle/translations/validators+intl-icu.fr.yaml b/src/Bundle/ChillPersonBundle/translations/validators+intl-icu.fr.yaml new file mode 100644 index 000000000..3b65462e1 --- /dev/null +++ b/src/Bundle/ChillPersonBundle/translations/validators+intl-icu.fr.yaml @@ -0,0 +1,7 @@ +person_identifier: + fixed_length: >- + {limit, plural, + =1 {L'identifier doit contenir exactement 1 caractère} + other {L'identifiant doit contenir exactement # caractères} + } + only_number: "L'identifiant ne doit contenir que des chiffres" diff --git a/src/Bundle/ChillPersonBundle/translations/validators.fr.yml b/src/Bundle/ChillPersonBundle/translations/validators.fr.yml index 691fef833..02ad95de7 100644 --- a/src/Bundle/ChillPersonBundle/translations/validators.fr.yml +++ b/src/Bundle/ChillPersonBundle/translations/validators.fr.yml @@ -23,7 +23,7 @@ The gender must be set: Le genre doit être renseigné You are not allowed to perform this action: Vous n'avez pas le droit de changer cette valeur. Sorry, but someone else has already changed this entity. Please refresh the page and apply the changes again: Désolé, mais quelqu'un d'autre a déjà modifié cette entité. Veuillez actualiser la page et appliquer à nouveau les modifications -A center is required: Un centre est requis +A center is required: Un territoire est requis #export list You must select at least one element: Vous devez sélectionner au moins un élément @@ -73,5 +73,9 @@ relationship: person_creation: If you want to create an household, an address is required: Pour la création d'un ménage, une adresse est requise +person_identifier: + This identifier must be set: Cet identifiant doit être présent. + Identifier must be unique. The same identifier already exists for {{ persons }}: Identifiant déjà utilisé pour {{ persons }} + accompanying_course_work: The endDate should be greater or equal than the start date: La date de fin doit être égale ou supérieure à la date de début diff --git a/src/Bundle/ChillPersonBundle/translations/validators.nl.yml b/src/Bundle/ChillPersonBundle/translations/validators.nl.yml index 014fa0822..2ca6e1549 100644 --- a/src/Bundle/ChillPersonBundle/translations/validators.nl.yml +++ b/src/Bundle/ChillPersonBundle/translations/validators.nl.yml @@ -9,4 +9,69 @@ 'Opening date is not valid': 'Startdatum is ongeldig' 'Opening date can not be null': 'Startdatum kan niet nul zijn' 'Closing date is not valid': 'De datum van afsluiten is niet correct' -'Closing date can not be null': 'De datum van afsluiten kan niet nul zijn' \ No newline at end of file +'Closing date can not be null': 'De datum van afsluiten kan niet nul zijn' +The date of closing is before the date of opening: De sluitingsdatum ligt voor de openingsdatum +The closing date must be later than the date of creation: De sluitingsdatum moet later zijn dan de aanmaakdatum van het traject +The birthdate must be before %date%: De geboortedatum moet voor %date% zijn +'Invalid phone number: it should begin with the international prefix starting with "+", hold only digits and be smaller than 20 characters. Ex: +33123456789': 'Ongeldig telefoonnummer: het moet beginnen met het internationale voorvoegsel voorafgegaan door "+", alleen cijfers bevatten en minder dan 20 tekens zijn. Bijv.: +31623456789' +'Invalid phone number: it should begin with the international prefix starting with "+", hold only digits and be smaller than 20 characters. Ex: +33623456789': 'Ongeldig telefoonnummer: het moet beginnen met het internationale voorvoegsel voorafgegaan door "+", alleen cijfers bevatten en minder dan 20 tekens zijn. Bijv.: +33623456789' +'The email is not valid': 'Het e-mailadres is niet geldig' +Two addresses has the same validFrom date: De geldigheidsdatum is identiek aan die van een ander adres +The firstname cannot be empty: De voornaam kan niet leeg zijn +The lastname cannot be empty: De achternaam kan niet leeg zijn +The gender must be set: Het geslacht moet worden ingevuld +You are not allowed to perform this action: U heeft niet het recht om deze waarde te wijzigen. +Sorry, but someone else has already changed this entity. Please refresh the page and apply the changes again: Sorry, maar iemand anders heeft deze entiteit al gewijzigd. Vernieuw de pagina en pas de wijzigingen opnieuw toe + +A center is required: Een territorium is vereist + +#export list +You must select at least one element: U moet ten minste één element selecteren + +# filter by person birthdate +The "date to" should not be empty: Het veld "geboren vóór deze datum" kan niet leeg zijn +The "date from" should not be empty: Het veld "geboren na deze datum" kan niet leeg zijn +The date "date to" should be after the date given in "date from" field: De datum "geboren vóór deze datum" moet eerder zijn dan de datum "geboren na deze datum". + +# filter by nationality +A nationality must be selected: Een nationaliteit moet worden gekozen + +# aggregator by country, nationality +You should select an option: Een optie moet worden gekozen. + +# aggregator by age +The date should not be empty: De datum mag niet leeg zijn + +# household +household: + max_holder_overflowed_infinity: Er kunnen niet meer dan twee houders tegelijk zijn. Met deze wijziging zal dit aantal vanaf {{ start }} worden overschreden. + max_holder_overflowed: Er kunnen niet meer dan twee houders tegelijk zijn. Met deze wijziging zal dit aantal tussen {{ start }} en {{ end }} worden overschreden. +household_membership: + The end date must be after start date: De einddatum van het lidmaatschap moet later zijn dan de begindatum. + Person with membership covering: Een gebruiker kan niet tegelijkertijd tot twee huishoudens behoren. Met deze wijziging zou %person_name% vanaf %from% tot %nbHousehold% huishoudens behoren. + +# Accompanying period +'{{ name }} is already associated to this accompanying course.': '{{ name }} is al gekoppeld aan dit traject.' +A course must contains at least one social issue: 'Een traject moet aan ten minste één sociale problematiek worden gekoppeld' +A course must be associated to at least one scope: 'Een traject moet aan ten minste één dienst worden gekoppeld' +The social %name% issue cannot be deleted because it is associated with an activity or an action: 'De sociale problematiek "%name%" kan niet worden verwijderd omdat deze is gekoppeld aan een uitwisseling of een actie' +A confidential parcours must have a referrer: 'Een vertrouwelijk traject moet een referent hebben' +Only the referrer can change the confidentiality of a parcours: 'Alleen de referent kan de vertrouwelijkheid wijzigen' + +# resource +You must associate at least one entity: Koppel een gebruiker, een derde of geef een vrije beschrijving op +You cannot associate a resource with the same person: U kunt de gebruiker zelf niet als hulpbron toevoegen. + +#location +The period must remain located: 'Een traject moet worden gelokaliseerd' +The person where the course is located must be associated to the course. Change course's location before removing the person.: "Het traject is gelokaliseerd bij deze gebruiker. Wijzig de lokalisatie van het traject voordat u de gebruiker verwijdert" + +#relationship +relationship: + duplicate: Er bestaat al een verwantschapsrelatie tussen deze 2 gebruikers + +person_creation: + If you want to create an household, an address is required: Voor het aanmaken van een huishouden is een adres vereist + +accompanying_course_work: + The endDate should be greater or equal than the start date: De einddatum moet gelijk zijn aan of later dan de begindatum diff --git a/src/Bundle/ChillReportBundle/translations/messages.fr.yml b/src/Bundle/ChillReportBundle/translations/messages.fr.yml index c22ed17fb..a8d5ed979 100644 --- a/src/Bundle/ChillReportBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillReportBundle/translations/messages.fr.yml @@ -9,7 +9,7 @@ 'Report list': 'Liste des rapports' Details: Détails Person: Usager -Scope: Cercle +Scope: Service Date: Date User: Utilisateur 'Report type': 'Type de rapport' diff --git a/src/Bundle/ChillReportBundle/translations/messages.intl-icu.nl.yaml b/src/Bundle/ChillReportBundle/translations/messages.intl-icu.nl.yaml new file mode 100644 index 000000000..3d7b67836 --- /dev/null +++ b/src/Bundle/ChillReportBundle/translations/messages.intl-icu.nl.yaml @@ -0,0 +1,7 @@ +total reports matching the search: >- + { total, plural, + =0 {Geen enkel rapport voldoet aan de zoekterm(en)} + one {Één rapport voldoet aan de zoekterm(en)} + many {# rapporten voldoen aan de zoekterm(en)} + other {# rapporten voldoen aan de zoekterm(en)} + } diff --git a/src/Bundle/ChillReportBundle/translations/messages.nl.yml b/src/Bundle/ChillReportBundle/translations/messages.nl.yml index 54bd178ff..77acf83ec 100644 --- a/src/Bundle/ChillReportBundle/translations/messages.nl.yml +++ b/src/Bundle/ChillReportBundle/translations/messages.nl.yml @@ -1,31 +1,55 @@ -'Report edit': Dossier uitgave -'Save report': Bewaar consultatiegegevens -'Reset report': Verwijder consultatiegegevens -'Add a report': Voeg een consultatierapport toe -'Add report': 'Voeg rapport toe' -'Create a new report': 'Maak een nieuw consultatieblad aan' -'Report view': "Details van de consultatiegegevens" -'Update the report': 'Vul dossier aan' -'Report list': 'Overzicht van de consultaties' +'Report edit': "Rapport bewerken" +'Save report': "Rapport opslaan" +'Reset report': "Resetten" +'Add a report': "Rapport toevoegen" +'Add report': 'Rapport toevoegen' +'Create a new report': 'Nieuw rapport aanmaken' +'Report view': "Details van een rapport" +'Update the report': 'Rapport bewerken' +'Report list': 'Lijst van rapporten' Details: Details -Person: Person -Scope: Scope +Person: Gebruiker +Scope: Dienst Date: Datum User: Gebruiker -'Report type': 'Soort of verslag' -'View the report': "Bekijk de consultatiegegevens" +'Report type': 'Type rapport' +'View the report': "Rapport bekijken" +Report data: Rapportgegevens +'Report view : %name%': 'Rapport: %name%' +Report: Rapport +No report registered for this person.: Geen rapport voor deze gebruiker. #Flash messages -'Success : report created!': "De consultatiegegevens zijn correct ingevuld!" -'The form is not valid. The report has not been created !': "De gegevens zijn niet correct, er wordt geen rapport gemaakt" -'Success : report updated!': "Update is gelukt" -'The form is not valid. The report has not been updated !': "Update is mislukt, probeer opnieuw." +'Success : report created!': "Succes: het rapport is aangemaakt!" +'The form is not valid. The report has not been created !': "Het formulier bevat fouten, het rapport is niet aangemaakt" +'Success : report updated!': "Succes: het rapport is bijgewerkt!" +'The form is not valid. The report has not been updated !': "Het formulier bevat fouten, het rapport is niet bijgewerkt" #Exception messags -'Unable to find this report.': Dossier is onvindbaar -'This is not the report of the person.': "Dit is niet het dossier van deze person" +'Unable to find this report.': Rapport niet gevonden. +'This is not the report of the person.': "De persoon en het geselecteerde rapport zijn niet gekoppeld" -'You are going to leave a page with unsubmitted data. Are you sure you want to leave ?': 'U bent aan te vertrekken een pagina die veranderdt datas behoudt. Bent u er zeker van dat u wil vertrekken ?' + +#search +'You may not set a date argument and a date in default': U hebt twee datums ingevoerd, één met het datumargument en de andere in de standaard zoekzone. Geef één van beide op +'You must provide either a date:YYYY-mm-dd argument or a YYYY-mm-dd default search': Geef ofwel een argument date:YYYY-mm-dd op, ofwel een datum in de standaardzoekopdracht. +'Reports search results': Zoeken in de rapporten #timeline -'%user% has filled a %report_label% report on %date%': "%user% heeft een rapport '%report_label%' toegevoegd om %date%" +'%user% has filled a %report_label% report': "%user% heeft een rapport '%report_label%' toegevoegd" + +#roles +CHILL_REPORT_UPDATE: Rapporten bewerken +CHILL_REPORT_SEE: Rapporten bekijken +CHILL_REPORT_CREATE: Rapporten aanmaken +CHILL_REPORT_LISTS: Lijst van rapporten + + +#exports +"List for report '%type%'": Lijst van rapporten "%type%" +"Generate list of report '%type%'": Genereert een lijst van rapporten "%type%" +"Report's question": Vraag van het rapport +Filter by report's date: Filteren op rapportdatum +Report is after this date: Rapporten na deze datum +Report is before this date: Rapporten vóór deze datum +"Filtered by report's date: between %date_from% and %date_to%": "Gefilterd op rapportdatum: tussen %date_from% en %date_to%" diff --git a/src/Bundle/ChillTaskBundle/Form/SingleTaskType.php b/src/Bundle/ChillTaskBundle/Form/SingleTaskType.php index 38432a487..3cbdf9586 100644 --- a/src/Bundle/ChillTaskBundle/Form/SingleTaskType.php +++ b/src/Bundle/ChillTaskBundle/Form/SingleTaskType.php @@ -65,8 +65,8 @@ class SingleTaskType extends AbstractType if ($isScopeConcerned && $this->parameterBag->get('chill_main')['acl']['form_show_scopes']) { $builder ->add('circle', ScopePickerType::class, [ - 'center' => $centers, 'role' => $options['role'], + 'subject' => $task, 'required' => true, ]); } diff --git a/src/Bundle/ChillTaskBundle/Resources/views/SingleTask/Person/list.html.twig b/src/Bundle/ChillTaskBundle/Resources/views/SingleTask/Person/list.html.twig index ffdd17a39..a4d5d81c9 100644 --- a/src/Bundle/ChillTaskBundle/Resources/views/SingleTask/Person/list.html.twig +++ b/src/Bundle/ChillTaskBundle/Resources/views/SingleTask/Person/list.html.twig @@ -5,7 +5,7 @@ {% block title 'Tasks for {{ name }}'|trans({ '{{ name }}' : person|chill_entity_render_string }) %} {% block content %} - <div class="col-md-10 col-xxl"> + <div class="task-list""> <h1>{{ block('title') }}</h1> diff --git a/src/Bundle/ChillTaskBundle/Resources/views/SingleTask/index.html.twig b/src/Bundle/ChillTaskBundle/Resources/views/SingleTask/index.html.twig index 02ab79664..658d9cba1 100644 --- a/src/Bundle/ChillTaskBundle/Resources/views/SingleTask/index.html.twig +++ b/src/Bundle/ChillTaskBundle/Resources/views/SingleTask/index.html.twig @@ -37,7 +37,7 @@ {% endblock %} {% else %} {% block content %} - <div class="col-md-10 col-xxl tasks"> + <div class="col-md-9 col-xxl tasks"> {% include '@ChillTask/SingleTask/AccompanyingCourse/list.html.twig' %} </div> diff --git a/src/Bundle/ChillTaskBundle/translations/messages+intl-icu.nl.yml b/src/Bundle/ChillTaskBundle/translations/messages+intl-icu.nl.yml new file mode 100644 index 000000000..80fd7e219 --- /dev/null +++ b/src/Bundle/ChillTaskBundle/translations/messages+intl-icu.nl.yml @@ -0,0 +1,14 @@ +#widget +nb tasks over deadline: >- + {nb, plural, + =0 {Geen taak met overschreden deadline} + one {Eén taak met overschreden deadline} + other {# taken met overschreden deadline} + } + +nb tasks near deadline: >- + {nb, plural, + =0 {Geen taak in herinnering} + one {Eén taak in herinnering} + other {# taken in herinnering} + } diff --git a/src/Bundle/ChillTaskBundle/translations/messages.fr.yml b/src/Bundle/ChillTaskBundle/translations/messages.fr.yml index b0d155f59..341e889ec 100644 --- a/src/Bundle/ChillTaskBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillTaskBundle/translations/messages.fr.yml @@ -4,7 +4,7 @@ Tasks: "Tâches" Title: Titre Description: Description Assignee: "Personne assignée" -Scope: Cercle +Scope: Service "Start date": "Date de début" "End date": "Date d'échéance" "Warning date": "Date d'avertissement" @@ -106,7 +106,7 @@ My tasks over deadline: Mes tâches à échéance dépassée #transition page Apply transition on task <em>%title%</em>: Appliquer la transition sur la tâche <em>%title%</em> -All centers: Tous les centres +All centers: Tous les territoires # ROLES CHILL_TASK_TASK_CREATE: Ajouter une tâche diff --git a/src/Bundle/ChillTaskBundle/translations/messages.nl.yml b/src/Bundle/ChillTaskBundle/translations/messages.nl.yml new file mode 100644 index 000000000..d90de6f1d --- /dev/null +++ b/src/Bundle/ChillTaskBundle/translations/messages.nl.yml @@ -0,0 +1,117 @@ +Tasks: "Taken" +"New task": "Nieuwe taak" +"Add a new task": "Nieuwe taak toevoegen" +Title: Titel +Description: Beschrijving +Assignee: "Toegewezen persoon" +Scope: Dienst +"Start date": "Startdatum" +"End date": "Einddatum" +"Warning date": "Waarschuwingsdatum" +"Warning interval": "Waarschuwingstermijn vóór de einddatum" +"Unknown dates": "Data niet gespecificeerd" +"N": "" +"Unit": "" +Task: Taak +Details: Details +Person: Gebruiker +Date: Datum +Dates: Data +User: Gebruiker +"Task list": "Takenlijst" +"Tasks with expired deadline": "Taken met een overschreden einddatum" +"Tasks with warning deadline reached": "Taken met een bereikte waarschuwingsdatum" +"Current tasks": "Lopende taken" +"Closed tasks": Afgesloten taken +"Tasks not started": "Niet-begonnen taken" +"Task start date": "Startdatum" +"Task warning date": "Waarschuwingsdatum" +"Task end date": "Einddatum" +"Start": "Start" +"Warning": "Waarschuwing" +"End": "Einddatum" +"Task type": "Type" +"Task status": "Status" +"Edit the task": "Taak bewerken" +"Edit task": "Taak bewerken" +"Save task": "Taak opslaan" +"View the task": "Taak bekijken" +"Update the task": "Taak bijwerken" +"Remove task": "Taak verwijderen" +"Delete": "Verwijderen" +"Change task status": "Status wijzigen" +'Are you sure you want to remove the task about "%name%" ?': 'Weet u zeker dat u de taak van "%name%" wilt verwijderen?' +'Are you sure you want to remove the task "%title%" ?': 'Weet u zeker dat u de taak "%title%" wilt verwijderen?' +"See more": "Meer zien" +"Associated tasks": "Gekoppelde taken" +"My tasks": "Mijn taken" +"Tasks for this accompanying period": "Taken voor dit begeleidingstraject" +"Tasks for {{ name }}": "Taken voor {{ name }}" +"No description": "Geen beschrijving" +"No dates specified": "Data niet gespecificeerd" +"No one assignee": "Geen toegewezen gebruiker" +"Task types": Taaktypes +Days: Dag(en) +Weeks: Week/Weken +Months: Maand(en) +Year: Jaar/Jaren +Filter the tasks: Taken filteren +Filter: Filteren +Any user: Alle gebruikers +Unassigned: Niet toegewezen +Associated person: Gekoppelde gebruiker +Default task: Standaardtaak +Not assigned: Geen toegewezen gebruiker +For person: Voor +By: Door +Any tasks: Geen taak +Filter by user: Filteren op gebruiker(s) + +# transitions - default task definition +"new": "nieuw" +"in_progress": "bezig" +"closed": "gesloten" +"canceled": "verwijderd" +start: starten +close: afsluiten +cancel: annuleren +Start_verb: Starten +Close_verb: Afsluiten +Set this task to cancel state: Deze taak markeren als geannuleerd +"%user% has closed the task": "%user% heeft de taak gesloten" +"%user% has canceled the task": "%user% heeft de taak geannuleerd" +"%user% has started the task": "%user% heeft de taak gestart" +"%user% has created the task": "%user% heeft de taak ingevoerd" +Are you sure you want to close this task ?: Weet u zeker dat u deze taak wilt afsluiten? +Are you sure you want to cancel this task ?: Weet u zeker dat u deze taak wilt annuleren? +Are you sure you want to start this task ?: Weet u zeker dat u deze taak wilt starten? + +#Flash messages +"The task is created": "De taak is aangemaakt" +"There is no tasks.": Geen taak. +"The task has been successfully removed.": "De taak is succesvol verwijderd" +"This form contains errors": "Dit formulier bevat fouten" +"The task has been updated": "De taak is bijgewerkt" +"The transition is successfully applied": "De transitie is succesvol toegepast" +"The transition could not be applied": "De transitie kon niet worden toegepast" + +Tasks near deadline: Taken met naderende einddatum +Tasks over deadline: Taken met overschreden einddatum +Tasks without alert: Taken met toekomstige of zonder einddatum + +#title +My tasks near deadline: Mijn taken met naderende einddatum +My tasks over deadline: Mijn taken met overschreden einddatum + +#transition page +Apply transition on task <em>%title%</em>: Transitie toepassen op taak <em>%title%</em> + +All centers: Alle territoria + +# ROLES +CHILL_TASK_TASK_CREATE: Taak toevoegen +CHILL_TASK_TASK_DELETE: Taak verwijderen +CHILL_TASK_TASK_SHOW: Taak bekijken +CHILL_TASK_TASK_UPDATE: Taak bewerken +CHILL_TASK_TASK_CREATE_FOR_COURSE: Taak aanmaken voor een traject +CHILL_TASK_TASK_CREATE_FOR_PERSON: Taak aanmaken voor een gebruiker diff --git a/src/Bundle/ChillTaskBundle/translations/validators.nl.yml b/src/Bundle/ChillTaskBundle/translations/validators.nl.yml new file mode 100644 index 000000000..cb2ab7fe5 --- /dev/null +++ b/src/Bundle/ChillTaskBundle/translations/validators.nl.yml @@ -0,0 +1,4 @@ +The start date must be before the end date: De startdatum moet voor de einddatum liggen +This form contains errors: Het formulier bevat fouten +The start date must be before warning date: De startdatum moet voor de waarschuwingsdatum liggen +An end date is required if a warning interval is set: Een einddatum is vereist wanneer een herinneringsinterval is ingesteld diff --git a/src/Bundle/ChillThirdPartyBundle/Entity/ThirdParty.php b/src/Bundle/ChillThirdPartyBundle/Entity/ThirdParty.php index cf9350abd..43e1c0ebb 100644 --- a/src/Bundle/ChillThirdPartyBundle/Entity/ThirdParty.php +++ b/src/Bundle/ChillThirdPartyBundle/Entity/ThirdParty.php @@ -17,7 +17,6 @@ use Chill\MainBundle\Entity\Address; use Chill\MainBundle\Entity\Center; use Chill\MainBundle\Entity\Civility; use Chill\MainBundle\Entity\User; -use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\ReadableCollection; @@ -71,6 +70,11 @@ use Symfony\Component\Validator\Constraints as Assert; * * The difference between categories and types is transparent for user: they choose the same fields into the UI, without * noticing a difference. + * + * ## Validation + * + * When a validation is inserted / updated, do not forget to update the related ThirdPartyEdit.vue component and the associated + * list of possible violations. */ #[ORM\Entity] #[ORM\Table(name: 'chill_3party.third_party')] @@ -207,12 +211,12 @@ class ThirdParty implements TrackCreationInterface, TrackUpdateInterface, \Strin #[Groups(['read', 'write', 'docgen:read', 'docgen:read:3party:parent'])] #[ORM\Column(name: 'telephone', type: 'phone_number', nullable: true)] - #[PhonenumberConstraint(type: 'any')] + #[\Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber] private ?PhoneNumber $telephone = null; #[Groups(['read', 'write', 'docgen:read', 'docgen:read:3party:parent'])] #[ORM\Column(name: 'telephone2', type: 'phone_number', nullable: true)] - #[PhonenumberConstraint(type: 'any')] + #[\Misd\PhoneNumberBundle\Validator\Constraints\PhoneNumber] private ?PhoneNumber $telephone2 = null; #[ORM\Column(name: 'types', type: Types::JSON, nullable: true)] diff --git a/src/Bundle/ChillThirdPartyBundle/Repository/ThirdPartyRepository.php b/src/Bundle/ChillThirdPartyBundle/Repository/ThirdPartyRepository.php index 138b5ea79..2dead3a35 100644 --- a/src/Bundle/ChillThirdPartyBundle/Repository/ThirdPartyRepository.php +++ b/src/Bundle/ChillThirdPartyBundle/Repository/ThirdPartyRepository.php @@ -18,6 +18,9 @@ use Doctrine\ORM\EntityRepository; use Doctrine\ORM\Query; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ObjectRepository; +use libphonenumber\PhoneNumber; +use libphonenumber\PhoneNumberFormat; +use libphonenumber\PhoneNumberUtil; /** * @implements ObjectRepository<ThirdParty> @@ -26,7 +29,7 @@ class ThirdPartyRepository implements ObjectRepository { private readonly EntityRepository $repository; - public function __construct(EntityManagerInterface $em, private readonly Connection $connection) + public function __construct(EntityManagerInterface $em, private readonly Connection $connection, private readonly PhoneNumberUtil $phonenumberUtil) { $this->repository = $em->getRepository(ThirdParty::class); } @@ -120,6 +123,43 @@ class ThirdPartyRepository implements ObjectRepository return $this->repository->findBy($criteria, $orderBy, $limit, $offset); } + /** + * Finds third-party records by phone number. + * + * The search is performed agains every phonenumber field (there are two phonenumber on a thirdParty). + * + * @param string|PhoneNumber $phonenumber The phone number to search for. Can be a string or a PhoneNumber object. + * @param int $firstResult the index of the first result to retrieve (pagination start) + * @param int $maxResults the maximum number of results to retrieve (pagination limit) + * + * @return list<ThirdParty> the result set containing matching third-party records + */ + public function findByPhonenumber(string|PhoneNumber $phonenumber, int $firstResult = 0, int $maxResults = 20): array + { + if ('' === $phonenumber) { + return []; + } + + $qb = $this->createQueryBuilder('tp'); + $qb->select('tp'); + + $qb->where( + $qb->expr()->orX( + $qb->expr()->eq('tp.telephone', ':phonenumber'), + $qb->expr()->eq('tp.telephone2', ':phonenumber') + ) + ); + + $qb->setParameter( + 'phonenumber', + is_string($phonenumber) ? $phonenumber : $this->phonenumberUtil->format($phonenumber, PhoneNumberFormat::E164) + ); + + $qb->setFirstResult($firstResult)->setMaxResults($maxResults); + + return $qb->getQuery()->getResult(); + } + /** * Search amongst parties associated to $centers, with $terms parameters. * diff --git a/src/Bundle/ChillThirdPartyBundle/Resources/public/types.ts b/src/Bundle/ChillThirdPartyBundle/Resources/public/types.ts index 20ae3309b..98524cfe1 100644 --- a/src/Bundle/ChillThirdPartyBundle/Resources/public/types.ts +++ b/src/Bundle/ChillThirdPartyBundle/Resources/public/types.ts @@ -1,47 +1,153 @@ import { - Address, - Center, - Civility, - DateTime, - User, + Address, + Center, + Civility, + DateTime, + SetAddress, + SetCivility, + User, } from "ChillMainAssets/types"; -export interface Thirdparty { - acronym: string | null; - active: boolean; - address: Address | null; - canonicalized: string | null; - categories: ThirdpartyCategory[]; - centers: Center[]; - children: Thirdparty[]; - civility: Civility | null; - comment: string | null; - contactDataAnonymous: boolean; - createdAt: DateTime; - createdBy: User | null; - email: string | null; - firstname: string | null; - id: number | null; - kind: string; - name: string; - nameCompany: string | null; - parent: Thirdparty | null; - profession: string; - telephone: string | null; - thirdPartyTypes: ThirdpartyType[] | null; - updatedAt: DateTime | null; - updatedBy: User | null; +export type ThirdPartyKind = "contact" | "child" | "company"; + +export interface BaseThirdParty { + type: "thirdparty"; + kind: "" | ThirdPartyKind; + text: string; + acronym: string | null; + active: boolean; + address: Address | null; + contactDataAnonymous: boolean; + createdAt: DateTime; + createdBy: User | null; + email: string | null; + firstname: string | null; + id: number; + nameCompany: string | null; + telephone: string | null; + telephone2: string | null; + updatedAt: DateTime | null; + updatedBy: User | null; +} + +function isBaseThirdParty(t: unknown): t is BaseThirdParty { + if (typeof t !== "object" || t === null) return false; + const o = t as Partial<BaseThirdParty>; + return ( + (o as any).type === "thirdparty" && + typeof o.id === "number" && + typeof o.text === "string" && + (o.kind === "" || + o.kind === "contact" || + o.kind === "child" || + o.kind === "company") && + typeof o.active === "boolean" + ); +} + +export interface ThirdpartyCompany extends BaseThirdParty { + kind: "company"; + text: string; + acronym: string | null; + children: Thirdparty[]; + category: ThirdpartyCategory[]; + thirdPartyTypes: ThirdpartyType[] | null; + address: Address | null; +} + +// Type guard to distinguish a ThirdpartyCompany +export function isThirdpartyCompany(t: BaseThirdParty): t is ThirdpartyCompany { + return t.type === "thirdparty" && t.kind === "company"; +} + +export interface ThirdpartyChild extends BaseThirdParty { + kind: "child"; + civility: Civility | null; + contactDataAnonymous: boolean; + parent: ThirdpartyCompany; + profession: string; + firstname: string; + /** + * the lastname for "Contact" and "Child", the name + */ + name: string; + comment: string | null; +} + +// Type guard to distinguish a ThirdpartyChild +export function isThirdpartyChild(t: BaseThirdParty): t is ThirdpartyChild { + return t.type === "thirdparty" && t.kind === "child"; +} + +export interface ThirdpartyContact extends BaseThirdParty { + kind: "contact"; + civility: Civility | null; + category: ThirdpartyCategory[]; + thirdPartyTypes: ThirdpartyType[] | null; + profession: string; + firstname: string; + /** + * the lastname for "Contact" and "Child", the name + */ + name: string; + address: Address | null; +} + +// Type guard to distinguish a ThirdpartyContact +export function isThirdpartyContact(t: BaseThirdParty): t is ThirdpartyContact { + return t.type === "thirdparty" && t.kind === "contact"; +} + +export type Thirdparty = + | ThirdpartyCompany + | ThirdpartyContact + | ThirdpartyChild; + +export function isThirdparty(t: unknown): t is Thirdparty { + if (!isBaseThirdParty(t)) { + return false; + } + + return ( + isThirdpartyCompany(t) || isThirdpartyContact(t) || isThirdpartyChild(t) + ); } interface ThirdpartyType { - key: string; - value: string; + key: string; + value: string; } export interface ThirdpartyCategory { - id: number; - active: boolean; - name: { - fr: string; - }; + id: number; + active: boolean; + name: { + fr: string; + }; +} + +/** + * Associate an existing ThirdParty during write operation. + */ +export interface SetThirdParty { + readonly type: "thirdparty"; + id: number; +} + +export interface ThirdPartyWrite { + readonly type: "thirdparty"; + kind: ThirdPartyKind; + civility: SetCivility | null; + profession: string; + firstname: string; + /** + * the lastname + */ + name: string; + email: string; + telephone: string; + telephone2: string; + address: null | SetAddress; + comment: string; + parent: SetThirdParty | null; } diff --git a/src/Bundle/ChillThirdPartyBundle/Resources/public/vuejs/_api/OnTheFly.js b/src/Bundle/ChillThirdPartyBundle/Resources/public/vuejs/_api/OnTheFly.js deleted file mode 100644 index eb8f11ef8..000000000 --- a/src/Bundle/ChillThirdPartyBundle/Resources/public/vuejs/_api/OnTheFly.js +++ /dev/null @@ -1,52 +0,0 @@ -/* - * GET a thirdparty by id - */ -const getThirdparty = (id) => { - const url = `/api/1.0/thirdparty/thirdparty/${id}.json`; - return fetch(url).then((response) => { - if (response.ok) { - return response.json(); - } - throw Error("Error with request resource response"); - }); -}; - -/* - * POST a new thirdparty - */ -const postThirdparty = (body) => { - const url = `/api/1.0/thirdparty/thirdparty.json`; - return fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json;charset=utf-8", - }, - body: JSON.stringify(body), - }).then((response) => { - if (response.ok) { - return response.json(); - } - throw Error("Error with request resource response"); - }); -}; - -/* - * PATCH an existing thirdparty - */ -const patchThirdparty = (id, body) => { - const url = `/api/1.0/thirdparty/thirdparty/${id}.json`; - return fetch(url, { - method: "PATCH", - headers: { - "Content-Type": "application/json;charset=utf-8", - }, - body: JSON.stringify(body), - }).then((response) => { - if (response.ok) { - return response.json(); - } - throw Error("Error with request resource response"); - }); -}; - -export { getThirdparty, postThirdparty, patchThirdparty }; diff --git a/src/Bundle/ChillThirdPartyBundle/Resources/public/vuejs/_api/OnTheFly.ts b/src/Bundle/ChillThirdPartyBundle/Resources/public/vuejs/_api/OnTheFly.ts new file mode 100644 index 000000000..467126362 --- /dev/null +++ b/src/Bundle/ChillThirdPartyBundle/Resources/public/vuejs/_api/OnTheFly.ts @@ -0,0 +1,85 @@ +/* + * GET a thirdparty by id + */ +import { + isThirdpartyChild, + isThirdpartyCompany, + isThirdpartyContact, + Thirdparty, + ThirdPartyWrite, +} from "../../types"; +import { makeFetch } from "ChillMainAssets/lib/api/apiMethods"; + +export const getThirdparty = async (id: number): Promise<Thirdparty> => { + const url = `/api/1.0/thirdparty/thirdparty/${id}.json`; + return fetch(url).then((response) => { + if (response.ok) { + return response.json(); + } + throw Error("Error with request resource response"); + }); +}; + +export const thirdpartyToWriteThirdParty = (t: Thirdparty): ThirdPartyWrite => { + // Determine kind-specific fields using available type guards + const isCompany = isThirdpartyCompany(t); + const isContact = isThirdpartyContact(t); + const isChild = isThirdpartyChild(t); + + return { + type: "thirdparty", + kind: t.kind, + civility: + (isContact || isChild) && t.civility + ? { type: "chill_main_civility", id: t.civility.id } + : null, + profession: isContact || isChild ? (t.profession ?? "") : "", + firstname: isCompany ? "" : (t.firstname ?? ""), + name: isCompany ? (t.nameCompany ?? "") : (t.name ?? ""), + email: t.email ?? "", + telephone: t.telephone ?? "", + telephone2: t.telephone2 ?? "", + address: null, + comment: isChild ? (t.comment ?? "") : "", + parent: + isChild && t.parent ? { type: "thirdparty", id: t.parent.id } : null, + }; +}; + +export interface WriteThirdPartyViolationMap extends Record< + string, + Record<string, string> +> { + email: { + "{{ value }}": string; + }; + name: { + "{{ value }}": string; + }; + telephone: { + "{{ value }}": string; + }; + telephone2: { + "{{ value }}": string; + }; +} + +/* + * POST a new thirdparty + */ +export const createThirdParty = async (body: ThirdPartyWrite) => { + const url = `/api/1.0/thirdparty/thirdparty.json`; + + return makeFetch<ThirdPartyWrite, Thirdparty>("POST", url, body); +}; + +/* + * PATCH an existing thirdparty + */ +export const patchThirdparty = async ( + id: number, + body: ThirdPartyWrite, +): Promise<Thirdparty> => { + const url = `/api/1.0/thirdparty/thirdparty/${id}.json`; + return makeFetch("PATCH", url, body); +}; diff --git a/src/Bundle/ChillThirdPartyBundle/Resources/public/vuejs/_components/Entity/ThirdPartyRenderBox.vue b/src/Bundle/ChillThirdPartyBundle/Resources/public/vuejs/_components/Entity/ThirdPartyRenderBox.vue index 0fee3dcdb..6fead8a45 100644 --- a/src/Bundle/ChillThirdPartyBundle/Resources/public/vuejs/_components/Entity/ThirdPartyRenderBox.vue +++ b/src/Bundle/ChillThirdPartyBundle/Resources/public/vuejs/_components/Entity/ThirdPartyRenderBox.vue @@ -1,243 +1,211 @@ <template> - <div class="item-bloc col"> - <section class="chill-entity entity-thirdparty"> - <div class="item-row entity-bloc"> - <div class="item-col"> - <div class="entity-label"> - <div :class="'denomination h' + options.hLevel"> - <a v-if="this.options.addLink === true" href="#"> - <span class="name">{{ thirdparty.text }}</span> - </a> - <span class="name" v-else>{{ - thirdparty.text - }}</span> + <div class="item-bloc col"> + <section class="chill-entity entity-thirdparty"> + <div class="item-row entity-bloc"> + <div class="item-col"> + <div class="entity-label"> + <div :class="'denomination h' + options.hLevel"> + <a v-if="options.addLink === true" href="#"> + <span class="name">{{ thirdparty.text }}</span> + </a> + <span class="name" v-else>{{ thirdparty.text }}</span> - <span - v-if="options.addId === true" - class="id-number" - :title="'n° ' + thirdparty.id" - > - {{ thirdparty.id }} - </span> + <span + v-if="options.addId === true" + class="id-number" + :title="'n° ' + thirdparty.id" + > + {{ thirdparty.id }} + </span> - <badge-entity - v-if="options.addEntity === true" - :entity="thirdparty" - :options="{ - displayLong: options.entityDisplayLong, - }" - /> - </div> + <badge-entity + v-if="options.addEntity === true" + :entity="thirdparty" + :options="{ + displayLong: options.entityDisplayLong, + }" + /> + </div> - <p - v-if="this.options.addInfo === true" - class="moreinfo" - /> - </div> - </div> + <p v-if="options.addInfo === true" class="moreinfo" /> + </div> + </div> - <div class="item-col"> - <div class="float-button bottom"> - <div class="box"> - <div class="action"> - <slot name="record-actions" /> - </div> - <ul class="list-content fa-ul"> - <li v-if="getProfession.length > 0"> - <i class="fa fa-li fa-id-card" /> - <p> - <span>{{ getProfession[0] }}</span> - </p> - </li> - <li v-if="hasParent"> - <i class="fa fa-li fa-hand-o-right" /> - <b class="me-2">{{ $t("child_of") }}</b> - <on-the-fly - :type="thirdparty.parent.type" - :id="thirdparty.parent.id" - :button-text="thirdparty.parent.text" - :display-badge="'true' === 'true'" - action="show" - /> - </li> - <!-- TODO hasChildren + <div class="item-col"> + <div class="float-button bottom"> + <div class="box"> + <div class="action"> + <slot name="record-actions" /> + </div> + <ul class="list-content fa-ul"> + <li v-if="getProfession.length > 0"> + <i class="fa fa-li fa-id-card" /> + <p> + <span>{{ getProfession[0] }}</span> + </p> + </li> + <li v-if="isThirdpartyChild(props.thirdparty)"> + <i class="fa fa-li fa-hand-o-right" /> + <b class="me-2">{{ trans(THIRDPARTY_MESSAGES_CHILD_OF) }}</b> + <on-the-fly + :type="props.thirdparty.parent.type" + :id="props.thirdparty.parent.id" + :button-text="props.thirdparty.parent.text" + :display-badge="'true' === 'true'" + action="show" + /> + </li> + <!-- TODO hasChildren NB: we cannot call on-the-fly from RenderBox. See error message in previous version of this file. --> - </ul> - <div v-if="thirdparty.contactDataAnonymous"> - <confidential :position-btn-far="false"> - <template #confidential-content> - <ul class="list-content fa-ul"> - <li v-if="thirdparty.address"> - <i - class="fa fa-li fa-map-marker" - /> - <address-render-box - :address=" - thirdparty.address - " - :is-multiline="isMultiline" - /> - </li> - <li v-if="thirdparty.telephone"> - <i class="fa fa-li fa-mobile" /> - <a - :href=" - 'tel: ' + - thirdparty.telephone - " - >{{ - thirdparty.telephone - }}</a - > - </li> - <li v-if="thirdparty.telephone2"> - <i class="fa fa-li fa-mobile" /> - <a - :href=" - 'tel: ' + - thirdparty.telephone2 - " - >{{ - thirdparty.telephone2 - }}</a - > - </li> - <li v-if="thirdparty.email"> - <i - class="fa fa-li fa-envelope-o" - /> - <a - :href=" - 'mailto: ' + - thirdparty.email - " - >{{ thirdparty.email }}</a - > - </li> - </ul> - </template> - </confidential> - </div> - <ul v-else class="list-content fa-ul"> - <li v-if="thirdparty.address"> - <i class="fa fa-li fa-map-marker" /> - <address-render-box - :address="thirdparty.address" - :is-multiline="isMultiline" - /> - </li> - <li v-if="thirdparty.telephone"> - <i class="fa fa-li fa-mobile" /> - <a :href="'tel: ' + thirdparty.telephone">{{ - thirdparty.telephone - }}</a> - </li> - <li v-if="thirdparty.telephone2"> - <i class="fa fa-li fa-mobile" /> - <a :href="'tel: ' + thirdparty.telephone2" - >{{ thirdparty.telephone2 }} - </a> - </li> - <li v-if="thirdparty.email"> - <i class="fa fa-li fa-envelope-o" /> - <a :href="'mailto: ' + thirdparty.email">{{ - thirdparty.email - }}</a> - </li> - </ul> - </div> - </div> - </div> + </ul> + <div v-if="thirdparty.contactDataAnonymous"> + <confidential :position-btn-far="false"> + <template #confidential-content> + <ul class="list-content fa-ul"> + <li v-if="thirdparty.address"> + <i class="fa fa-li fa-map-marker" /> + <address-render-box + :address="thirdparty.address" + :is-multiline="isMultiline" + /> + </li> + <li v-if="thirdparty.telephone"> + <i class="fa fa-li fa-mobile" /> + <a :href="'tel: ' + thirdparty.telephone">{{ + thirdparty.telephone + }}</a> + </li> + <li v-if="thirdparty.telephone2"> + <i class="fa fa-li fa-mobile" /> + <a :href="'tel: ' + thirdparty.telephone2">{{ + thirdparty.telephone2 + }}</a> + </li> + <li v-if="thirdparty.email"> + <i class="fa fa-li fa-envelope-o" /> + <a :href="'mailto: ' + thirdparty.email">{{ + thirdparty.email + }}</a> + </li> + </ul> + </template> + </confidential> + </div> + <ul v-else class="list-content fa-ul"> + <li v-if="thirdparty.address"> + <i class="fa fa-li fa-map-marker" /> + <address-render-box + :address="thirdparty.address" + :is-multiline="isMultiline" + /> + </li> + <li v-if="thirdparty.telephone"> + <i class="fa fa-li fa-mobile" /> + <a :href="'tel: ' + thirdparty.telephone">{{ + thirdparty.telephone + }}</a> + </li> + <li v-if="thirdparty.telephone2"> + <i class="fa fa-li fa-mobile" /> + <a :href="'tel: ' + thirdparty.telephone2" + >{{ thirdparty.telephone2 }} + </a> + </li> + <li v-if="thirdparty.email"> + <i class="fa fa-li fa-envelope-o" /> + <a :href="'mailto: ' + thirdparty.email">{{ + thirdparty.email + }}</a> + </li> + </ul> </div> - <slot name="end-bloc" /> - </section> - </div> + </div> + </div> + </div> + <slot name="end-bloc" /> + </section> + </div> </template> -<script> +<script setup lang="ts"> +import { computed, defineAsyncComponent } from "vue"; import AddressRenderBox from "ChillMainAssets/vuejs/_components/Entity/AddressRenderBox.vue"; import Confidential from "ChillMainAssets/vuejs/_components/Confidential.vue"; import BadgeEntity from "ChillMainAssets/vuejs/_components/BadgeEntity.vue"; +import { + isThirdpartyChild, + isThirdpartyCompany, + Thirdparty, +} from "../../../types"; +import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper"; +import OnTheFly from "ChillMainAssets/vuejs/OnTheFly/components/OnTheFly.vue"; +import { THIRDPARTY_MESSAGES_CHILD_OF, trans } from "translator"; -export default { - name: "ThirdPartyRenderBox", - components: { - AddressRenderBox, - Confidential, - BadgeEntity, - }, - // To avoid components recursively invoking eachother resolve OnTheFly component here - beforeCreate() { - this.$options.components.OnTheFly = - require("ChillMainAssets/vuejs/OnTheFly/components/OnTheFly").default; - }, - i18n: { - messages: { - fr: { - children: "Personnes de contact: ", - child_of: "Contact de: ", - }, - }, - }, - props: ["thirdparty", "options"], - computed: { - isMultiline: function () { - if (this.options.isMultiline) { - return this.options.isMultiline; - } else { - return false; - } - }, - hasParent() { - return !( - this.thirdparty.parent === null || - this.thirdparty.parent === undefined - ); - }, - getProfession() { - let profession = []; - if (this.hasParent && this.thirdparty.profession) { - profession.push(this.thirdparty.profession); - return profession; - } +// Async to avoid recursive resolution issues +/* +const OnTheFly = defineAsyncComponent(() => + import("ChillMainAssets/vuejs/OnTheFly/components/OnTheFly.vue") +); + */ - if (!this.hasParent && this.thirdparty.category) { - profession = this.thirdparty.category.map( - (category) => category.text, - ); - } +interface RenderOptions { + addInfo?: boolean; + addEntity?: boolean; + addId?: boolean; + addLink?: boolean; + hLevel?: number; + entityDisplayLong?: boolean; + isMultiline?: boolean; +} - return profession; - }, - /* TODO need backend normalizer to serve children without circular reference - hasChildren() { - //console.log(this.thirdparty.activeChildren.length > 0) - return false - } */ - }, -}; +const props = defineProps<{ thirdparty: Thirdparty; options: RenderOptions }>(); + +const isMultiline = computed<boolean>( + () => props.options?.isMultiline ?? false, +); + +const hasParent = computed<boolean>(() => { + return isThirdpartyChild(props.thirdparty); +}); + +const getProfession = computed<string[]>(() => { + const t = props.thirdparty; + const prof: string[] = []; + + if (!isThirdpartyCompany(t)) { + prof.push(t.profession); + } + + if (!isThirdpartyChild(t)) { + for (const c of t.category) { + prof.push(localizeString(c.name)); + } + } + + return prof; +}); </script> <style lang="scss"> .name { - &:before { - content: " "; - } + &:before { + content: " "; + } - &.tparty-parent { - font-weight: bold; - font-variant: all-small-caps; - } + &.tparty-parent { + font-weight: bold; + font-variant: all-small-caps; + } } .list-professions { - &::after { - content: " | "; - } + &::after { + content: " | "; + } - &:last-child::after { - content: ""; - } + &:last-child::after { + content: ""; + } } </style> diff --git a/src/Bundle/ChillThirdPartyBundle/Resources/public/vuejs/_components/Entity/ThirdPartyText.vue b/src/Bundle/ChillThirdPartyBundle/Resources/public/vuejs/_components/Entity/ThirdPartyText.vue index 2214605e2..dfd46efd9 100644 --- a/src/Bundle/ChillThirdPartyBundle/Resources/public/vuejs/_components/Entity/ThirdPartyText.vue +++ b/src/Bundle/ChillThirdPartyBundle/Resources/public/vuejs/_components/Entity/ThirdPartyText.vue @@ -1,28 +1,28 @@ <template> - <span v-if="isCut">{{ cutText }}</span> - <span v-else class="thirdparty-text"> - <span class="firstname">{{ thirdparty.text }}</span> - </span> + <span v-if="isCut">{{ cutText }}</span> + <span v-else class="thirdparty-text"> + <span class="firstname">{{ thirdparty.text }}</span> + </span> </template> <script> export default { - name: "ThirdPartyText", - props: { - thirdparty: { - required: true, - }, - isCut: { - type: Boolean, - required: false, - default: false, - }, + name: "ThirdPartyText", + props: { + thirdparty: { + required: true, }, - computed: { - cutText: function () { - let more = this.thirdparty.text.length > 15 ? "…" : ""; - return this.thirdparty.text.slice(0, 15) + more; - }, + isCut: { + type: Boolean, + required: false, + default: false, }, + }, + computed: { + cutText: function () { + let more = this.thirdparty.text.length > 15 ? "…" : ""; + return this.thirdparty.text.slice(0, 15) + more; + }, + }, }; </script> diff --git a/src/Bundle/ChillThirdPartyBundle/Resources/public/vuejs/_components/OnTheFly/ThirdParty.vue b/src/Bundle/ChillThirdPartyBundle/Resources/public/vuejs/_components/OnTheFly/ThirdParty.vue index 623926807..0a83f1b3b 100644 --- a/src/Bundle/ChillThirdPartyBundle/Resources/public/vuejs/_components/OnTheFly/ThirdParty.vue +++ b/src/Bundle/ChillThirdPartyBundle/Resources/public/vuejs/_components/OnTheFly/ThirdParty.vue @@ -1,449 +1,98 @@ <template> - <div v-if="action === 'show'"> - <div class="flex-table"> - <third-party-render-box - :thirdparty="thirdparty" - :options="{ - addInfo: true, - addEntity: true, - entityDisplayLong: true, - addAltNames: true, - addId: true, - addLink: false, - addAge: false, - hLevel: 3, - addCenter: true, - addNoData: true, - isMultiline: true, - }" - /> - </div> - </div> - <div - v-else-if=" - action === 'edit' || action === 'create' || action === 'addContact' - " - > - <div v-if="parent"> - <div class="parent-info"> - <i class="fa fa-li fa-hand-o-right" /> - <b class="me-2">{{ $t("child_of") }}</b> - <span class="chill-entity badge-thirdparty">{{ - parent.text - }}</span> - </div> - </div> - <div class="form-floating mb-3" v-else-if="kind !== 'child'"> - <div class="form-check"> - <input - class="form-check-input mt-0" - type="radio" - v-model="kind" - value="company" - id="tpartyKindInstitution" - /> - <label for="tpartyKindInstitution" class="required"> - <badge-entity - :entity="{ type: 'thirdparty', kind: 'company' }" - :options="{ displayLong: true }" - /> - </label> - </div> - <div class="form-check"> - <input - class="form-check-input mt-0" - type="radio" - v-model="kind" - value="contact" - id="tpartyKindContact" - /> - <label for="tpartyKindContact" class="required"> - <badge-entity - :entity="{ type: 'thirdparty', kind: 'contact' }" - :options="{ displayLong: true }" - /> - </label> - </div> - </div> - <div v-else> - <p>Contact de :</p> - <third-party-render-box - :thirdparty="thirdparty.parent" - :options="{ - addInfo: true, - addEntity: false, - addAltNames: true, - addId: false, - addLink: false, - addAge: false, - hLevel: 4, - addCenter: false, - addNoData: true, - isMultiline: false, - }" - /> - </div> - - <div - v-if="thirdparty.kind === 'child' || thirdparty.kind === 'contact'" - > - <div class="child-info"> - <div class="input-group mb-3 input-section"> - <select - class="form-select form-select-lg" - id="civility" - v-model="thirdparty.civility" - > - <option selected disabled :value="null"> - {{ $t("thirdparty.civility") }} - </option> - <option - v-for="civility in civilities" - :key="civility.id" - :value="civility" - > - {{ localizeString(civility.name) }} - </option> - </select> - </div> - <div class="input-group mb-3 input-section"> - <input - class="form-control form-control-lg" - v-model="thirdparty.profession" - :placeholder="$t('thirdparty.profession')" - :aria-label="$t('thirdparty.profession')" - aria-describedby="profession" - /> - </div> - </div> - <div class="child-info"> - <div class="input-section"> - <div class="form-floating mb-3"> - <input - class="form-control form-control-lg" - id="firstname" - v-model="thirdparty.firstname" - :placeholder="$t('thirdparty.firstname')" - /> - <label for="firstname">{{ - $t("thirdparty.firstname") - }}</label> - </div> - <div v-if="queryItems"> - <ul class="list-suggest add-items inline"> - <li - v-for="(qi, i) in queryItems" - :key="i" - @click="addQueryItem('firstName', qi)" - > - <span class="person-text">{{ qi }}</span> - </li> - </ul> - </div> - </div> - <div class="input-section"> - <div class="form-floating mb-3"> - <input - class="form-control form-control-lg" - id="name" - v-model="thirdparty.name" - :placeholder="$t('thirdparty.lastname')" - /> - <label for="name">{{ - $t("thirdparty.lastname") - }}</label> - </div> - <div v-if="queryItems"> - <ul class="list-suggest add-items inline"> - <li - v-for="(qi, i) in queryItems" - :key="i" - @click="addQueryItem('name', qi)" - > - <span class="person-text">{{ qi }}</span> - </li> - </ul> - </div> - </div> - </div> - </div> - - <div v-if="thirdparty.kind === 'company'"> - <div class="form-floating mb-3"> - <input - class="form-control form-control-lg" - id="name" - v-model="thirdparty.name" - :placeholder="$t('thirdparty.name')" - /> - <label for="name">{{ $t("thirdparty.name") }}</label> - </div> - <div v-if="query"> - <ul class="list-suggest add-items inline"> - <li @click="addQuery(query)"> - <span class="person-text">{{ query }}</span> - </li> - </ul> - </div> - </div> - - <template v-if="thirdparty.kind !== 'child'"> - <add-address - key="thirdparty" - :context="context" - :options="addAddress.options" - :address-changed-callback="submitAddress" - ref="addAddress" - /> - </template> - - <div class="input-group mb-3"> - <span class="input-group-text" id="email" - ><i class="fa fa-fw fa-envelope" - /></span> - <input - class="form-control form-control-lg" - v-model="thirdparty.email" - :placeholder="$t('thirdparty.email')" - :aria-label="$t('thirdparty.email')" - aria-describedby="email" - /> - </div> - - <div class="input-group mb-3"> - <span class="input-group-text" id="phonenumber" - ><i class="fa fa-fw fa-phone" - /></span> - <input - class="form-control form-control-lg" - v-model="thirdparty.telephone" - :placeholder="$t('thirdparty.phonenumber')" - :aria-label="$t('thirdparty.phonenumber')" - aria-describedby="phonenumber" - /> - </div> - - <div class="input-group mb-3"> - <span class="input-group-text" id="phonenumber2" - ><i class="fa fa-fw fa-phone" - /></span> - <input - class="form-control form-control-lg" - v-model="thirdparty.telephone2" - :placeholder="$t('thirdparty.phonenumber2')" - :aria-label="$t('thirdparty.phonenumber2')" - aria-describedby="phonenumber2" - /> - </div> - - <div v-if="parent"> - <div class="input-group mb-3"> - <span class="input-group-text" id="comment" - ><i class="fa fa-fw fa-pencil" - /></span> - <textarea - class="form-control form-control-lg" - :placeholder="$t('thirdparty.comment')" - v-model="thirdparty.comment" - /> - </div> - </div> + <div v-if="action === 'show' && thirdparty !== null"> + <div class="flex-table"> + <third-party-render-box + :thirdparty="thirdparty" + :options="{ + addInfo: true, + addEntity: true, + entityDisplayLong: true, + addId: true, + addLink: false, + hLevel: 3, + isMultiline: true, + }" + /> </div> + </div> + <div + v-else-if=" + action === 'edit' || action === 'create' || action === 'addContact' + " + > + <ThirdPartyEdit + :id="id" + :type="type" + :action="action" + :query="query" + :parent="parent" + /> + </div> </template> -<script> +<script setup lang="ts"> +import { onMounted, ref } from "vue"; import ThirdPartyRenderBox from "../Entity/ThirdPartyRenderBox.vue"; -import AddAddress from "ChillMainAssets/vuejs/Address/components/AddAddress"; +import ThirdPartyEdit from "./ThirdPartyEdit.vue"; import { getThirdparty } from "../../_api/OnTheFly"; -import BadgeEntity from "ChillMainAssets/vuejs/_components/BadgeEntity.vue"; -import { makeFetch } from "ChillMainAssets/lib/api/apiMethods"; -import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper"; +import { Thirdparty, ThirdpartyCompany } from "../../../types"; -export default { - name: "OnTheFlyThirdParty", - props: ["id", "type", "action", "query", "parent"], - components: { - ThirdPartyRenderBox, - AddAddress, - BadgeEntity, - }, - data() { - return { - //context: {}, <-- - thirdparty: { - type: "thirdparty", - address: null, - kind: "company", - firstname: "", - name: "", - telephone: "", - telephone2: "", - civility: null, - profession: "", - }, - civilities: [], - addAddress: { - options: { - openPanesInModal: true, - onlyButton: false, - button: { - size: "btn-sm", - }, - title: { - create: "add_an_address_title", - edit: "edit_address", - }, - }, - }, - }; - }, - computed: { - kind: { - get() { - // note: there are also default to 'institution' set in the "mounted" method - if (this.$data.thirdparty.kind !== undefined) { - return this.$data.thirdparty.kind; - } else { - return "company"; - } - }, - set(v) { - this.$data.thirdparty.kind = v; - }, - }, - context() { - let context = { - target: { - name: this.type, - id: this.id, - }, - edit: false, - addressId: null, - defaults: window.addaddress, - }; - if ( - !( - this.thirdparty.address === undefined || - this.thirdparty.address === null - ) && - this.thirdparty.address.address_id !== null - ) { - // to complete - context.addressId = this.thirdparty.address.address_id; - context.edit = true; - } - //this.context = context; <-- - return context; - }, - queryItems() { - return this.query ? this.query.split(" ") : null; - }, - }, - methods: { - localizeString, - loadData() { - return getThirdparty(this.id).then( - (thirdparty) => - new Promise((resolve) => { - this.thirdparty = thirdparty; - this.thirdparty.kind = thirdparty.kind; - if (this.action !== "show") { - if (thirdparty.address !== null) { - // bof! we force getInitialAddress because addressId not available when mounted - this.$refs.addAddress.getInitialAddress( - thirdparty.address.address_id, - ); - } - } - resolve(); - }), - ); - }, - loadCivilities() { - const url = `/api/1.0/main/civility.json`; - return makeFetch("GET", url) - .then((response) => { - this.$data.civilities = response.results; - return Promise.resolve(); - }) - .catch((error) => { - console.log(error); - this.$toast.open({ message: error.body }); - }); - }, - submitAddress(payload) { - console.log("submitAddress", payload); - if (typeof payload.addressId !== "undefined") { - // <-- - this.context.edit = true; - this.context.addressId = payload.addressId; // bof! use legacy and not legacy in payload - this.thirdparty.address = payload.address; // <-- - console.log("switch address to edit mode", this.context); - } - }, - addQueryItem(field, queryItem) { - switch (field) { - case "name": - if (this.thirdparty.name) { - this.thirdparty.name += ` ${queryItem}`; - } else { - this.thirdparty.name = queryItem; - } - break; - case "firstName": - this.thirdparty.firstname = queryItem; - break; - } - }, - addQuery(query) { - this.thirdparty.name = query; - }, - }, - mounted() { - let dependencies = []; - dependencies.push(this.loadCivilities()); - if (this.action !== "create") { - if (this.id) { - dependencies.push(this.loadData()); - // here we can do something when all promises are resolve, with - // Promise.all(dependencies).then(() => { /* do something */ }); - } - if (this.action === "addContact") { - this.$data.thirdparty.kind = "child"; - // this.$data.thirdparty.parent = this.parent.id - this.$data.thirdparty.address = null; - } - } else { - this.thirdparty.kind = "company"; - } - }, -}; +interface ThirdPartyProp { + id: number; + type: "thirdparty"; + action: "show" | "edit" | "create"; + parent?: null; +} +interface ThirdPartyAddContact { + id: number; + type: "thirdparty"; + action: "addContact"; + parent: ThirdpartyCompany; +} + +type ThirdPartyProps = ThirdPartyProp | ThirdPartyAddContact; + +const props = withDefaults(defineProps<ThirdPartyProps>(), { + parent: null, +}); + +const thirdparty = ref<Thirdparty | null>(null); + +async function loadData() { + thirdparty.value = await getThirdparty(props.id); +} + +onMounted(() => { + if (props.action === "show" && props.id) { + loadData(); + } +}); </script> <style lang="scss" scoped> div.flex-table { - div.item-bloc { - div.item-row { - div.item-col:last-child { - justify-content: flex-start; - } - } + div.item-bloc { + div.item-row { + div.item-col:last-child { + justify-content: flex-start; + } } + } } dl { - dd { - margin-left: 1em; - } + dd { + margin-left: 1em; + } } .parent-info { - margin-bottom: 1rem; + margin-bottom: 1rem; } .child-info { - display: flex; - justify-content: space-between; - .input-section { - width: 49%; - } + display: flex; + justify-content: space-between; + .input-section { + width: 49%; + } } </style> diff --git a/src/Bundle/ChillThirdPartyBundle/Resources/public/vuejs/_components/OnTheFly/ThirdPartyEdit.vue b/src/Bundle/ChillThirdPartyBundle/Resources/public/vuejs/_components/OnTheFly/ThirdPartyEdit.vue new file mode 100644 index 000000000..cb3d93ea2 --- /dev/null +++ b/src/Bundle/ChillThirdPartyBundle/Resources/public/vuejs/_components/OnTheFly/ThirdPartyEdit.vue @@ -0,0 +1,588 @@ +<template> + <div> + <div v-if="resolvedParent"> + <div class="parent-info"> + <i class="fa fa-li fa-hand-o-right" /> + <b class="me-2">{{ trans(THIRDPARTY_MESSAGES_CHILD_OF) }}</b> + <span class="chill-entity badge-thirdparty">{{ + resolvedParent.text + }}</span> + </div> + </div> + <div class="form-floating mb-3" v-else-if="props.action === 'create'"> + <div class="form-check"> + <input + class="form-check-input mt-0" + type="radio" + v-model="kind" + value="company" + id="tpartyKindInstitution" + /> + <label for="tpartyKindInstitution" class="required"> + <badge-entity + :entity="{ type: 'thirdparty', kind: 'company' }" + :options="{ displayLong: true }" + /> + </label> + </div> + <div class="form-check"> + <input + class="form-check-input mt-0" + type="radio" + v-model="kind" + value="contact" + id="tpartyKindContact" + /> + <label for="tpartyKindContact" class="required"> + <badge-entity + :entity="{ type: 'thirdparty', kind: 'contact' }" + :options="{ displayLong: true }" + /> + </label> + </div> + </div> + <div v-else-if="resolvedParent"> + <p>Contact de :</p> + <third-party-render-box + :thirdparty="resolvedParent" + :options="{ + addInfo: true, + addEntity: false, + addId: false, + addLink: false, + hLevel: 4, + isMultiline: false, + }" + /> + </div> + + <div v-if="thirdParty.kind === 'child' || thirdParty.kind === 'contact'"> + <div class="child-info"> + <div class="input-section"> + <div class="mb-3"> + <div class="input-group"> + <div class="form-floating"> + <select + class="form-select form-select-lg" + name="civility" + id="civility" + v-model="civility" + > + <option selected disabled :value="null"> + {{ trans(THIRDPARTY_MESSAGES_THIRDPARTY_CIVILITY) }} + </option> + <option + v-for="civility in civilities" + :key="civility.id" + :value="civility.id" + > + {{ localizeString(civility.name) }} + </option> + </select> + <label for="civility">{{ + trans(THIRDPARTY_MESSAGES_THIRDPARTY_CIVILITY) + }}</label> + </div> + </div> + </div> + </div> + <div class="input-section"> + <div class="mb-3"> + <div class="form-floating"> + <input + class="form-control form-control-lg" + name="profession" + v-model="thirdParty.profession" + :placeholder="trans(THIRDPARTY_MESSAGES_THIRDPARTY_PROFESSION)" + :aria-label="trans(THIRDPARTY_MESSAGES_THIRDPARTY_PROFESSION)" + aria-describedby="profession" + /> + <label for="profession">{{ + trans(THIRDPARTY_MESSAGES_THIRDPARTY_PROFESSION) + }}</label> + </div> + </div> + </div> + </div> + <div class="child-info"> + <div class="input-section"> + <div class="mb-3"> + <div class="input-group"> + <div class="form-floating"> + <input + class="form-control form-control-lg" + id="firstname" + v-model="thirdParty.firstname" + :placeholder="trans(THIRDPARTY_MESSAGES_THIRDPARTY_FIRSTNAME)" + /> + <label for="firstname">{{ + trans(THIRDPARTY_MESSAGES_THIRDPARTY_FIRSTNAME) + }}</label> + </div> + </div> + <div v-if="queryItems"> + <ul class="list-suggest add-items inline"> + <li + v-for="(qi, i) in queryItems" + :key="i" + @click="addQueryItem('firstName', qi)" + > + <span class="person-text">{{ qi }}</span> + </li> + </ul> + </div> + </div> + </div> + <div class="input-section"> + <div class="mb3"> + <div class="input-group has-validation"> + <div class="form-floating"> + <input + class="form-control form-control-lg" + :class="{ 'is-invalid': violations.hasViolation('name') }" + id="name" + v-model="thirdParty.name" + :placeholder="trans(THIRDPARTY_MESSAGES_THIRDPARTY_LASTNAME)" + /> + <label for="name">{{ + trans(THIRDPARTY_MESSAGES_THIRDPARTY_LASTNAME) + }}</label> + </div> + </div> + <div + v-for="err in violations.violationTitles('name')" + class="invalid-feedback was-validated-force" + > + {{ err }} + </div> + <div v-if="queryItems"> + <ul class="list-suggest add-items inline"> + <li + v-for="(qi, i) in queryItems" + :key="i" + @click="addQueryItem('name', qi)" + > + <span class="person-text">{{ qi }}</span> + </li> + </ul> + </div> + </div> + </div> + </div> + </div> + + <div v-if="thirdParty.kind === 'company'"> + <div class="mb-3"> + <div class="input-group"> + <div class="form-floating"> + <input + class="form-control form-control-lg" + :class="{ 'is-invalid': violations.hasViolation('name') }" + id="name" + v-model="thirdParty.name" + :placeholder="trans(THIRDPARTY_MESSAGES_THIRDPARTY_NAME)" + /> + <label for="name">{{ + trans(THIRDPARTY_MESSAGES_THIRDPARTY_NAME) + }}</label> + </div> + </div> + + <div + v-for="err in violations.violationTitles('name')" + class="invalid-feedback was-validated-force" + > + {{ err }} + </div> + + <div v-if="query"> + <ul class="list-suggest add-items inline"> + <li @click="addQuery(query)"> + <span class="person-text">{{ query }}</span> + </li> + </ul> + </div> + </div> + </div> + + <template v-if="thirdParty.kind !== 'child'"> + <AddAddress + key="thirdparty" + :context="context" + :options="addAddress.options" + :address-changed-callback="submitAddress" + ref="addAddressRef" + /> + </template> + + <div class="mb-3"> + <div class="input-group has-validation"> + <span id="email" class="input-group-text"> + <i class="fa fa-fw fa-at" /> + </span> + <div class="form-floating"> + <input + class="form-control form-control-lg" + :class="{ 'is-invalid': violations.hasViolation('email') }" + name="email" + v-model="thirdParty.email" + :placeholder="trans(THIRDPARTY_MESSAGES_THIRDPARTY_EMAIL)" + :aria-label="trans(THIRDPARTY_MESSAGES_THIRDPARTY_EMAIL)" + aria-describedby="email" + /> + <label for="email">{{ + trans(THIRDPARTY_MESSAGES_THIRDPARTY_EMAIL) + }}</label> + </div> + </div> + <div + v-for="err in violations.violationTitles('email')" + class="invalid-feedback was-validated-force" + > + {{ err }} + </div> + </div> + + <div class="mb-3"> + <div class="input-group has-validation"> + <span class="input-group-text" id="phonenumber"> + <i class="fa fa-fw fa-phone" /> + </span> + <div class="form-floating"> + <input + class="form-control form-control-lg" + :class="{ 'is-invalid': violations.hasViolation('telephone') }" + name="phonenumber" + v-model="thirdParty.telephone" + :placeholder="trans(THIRDPARTY_MESSAGES_THIRDPARTY_PHONENUMBER)" + :aria-label="trans(THIRDPARTY_MESSAGES_THIRDPARTY_PHONENUMBER)" + aria-describedby="phonenumber" + /> + <label for="phonenumber">{{ + trans(THIRDPARTY_MESSAGES_THIRDPARTY_PHONENUMBER) + }}</label> + </div> + </div> + <div + v-for="err in violations.violationTitles('telephone')" + class="invalid-feedback was-validated-force" + > + {{ err }} + </div> + </div> + + <div class="mb-3"> + <div class="input-group has-validation"> + <span class="input-group-text" id="phonenumber2" + ><i class="fa fa-fw fa-phone" + /></span> + <div class="form-floating"> + <input + class="form-control form-control-lg" + :class="{ 'is-invalid': violations.hasViolation('telephone2') }" + name="phonenumber2" + v-model="thirdParty.telephone2" + :placeholder="trans(THIRDPARTY_MESSAGES_THIRDPARTY_PHONENUMBER2)" + :aria-label="trans(THIRDPARTY_MESSAGES_THIRDPARTY_PHONENUMBER2)" + aria-describedby="phonenumber2" + /> + <label for="phonenumber2">{{ + trans(THIRDPARTY_MESSAGES_THIRDPARTY_PHONENUMBER2) + }}</label> + </div> + </div> + <div + v-for="err in violations.violationTitles('telephone2')" + class="invalid-feedback was-validated-force" + > + {{ err }} + </div> + </div> + + <div v-if="props.action !== 'addContact'"> + <div class="input-group mb-3"> + <span class="input-group-text" id="comment" + ><i class="fa fa-fw fa-pencil" + /></span> + <textarea + class="form-control form-control-lg" + :placeholder="trans(THIRDPARTY_MESSAGES_THIRDPARTY_COMMENT)" + v-model="thirdParty.comment" + /> + </div> + </div> + </div> +</template> + +<script setup lang="ts"> +import { ref, reactive, computed, onMounted, getCurrentInstance } from "vue"; +import ThirdPartyRenderBox from "../Entity/ThirdPartyRenderBox.vue"; +import AddAddress from "ChillMainAssets/vuejs/Address/components/AddAddress.vue"; +import { + createThirdParty, + getThirdparty, + patchThirdparty, + thirdpartyToWriteThirdParty, + WriteThirdPartyViolationMap, +} from "../../_api/OnTheFly"; +import BadgeEntity from "ChillMainAssets/vuejs/_components/BadgeEntity.vue"; +import { localizeString as _localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper"; +import { + trans, + THIRDPARTY_MESSAGES_THIRDPARTY_FIRSTNAME, + THIRDPARTY_MESSAGES_THIRDPARTY_LASTNAME, + THIRDPARTY_MESSAGES_THIRDPARTY_NAME, + THIRDPARTY_MESSAGES_THIRDPARTY_EMAIL, + THIRDPARTY_MESSAGES_THIRDPARTY_PHONENUMBER, + THIRDPARTY_MESSAGES_THIRDPARTY_PHONENUMBER2, + THIRDPARTY_MESSAGES_THIRDPARTY_COMMENT, + THIRDPARTY_MESSAGES_THIRDPARTY_PROFESSION, + THIRDPARTY_MESSAGES_THIRDPARTY_CIVILITY, + THIRDPARTY_MESSAGES_CHILD_OF, + PERSON_EDIT_ERROR_WHILE_SAVING, +} from "translator"; +import { + createPerson, + getCivilities, + WritePersonViolationMap, +} from "ChillPersonAssets/vuejs/_api/OnTheFly"; +import { + isThirdpartyChild, + isThirdpartyCompany, + Thirdparty, + ThirdpartyCompany, + ThirdPartyKind, + ThirdPartyWrite, +} from "../../../types"; +import { Civility, SetCivility } from "ChillMainAssets/types"; +import { isValidationException } from "ChillMainAssets/lib/api/apiMethods"; +import { useViolationList } from "ChillMainAssets/vuejs/_composables/violationList"; +import { useToast } from "vue-toast-notification"; + +interface ThirdPartyEditWriteProps { + id?: number; + type?: "thirdparty"; + action: "edit" | "create"; + query?: string; + parent?: null; +} + +interface ThirdPartyAddContactProps { + id?: null; + type?: "thirdparty"; + action: "addContact"; + query?: ""; + parent: ThirdpartyCompany; +} + +type ThirdPartyEditProps = ThirdPartyAddContactProps | ThirdPartyEditWriteProps; + +const props = withDefaults(defineProps<ThirdPartyEditProps>(), { + type: "thirdparty", + query: "", + parent: null, +}); + +const emit = + defineEmits< + (e: "onThirdPartyCreated", payload: { thirdParty: Thirdparty }) => void + >(); + +defineExpose({ postThirdParty }); + +const toast = useToast(); + +const thirdParty = ref<ThirdPartyWrite>({ + type: "thirdparty", + kind: "company", + address: null, + civility: null, + email: "", + firstname: "", + name: "", + profession: "", + telephone: "", + telephone2: "", + comment: "", + parent: null, +}); + +const originalThirdParty = ref<Thirdparty | null>(null); + +const civility = computed<number | null>({ + get: () => { + if (thirdParty.value.civility !== null) { + return thirdParty.value.civility.id; + } + + return null; + }, + set: (value: number | null) => { + thirdParty.value.civility = + value !== null ? { id: value, type: "chill_main_civility" } : null; + }, +}); + +const civilities = ref<Civility[]>([]); + +const addAddress = reactive({ + options: { + openPanesInModal: true, + onlyButton: false, + button: { size: "btn-sm" }, + title: { create: "add_an_address_title", edit: "edit_address" }, + }, +}); +const addAddressRef = ref<any>(null); + +/** + * We need a specific computed for the kind + */ +const kind = computed<ThirdPartyKind>({ + get() { + return thirdParty.value.kind; + }, + set(v) { + thirdParty.value.kind = v; + }, +}); + +const context = computed(() => { + const ctx: any = { + target: { name: props.type, id: props.id }, + edit: false, + addressId: null as number | null, + defaults: (window as any).addaddress, + }; + if (thirdParty.value.address) { + ctx.addressId = thirdParty.value.address.id; + ctx.edit = true; + } + return ctx; +}); + +const resolvedParent = computed<null | ThirdpartyCompany>(() => { + if ( + null !== originalThirdParty.value && + isThirdpartyChild(originalThirdParty.value) + ) { + return originalThirdParty.value.parent; + } + + return props.parent ?? null; +}); + +/** + * Find the query items to display for suggestion + */ +const queryItems = computed(() => { + const words: null | string[] = props.query ? props.query.split(" ") : null; + + if (null === words) { + return null; + } + + const firstNameWords = (thirdParty.value.firstname || "") + .trim() + .toLowerCase() + .split(" "); + const lastNameWords = (thirdParty.value.name || "") + .trim() + .toLowerCase() + .split(" "); + + return words + .filter((word) => !firstNameWords.includes(word.toLowerCase())) + .filter((word) => !lastNameWords.includes(word.toLowerCase())); +}); + +function localizeString(str: any) { + return _localizeString(str); +} + +onMounted(() => { + getCivilities().then((cv) => { + civilities.value = cv; + }); + if (props.action === "edit") { + loadData(); + } else if (props.action === "addContact") { + thirdParty.value.kind = "child"; + thirdParty.value.address = null; + thirdParty.value.parent = { id: props.parent.id, type: "thirdparty" }; + } +}); + +async function loadData(): Promise<void> { + if (!props.id) return Promise.resolve(); + const t = await getThirdparty(props.id); + originalThirdParty.value = t; + thirdParty.value = thirdpartyToWriteThirdParty(t); +} + +function submitAddress(payload: { addressId: number }) { + console.log(payload); + thirdParty.value.address = { id: payload.addressId }; +} + +function addQueryItem(field: "name" | "firstName", queryItem: string) { + switch (field) { + case "name": + if (thirdParty.value.name) { + thirdParty.value.name += ` ${queryItem}`; + } else { + thirdParty.value.name = queryItem; + } + break; + case "firstName": + thirdParty.value.firstname = queryItem; + break; + } +} + +function addQuery(query: string) { + thirdParty.value.name = query; +} + +const violations = useViolationList<WriteThirdPartyViolationMap>(); + +async function postThirdParty(): Promise<Thirdparty> { + try { + if (props.action === "edit" && props.id) { + const tp = await patchThirdparty(props.id, thirdParty.value); + return Promise.resolve(tp); + } else if (props.action === "addContact" || props.action === "create") { + const tp = await createThirdParty(thirdParty.value); + emit("onThirdPartyCreated", { thirdParty: tp }); + return Promise.resolve(tp); + } + } catch (e: unknown) { + if (isValidationException<WriteThirdPartyViolationMap>(e)) { + violations.setValidationException(e); + } else { + toast.error("An error occurred while creating the third party"); + } + throw e; + } + throw "'action' is not edit with and id, or addContact or create"; +} +</script> + +<style scoped lang="scss"> +.was-validated-force { + display: block; +} + +.parent-info { + margin-bottom: 1rem; +} + +.child-info { + display: flex; + justify-content: space-between; + + .input-section { + width: 49%; + } +} +</style> diff --git a/src/Bundle/ChillThirdPartyBundle/Service/ThirdpartyMergeService.php b/src/Bundle/ChillThirdPartyBundle/Service/ThirdpartyMergeService.php index 4eecb09b4..a5aecea45 100644 --- a/src/Bundle/ChillThirdPartyBundle/Service/ThirdpartyMergeService.php +++ b/src/Bundle/ChillThirdPartyBundle/Service/ThirdpartyMergeService.php @@ -69,10 +69,11 @@ readonly class ThirdpartyMergeService if (($assoc['type'] & ClassMetadata::TO_ONE) !== 0) { $joinColumn = $meta->getSingleAssociationJoinColumnName($assoc['fieldName']); - $suffix = (ThirdParty::class === $assoc['sourceEntity']) ? 'chill_3party.' : ''; + $schema = $meta->getSchemaName(); + $prefix = null !== $schema && '' !== $schema ? $schema.'.' : ''; $queries[] = [ - 'sql' => "UPDATE {$suffix}{$tableName} SET {$joinColumn} = :toKeep WHERE {$joinColumn} = :toDelete", + 'sql' => "UPDATE {$prefix}{$tableName} SET {$joinColumn} = :toKeep WHERE {$joinColumn} = :toDelete", 'params' => ['toKeep' => $toKeep->getId(), 'toDelete' => $toDelete->getId()], ]; } elseif (ClassMetadata::MANY_TO_MANY === $assoc['type'] && isset($assoc['joinTable'])) { @@ -85,13 +86,36 @@ readonly class ThirdpartyMergeService ]; $queries[] = [ - 'sql' => "DELETE FROM {$joinTable} WHERE {$joinColumn} = :toDelete", + 'sql' => "DELETE FROM {$prefix}{$joinTable} WHERE {$joinColumn} = :toDelete", 'params' => ['toDelete' => $toDelete->getId()], ]; } } } + // Also handle many-to-many where ThirdParty is the source + $thirdPartyMeta = $this->em->getClassMetadata(ThirdParty::class); + foreach ($thirdPartyMeta->getAssociationMappings() as $assoc) { + if (ClassMetadata::MANY_TO_MANY === $assoc['type'] && isset($assoc['joinTable'])) { + $joinTable = $assoc['joinTable']['name']; + $prefix = null !== ($assoc['joinTable']['schema'] ?? null) ? $assoc['joinTable']['schema'].'.' : ''; + $joinColumn = $assoc['joinTable']['joinColumns'][0]['name']; // Note: joinColumns, not inverseJoinColumns + + // Get the other column name to build proper duplicate check + $otherColumn = $assoc['joinTable']['inverseJoinColumns'][0]['name']; + + $queries[] = [ + 'sql' => "UPDATE {$prefix}{$joinTable} SET {$joinColumn} = :toKeep WHERE {$joinColumn} = :toDelete AND NOT EXISTS (SELECT 1 FROM {$prefix}{$joinTable} AS t2 WHERE t2.{$joinColumn} = :toKeep AND t2.{$otherColumn} = {$prefix}{$joinTable}.{$otherColumn})", + 'params' => ['toKeep' => $toKeep->getId(), 'toDelete' => $toDelete->getId()], + ]; + + $queries[] = [ + 'sql' => "DELETE FROM {$prefix}{$joinTable} WHERE {$joinColumn} = :toDelete", + 'params' => ['toDelete' => $toDelete->getId()], + ]; + } + } + return $queries; } @@ -102,10 +126,6 @@ readonly class ThirdpartyMergeService 'sql' => 'UPDATE chill_3party.third_party SET parent_id = :toKeep WHERE parent_id = :toDelete', 'params' => ['toKeep' => $toKeep->getId(), 'toDelete' => $toDelete->getId()], ], - [ - 'sql' => 'UPDATE chill_3party.thirdparty_category SET thirdparty_id = :toKeep WHERE thirdparty_id = :toDelete AND NOT EXISTS (SELECT 1 FROM chill_3party.thirdparty_category WHERE thirdparty_id = :toKeep)', - 'params' => ['toKeep' => $toKeep->getId(), 'toDelete' => $toDelete->getId()], - ], [ 'sql' => 'DELETE FROM chill_3party.third_party WHERE id = :toDelete', 'params' => ['toDelete' => $toDelete->getId()], diff --git a/src/Bundle/ChillThirdPartyBundle/Tests/Service/ThirdpartyMergeServiceTest.php b/src/Bundle/ChillThirdPartyBundle/Tests/Service/ThirdpartyMergeServiceTest.php index 4b2819751..4d8afbb7d 100644 --- a/src/Bundle/ChillThirdPartyBundle/Tests/Service/ThirdpartyMergeServiceTest.php +++ b/src/Bundle/ChillThirdPartyBundle/Tests/Service/ThirdpartyMergeServiceTest.php @@ -12,6 +12,7 @@ declare(strict_types=1); namespace Chill\ThirdPartyBundle\Tests\Service; use Chill\ActivityBundle\Entity\Activity; +use Chill\MainBundle\Entity\Center; use Chill\ThirdPartyBundle\Entity\ThirdParty; use Chill\ThirdPartyBundle\Entity\ThirdPartyCategory; use Chill\ThirdPartyBundle\Service\ThirdpartyMergeService; @@ -47,19 +48,20 @@ class ThirdpartyMergeServiceTest extends KernelTestCase $toDelete->setName('Thirdparty to delete'); $this->em->persist($toDelete); - // Create a related entity with TO_ONE relation (thirdparty parent) + // Create a related entity with TO_ONE relation (thirdparty parent) - tests schema handling for TO_ONE $relatedToOneEntity = new ThirdParty(); $relatedToOneEntity->setName('RelatedToOne thirdparty'); $relatedToOneEntity->setParent($toDelete); $this->em->persist($relatedToOneEntity); - // Create a related entity with TO_MANY relation (thirdparty category) + // Create a related entity with MANY_TO_MANY relation (thirdparty category) - tests schema handling for MANY_TO_MANY where ThirdParty is target $thirdpartyCategory = new ThirdPartyCategory(); $thirdpartyCategory->setName(['fr' => 'Thirdparty category']); $this->em->persist($thirdpartyCategory); $toDelete->addCategory($thirdpartyCategory); $this->em->persist($toDelete); + // Test MANY_TO_MANY relation from another bundle (Activity) - tests cross-bundle schema handling $activity = new Activity(); $activity->setDate(new \DateTime()); $activity->addThirdParty($toDelete); @@ -73,14 +75,55 @@ class ThirdpartyMergeServiceTest extends KernelTestCase $this->em->refresh($relatedToOneEntity); // Check that references were updated - $this->assertEquals($toKeep->getId(), $relatedToOneEntity->getParent()->getId(), 'The parent thirdparty was succesfully merged'); + // Test TO_ONE relation in chill_3party schema was properly handled + $this->assertEquals($toKeep->getId(), $relatedToOneEntity->getParent()->getId(), 'The parent thirdparty in chill_3party schema was successfully merged'); + + // Test MANY_TO_MANY relation in chill_3party schema was properly handled $updatedRelatedManyEntity = $this->em->find(ThirdPartyCategory::class, $thirdpartyCategory->getId()); - $this->assertContains($updatedRelatedManyEntity, $toKeep->getCategories(), 'The thirdparty category was found in the toKeep entity'); + $this->assertContains($updatedRelatedManyEntity, $toKeep->getCategories(), 'The thirdparty category in chill_3party schema was found in the toKeep entity'); + + // Test MANY_TO_MANY relation from different schema (Activity bundle) was properly handled + $this->em->refresh($activity); + $this->assertContains($toKeep, $activity->getThirdParties(), 'The activity relation from different schema was successfully merged'); + $this->assertNotContains($toDelete, $activity->getThirdParties(), 'The toDelete thirdparty was removed from activity relation'); // Check that toDelete was removed $this->em->clear(); $deletedThirdParty = $this->em->find(ThirdParty::class, $toDelete->getId()); - $this->assertNull($deletedThirdParty); + $this->assertNull($deletedThirdParty, 'The toDelete thirdparty was successfully removed'); + } + + public function testMergeWithSharedCenterDoesNotCauseUniqueConstraintViolation(): void + { + // Create a center that will be shared by both thirdparties + $sharedCenter = new Center(); + $sharedCenter->setName('Shared Center'); + $this->em->persist($sharedCenter); + + // Create ThirdParty entities + $toKeep = new ThirdParty(); + $toKeep->setName('Thirdparty to keep'); + $toKeep->addCenter($sharedCenter); // Both thirdparties linked to same center + $this->em->persist($toKeep); + + $toDelete = new ThirdParty(); + $toDelete->setName('Thirdparty to delete'); + $toDelete->addCenter($sharedCenter); // Both thirdparties linked to same center + $this->em->persist($toDelete); + + $this->em->flush(); + + // This should not throw a unique constraint violation + $this->service->merge($toKeep, $toDelete); + + // Verify that toKeep still has the shared center + $this->em->refresh($toKeep); + $this->assertContains($sharedCenter, $toKeep->getCenters(), 'The shared center is still linked to the kept thirdparty'); + + // Verify that toDelete was removed + $this->em->clear(); + $deletedThirdParty = $this->em->find(ThirdParty::class, $toDelete->getId()); + $this->assertNull($deletedThirdParty, 'The toDelete thirdparty was successfully removed'); } } diff --git a/src/Bundle/ChillThirdPartyBundle/translations/messages.fr.yml b/src/Bundle/ChillThirdPartyBundle/translations/messages.fr.yml index 2c5e8f262..6e6e96d7a 100644 --- a/src/Bundle/ChillThirdPartyBundle/translations/messages.fr.yml +++ b/src/Bundle/ChillThirdPartyBundle/translations/messages.fr.yml @@ -68,13 +68,17 @@ Remove a contact: Supprimer Contacts: Contacts No contacts associated: Aucun contact +thirdparty: + addcontact: Ajouter un contact + addcontact_title: Ajouter un contact + No nameCompany given: Aucune raison sociale renseignée No acronym given: Aucun sigle renseigné No phone given: Aucun téléphone renseigné No email given: Aucune adresse courriel renseignée -The party is visible in those centers: Le tiers est visible dans ces centres -The party is not visible in any center: Le tiers n'est associé à aucun centre +The party is visible in those centers: Le tiers est visible dans ces territoires +The party is not visible in any center: Le tiers n'est associé à aucun territoire No third parties: Aucun tiers Any third party selected: Aucun tiers sélectionné @@ -154,6 +158,19 @@ Telephone2: Autre téléphone Contact email: Courrier électronique du contact Contact address: Adresse du contact Contact profession: Profession du contact +thirdpartyMessages: + thirdparty: + firstname: "Prénom" + lastname: "Nom" + name: "Dénomination" + email: "Courriel" + phonenumber: "Téléphone" + phonenumber2: "Autre numéro de téléphone" + comment: "Commentaire" + profession: "Qualité" + civility: "Civilité" + child_of: "Contact d'une institution" + children: "Personnes de contact: " thirdparty_duplicate: title: Fusionner les tiers doublons diff --git a/src/Bundle/ChillThirdPartyBundle/translations/messages.nl.yml b/src/Bundle/ChillThirdPartyBundle/translations/messages.nl.yml new file mode 100644 index 000000000..213997522 --- /dev/null +++ b/src/Bundle/ChillThirdPartyBundle/translations/messages.nl.yml @@ -0,0 +1,169 @@ +Third party: Externe partner +Third parties: Externe partners +third parties: externe partners +firstname: Voornaam +name: Naam +telephone: Telefoon +telephone2: Ander telefoonnummer +adress: Adres +email: E-mail +comment: Opmerking +thirdparty.type: type +thirdparty.Type: Type +thirdparty.No_phonenumber: Geen telefoonnummer +thirdparty.No_email: Geen e-mail +thirdparty.No_comment: Geen opmerking + +thirdparty.NameCompany: Dienst/Afdeling +thirdparty.Acronym: Afkorting +thirdparty.Categories: Categorieën +thirdparty.no_categories: Geen categorie +thirdparty.Child: Contactpersoon +thirdparty.child: Contactpersoon +thirdparty.Children: Contactpersonen +thirdparty.children: Contactpersonen +thirdparty.Parent: Institutionele externe partner +thirdparty.Parents: Institutionele externe partners +thirdparty.Civility: Aanspreekvorm +thirdparty.choose civility: -- +thirdparty.Profession: Hoedanigheid +thirdparty.choose profession: -- +thirdparty.CreatedAt.short: 'Aangemaakt op ' +thirdparty.UpdatedAt.short: 'Gewijzigd op ' +thirdparty.UpdateBy.short: ' door ' +thirdparty.CreatedAt.long: Aanmaakdatum +thirdparty.UpdatedAt.long: Datum van de laatste wijziging +thirdparty.UpdateBy.long: Gebruiker die de laatste wijziging heeft uitgevoerd +thirdparty.A company: Een rechtspersoon +thirdparty.company: Rechtspersoon +thirdparty.A contact: Een natuurlijk persoon +thirdparty.contact: Natuurlijk persoon +thirdparty.Contact of: Contact van +thirdparty.a_company_explanation: >- + Rechtspersonen kunnen één of meerdere contacten hebben, intern aan de instelling. Het is ook mogelijk om + hen een afkorting en de naam van een dienst toe te kennen. +thirdparty.a_contact_explanation: >- + Natuurlijke personen hebben geen afkorting, dienst of onderliggende contacten. Het is mogelijk om hen + een aanspreekvorm en een beroep toe te kennen. +thirdparty.Which kind of third party ?: Welk type externe partner wilt u aanmaken? +thirdparty.Contact data are confidential: Contactgegevens zijn vertrouwelijk + +New third party: Nieuwe externe partner toevoegen +Show third party %name%: Externe partner "%name%" +Create third party: Externe partner aanmaken +Update third party %name%: Externe partner "%name%" bijwerken +List of third parties: Lijst van externe partners +Third party updated: De externe partner is bijgewerkt +Third party created: De externe partner is aangemaakt +thirdparty.Status: Status +Active, shown to users: Actief, zichtbaar voor gebruikers +Active: Actief +shown to users: zichtbaar voor gebruikers +Inactive, not shown to users: Inactief, onzichtbaar voor gebruikers +Inactive: Inactief +not shown to users: onzichtbaar voor gebruikers +Show thirdparty: Externe partner bekijken +Add a contact: Contact toevoegen +Remove a contact: Verwijderen +Contacts: Contacten +No contacts associated: Geen contact + +No nameCompany given: Geen bedrijfsnaam ingevuld +No acronym given: Geen afkorting ingevuld +No phone given: Geen telefoon ingevuld +No email given: Geen e-mailadres ingevuld + +The party is visible in those centers: De externe partner is zichtbaar in deze territoria +The party is not visible in any center: De externe partner is niet gekoppeld aan een territorium +No third parties: Geen externe partners +Any third party selected: Geen externe partner geselecteerd + +Thirdparty handling: Behandelende externe partner +Thirdparty workers: Tussenkomende externe partners +Third party category: Categorieën van externe partners + +Third party configuration: Beheer van externe partners + +# person resource +Select a thirdparty: "Kies een externe partner" +Contact person: "Contactpersoon" + +# Residential address +The address of a third party: Het adres van een externe partner +residential_address_third_party_explanation: Het adres zal worden gekoppeld aan dat van een externe partner. +Host third party: Het adres van een externe partner kiezen + +# ROLES +CHILL_3PARTY_3PARTY_CREATE: Externe partner toevoegen +CHILL_3PARTY_3PARTY_SHOW: Externe partner bekijken +CHILL_3PARTY_3PARTY_UPDATE: Externe partner bewerken + +# crud: +crud: + 3party_3party: + index: + add_new: Aanmaken + thirdparty_thirdparty-category: + index: + title: Lijst van categorieën van externe partners + add_new: Nieuwe toevoegen + title_new: Nieuwe categorie van externe partners + title_edit: Categorie van externe partners bewerken + +# docgen +docgen: + A context for person with a third party (for sending mail): Een context van een persoon met een externe partner (om bijvoorbeeld een brief naar deze externe partner te sturen) + Person with third party: Persoon met keuze van een externe partner + Ask for thirdParty: Vragen aan de gebruiker om een externe partner te specificeren + thirdParty label: Benaming van de externe partner + +# exports +export: + list: + acp: + isRequestorThirdParty: Is de aanvrager een externe partner? + requestorThirdPartyId: Identificatie van de externe partner + acprequestorThirdPaty: Naam van de aanvragende externe partner + acpw: + handlingThierParty: Behandelende externe partner + thirdParties: Tussenkomende externe partner + +# exports filters/aggregators +Filtered by person\'s who have a residential address located at a thirdparty of type %thirparty_type%: Alleen de gebruikers die een woonadres hebben bij een externe partner van categorie %thirdparty_type% +is thirdparty: De aanvrager is een externe partner + +Filter by person's who have a residential address located at a thirdparty of type: Gebruikers filteren die een woonadres hebben bij een externe partner +"Filtered by person's who have a residential address located at a thirdparty of type %thirdparty_type% and valid on %date_calc%": "Alleen de gebruikers die een woonadres hebben bij een externe partner van categorie %thirdparty_type% en geldig op datum %date_calc%" + +# admin +admin: + export_description: Lijst van externe partners (CSV-formaat) + +Profession: Beroep +Firstname: Voornaam +Name_company: Dienst/Afdeling +Address: Adres +Civility: Aanspreekvorm +Id: Identificatie +Contact id: Identificatie van het contact +Contact name: Naam van het contact +Contact firstname: Voornaam van het contact +Contact phone: Telefoon van het contact +Contact phone2: Andere telefoon van het contact +Telephone2: Andere telefoon +Contact email: E-mail van het contact +Contact address: Adres van het contact +Contact profession: Beroep van het contact + +thirdparty_duplicate: + title: Dubbele externe partners samenvoegen + find: Een dubbele externe partner aanwijzen + Thirdparty to keep: Te behouden externe partner + Thirdparty to delete: Te verwijderen externe partner + Thirdparty to delete explanation: Deze externe partner zal worden verwijderd. Alleen de contacten van deze externe partner, hieronder opgesomd, zullen worden overgedragen. + Thirdparty to keep explanation: Deze externe partner zal worden behouden + Data to keep: Bewaarde gegevens + You cannot merge a thirdparty with itself. Please choose a different thirdparty: U kunt een externe partner niet met zichzelf samenvoegen. Kies een andere externe partner. + A thirdparty can only be merged with a thirdparty of the same kind: Een externe partner kan alleen worden samengevoegd met een externe partner van hetzelfde type. + Two child thirdparties must have the same parent: Twee externe partners van het type "contact" moeten gelinked zijn aan dezelfde hiërarchische "ouder". + Merge successful: Het samenvoegen is succesvol uitgevoerd diff --git a/src/Bundle/ChillThirdPartyBundle/translations/validators.nl.yml b/src/Bundle/ChillThirdPartyBundle/translations/validators.nl.yml new file mode 100644 index 000000000..1dc2fd7da --- /dev/null +++ b/src/Bundle/ChillThirdPartyBundle/translations/validators.nl.yml @@ -0,0 +1,2 @@ +thirdParty: + thirdParty_has_no_email: De externe partner {{ thirdParty }} heeft geen e-mail adres. diff --git a/src/Bundle/ChillTicketBundle/.vscode/settings.json b/src/Bundle/ChillTicketBundle/.vscode/settings.json new file mode 100644 index 000000000..c10f4d386 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "idf.pythonInstallPath": "/usr/bin/python3" +} diff --git a/src/Bundle/ChillTicketBundle/chill.api.specs.yaml b/src/Bundle/ChillTicketBundle/chill.api.specs.yaml new file mode 100644 index 000000000..b1bdc4269 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/chill.api.specs.yaml @@ -0,0 +1,652 @@ +components: + schemas: + TicketComment: + type: object + properties: + id: + type: integer + TicketSimple: + type: object + properties: + id: + type: integer + Motive: + type: object + properties: + id: + type: integer + label: + type: object + additionalProperties: + type: string + example: + fr: Retard de livraison + active: + type: boolean + MotiveById: + type: object + properties: + id: + type: integer + type: + type: string + enum: + - ticket_motive + required: + - id + - type + +paths: + /1.0/ticket/ticket/{id}: + get: + tags: + - ticket + summary: Details of a ticket + parameters: + - name: id + in: path + required: true + description: The ticket id + schema: + type: integer + format: integer + minimum: 1 + responses: + 200: + description: "OK" + /1.0/ticket/search/authorized-centers: + get: + tags: + - ticket + summary: List of centers authorized for the current user + responses: + 200: + description: "OK" + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Center' + /1.0/ticket/ticket/list: + get: + tags: + - ticket + summary: List of tickets + parameters: + - name: byTicketId + in: query + description: > + The id of the ticket. + + When the id of the ticket is set, the other parameters are ignored. + required: false + style: form + explode: false + schema: + type: integer + minimum: 0 + - name: byPerson + in: query + description: the id of the person + required: false + style: form + explode: false + schema: + type: array + items: + type: integer + format: integer + minimum: 1 + - name: byCurrentState + in: query + description: the current state of the ticket + required: false + style: form + explode: false + schema: + type: array + items: + type: string + enum: + - open + - closed + - name: byCurrentStateEmergency + in: query + description: the current state emergency of the ticket + required: false + style: form + explode: false + schema: + type: array + items: + type: string + enum: + - yes + - no + - name: byMotives + in: query + description: the motives of the ticket. All the descendants of the motive are taken into account. + required: false + style: form + explode: false + schema: + type: array + items: + type: integer + format: integer + minimum: 1 + - name: byCreatedBefore + in: query + description: "Filter by the creation date for the ticket: only tickets created before the given date." + required: false + schema: + type: string + format: date-time + - name: byCreatedAfter + in: query + description: "Filter by the creation date for the ticket: only tickets crated after the given date." + required: false + schema: + type: string + format: date-time + - name: byResponseTimeExceeded + in: query + allowEmptyValue: true + description: | + Filter tickets that are not closed and have a response time exceeded (configuration parameter). + + The value of this parameter is ignored. + + **Warning**: This silently remove the filters "byCurrentState" and "byCreatedBefore". + schema: + type: string + - name: byAddressee + in: query + description: "The id of the addressee to search for. The search is also performed against user groups: the api endpoint filter for ticket assigned to the groups of the user given as parameter." + required: false + style: form + explode: false + schema: + type: array + items: + type: integer + format: integer + minimum: 1 + - name: byAddresseeToMe + in: query + description: filter tickets assigned to the current authenticated users + required: false + allowEmptyValue: true + schema: + type: string + - name: byAddresseeGroup + in: query + description: the id of the addressee users group to search for. + required: false + style: form + explode: false + schema: + type: array + items: + type: integer + format: integer + minimum: 1 + - name: byCreator + in: query + description: The id of the creator to search for. + required: false + style: form + explode: false + schema: + type: array + items: + type: integer + format: integer + minimum: 1 + - name: byPersonCenter + in: query + description: The id of the centers. The list of centers can be search through /api/1.0/ticket/search/authorized-centers endpoint. + required: false + style: form + explode: false + schema: + type: array + items: + type: integer + format: integer + minimum: 1 + responses: + 200: + description: OK + content: + application/json: + schema: + allOf: + - $ref: '#components/schemas/Collection' + - type: object + properties: + results: + type: array + items: + $ref: '#components/schemas/TicketSimple' + + + /1.0/ticket/motive.json: + get: + tags: + - ticket + summary: A list of available ticket's motive + responses: + 200: + description: "OK" + + /1.0/ticket/{id}/motive/set: + post: + tags: + - ticket + summary: Replace the existing ticket's motive by a new one + parameters: + - name: id + in: path + required: true + description: The ticket id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + motive: + $ref: "#/components/schemas/MotiveById" + responses: + 201: + description: "ACCEPTED" + 422: + description: "UNPROCESSABLE ENTITY" + + /1.0/ticket/{id}/comment/add: + post: + tags: + - ticket + summary: Add a comment to an existing ticket + parameters: + - name: id + in: path + required: true + description: The ticket id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + content: + type: string + responses: + 201: + description: "ACCEPTED" + 422: + description: "UNPROCESSABLE ENTITY" + /1.0/ticket/comment/{id}/edit: + post: + tags: + - ticket + summary: Edit the comment' content + parameters: + - name: id + in: path + required: true + description: The comment's id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + content: + type: string + responses: + 200: + description: "OK" + content: + application/json: + schema: + $ref: '#/components/schemas/TicketComment' + 403: + description: "Unauthorized" + + /1.0/ticket/comment/{id}/delete: + post: + tags: + - ticket + summary: Soft-delete a comment within a ticket + description: | + This will soft delete a comment within a ticket. + + Only the author of the comment is allowed to edit the comment. + + If a comment is deleted, it can be restored by using the "restore" call. + + The method is idempotent: it will have no effect on an already deleted comment. + parameters: + - name: id + in: path + required: true + description: The comment's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 200: + description: "OK" + content: + application/json: + schema: + $ref: '#/components/schemas/TicketComment' + 403: + description: "Unauthorized" + + /1.0/ticket/comment/{id}/restore: + post: + tags: + - ticket + summary: Restore a comment within a ticket + description: | + This will restore a comment of a ticket. + + Only the author of the comment is allowed to restore the comment. + + If the comment is not deleted, this method has no effect. + + parameters: + - name: id + in: path + required: true + description: The comment's id + schema: + type: integer + format: integer + minimum: 1 + responses: + 200: + description: "OK" + content: + application/json: + schema: + $ref: '#/components/schemas/TicketComment' + 403: + description: "Unauthorized" + + /1.0/ticket/{id}/persons/set: + post: + tags: + - ticket + summary: Associate a person with the ticket + parameters: + - name: id + in: path + required: true + description: The ticket id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + persons: + type: array + items: + $ref: '#/components/schemas/PersonById' + responses: + 200: + description: "OK" + /1.0/ticket/{id}/addressees/set: + post: + tags: + - ticket + summary: Set the addresses for an existing ticket (will replace all the existing addresses) + parameters: + - name: id + in: path + required: true + description: The ticket id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + addressees: + type: array + items: + oneOf: + - $ref: '#/components/schemas/UserGroupById' + - $ref: '#/components/schemas/UserById' + + + responses: + 201: + description: "ACCEPTED" + 422: + description: "UNPROCESSABLE ENTITY" + + /1.0/ticket/{id}/addressee/add: + post: + tags: + - ticket + summary: Add an addressee to a ticket, without removing existing ones. + parameters: + - name: id + in: path + required: true + description: The ticket id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + addressee: + oneOf: + - $ref: '#/components/schemas/UserGroupById' + - $ref: '#/components/schemas/UserById' + + + responses: + 201: + description: "ACCEPTED" + 422: + description: "UNPROCESSABLE ENTITY" + + /1.0/ticket/ticket/{id}/close: + post: + tags: + - ticket + summary: Close a ticket + description: | + Close an existing ticket. + + If the ticket is already close, no action will be performed on this ticket: his state will remains unchanged, and the + ticket will be returned. + parameters: + - name: id + in: path + required: true + description: The ticket id + schema: + type: integer + format: integer + minimum: 1 + responses: + 200: + description: "OK" + 401: + description: "UNAUTHORIZED" + /1.0/ticket/ticket/{id}/open: + post: + tags: + - ticket + summary: Open a ticket + description: | + Re-open an existing ticket. + + If the ticket is already opened, no action will be performed on this ticket: his state will remains unchanged, and the + ticket will be returned. + parameters: + - name: id + in: path + required: true + description: The ticket id + schema: + type: integer + format: integer + minimum: 1 + responses: + 200: + description: "OK" + 401: + description: "UNAUTHORIZED" + /1.0/ticket/ticket/{id}/emergency/{emergency}: + post: + tags: + - ticket + summary: Set a ticket as emergency + description: | + Re-open an existing ticket. + + If the ticket is already opened, no action will be performed on this ticket: his state will remains unchanged, and the + ticket will be returned. + parameters: + - name: id + in: path + required: true + description: The ticket id + schema: + type: integer + format: integer + minimum: 1 + - name: emergency + in: path + required: true + description: the new state of emergency + schema: + type: string + enum: + - yes + - no + responses: + 200: + description: "OK" + 401: + description: "UNAUTHORIZED" + + /1.0/ticket/ticket/{id}/set-caller: + post: + tags: + - ticket + summary: Set a caller for this ticket + description: | + Set a caller to the ticket. + + To remove the caller, set the caller field to null + parameters: + - name: id + in: path + required: true + description: The ticket id + schema: + type: integer + format: integer + minimum: 1 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + caller: + nullable: true + oneOf: + - $ref: '#/components/schemas/PersonById' + - $ref: '#/components/schemas/ThirdPartyById' + examples: + add_user: + value: + caller: + type: person + id: 8 + summary: Set the person with id 8 + add_third_party: + value: + caller: + type: thirdparty + id: 10 + summary: Set the third party with id 10 + remove: + value: + caller: null + summary: Remove the caller (set the caller to null) + + responses: + 200: + description: "OK" + 401: + description: "UNAUTHORIZED" + /1.0/ticket/ticket/{id}/suggest-person: + get: + tags: + - ticket + summary: Get a list of person suggested for the given ticket + parameters: + - name: id + in: path + required: true + description: The ticket id + schema: + type: integer + format: integer + minimum: 1 + responses: + 401: + description: "UNAUTHORIZED" + 200: + description: "OK" + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Person' diff --git a/src/Bundle/ChillTicketBundle/chill.webpack.config.js b/src/Bundle/ChillTicketBundle/chill.webpack.config.js new file mode 100644 index 000000000..57ae98f0d --- /dev/null +++ b/src/Bundle/ChillTicketBundle/chill.webpack.config.js @@ -0,0 +1,14 @@ +module.exports = function (encore, entries) { + encore.addEntry( + "page_ticket", + __dirname + "/src/Resources/public/page/ticket/index.ts", + ); + encore.addEntry( + "vue_ticket_app", + __dirname + "/src/Resources/public/vuejs/TicketApp/index.ts", + ); + encore.addEntry( + "vue_ticket_list", + __dirname + "/src/Resources/public/vuejs/TicketList/index.ts", + ); +}; diff --git a/src/Bundle/ChillTicketBundle/src/Action/Comment/Handler/UpdateCommentContentCommandHandler.php b/src/Bundle/ChillTicketBundle/src/Action/Comment/Handler/UpdateCommentContentCommandHandler.php new file mode 100644 index 000000000..a9a44070c --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Action/Comment/Handler/UpdateCommentContentCommandHandler.php @@ -0,0 +1,26 @@ +<?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\TicketBundle\Action\Comment\Handler; + +use Chill\TicketBundle\Action\Comment\UpdateCommentContentCommand; +use Chill\TicketBundle\Entity\Comment; + +final readonly class UpdateCommentContentCommandHandler implements UpdateCommentContentCommandHandlerInterface +{ + public function __construct( + ) {} + + public function handle(Comment $comment, UpdateCommentContentCommand $command): void + { + $comment->setContent($command->content); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Action/Comment/Handler/UpdateCommentContentCommandHandlerInterface.php b/src/Bundle/ChillTicketBundle/src/Action/Comment/Handler/UpdateCommentContentCommandHandlerInterface.php new file mode 100644 index 000000000..b57838ad2 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Action/Comment/Handler/UpdateCommentContentCommandHandlerInterface.php @@ -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\TicketBundle\Action\Comment\Handler; + +use Chill\TicketBundle\Action\Comment\UpdateCommentContentCommand; +use Chill\TicketBundle\Entity\Comment; + +interface UpdateCommentContentCommandHandlerInterface +{ + public function handle(Comment $comment, UpdateCommentContentCommand $command): void; +} diff --git a/src/Bundle/ChillTicketBundle/src/Action/Comment/Handler/UpdateCommentDeletedStatusCommandHandler.php b/src/Bundle/ChillTicketBundle/src/Action/Comment/Handler/UpdateCommentDeletedStatusCommandHandler.php new file mode 100644 index 000000000..f2cfa43ce --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Action/Comment/Handler/UpdateCommentDeletedStatusCommandHandler.php @@ -0,0 +1,26 @@ +<?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\TicketBundle\Action\Comment\Handler; + +use Chill\TicketBundle\Action\Comment\UpdateCommentDeletedStatusCommand; +use Chill\TicketBundle\Entity\Comment; + +final readonly class UpdateCommentDeletedStatusCommandHandler implements UpdateCommentDeletedStatusCommandHandlerInterface +{ + public function __construct( + ) {} + + public function handle(Comment $comment, UpdateCommentDeletedStatusCommand $command): void + { + $comment->setDeleted($command->delete); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Action/Comment/Handler/UpdateCommentDeletedStatusCommandHandlerInterface.php b/src/Bundle/ChillTicketBundle/src/Action/Comment/Handler/UpdateCommentDeletedStatusCommandHandlerInterface.php new file mode 100644 index 000000000..7136c2aad --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Action/Comment/Handler/UpdateCommentDeletedStatusCommandHandlerInterface.php @@ -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\TicketBundle\Action\Comment\Handler; + +use Chill\TicketBundle\Action\Comment\UpdateCommentDeletedStatusCommand; +use Chill\TicketBundle\Entity\Comment; + +interface UpdateCommentDeletedStatusCommandHandlerInterface +{ + public function handle(Comment $comment, UpdateCommentDeletedStatusCommand $command): void; +} diff --git a/src/Bundle/ChillTicketBundle/src/Action/Comment/UpdateCommentContentCommand.php b/src/Bundle/ChillTicketBundle/src/Action/Comment/UpdateCommentContentCommand.php new file mode 100644 index 000000000..724617335 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Action/Comment/UpdateCommentContentCommand.php @@ -0,0 +1,30 @@ +<?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\TicketBundle\Action\Comment; + +use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Serializer\Annotation as Serializer; + +final readonly class UpdateCommentContentCommand +{ + public function __construct( + /** + * The new content of the command. + * + * The command accept null values, but it should raise a validation error. + */ + #[Assert\NotBlank()] + #[Assert\NotNull] + #[Serializer\Groups(['write'])] + public ?string $content, + ) {} +} diff --git a/src/Bundle/ChillTicketBundle/src/Action/Comment/UpdateCommentDeletedStatusCommand.php b/src/Bundle/ChillTicketBundle/src/Action/Comment/UpdateCommentDeletedStatusCommand.php new file mode 100644 index 000000000..fded9b9c3 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Action/Comment/UpdateCommentDeletedStatusCommand.php @@ -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\TicketBundle\Action\Comment; + +use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Serializer\Annotation as Serializer; + +final readonly class UpdateCommentDeletedStatusCommand +{ + public function __construct( + /** + * The deleted status to set for the comment. + */ + #[Assert\NotNull] + #[Serializer\Groups(['write'])] + public bool $delete, + ) {} +} diff --git a/src/Bundle/ChillTicketBundle/src/Action/Ticket/AddAddresseeCommand.php b/src/Bundle/ChillTicketBundle/src/Action/Ticket/AddAddresseeCommand.php new file mode 100644 index 000000000..0e16876d8 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Action/Ticket/AddAddresseeCommand.php @@ -0,0 +1,29 @@ +<?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\TicketBundle\Action\Ticket; + +use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Entity\UserGroup; +use Symfony\Component\Serializer\Annotation\Groups; + +/** + * Add a single addressee to the ticket. + * + * This command is converted into an "SetAddresseesCommand" for handling + */ +final readonly class AddAddresseeCommand +{ + public function __construct( + #[Groups(['read'])] + public User|UserGroup $addressee, + ) {} +} diff --git a/src/Bundle/ChillTicketBundle/src/Action/Ticket/AddCommentCommand.php b/src/Bundle/ChillTicketBundle/src/Action/Ticket/AddCommentCommand.php new file mode 100644 index 000000000..1c009cfc8 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Action/Ticket/AddCommentCommand.php @@ -0,0 +1,25 @@ +<?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\TicketBundle\Action\Ticket; + +use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Serializer\Annotation as Serializer; + +final readonly class AddCommentCommand +{ + public function __construct( + #[Assert\NotBlank()] + #[Assert\NotNull] + #[Serializer\Groups(['write'])] + public ?string $content = null, + ) {} +} diff --git a/src/Bundle/ChillTicketBundle/src/Action/Ticket/AssociateByPhonenumberCommand.php b/src/Bundle/ChillTicketBundle/src/Action/Ticket/AssociateByPhonenumberCommand.php new file mode 100644 index 000000000..47b19c381 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Action/Ticket/AssociateByPhonenumberCommand.php @@ -0,0 +1,19 @@ +<?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\TicketBundle\Action\Ticket; + +class AssociateByPhonenumberCommand +{ + public function __construct( + public string $phonenumber, + ) {} +} diff --git a/src/Bundle/ChillTicketBundle/src/Action/Ticket/ChangeEmergencyStateCommand.php b/src/Bundle/ChillTicketBundle/src/Action/Ticket/ChangeEmergencyStateCommand.php new file mode 100644 index 000000000..7a72b9910 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Action/Ticket/ChangeEmergencyStateCommand.php @@ -0,0 +1,24 @@ +<?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\TicketBundle\Action\Ticket; + +use Chill\TicketBundle\Entity\EmergencyStatusEnum; + +/** + * Command to change the emergency status of a ticket. + */ +final readonly class ChangeEmergencyStateCommand +{ + public function __construct( + public EmergencyStatusEnum $newEmergencyStatus, + ) {} +} diff --git a/src/Bundle/ChillTicketBundle/src/Action/Ticket/ChangeStateCommand.php b/src/Bundle/ChillTicketBundle/src/Action/Ticket/ChangeStateCommand.php new file mode 100644 index 000000000..f668a2455 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Action/Ticket/ChangeStateCommand.php @@ -0,0 +1,24 @@ +<?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\TicketBundle\Action\Ticket; + +use Chill\TicketBundle\Entity\StateEnum; + +/** + * Command to change the state of a ticket. + */ +final readonly class ChangeStateCommand +{ + public function __construct( + public StateEnum $newState, + ) {} +} diff --git a/src/Bundle/ChillTicketBundle/src/Action/Ticket/CreateTicketCommand.php b/src/Bundle/ChillTicketBundle/src/Action/Ticket/CreateTicketCommand.php new file mode 100644 index 000000000..94e4310f9 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Action/Ticket/CreateTicketCommand.php @@ -0,0 +1,19 @@ +<?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\TicketBundle\Action\Ticket; + +final readonly class CreateTicketCommand +{ + public function __construct( + public string $externalReference = '', + ) {} +} diff --git a/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/AddCommentCommandHandler.php b/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/AddCommentCommandHandler.php new file mode 100644 index 000000000..a5a211aef --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/AddCommentCommandHandler.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\TicketBundle\Action\Ticket\Handler; + +use Chill\TicketBundle\Action\Ticket\AddCommentCommand; +use Chill\TicketBundle\Entity\Comment; +use Chill\TicketBundle\Entity\Ticket; +use Doctrine\ORM\EntityManagerInterface; + +final readonly class AddCommentCommandHandler +{ + public function __construct( + private EntityManagerInterface $entityManager, + ) {} + + public function handle(Ticket $ticket, AddCommentCommand $command): Comment + { + $comment = new Comment($command->content, $ticket); + + $this->entityManager->persist($comment); + + return $comment; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/AssociateByPhonenumberCommandHandler.php b/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/AssociateByPhonenumberCommandHandler.php new file mode 100644 index 000000000..a5518504f --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/AssociateByPhonenumberCommandHandler.php @@ -0,0 +1,46 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\TicketBundle\Action\Ticket\Handler; + +use Chill\MainBundle\Phonenumber\PhonenumberHelper; +use Chill\PersonBundle\Repository\PersonACLAwareRepositoryInterface; +use Chill\ThirdPartyBundle\Repository\ThirdPartyRepository; +use Chill\TicketBundle\Action\Ticket\AssociateByPhonenumberCommand; +use Chill\TicketBundle\Entity\CallerHistory; +use Chill\TicketBundle\Entity\Ticket; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\Clock\ClockInterface; + +class AssociateByPhonenumberCommandHandler +{ + public function __construct( + private readonly PersonACLAwareRepositoryInterface $personRepository, + private readonly ThirdPartyRepository $thirdPartyRepository, + private readonly PhonenumberHelper $phonenumberHelper, + private readonly ClockInterface $clock, + private readonly EntityManagerInterface $entityManager, + ) {} + + public function __invoke(Ticket $ticket, AssociateByPhonenumberCommand $command): void + { + $phone = $this->phonenumberHelper->parse($command->phonenumber); + $callers = [ + ...$this->personRepository->findByPhone($phone), + ...$this->thirdPartyRepository->findByPhonenumber($phone), + ]; + + if (count($callers) > 0) { + $history = new CallerHistory($callers[0], $ticket, $this->clock->now()); + $this->entityManager->persist($history); + } + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/ChangeEmergencyStateCommandHandler.php b/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/ChangeEmergencyStateCommandHandler.php new file mode 100644 index 000000000..0ff810d07 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/ChangeEmergencyStateCommandHandler.php @@ -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\TicketBundle\Action\Ticket\Handler; + +use Chill\TicketBundle\Action\Ticket\ChangeEmergencyStateCommand; +use Chill\TicketBundle\Entity\EmergencyStatusHistory; +use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Event\EmergencyStatusUpdateEvent; +use Chill\TicketBundle\Event\TicketUpdateEvent; +use Symfony\Component\Clock\ClockInterface; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; + +/** + * Handler for changing the emergency status of a ticket. + */ +class ChangeEmergencyStateCommandHandler +{ + public function __construct( + private readonly ClockInterface $clock, + private readonly EventDispatcherInterface $eventDispatcher, + ) {} + + public function __invoke(Ticket $ticket, ChangeEmergencyStateCommand $command): Ticket + { + // If the ticket is already in the requested emergency status, return it without changes + if ($command->newEmergencyStatus === $ticket->getEmergencyStatus()) { + return $ticket; + } + + $previous = $ticket->getEmergencyStatus(); + + // End the current emergency status history (if any) + foreach ($ticket->getEmergencyStatusHistories() as $emergencyStatusHistory) { + if (null === $emergencyStatusHistory->getEndDate()) { + $emergencyStatusHistory->setEndDate($this->clock->now()); + } + } + + // Create a new emergency status history with the new status + new EmergencyStatusHistory( + $command->newEmergencyStatus, + $ticket, + $this->clock->now(), + ); + + // Dispatch event about the toggle + if (null !== $previous) { + $event = new EmergencyStatusUpdateEvent($ticket, $previous, $command->newEmergencyStatus); + $this->eventDispatcher->dispatch($event, TicketUpdateEvent::class); + } + + return $ticket; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/ChangeStateCommandHandler.php b/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/ChangeStateCommandHandler.php new file mode 100644 index 000000000..7558b0d75 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/ChangeStateCommandHandler.php @@ -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\TicketBundle\Action\Ticket\Handler; + +use Chill\TicketBundle\Action\Ticket\ChangeStateCommand; +use Chill\TicketBundle\Entity\StateHistory; +use Chill\TicketBundle\Entity\Ticket; +use Symfony\Component\Clock\ClockInterface; + +/** + * Handler for changing the state of a ticket. + */ +class ChangeStateCommandHandler +{ + public function __construct(private readonly ClockInterface $clock) {} + + public function __invoke(Ticket $ticket, ChangeStateCommand $command): Ticket + { + // If the ticket is already in the requested state, return it without changes + if ($command->newState === $ticket->getState()) { + return $ticket; + } + + // End the current state history (if any) + foreach ($ticket->getStateHistories() as $stateHistory) { + if (null === $stateHistory->getEndDate()) { + $stateHistory->setEndDate($this->clock->now()); + } + } + + // Create a new state history with the new state + new StateHistory( + $command->newState, + $ticket, + $this->clock->now(), + ); + + return $ticket; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/CreateTicketCommandHandler.php b/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/CreateTicketCommandHandler.php new file mode 100644 index 000000000..c22f95ee9 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/CreateTicketCommandHandler.php @@ -0,0 +1,37 @@ +<?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\TicketBundle\Action\Ticket\Handler; + +use Chill\TicketBundle\Action\Ticket\CreateTicketCommand; +use Chill\TicketBundle\Entity\EmergencyStatusEnum; +use Chill\TicketBundle\Entity\EmergencyStatusHistory; +use Chill\TicketBundle\Entity\StateEnum; +use Chill\TicketBundle\Entity\StateHistory; +use Chill\TicketBundle\Entity\Ticket; +use Symfony\Component\Clock\ClockInterface; + +class CreateTicketCommandHandler +{ + public function __construct(private readonly ClockInterface $clock) {} + + public function __invoke(CreateTicketCommand $command): Ticket + { + $ticket = new Ticket(); + $ticket->setExternalRef($command->externalReference); + + // initialize the first states + new StateHistory(StateEnum::OPEN, $ticket, $this->clock->now()); + new EmergencyStatusHistory(EmergencyStatusEnum::NO, $ticket, $this->clock->now()); + + return $ticket; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/ReplaceMotiveCommandHandler.php b/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/ReplaceMotiveCommandHandler.php new file mode 100644 index 000000000..985031156 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/ReplaceMotiveCommandHandler.php @@ -0,0 +1,76 @@ +<?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\TicketBundle\Action\Ticket\Handler; + +use Chill\TicketBundle\Action\Ticket\ChangeEmergencyStateCommand; +use Chill\TicketBundle\Action\Ticket\ReplaceMotiveCommand; +use Chill\TicketBundle\Entity\MotiveHistory; +use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Event\MotiveUpdateEvent; +use Chill\TicketBundle\Event\TicketUpdateEvent; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\Clock\ClockInterface; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; + +class ReplaceMotiveCommandHandler +{ + public function __construct( + private readonly ClockInterface $clock, + private readonly EntityManagerInterface $entityManager, + private readonly ChangeEmergencyStateCommandHandler $changeEmergencyStateCommandHandler, + private readonly EventDispatcherInterface $eventDispatcher, + ) {} + + public function handle(Ticket $ticket, ReplaceMotiveCommand $command): void + { + if (null === $command->motive) { + throw new \InvalidArgumentException('The new motive cannot be null'); + } + + $event = new MotiveUpdateEvent($ticket); + + // will add if there are no existing motive + $readyToAdd = 0 === count($ticket->getMotiveHistories()); + + foreach ($ticket->getMotiveHistories() as $history) { + if (null !== $history->getEndDate()) { + continue; + } + + if ($history->getMotive() === $command->motive) { + // we apply the same motive, we do nothing + continue; + } + + // collect previous active motives before closing + $event->previousMotive = $history->getMotive(); + $history->setEndDate($this->clock->now()); + $readyToAdd = true; + } + + if ($readyToAdd) { + $history = new MotiveHistory($command->motive, $ticket, $this->clock->now()); + $this->entityManager->persist($history); + $event->newMotive = $command->motive; + + // Check if the motive has makeTicketEmergency set and update the ticket's emergency status if needed + if ($command->motive->isMakeTicketEmergency()) { + $changeEmergencyCommand = new ChangeEmergencyStateCommand($command->motive->getMakeTicketEmergency()); + ($this->changeEmergencyStateCommandHandler)($ticket, $changeEmergencyCommand); + } + } + + if ($event->hasChanges()) { + $this->eventDispatcher->dispatch($event, TicketUpdateEvent::class); + } + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/SetAddresseesCommandHandler.php b/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/SetAddresseesCommandHandler.php new file mode 100644 index 000000000..b2805ed2f --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/SetAddresseesCommandHandler.php @@ -0,0 +1,56 @@ +<?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\TicketBundle\Action\Ticket\Handler; + +use Chill\MainBundle\Entity\User; +use Chill\TicketBundle\Action\Ticket\SetAddresseesCommand; +use Chill\TicketBundle\Entity\AddresseeHistory; +use Chill\TicketBundle\Entity\Ticket; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\Clock\ClockInterface; +use Symfony\Component\Security\Core\Security; + +final readonly class SetAddresseesCommandHandler +{ + public function __construct( + private ClockInterface $clock, + private EntityManagerInterface $entityManager, + private Security $security, + ) {} + + public function handle(Ticket $ticket, SetAddresseesCommand $command): void + { + // remove existing addresses which are not in the new addresses + foreach ($ticket->getAddresseeHistories() as $addressHistory) { + if (null !== $addressHistory->getEndDate()) { + continue; + } + + if (!in_array($addressHistory->getAddressee(), $command->addressees, true)) { + $addressHistory->setEndDate($this->clock->now()); + if (($user = $this->security->getUser()) instanceof User) { + $addressHistory->setRemovedBy($user); + } + } + } + + // add new addresses + foreach ($command->addressees as $address) { + if (in_array($address, $ticket->getCurrentAddressee(), true)) { + continue; + } + + $history = new AddresseeHistory($address, $this->clock->now(), $ticket); + $this->entityManager->persist($history); + } + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/SetCallerCommandHandler.php b/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/SetCallerCommandHandler.php new file mode 100644 index 000000000..ae37f3945 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/SetCallerCommandHandler.php @@ -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\TicketBundle\Action\Ticket\Handler; + +use Chill\TicketBundle\Action\Ticket\SetCallerCommand; +use Chill\TicketBundle\Entity\CallerHistory; +use Chill\TicketBundle\Entity\Ticket; +use Symfony\Component\Clock\ClockInterface; + +/** + * Handler for setting the caller of a ticket. + */ +class SetCallerCommandHandler +{ + public function __construct(private readonly ClockInterface $clock) {} + + public function __invoke(Ticket $ticket, SetCallerCommand $command): Ticket + { + // If the ticket already has the requested caller, return it without changes + $currentCaller = $ticket->getCaller(); + if ($currentCaller === $command->caller) { + return $ticket; + } + + // End the current caller history (if any) + foreach ($ticket->getCallerHistories() as $callerHistory) { + if (null === $callerHistory->getEndDate()) { + $callerHistory->setEndDate($this->clock->now()); + } + } + + // Create a new caller history with the new caller + new CallerHistory( + $command->caller, + $ticket, + $this->clock->now(), + ); + + return $ticket; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/SetPersonsCommandHandler.php b/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/SetPersonsCommandHandler.php new file mode 100644 index 000000000..cab723e7f --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Action/Ticket/Handler/SetPersonsCommandHandler.php @@ -0,0 +1,68 @@ +<?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\TicketBundle\Action\Ticket\Handler; + +use Chill\MainBundle\Entity\User; +use Chill\TicketBundle\Action\Ticket\SetPersonsCommand; +use Chill\TicketBundle\Entity\PersonHistory; +use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Event\PersonsUpdateEvent; +use Chill\TicketBundle\Event\TicketUpdateEvent; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\Clock\ClockInterface; +use Symfony\Component\Security\Core\Security; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; + +final readonly class SetPersonsCommandHandler +{ + public function __construct( + private ClockInterface $clock, + private EntityManagerInterface $entityManager, + private Security $security, + private EventDispatcherInterface $eventDispatcher, + ) {} + + public function handle(Ticket $ticket, SetPersonsCommand $command): void + { + $event = new PersonsUpdateEvent($ticket); + + // remove existing addresses which are not in the new addresses + foreach ($ticket->getPersonHistories() as $personHistory) { + if (null !== $personHistory->getEndDate()) { + continue; + } + + if (!in_array($personHistory->getPerson(), $command->persons, true)) { + $personHistory->setEndDate($this->clock->now()); + if (($user = $this->security->getUser()) instanceof User) { + $personHistory->setRemovedBy($user); + } + $event->personsRemoved[] = $personHistory->getPerson(); + } + } + + // add new addresses + foreach ($command->persons as $person) { + if (in_array($person, $ticket->getPersons(), true)) { + continue; + } + + $history = new PersonHistory($person, $ticket, $this->clock->now()); + $this->entityManager->persist($history); + $event->personsAdded[] = $person; + } + + if ($event->hasChanges()) { + $this->eventDispatcher->dispatch($event, TicketUpdateEvent::class); + } + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Action/Ticket/ReplaceMotiveCommand.php b/src/Bundle/ChillTicketBundle/src/Action/Ticket/ReplaceMotiveCommand.php new file mode 100644 index 000000000..75e2a3f1b --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Action/Ticket/ReplaceMotiveCommand.php @@ -0,0 +1,25 @@ +<?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\TicketBundle\Action\Ticket; + +use Chill\TicketBundle\Entity\Motive; +use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Validator\Constraints as Assert; + +final readonly class ReplaceMotiveCommand +{ + public function __construct( + #[Assert\NotNull] + #[Groups(['write'])] + public ?Motive $motive, + ) {} +} diff --git a/src/Bundle/ChillTicketBundle/src/Action/Ticket/SetAddresseesCommand.php b/src/Bundle/ChillTicketBundle/src/Action/Ticket/SetAddresseesCommand.php new file mode 100644 index 000000000..f258731f7 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Action/Ticket/SetAddresseesCommand.php @@ -0,0 +1,40 @@ +<?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\TicketBundle\Action\Ticket; + +use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Entity\UserGroup; +use Chill\MainBundle\Validation\Constraint\UserGroupDoNotExclude; +use Chill\TicketBundle\Entity\Ticket; +use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Validator\Constraints\GreaterThan; + +final readonly class SetAddresseesCommand +{ + public function __construct( + /** + * @var list<UserGroup|User> + */ + #[UserGroupDoNotExclude] + #[GreaterThan(0)] + #[Groups(['read'])] + public array $addressees, + ) {} + + public static function fromAddAddresseeCommand(AddAddresseeCommand $command, Ticket $ticket): self + { + return new self([ + $command->addressee, + ...$ticket->getCurrentAddressee(), + ]); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Action/Ticket/SetCallerCommand.php b/src/Bundle/ChillTicketBundle/src/Action/Ticket/SetCallerCommand.php new file mode 100644 index 000000000..2589abac4 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Action/Ticket/SetCallerCommand.php @@ -0,0 +1,29 @@ +<?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\TicketBundle\Action\Ticket; + +use Chill\PersonBundle\Entity\Person; +use Chill\ThirdPartyBundle\Entity\ThirdParty; + +/** + * Command to set the caller of a ticket. + * The caller can be either a Person or a ThirdParty. + */ +final readonly class SetCallerCommand +{ + /** + * @param Person|ThirdParty|null $caller The caller to associate with the ticket + */ + public function __construct( + public Person|ThirdParty|null $caller, + ) {} +} diff --git a/src/Bundle/ChillTicketBundle/src/Action/Ticket/SetPersonsCommand.php b/src/Bundle/ChillTicketBundle/src/Action/Ticket/SetPersonsCommand.php new file mode 100644 index 000000000..aab0b968b --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Action/Ticket/SetPersonsCommand.php @@ -0,0 +1,30 @@ +<?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\TicketBundle\Action\Ticket; + +use Chill\PersonBundle\Entity\Person; +use Chill\TicketBundle\Validation\Validator\SetPersonCommandConstraint; +use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Validator\Constraints\GreaterThan; + +#[SetPersonCommandConstraint] +class SetPersonsCommand +{ + public function __construct( + /** + * @var list<Person> + */ + #[GreaterThan(0)] + #[Groups(['read'])] + public array $persons, + ) {} +} diff --git a/src/Bundle/ChillTicketBundle/src/ChillTicketBundle.php b/src/Bundle/ChillTicketBundle/src/ChillTicketBundle.php new file mode 100644 index 000000000..de1b1535e --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/ChillTicketBundle.php @@ -0,0 +1,16 @@ +<?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\TicketBundle; + +use Symfony\Component\HttpKernel\Bundle\Bundle; + +class ChillTicketBundle extends Bundle {} diff --git a/src/Bundle/ChillTicketBundle/src/Command/ImportTicketMotiveConfigurationCommand.php b/src/Bundle/ChillTicketBundle/src/Command/ImportTicketMotiveConfigurationCommand.php new file mode 100644 index 000000000..02808960e --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Command/ImportTicketMotiveConfigurationCommand.php @@ -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\TicketBundle\Command; + +use Chill\TicketBundle\Service\Import\ImportMotivesFromDirectory; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +#[AsCommand(name: 'chill:ticket:import_ticket_motive_configuration', description: 'Import ticket motives from a directory configuration.')] +class ImportTicketMotiveConfigurationCommand extends Command +{ + protected static $defaultName = 'chill:ticket:import_ticket_motive_configuration'; + + public function __construct(private readonly ImportMotivesFromDirectory $importMotivesFromDirectory) + { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->setDescription('Import ticket motives from a directory containing motives.yaml and referenced files') + ->addArgument('directory', InputArgument::REQUIRED, 'The directory path containing motives.yaml') + ->addArgument('lang', InputArgument::REQUIRED, 'The language key to use for matching labels (e.g., fr)'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $directory = (string) $input->getArgument('directory'); + $lang = (string) $input->getArgument('lang'); + + $this->importMotivesFromDirectory->import($directory, $lang); + + $output->writeln('<info>Ticket motives import completed successfully.</info>'); + + return Command::SUCCESS; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Controller/AddCommentController.php b/src/Bundle/ChillTicketBundle/src/Controller/AddCommentController.php new file mode 100644 index 000000000..08ff93453 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Controller/AddCommentController.php @@ -0,0 +1,73 @@ +<?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\TicketBundle\Controller; + +use Chill\TicketBundle\Action\Ticket\AddCommentCommand; +use Chill\TicketBundle\Action\Ticket\Handler\AddCommentCommandHandler; +use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Security\Voter\CommentVoter; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Validator\Validator\ValidatorInterface; + +final readonly class AddCommentController +{ + public function __construct( + private Security $security, + private SerializerInterface $serializer, + private ValidatorInterface $validator, + private AddCommentCommandHandler $addCommentCommandHandler, + private EntityManagerInterface $entityManager, + ) {} + + #[Route('/api/1.0/ticket/{id}/comment/add', name: 'chill_ticket_comment_add', methods: ['POST'])] + public function __invoke(Ticket $ticket, Request $request): Response + { + if (!$this->security->isGranted('ROLE_USER')) { + throw new AccessDeniedHttpException('Only user can add ticket comments.'); + } + + $command = $this->serializer->deserialize($request->getContent(), AddCommentCommand::class, 'json', ['groups' => 'write']); + + $errors = $this->validator->validate($command); + + if (count($errors) > 0) { + return new JsonResponse( + $this->serializer->serialize($errors, 'json'), + Response::HTTP_UNPROCESSABLE_ENTITY, + [], + true + ); + } + + $comment = $this->addCommentCommandHandler->handle($ticket, $command); + + if (!$this->security->isGranted(CommentVoter::CREATE, $comment)) { + throw new AccessDeniedHttpException('You are not allowed to add comments to this ticket.'); + } + + $this->entityManager->flush(); + + return new JsonResponse( + $this->serializer->serialize($ticket, 'json', ['groups' => 'read']), + Response::HTTP_CREATED, + [], + true + ); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Controller/Admin/MotiveController.php b/src/Bundle/ChillTicketBundle/src/Controller/Admin/MotiveController.php new file mode 100644 index 000000000..891f82d07 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Controller/Admin/MotiveController.php @@ -0,0 +1,54 @@ +<?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\TicketBundle\Controller\Admin; + +use Chill\MainBundle\CRUD\Controller\CRUDController; +use Chill\MainBundle\Pagination\PaginatorInterface; +use Chill\TicketBundle\Entity\Motive; +use Chill\TicketBundle\MotiveDTO; +use Doctrine\ORM\QueryBuilder; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\HttpFoundation\Request; + +class MotiveController extends CRUDController +{ + protected function orderQuery(string $action, $query, Request $request, PaginatorInterface $paginator) + { + /* @var QueryBuilder $query */ + $query->addOrderBy('e.ordering', 'ASC'); + + return parent::orderQuery($action, $query, $request, $paginator); + } + + protected function createFormFor(string $action, mixed $entity, ?string $formClass = null, array $formOptions = []): FormInterface + { + if (in_array($action, ['new', 'edit'], true) && $entity instanceof Motive) { + $dto = MotiveDTO::fromMotive($entity); + + return parent::createFormFor($action, $dto, $formClass, $formOptions); + } + + return parent::createFormFor($action, $entity, $formClass, $formOptions); + } + + protected function onFormValid(string $action, object $entity, FormInterface $form, Request $request): void + { + if (in_array($action, ['new', 'edit'], true) && $entity instanceof Motive) { + $dto = $form->getData(); + if ($dto instanceof MotiveDTO) { + $dto->applyToMotive($entity); + } + } + + parent::onFormValid($action, $entity, $form, $request); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Controller/AdminController.php b/src/Bundle/ChillTicketBundle/src/Controller/AdminController.php new file mode 100644 index 000000000..9bf5028c1 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Controller/AdminController.php @@ -0,0 +1,31 @@ +<?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\TicketBundle\Controller; + +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\Routing\Annotation\Route; + +/** + * Class AdminController + * Controller for the ticket configuration section (in admin section). + */ +class AdminController extends AbstractController +{ + /** + * Ticket admin. + */ + #[Route(path: '/{_locale}/admin/ticket', name: 'chill_ticket_admin_index')] + public function indexAdminAction() + { + return $this->render('@ChillTicket/Admin/index.html.twig'); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Controller/CenterForTicketListApiController.php b/src/Bundle/ChillTicketBundle/src/Controller/CenterForTicketListApiController.php new file mode 100644 index 000000000..11b2ac961 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Controller/CenterForTicketListApiController.php @@ -0,0 +1,52 @@ +<?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\TicketBundle\Controller; + +use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Security\Authorization\AuthorizationHelperForCurrentUserInterface; +use Chill\PersonBundle\Security\Authorization\PersonVoter; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\SerializerInterface; + +/** + * Give a list of Centers usable for searching amongst tickets. + */ +final readonly class CenterForTicketListApiController +{ + public function __construct( + private AuthorizationHelperForCurrentUserInterface $authorizationHelperForCurrentUser, + private Security $security, + private SerializerInterface $serializer, + ) {} + + #[Route(path: '/api/1.0/ticket/search/authorized-centers')] + public function __invoke(): JsonResponse + { + $user = $this->security->getUser(); + + if (!$user instanceof User) { + throw new AccessDeniedHttpException(); + } + + $centers = $this->authorizationHelperForCurrentUser->getReachableCenters(PersonVoter::SEE); + + return new JsonResponse( + $this->serializer->serialize($centers, 'json', ['groups' => 'read']), + Response::HTTP_OK, + json: true + ); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Controller/ChangeEmergencyStateApiController.php b/src/Bundle/ChillTicketBundle/src/Controller/ChangeEmergencyStateApiController.php new file mode 100644 index 000000000..1ebc442d6 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Controller/ChangeEmergencyStateApiController.php @@ -0,0 +1,73 @@ +<?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\TicketBundle\Controller; + +use Chill\TicketBundle\Action\Ticket\ChangeEmergencyStateCommand; +use Chill\TicketBundle\Action\Ticket\Handler\ChangeEmergencyStateCommandHandler; +use Chill\TicketBundle\Entity\EmergencyStatusEnum; +use Chill\TicketBundle\Entity\Ticket; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\SerializerInterface; + +/** + * Controller for changing the emergency status of a ticket. + */ +final readonly class ChangeEmergencyStateApiController +{ + public function __construct( + private ChangeEmergencyStateCommandHandler $changeEmergencyStateCommandHandler, + private Security $security, + private EntityManagerInterface $entityManager, + private SerializerInterface $serializer, + ) {} + + #[Route('/api/1.0/ticket/ticket/{id}/emergency/yes', name: 'chill_ticket_ticket_emergency_yes_api', requirements: ['id' => '\d+'], methods: ['POST'])] + public function setEmergencyYes(Ticket $ticket): Response + { + if (!$this->security->isGranted('ROLE_USER')) { + throw new AccessDeniedHttpException('Only users are allowed to set emergency status to YES.'); + } + + $command = new ChangeEmergencyStateCommand(EmergencyStatusEnum::YES); + $this->changeEmergencyStateCommandHandler->__invoke($ticket, $command); + + $this->entityManager->flush(); + + return new JsonResponse( + $this->serializer->serialize($ticket, 'json', ['groups' => ['read']]), + json: true + ); + } + + #[Route('/api/1.0/ticket/ticket/{id}/emergency/no', name: 'chill_ticket_ticket_emergency_no_api', requirements: ['id' => '\d+'], methods: ['POST'])] + public function setEmergencyNo(Ticket $ticket): Response + { + if (!$this->security->isGranted('ROLE_USER')) { + throw new AccessDeniedHttpException('Only users are allowed to set emergency status to NO.'); + } + + $command = new ChangeEmergencyStateCommand(EmergencyStatusEnum::NO); + $this->changeEmergencyStateCommandHandler->__invoke($ticket, $command); + + $this->entityManager->flush(); + + return new JsonResponse( + $this->serializer->serialize($ticket, 'json', ['groups' => ['read']]), + json: true + ); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Controller/ChangeStateApiController.php b/src/Bundle/ChillTicketBundle/src/Controller/ChangeStateApiController.php new file mode 100644 index 000000000..55e09162d --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Controller/ChangeStateApiController.php @@ -0,0 +1,73 @@ +<?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\TicketBundle\Controller; + +use Chill\TicketBundle\Action\Ticket\ChangeStateCommand; +use Chill\TicketBundle\Action\Ticket\Handler\ChangeStateCommandHandler; +use Chill\TicketBundle\Entity\StateEnum; +use Chill\TicketBundle\Entity\Ticket; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\SerializerInterface; + +/** + * Controller for changing the state of a ticket. + */ +final readonly class ChangeStateApiController +{ + public function __construct( + private ChangeStateCommandHandler $changeStateCommandHandler, + private Security $security, + private EntityManagerInterface $entityManager, + private SerializerInterface $serializer, + ) {} + + #[Route('/api/1.0/ticket/ticket/{id}/close', name: 'chill_ticket_ticket_close_api', requirements: ['id' => '\d+'], methods: ['POST'])] + public function close(Ticket $ticket): Response + { + if (!$this->security->isGranted('ROLE_USER')) { + throw new AccessDeniedHttpException('Only users are allowed to close tickets.'); + } + + $command = new ChangeStateCommand(StateEnum::CLOSED); + $this->changeStateCommandHandler->__invoke($ticket, $command); + + $this->entityManager->flush(); + + return new JsonResponse( + $this->serializer->serialize($ticket, 'json', ['groups' => ['read']]), + json: true + ); + } + + #[Route('/api/1.0/ticket/ticket/{id}/open', name: 'chill_ticket_ticket_open_api', requirements: ['id' => '\d+'], methods: ['POST'])] + public function open(Ticket $ticket): Response + { + if (!$this->security->isGranted('ROLE_USER')) { + throw new AccessDeniedHttpException('Only users are allowed to open tickets.'); + } + + $command = new ChangeStateCommand(StateEnum::OPEN); + $this->changeStateCommandHandler->__invoke($ticket, $command); + + $this->entityManager->flush(); + + return new JsonResponse( + $this->serializer->serialize($ticket, 'json', ['groups' => ['read']]), + json: true + ); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Controller/CreateTicketController.php b/src/Bundle/ChillTicketBundle/src/Controller/CreateTicketController.php new file mode 100644 index 000000000..8e47d3afa --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Controller/CreateTicketController.php @@ -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\TicketBundle\Controller; + +use Chill\TicketBundle\Action\Ticket\AssociateByPhonenumberCommand; +use Chill\TicketBundle\Action\Ticket\Handler\AssociateByPhonenumberCommandHandler; +use Chill\TicketBundle\Action\Ticket\CreateTicketCommand; +use Chill\TicketBundle\Action\Ticket\Handler\CreateTicketCommandHandler; +use Chill\TicketBundle\Repository\TicketRepositoryInterface; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\HttpFoundation\RedirectResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\Component\Security\Core\Security; + +final readonly class CreateTicketController +{ + public function __construct( + private CreateTicketCommandHandler $createTicketCommandHandler, + private AssociateByPhonenumberCommandHandler $associateByPhonenumberCommandHandler, + private Security $security, + private UrlGeneratorInterface $urlGenerator, + private EntityManagerInterface $entityManager, + private TicketRepositoryInterface $ticketRepository, + ) {} + + #[Route('{_locale}/ticket/ticket/create')] + public function __invoke(Request $request): Response + { + if (!$this->security->isGranted('ROLE_USER')) { + throw new AccessDeniedHttpException('Only users are allowed to create tickets.'); + } + + if ('' !== $extId = $request->query->get('extId', '')) { + if (null !== $ticket = $this->ticketRepository->findOneByExternalRef($extId)) { + return new RedirectResponse( + $this->urlGenerator->generate('chill_ticket_ticket_edit', ['id' => $ticket->getId()]) + ); + } + } + + $createCommand = new CreateTicketCommand($request->query->get('extId', '')); + $ticket = $this->createTicketCommandHandler->__invoke($createCommand); + + $this->entityManager->persist($ticket); + + if ($request->query->has('caller')) { + $associateByPhonenumberCommand = new AssociateByPhonenumberCommand($request->query->get('caller')); + $this->associateByPhonenumberCommandHandler->__invoke($ticket, $associateByPhonenumberCommand); + } + + $this->entityManager->flush(); + + $query = []; + if ($request->query->has('returnPath')) { + $query['returnPath'] = $request->query->get('returnPath'); + } + + return new RedirectResponse( + $this->urlGenerator->generate('chill_ticket_ticket_edit', ['id' => $ticket->getId(), ...$query]) + ); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Controller/EditTicketController.php b/src/Bundle/ChillTicketBundle/src/Controller/EditTicketController.php new file mode 100644 index 000000000..a686208ad --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Controller/EditTicketController.php @@ -0,0 +1,45 @@ +<?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\TicketBundle\Controller; + +use Chill\TicketBundle\Entity\Ticket; +use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Annotation\Route; +use Twig\Environment; + +class EditTicketController +{ + private readonly string $personPerTicket; + + public function __construct( + private readonly Environment $templating, + ParameterBagInterface $parameterBag, + ) { + $this->personPerTicket = $parameterBag->get('chill_ticket')['ticket']['person_per_ticket']; + } + + #[Route('/{_locale}/ticket/ticket/{id}/edit', name: 'chill_ticket_ticket_edit')] + public function __invoke( + Ticket $ticket, + ): Response { + return new Response( + $this->templating->render( + '@ChillTicket/Ticket/edit.html.twig', + [ + 'ticket' => $ticket, + 'personPerTicket' => $this->personPerTicket, + ] + ) + ); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Controller/MotiveApiController.php b/src/Bundle/ChillTicketBundle/src/Controller/MotiveApiController.php new file mode 100644 index 000000000..1b9ce263c --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Controller/MotiveApiController.php @@ -0,0 +1,35 @@ +<?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\TicketBundle\Controller; + +use Chill\DocStoreBundle\Serializer\Normalizer\StoredObjectNormalizer; +use Chill\MainBundle\CRUD\Controller\ApiController; +use Chill\TicketBundle\Serializer\Normalizer\MotiveNormalizer; +use Doctrine\ORM\QueryBuilder; +use Symfony\Component\HttpFoundation\Request; + +final class MotiveApiController extends ApiController +{ + protected function customizeQuery(string $action, Request $request, $query): void + { + /* @var $query QueryBuilder */ + $query->andWhere('e.active = TRUE')->andWhere('e.parent IS NULL'); + } + + protected function getContextForSerialization(string $action, Request $request, string $_format, $entity): array + { + return match ($request->getMethod()) { + Request::METHOD_GET => ['groups' => ['read', 'read:extended', StoredObjectNormalizer::DOWNLOAD_LINK_ONLY, MotiveNormalizer::GROUP_PARENT_TO_CHILDREN]], + default => parent::getContextForSerialization($action, $request, $_format, $entity), + }; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Controller/ReplaceMotiveController.php b/src/Bundle/ChillTicketBundle/src/Controller/ReplaceMotiveController.php new file mode 100644 index 000000000..3074ee6c1 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Controller/ReplaceMotiveController.php @@ -0,0 +1,69 @@ +<?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\TicketBundle\Controller; + +use Chill\TicketBundle\Action\Ticket\Handler\ReplaceMotiveCommandHandler; +use Chill\TicketBundle\Action\Ticket\ReplaceMotiveCommand; +use Chill\TicketBundle\Entity\Ticket; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; +use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Validator\Validator\ValidatorInterface; + +final readonly class ReplaceMotiveController +{ + public function __construct( + private Security $security, + private ReplaceMotiveCommandHandler $replaceMotiveCommandHandler, + private SerializerInterface $serializer, + private ValidatorInterface $validator, + private EntityManagerInterface $entityManager, + ) {} + + #[Route('/api/1.0/ticket/{id}/motive/set', name: 'chill_ticket_motive_set', methods: ['POST'])] + public function __invoke(Ticket $ticket, Request $request): Response + { + if (!$this->security->isGranted('ROLE_USER')) { + throw new AccessDeniedHttpException(''); + } + + $command = $this->serializer->deserialize($request->getContent(), ReplaceMotiveCommand::class, 'json', [ + AbstractNormalizer::GROUPS => ['write'], + ]); + + $errors = $this->validator->validate($command); + + if (0 < $errors->count()) { + return new JsonResponse( + $this->serializer->serialize($errors, 'json'), + Response::HTTP_UNPROCESSABLE_ENTITY, + ); + } + + $this->replaceMotiveCommandHandler->handle($ticket, $command); + + $this->entityManager->flush(); + + return new JsonResponse( + $this->serializer->serialize($ticket, 'json', ['groups' => ['read']]), + Response::HTTP_CREATED, + [], + true + ); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Controller/SetAddresseesController.php b/src/Bundle/ChillTicketBundle/src/Controller/SetAddresseesController.php new file mode 100644 index 000000000..9fa6dc366 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Controller/SetAddresseesController.php @@ -0,0 +1,85 @@ +<?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\TicketBundle\Controller; + +use Chill\TicketBundle\Action\Ticket\AddAddresseeCommand; +use Chill\TicketBundle\Action\Ticket\Handler\SetAddresseesCommandHandler; +use Chill\TicketBundle\Action\Ticket\SetAddresseesCommand; +use Chill\TicketBundle\Entity\Ticket; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; +use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Validator\Validator\ValidatorInterface; + +final readonly class SetAddresseesController +{ + public function __construct( + private Security $security, + private EntityManagerInterface $entityManager, + private SerializerInterface $serializer, + private SetAddresseesCommandHandler $addressesCommandHandler, + private ValidatorInterface $validator, + ) {} + + #[Route('/api/1.0/ticket/{id}/addressees/set', methods: ['POST'])] + public function setAddressees(Ticket $ticket, Request $request): Response + { + if (!$this->security->isGranted('ROLE_USER')) { + throw new AccessDeniedHttpException('Only users can set addressees.'); + } + + $command = $this->serializer->deserialize($request->getContent(), SetAddresseesCommand::class, 'json', [AbstractNormalizer::GROUPS => ['read']]); + + return $this->registerSetAddressees($command, $ticket); + } + + #[Route('/api/1.0/ticket/{id}/addressee/add', methods: ['POST'])] + public function addAddressee(Ticket $ticket, Request $request): Response + { + if (!$this->security->isGranted('ROLE_USER')) { + throw new AccessDeniedHttpException('Only users can add addressees.'); + } + + $command = $this->serializer->deserialize($request->getContent(), AddAddresseeCommand::class, 'json', [AbstractNormalizer::GROUPS => ['read']]); + + return $this->registerSetAddressees(SetAddresseesCommand::fromAddAddresseeCommand($command, $ticket), $ticket); + } + + private function registerSetAddressees(SetAddresseesCommand $command, Ticket $ticket): Response + { + if (0 < count($errors = $this->validator->validate($command))) { + return new JsonResponse( + $this->serializer->serialize($errors, 'json'), + Response::HTTP_UNPROCESSABLE_ENTITY, + [], + true + ); + } + + $this->addressesCommandHandler->handle($ticket, $command); + + $this->entityManager->flush(); + + return new JsonResponse( + $this->serializer->serialize($ticket, 'json', ['groups' => 'read']), + Response::HTTP_OK, + [], + true, + ); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Controller/SetCallerApiController.php b/src/Bundle/ChillTicketBundle/src/Controller/SetCallerApiController.php new file mode 100644 index 000000000..8393ca4d4 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Controller/SetCallerApiController.php @@ -0,0 +1,66 @@ +<?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\TicketBundle\Controller; + +use Chill\TicketBundle\Action\Ticket\SetCallerCommand; +use Chill\TicketBundle\Action\Ticket\Handler\SetCallerCommandHandler; +use Chill\TicketBundle\Entity\Ticket; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\SerializerInterface; + +/** + * Controller for setting the caller of a ticket. + */ +final readonly class SetCallerApiController +{ + public function __construct( + private SetCallerCommandHandler $setCallerCommandHandler, + private Security $security, + private EntityManagerInterface $entityManager, + private SerializerInterface $serializer, + ) {} + + #[Route('/api/1.0/ticket/ticket/{id}/set-caller', name: 'chill_ticket_ticket_set_caller_api', requirements: ['id' => '\d+'], methods: ['POST'])] + public function setCaller(Ticket $ticket, Request $request): Response + { + if (!$this->security->isGranted('ROLE_USER')) { + throw new AccessDeniedHttpException('Only users are allowed to set ticket callers.'); + } + + try { + /** @var SetCallerCommand $command */ + $command = $this->serializer->deserialize( + $request->getContent(), + SetCallerCommand::class, + 'json' + ); + } catch (\Throwable $e) { + throw new BadRequestHttpException('Invalid request body: '.$e->getMessage(), $e); + } + + $this->setCallerCommandHandler->__invoke($ticket, $command); + + $this->entityManager->flush(); + + return new JsonResponse( + $this->serializer->serialize($ticket, 'json', ['groups' => ['read']]), + json: true + ); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Controller/SetPersonsController.php b/src/Bundle/ChillTicketBundle/src/Controller/SetPersonsController.php new file mode 100644 index 000000000..3fbe254a8 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Controller/SetPersonsController.php @@ -0,0 +1,72 @@ +<?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\TicketBundle\Controller; + +use Chill\TicketBundle\Action\Ticket\Handler\SetPersonsCommandHandler; +use Chill\TicketBundle\Action\Ticket\SetPersonsCommand; +use Chill\TicketBundle\Entity\Ticket; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; +use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Validator\Validator\ValidatorInterface; + +final readonly class SetPersonsController +{ + public function __construct( + private Security $security, + private EntityManagerInterface $entityManager, + private SerializerInterface $serializer, + private SetPersonsCommandHandler $setPersonsCommandHandler, + private ValidatorInterface $validator, + ) {} + + #[Route('/api/1.0/ticket/{id}/persons/set', methods: ['POST'])] + public function setPersons(Ticket $ticket, Request $request): Response + { + if (!$this->security->isGranted('ROLE_USER')) { + throw new AccessDeniedHttpException('Only users can set addressees.'); + } + + $command = $this->serializer->deserialize($request->getContent(), SetPersonsCommand::class, 'json', [AbstractNormalizer::GROUPS => ['read']]); + + return $this->registerSetPersons($command, $ticket); + } + + private function registerSetPersons(SetPersonsCommand $command, Ticket $ticket): Response + { + if (0 < count($errors = $this->validator->validate($command))) { + return new JsonResponse( + $this->serializer->serialize($errors, 'json'), + Response::HTTP_UNPROCESSABLE_ENTITY, + [], + true + ); + } + + $this->setPersonsCommandHandler->handle($ticket, $command); + + $this->entityManager->flush(); + + return new JsonResponse( + $this->serializer->serialize($ticket, 'json', ['groups' => 'read']), + Response::HTTP_OK, + [], + true, + ); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Controller/SuggestPersonForTicketApiController.php b/src/Bundle/ChillTicketBundle/src/Controller/SuggestPersonForTicketApiController.php new file mode 100644 index 000000000..a71065b73 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Controller/SuggestPersonForTicketApiController.php @@ -0,0 +1,45 @@ +<?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\TicketBundle\Controller; + +use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Service\Ticket\SuggestPersonForTicketInterface; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\SerializerInterface; + +final readonly class SuggestPersonForTicketApiController +{ + public function __construct( + private SuggestPersonForTicketInterface $suggestPersonForTicket, + private SerializerInterface $serializer, + private Security $security, + ) {} + + #[Route('/api/1.0/ticket/ticket/{id}/suggest-person', name: 'chill_ticket_ticket_suggest_person_api', requirements: ['id' => '\d+'], methods: ['GET'])] + public function __invoke(Ticket $ticket): Response + { + if (!$this->security->isGranted('ROLE_USER')) { + throw new AccessDeniedHttpException('Only users are allowed to suggest persons for tickets.'); + } + + $persons = $this->suggestPersonForTicket->suggestPerson($ticket, 0, 10); + + return new JsonResponse( + $this->serializer->serialize($persons, 'json', ['groups' => ['read']]), + json: true, + ); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Controller/TicketControllerApi.php b/src/Bundle/ChillTicketBundle/src/Controller/TicketControllerApi.php new file mode 100644 index 000000000..aa9b16a59 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Controller/TicketControllerApi.php @@ -0,0 +1,31 @@ +<?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\TicketBundle\Controller; + +use Chill\TicketBundle\Entity\Ticket; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Serializer\SerializerInterface; + +class TicketControllerApi +{ + public function __construct(private readonly SerializerInterface $serializer) {} + + #[Route('/api/1.0/ticket/ticket/{id}', requirements: ['id' => '\d+'], methods: ['GET'])] + public function get(Ticket $ticket): JsonResponse + { + return new JsonResponse( + $this->serializer->serialize($ticket, 'json', ['groups' => ['read']]), + json: true + ); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Controller/TicketListApiController.php b/src/Bundle/ChillTicketBundle/src/Controller/TicketListApiController.php new file mode 100644 index 000000000..1a4e5ccdb --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Controller/TicketListApiController.php @@ -0,0 +1,223 @@ +<?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\TicketBundle\Controller; + +use Chill\MainBundle\Pagination\PaginatorFactoryInterface; +use Chill\MainBundle\Repository\CenterRepositoryInterface; +use Chill\MainBundle\Repository\UserRepositoryInterface; +use Chill\MainBundle\Repository\UserGroupRepositoryInterface; +use Chill\MainBundle\Serializer\Model\Collection; +use Chill\PersonBundle\Repository\PersonRepository; +use Chill\PersonBundle\Security\Authorization\PersonVoter; +use Chill\TicketBundle\Entity\EmergencyStatusEnum; +use Chill\TicketBundle\Entity\StateEnum; +use Chill\TicketBundle\Repository\MotiveRepository; +use Chill\TicketBundle\Repository\TicketACLAwareRepositoryInterface; +use Symfony\Component\Clock\ClockInterface; +use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\SerializerInterface; + +final readonly class TicketListApiController +{ + private \DateInterval $expectedTimeResponseDelay; + + public function __construct( + private Security $security, + private TicketACLAwareRepositoryInterface $ticketRepository, + private PaginatorFactoryInterface $paginatorFactory, + private SerializerInterface $serializer, + private PersonRepository $personRepository, + private MotiveRepository $motiveRepository, + private ClockInterface $clock, + ParameterBagInterface $parameterBag, + private UserRepositoryInterface $userRepository, + private UserGroupRepositoryInterface $userGroupRepository, + private CenterRepositoryInterface $centerRepository, + ) { + $this->expectedTimeResponseDelay = new \DateInterval($parameterBag->get('chill_ticket')['ticket']['response_time_exceeded_delay']); + } + + #[Route('/api/1.0/ticket/ticket/list', name: 'chill_ticket_list_api', methods: ['GET'])] + public function listTicket(Request $request): JsonResponse + { + if (!$this->security->isGranted('ROLE_USER')) { + throw new AccessDeniedHttpException('Only users are allowed to list tickets.'); + } + + $params = []; + + if ($request->query->has('byTicketId')) { + $params['byTicketId'] = $request->query->getInt('byTicketId', -1); + + if (-1 === $params['byTicketId']) { + throw new BadRequestHttpException("The parameter 'byTicketId' is not an integer."); + } + } + + if ($request->query->has('byPerson')) { + $personIds = explode(',', $request->query->get('byPerson')); + foreach ($personIds as $id) { + $params['byPerson'][] = $person = $this->personRepository->find($id); + + if (!$this->security->isGranted(PersonVoter::SEE, $person)) { + throw new AccessDeniedHttpException(sprintf('Not allowed to see a person with id %d', $id)); + } + } + } + + if ($request->query->has('byCurrentState')) { + try { + $params['byCurrentState'] = array_map( + fn (string $state): StateEnum => StateEnum::fromValue($state), + explode(',', $request->query->get('byCurrentState')) + ); + } catch (\InvalidArgumentException $e) { + throw new BadRequestHttpException($e->getMessage(), $e); + } + } + + if ($request->query->has('byCurrentStateEmergency')) { + try { + $params['byCurrentStateEmergency'] = array_map( + fn (string $state): EmergencyStatusEnum => EmergencyStatusEnum::fromValue($state), + explode(',', $request->query->get('byCurrentStateEmergency')) + ); + } catch (\InvalidArgumentException $e) { + throw new BadRequestHttpException($e->getMessage(), $e); + } + } + + if ($request->query->has('byMotives')) { + $motivesIds = explode(',', $request->query->get('byMotives')); + foreach ($motivesIds as $id) { + if (!is_numeric($id) || 0 === ((int) $id)) { + throw new BadRequestHttpException('Only numbers are allowed in by motives parameter'); + } + $params['byMotives'][] = $motive = $this->motiveRepository->find($id); + + if (null === $motive) { + throw new BadRequestHttpException('Motive not found'); + } + } + } + + if ($request->query->has('byCreatedBefore')) { + $params['byCreatedBefore'] = + \DateTimeImmutable::createFromFormat(\DateTimeImmutable::RFC3339, $request->query->get('byCreatedBefore')); + + if (false === $params['byCreatedBefore']) { + $params['byCreatedBefore'] = \DateTimeImmutable::createFromFormat(\DateTimeImmutable::RFC3339_EXTENDED, $request->query->get('byCreatedBefore')); + } + if (false === $params['byCreatedBefore']) { + throw new BadRequestHttpException('Invalid date for byCreatedBefore'); + } + } + + if ($request->query->has('byCreatedAfter')) { + $params['byCreatedAfter'] = + \DateTimeImmutable::createFromFormat(\DateTimeImmutable::RFC3339, $request->query->get('byCreatedAfter')); + if (false === $params['byCreatedAfter']) { + $params['byCreatedAfter'] = + \DateTimeImmutable::createFromFormat(\DateTimeImmutable::RFC3339_EXTENDED, $request->query->get('byCreatedAfter')); + } + if (false === $params['byCreatedAfter']) { + throw new BadRequestHttpException('Invalid date for byCreatedAfter'); + } + } + + if ($request->query->has('byResponseTimeExceeded')) { + $params['byCurrentState'] = [StateEnum::OPEN]; + $params['byCreatedBefore'] = $this->clock->now()->sub($this->expectedTimeResponseDelay); + unset($params['byCreatedAfter']); + } + + if ($request->query->has('byAddresseeToMe')) { + $params['byAddressee'][] = $this->security->getUser(); + } + + if ($request->query->has('byAddressee')) { + $userIds = explode(',', $request->query->get('byAddressee')); + foreach ($userIds as $id) { + if (!is_numeric($id) || 0 === ((int) $id)) { + throw new BadRequestHttpException('Only numbers are allowed in by addressee parameter'); + } + + $params['byAddressee'][] = $user = $this->userRepository->find($id); + if (null === $user) { + throw new BadRequestHttpException('User not found'); + } + } + } + + if ($request->query->has('byCreator')) { + $userIds = explode(',', $request->query->get('byCreator')); + foreach ($userIds as $id) { + if (!is_numeric($id) || 0 === ((int) $id)) { + throw new BadRequestHttpException('Only numbers are allowed in by creator parameter'); + } + + $params['byCreator'][] = $user = $this->userRepository->find($id); + if (null === $user) { + throw new BadRequestHttpException('User not found'); + } + } + } + + if ($request->query->has('byAddresseeGroup')) { + $groupIds = explode(',', $request->query->get('byAddresseeGroup')); + foreach ($groupIds as $id) { + if (!is_numeric($id) || 0 === ((int) $id)) { + throw new BadRequestHttpException('Only numbers are allowed in by addressee group parameter'); + } + + $group = $this->userGroupRepository->find($id); + if (null === $group) { + throw new BadRequestHttpException('User group not found'); + } + $params['byAddresseeGroup'][] = $group; + } + } + + if ($request->query->has('byPersonCenter')) { + $centerIds = explode(',', $request->query->get('byPersonCenter')); + foreach ($centerIds as $id) { + if (!is_numeric($id) || 0 === ((int) $id)) { + throw new BadRequestHttpException('Only numbers are allowed in by center parameter'); + } + + $center = $this->centerRepository->find($id); + if (null === $center) { + throw new BadRequestHttpException('Center not found'); + } + $params['byPersonCenter'][] = $center; + } + } + + $nb = $this->ticketRepository->countTickets($params); + $paginator = $this->paginatorFactory->create($nb); + + $tickets = $this->ticketRepository->findTickets($params, $paginator->getCurrentPageFirstItemNumber(), $paginator->getItemsPerPage()); + + $collection = new Collection($tickets, $paginator); + + return new JsonResponse( + $this->serializer->serialize($collection, 'json', ['groups' => 'read:simple']), + json: true + ); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Controller/TicketListController.php b/src/Bundle/ChillTicketBundle/src/Controller/TicketListController.php new file mode 100644 index 000000000..7a25bbbc5 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Controller/TicketListController.php @@ -0,0 +1,55 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\TicketBundle\Controller; + +use Chill\PersonBundle\Security\Authorization\PersonVoter; +use Chill\PersonBundle\Entity\Person; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Security\Core\Security; +use Twig\Environment; + +final readonly class TicketListController +{ + public function __construct( + private Security $security, + private Environment $twig, + ) {} + + #[Route('/{_locale}/ticket/ticket/list', name: 'chill_ticket_ticket_list')] + public function __invoke(Request $request): Response + { + if (!$this->security->isGranted('ROLE_USER')) { + throw new AccessDeniedHttpException('only user can access this page'); + } + + return new Response( + $this->twig->render('@ChillTicket/Ticket/list.html.twig') + ); + } + + #[Route('/{_locale}/ticket/by-person/{id}/list', name: 'chill_person_ticket_list')] + public function listByPerson(Request $request, Person $person): Response + { + if (!$this->security->isGranted(PersonVoter::SEE, $person)) { + throw new AccessDeniedHttpException('you are not allowed to see this person'); + } + + return new Response( + $this->twig->render('@ChillTicket/Person/list.html.twig', [ + 'person' => $person, + ]) + ); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Controller/UpdateCommentController.php b/src/Bundle/ChillTicketBundle/src/Controller/UpdateCommentController.php new file mode 100644 index 000000000..5e35c534d --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Controller/UpdateCommentController.php @@ -0,0 +1,69 @@ +<?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\TicketBundle\Controller; + +use Chill\TicketBundle\Action\Comment\Handler\UpdateCommentContentCommandHandlerInterface; +use Chill\TicketBundle\Action\Comment\UpdateCommentContentCommand; +use Chill\TicketBundle\Entity\Comment; +use Chill\TicketBundle\Security\Voter\CommentVoter; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Validator\Validator\ValidatorInterface; + +final readonly class UpdateCommentController +{ + public function __construct( + private Security $security, + private SerializerInterface $serializer, + private ValidatorInterface $validator, + private UpdateCommentContentCommandHandlerInterface $updateCommentContentCommandHandler, + private EntityManagerInterface $entityManager, + ) {} + + #[Route('/api/1.0/ticket/comment/{id}/edit', name: 'chill_ticket_comment_edit', methods: ['POST'])] + public function __invoke(Comment $comment, Request $request): Response + { + if (!$this->security->isGranted(CommentVoter::EDIT, $comment)) { + throw new AccessDeniedHttpException('You are not allowed to edit this comment.'); + } + + $command = $this->serializer->deserialize($request->getContent(), UpdateCommentContentCommand::class, 'json', ['groups' => 'write']); + + $errors = $this->validator->validate($command); + + if (count($errors) > 0) { + return new JsonResponse( + $this->serializer->serialize($errors, 'json'), + Response::HTTP_UNPROCESSABLE_ENTITY, + [], + true + ); + } + + $this->updateCommentContentCommandHandler->handle($comment, $command); + + $this->entityManager->flush(); + + return new JsonResponse( + $this->serializer->serialize($comment, 'json', ['groups' => 'read']), + Response::HTTP_OK, + [], + true + ); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Controller/UpdateCommentDeletedStatusController.php b/src/Bundle/ChillTicketBundle/src/Controller/UpdateCommentDeletedStatusController.php new file mode 100644 index 000000000..dcdb93a81 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Controller/UpdateCommentDeletedStatusController.php @@ -0,0 +1,66 @@ +<?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\TicketBundle\Controller; + +use Chill\TicketBundle\Action\Comment\Handler\UpdateCommentDeletedStatusCommandHandlerInterface; +use Chill\TicketBundle\Action\Comment\UpdateCommentDeletedStatusCommand; +use Chill\TicketBundle\Entity\Comment; +use Chill\TicketBundle\Security\Voter\CommentVoter; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\SerializerInterface; + +final readonly class UpdateCommentDeletedStatusController +{ + public function __construct( + private Security $security, + private SerializerInterface $serializer, + private UpdateCommentDeletedStatusCommandHandlerInterface $updateCommentDeletedStatusCommandHandler, + private EntityManagerInterface $entityManager, + ) {} + + #[Route('/api/1.0/ticket/comment/{id}/delete', name: 'chill_ticket_comment_delete', methods: ['POST'])] + public function deleteComment(Comment $comment): Response + { + return $this->updateCommentDeletedStatus($comment, true); + } + + #[Route('/api/1.0/ticket/comment/{id}/restore', name: 'chill_ticket_comment_restore', methods: ['POST'])] + public function restoreComment(Comment $comment): Response + { + return $this->updateCommentDeletedStatus($comment, false); + } + + private function updateCommentDeletedStatus(Comment $comment, bool $delete): Response + { + if (!$this->security->isGranted(CommentVoter::EDIT, $comment)) { + throw new AccessDeniedHttpException('You are not allowed to edit this comment.'); + } + + $command = new UpdateCommentDeletedStatusCommand(delete: $delete); + + $this->updateCommentDeletedStatusCommandHandler->handle($comment, $command); + + $this->entityManager->flush(); + + return new JsonResponse( + $this->serializer->serialize($comment, 'json', ['groups' => 'read']), + Response::HTTP_OK, + [], + true + ); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/DataFixtures/ORM/LoadMotives.php b/src/Bundle/ChillTicketBundle/src/DataFixtures/ORM/LoadMotives.php new file mode 100644 index 000000000..d5191898e --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/DataFixtures/ORM/LoadMotives.php @@ -0,0 +1,142 @@ +<?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\TicketBundle\DataFixtures\ORM; + +use Chill\DocStoreBundle\Entity\StoredObject; +use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; +use Chill\TicketBundle\Entity\Motive; +use Doctrine\Bundle\FixturesBundle\Fixture; +use Doctrine\Bundle\FixturesBundle\FixtureGroupInterface; +use Doctrine\Persistence\ObjectManager; + +final class LoadMotives extends Fixture implements FixtureGroupInterface +{ + public function __construct(private readonly StoredObjectManagerInterface $storedObjectManager) {} + + public static function getGroups(): array + { + return ['ticket']; + } + + public function load(ObjectManager $manager) + { + $docs = [ + ['label' => '🌙 De 21h à 07h du matin', 'path' => __DIR__.'/docs/peloton_1.pdf'], + ['label' => '☀️ De 07h à 21h', 'path' => __DIR__.'/docs/peloton_2.pdf'], + ['label' => 'Dimanche et jours fériés', 'path' => __DIR__.'/docs/schema_1.png'], + ]; + $motivesByLabel = []; + + foreach (explode("\n", self::MOTIVES) as $row) { + if ('' === trim($row)) { + continue; + } + + $data = str_getcsv($row); + if ('' === $data[0]) { + continue; + } + + $labels = explode(' > ', (string) $data[0]); + $parent = null; + + while (count($labels) > 0) { + $label = array_shift($labels); + dump($labels); + if (isset($motivesByLabel[$label])) { + $motive = $motivesByLabel[$label]; + } else { + $motive = new Motive(); + $motive->setLabel(['fr' => $label]); + $motivesByLabel[$label] = $motive; + } + + if (null !== $parent) { + $motive->setParent($parent); + } + + $manager->persist($motive); + $parent = $motive; + + if (0 === count($labels)) { + // this is the last one, we add data + $numberOfDocs = (int) $data[2]; + for ($i = 1; $i <= $numberOfDocs; ++$i) { + $doc = $docs[$i - 1]; + $storedObject = new StoredObject(); + $storedObject->setTitle($doc['label']); + + $content = file_get_contents($doc['path']); + $contentType = match (substr($doc['path'], -3, 3)) { + 'pdf' => 'application/pdf', + 'png' => 'image/png', + default => throw new \UnexpectedValueException('Not supported content type here'), + }; + $this->storedObjectManager->write($storedObject, $content, $contentType); + + $motive->addStoredObject($storedObject); + $manager->persist($storedObject); + } + + + foreach (array_slice($data, 3) as $supplementaryComment) { + if ('' !== trim((string) $supplementaryComment)) { + $motive->addSupplementaryComment(['label' => trim((string) $supplementaryComment)]); + } + } + } + } + } + $manager->flush(); + } + + private const MOTIVES = <<<'CSV' + "Motif administratif > Coordonnées",false,"3","Nouvelles coordonnées", + "Organisation > Horaire de passage",false,"0", + "Organisation > Livraison > Retard de livraison",false,"0", + "Organisation > Livraison > Erreur de livraison",false,"0", + "Organisation > Livraison > Colis incomplet",false,"0", + "MATLOC",false,"0", + "Retard DASRI",false,"1", + "Planning d'astreintes",false,"0", + "Planning des tournées",false,"0", + "Contrôle pompe",true,"0", + "Changement de rendez-vous",false,"0","Date du nouveau rendez-vous","Lieu du nouveau rendez-vous", + "Renseignement facturation/prestation",false,"0", + "Décès patient",false,"0","Date et heures du décès","Autorisation préalable du médecin pour le décès", + "Demande de prise en charge",false,"0", + "Information absence",false,"0", + "Demande bulletin de situation",false,"0", + "Difficultés accès logement",false,"0", + "Déplacement inutile",false,"0", + "Problème de prélèvement/de commande",false,"0", + "Parc auto",false,"0", + "Demande d'admission",false,"0", + "Retrait de matériel au domicile",false,"0", + "Comptes-rendus",false,"0", + "Démarchage commercial",false,"0", + "Demande de transport",false,"0", + "Demande laboratoire",false,"0", + "Demande admission",false,"0", + "Suivi de prise en charge",false,"0", + "Mauvaise adresse",false,"0", + "Patient absent",false,"0", + "Annulation",false,"0", + "Organisation > Livraison > Colis perdu",false,"0", + "Changement de rendez-vous",false,"0", + "Coordination interservices",false,"0", + "Problème de substitution produits",true,"0", + "Problème ordonnance",false,"3", + "Réclamations facture",false,"0","Numéro de facture concerné", + "Préparation urgente",true,"3", + CSV; +} diff --git a/src/Bundle/ChillTicketBundle/src/DataFixtures/ORM/docs/peloton_1.pdf b/src/Bundle/ChillTicketBundle/src/DataFixtures/ORM/docs/peloton_1.pdf new file mode 100644 index 000000000..7b393d6e2 Binary files /dev/null and b/src/Bundle/ChillTicketBundle/src/DataFixtures/ORM/docs/peloton_1.pdf differ diff --git a/src/Bundle/ChillTicketBundle/src/DataFixtures/ORM/docs/peloton_2.pdf b/src/Bundle/ChillTicketBundle/src/DataFixtures/ORM/docs/peloton_2.pdf new file mode 100644 index 000000000..7a6e626a4 Binary files /dev/null and b/src/Bundle/ChillTicketBundle/src/DataFixtures/ORM/docs/peloton_2.pdf differ diff --git a/src/Bundle/ChillTicketBundle/src/DataFixtures/ORM/docs/schema_1.png b/src/Bundle/ChillTicketBundle/src/DataFixtures/ORM/docs/schema_1.png new file mode 100644 index 000000000..edb9ed774 Binary files /dev/null and b/src/Bundle/ChillTicketBundle/src/DataFixtures/ORM/docs/schema_1.png differ diff --git a/src/Bundle/ChillTicketBundle/src/DependencyInjection/ChillTicketExtension.php b/src/Bundle/ChillTicketBundle/src/DependencyInjection/ChillTicketExtension.php new file mode 100644 index 000000000..a990cfa16 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/DependencyInjection/ChillTicketExtension.php @@ -0,0 +1,105 @@ +<?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\TicketBundle\DependencyInjection; + +use Chill\TicketBundle\Controller\Admin\MotiveController; +use Chill\TicketBundle\Controller\MotiveApiController; +use Chill\TicketBundle\Entity\Motive; +use Chill\TicketBundle\Form\MotiveType; +use Symfony\Component\Config\FileLocator; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; +use Symfony\Component\DependencyInjection\Loader; +use Symfony\Component\DependencyInjection\Extension\Extension; +use Symfony\Component\HttpFoundation\Request; + +class ChillTicketExtension extends Extension implements PrependExtensionInterface +{ + public function load(array $configs, ContainerBuilder $container) + { + $configuration = $this->getConfiguration($configs, $container); + $config = $this->processConfiguration($configuration, $configs); + + $container->setParameter('chill_ticket', $config); + + $loader = new Loader\YamlFileLoader($container, new FileLocator(__DIR__.'/../config')); + $loader->load('services.yaml'); + } + + public function prepend(ContainerBuilder $container) + { + $this->prependApi($container); + $this->prependCruds($container); + } + + private function prependApi(ContainerBuilder $container): void + { + $container->prependExtensionConfig('chill_main', [ + 'apis' => [ + [ + 'class' => Motive::class, + 'name' => 'motive', + 'base_path' => '/api/1.0/ticket/motive', + 'controller' => MotiveApiController::class, + 'base_role' => 'ROLE_USER', + 'actions' => [ + '_index' => [ + 'methods' => [ + Request::METHOD_GET => true, + Request::METHOD_HEAD => true, + ], + ], + '_entity' => [ + 'methods' => [ + Request::METHOD_GET => true, + Request::METHOD_HEAD => true, + ], + ], + ], + ], + ], + ]); + } + + protected function prependCruds(ContainerBuilder $container): void + { + $container->prependExtensionConfig('chill_main', [ + 'cruds' => [ + [ + 'class' => Motive::class, + 'name' => 'motive', + 'base_path' => '/admin/ticket/motive', + 'form_class' => MotiveType::class, + 'controller' => MotiveController::class, + 'actions' => [ + 'index' => [ + 'template' => '@ChillTicket/Admin/Motive/index.html.twig', + 'role' => 'ROLE_ADMIN', + ], + 'new' => [ + 'role' => 'ROLE_ADMIN', + 'template' => '@ChillTicket/Admin/Motive/new.html.twig', + ], + 'view' => [ + 'role' => 'ROLE_ADMIN', + 'template' => '@ChillTicket/Admin/Motive/view.html.twig', + ], + 'edit' => [ + 'role' => 'ROLE_ADMIN', + 'template' => '@ChillTicket/Admin/Motive/edit.html.twig', + ], + ], + ], + ], + ]); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/DependencyInjection/Configuration.php b/src/Bundle/ChillTicketBundle/src/DependencyInjection/Configuration.php new file mode 100644 index 000000000..fbea3d280 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/DependencyInjection/Configuration.php @@ -0,0 +1,60 @@ +<?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\TicketBundle\DependencyInjection; + +use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition; +use Symfony\Component\Config\Definition\Builder\TreeBuilder; +use Symfony\Component\Config\Definition\ConfigurationInterface; + +class Configuration implements ConfigurationInterface +{ + public function getConfigTreeBuilder(): TreeBuilder + { + $treeBuilder = new TreeBuilder('chill_ticket'); + /** @var ArrayNodeDefinition $rootNode */ + $rootNode = $treeBuilder->getRootNode(); + + /** @var ArrayNodeDefinition $ticketArray */ + $ticketArray = $rootNode + ->children() + ->arrayNode('ticket') + ->addDefaultsIfNotSet() + ; + + $ticketArray + ->children() + ->enumNode('person_per_ticket') + ->values(['one', 'many']) + ->defaultValue('many') + ->end(); + + $ticketArray + ->children() + ->scalarNode('response_time_exceeded_delay') + ->info('The delay to declare a ticket as having an exceeded response time. Must be expressed as a valid parameter for instantiating a \DateInterval') + ->defaultValue('PT12H') + ->validate() + ->ifTrue(function (string $value) { + try { + new \DateInterval($value); + } catch (\Throwable) { + return true; + } + + return false; + }) + ->thenInvalid('The date interval provided for response_time_exceeded_delay (%s) is invalid') + ; + + return $treeBuilder; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Entity/AddresseeHistory.php b/src/Bundle/ChillTicketBundle/src/Entity/AddresseeHistory.php new file mode 100644 index 000000000..ee1d87dee --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Entity/AddresseeHistory.php @@ -0,0 +1,137 @@ +<?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\TicketBundle\Entity; + +use Chill\MainBundle\Doctrine\Model\TrackCreationInterface; +use Chill\MainBundle\Doctrine\Model\TrackCreationTrait; +use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface; +use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait; +use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Entity\UserGroup; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation as Serializer; + +/** + * Represents the history entity for addressees in the context of a ticketing system. + * + * Tracks information about addressees for a specific ticket, including user or group associability, + * timestamps marking the start and end of the association, and the removal of assignments. + * Implements mechanisms for tracking entity creation and updates. + */ +#[ORM\Entity()] +#[ORM\Table(name: 'addressee_history', schema: 'chill_ticket')] +#[Serializer\DiscriminatorMap(typeProperty: 'type', mapping: ['ticket_addressee_history' => AddresseeHistory::class])] +class AddresseeHistory implements TrackUpdateInterface, TrackCreationInterface +{ + use TrackCreationTrait; + use TrackUpdateTrait; + + #[ORM\Id] + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: false)] + #[ORM\GeneratedValue(strategy: 'AUTO')] + #[Serializer\Groups(['read'])] + private ?int $id = null; + + #[ORM\ManyToOne(targetEntity: User::class)] + #[ORM\JoinColumn(nullable: true)] + private ?User $addresseeUser = null; + + #[ORM\ManyToOne(targetEntity: UserGroup::class)] + #[ORM\JoinColumn(nullable: true)] + private ?UserGroup $addresseeGroup = null; + + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true, options: ['default' => 'null'])] + #[Serializer\Groups(['read'])] + private ?\DateTimeImmutable $endDate = null; + + #[ORM\ManyToOne(targetEntity: User::class)] + #[ORM\JoinColumn(nullable: true)] + #[Serializer\Groups(['read'])] + private ?User $removedBy = null; + + public function __construct( + User|UserGroup $addressee, + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: false)] + #[Serializer\Groups(['read'])] + private \DateTimeImmutable $startDate, + #[ORM\ManyToOne(targetEntity: Ticket::class)] + #[ORM\JoinColumn(nullable: false)] + private Ticket $ticket, + ) { + if ($addressee instanceof User) { + $this->addresseeUser = $addressee; + } else { + $this->addresseeGroup = $addressee; + } + + $this->ticket->addAddresseeHistory($this); + } + + #[Serializer\Groups(['read'])] + public function getAddressee(): UserGroup|User + { + if (null !== $this->addresseeGroup) { + return $this->addresseeGroup; + } + + return $this->addresseeUser; + } + + public function getAddresseeGroup(): ?UserGroup + { + return $this->addresseeGroup; + } + + public function getAddresseeUser(): ?User + { + return $this->addresseeUser; + } + + public function getEndDate(): ?\DateTimeImmutable + { + return $this->endDate; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getStartDate(): \DateTimeImmutable + { + return $this->startDate; + } + + public function getTicket(): Ticket + { + return $this->ticket; + } + + public function getRemovedBy(): ?User + { + return $this->removedBy; + } + + public function setRemovedBy(?User $removedBy): self + { + $this->removedBy = $removedBy; + + return $this; + } + + public function setEndDate(?\DateTimeImmutable $endDate): self + { + $this->endDate = $endDate; + + return $this; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Entity/CallerHistory.php b/src/Bundle/ChillTicketBundle/src/Entity/CallerHistory.php new file mode 100644 index 000000000..491f347a5 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Entity/CallerHistory.php @@ -0,0 +1,148 @@ +<?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\TicketBundle\Entity; + +use Chill\MainBundle\Doctrine\Model\TrackCreationInterface; +use Chill\MainBundle\Doctrine\Model\TrackCreationTrait; +use Chill\PersonBundle\Entity\Person; +use Chill\ThirdPartyBundle\Entity\ThirdParty; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation as Serializer; + +/** + * Represents the history of a caller associated with a ticket. + * + * This entity is used to track the changes in a ticket's caller over time. + * The caller can be either a Person or a ThirdParty. + * Implements the TrackCreationInterface for tracking entity lifecycle creation. + */ +#[ORM\Entity] +#[ORM\Table(name: 'caller_history', schema: 'chill_ticket')] +#[Serializer\DiscriminatorMap(typeProperty: 'type', mapping: ['ticket_caller_history' => CallerHistory::class])] +class CallerHistory implements TrackCreationInterface +{ + use TrackCreationTrait; + + #[ORM\Id] + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: false)] + #[ORM\GeneratedValue(strategy: 'AUTO')] + #[Serializer\Groups(['read'])] + private ?int $id = null; + + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true, options: ['default' => null])] + #[Serializer\Groups(['read'])] + private ?\DateTimeImmutable $endDate = null; + + #[ORM\ManyToOne(targetEntity: Person::class)] + #[ORM\JoinColumn(nullable: true)] + #[Serializer\Groups(['read'])] + private ?Person $person = null; + + #[ORM\ManyToOne(targetEntity: ThirdParty::class)] + #[ORM\JoinColumn(nullable: true)] + #[Serializer\Groups(['read'])] + private ?ThirdParty $thirdParty = null; + + public function __construct( + ThirdParty|Person|null $caller, + #[ORM\ManyToOne(targetEntity: Ticket::class)] + #[ORM\JoinColumn(nullable: false)] + private Ticket $ticket, + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: false)] + #[Serializer\Groups(['read'])] + private \DateTimeImmutable $startDate = new \DateTimeImmutable('now'), + ) { + $this->setCaller($caller); + $ticket->addCallerHistory($this); + } + + public function getEndDate(): ?\DateTimeImmutable + { + return $this->endDate; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getPerson(): ?Person + { + return $this->person; + } + + public function getThirdParty(): ?ThirdParty + { + return $this->thirdParty; + } + + public function getStartDate(): \DateTimeImmutable + { + return $this->startDate; + } + + public function getTicket(): Ticket + { + return $this->ticket; + } + + public function setEndDate(?\DateTimeImmutable $endDate): void + { + $this->endDate = $endDate; + } + + public function setPerson(?Person $person): self + { + $this->person = $person; + + // If setting a person, ensure thirdParty is null + if (null !== $person) { + $this->thirdParty = null; + } + + return $this; + } + + public function setThirdParty(?ThirdParty $thirdParty): self + { + $this->thirdParty = $thirdParty; + + // If setting a thirdParty, ensure person is null + if (null !== $thirdParty) { + $this->person = null; + } + + return $this; + } + + /** + * Set the caller. + * + * This is a private method and should be only called while instance creation + */ + private function setCaller(Person|ThirdParty|null $caller): void + { + if ($caller instanceof Person) { + $this->setPerson($caller); + } elseif ($caller instanceof ThirdParty) { + $this->setThirdParty($caller); + } + } + + /** + * Get the caller, which can be either a Person or a ThirdParty. + */ + public function getCaller(): Person|ThirdParty|null + { + return $this->person ?? $this->thirdParty; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Entity/Comment.php b/src/Bundle/ChillTicketBundle/src/Entity/Comment.php new file mode 100644 index 000000000..f33fab4e1 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Entity/Comment.php @@ -0,0 +1,85 @@ +<?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\TicketBundle\Entity; + +use Chill\MainBundle\Doctrine\Model\TrackCreationInterface; +use Chill\MainBundle\Doctrine\Model\TrackCreationTrait; +use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface; +use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait; +use Doctrine\ORM\Mapping as ORM; +use Doctrine\ORM\Mapping\JoinColumn; +use Symfony\Component\Serializer\Annotation as Serializer; + +/** + * Represents a comment entity within the application. + * + * This entity is associated with a specific ticket and includes creation and update tracking. + */ +#[ORM\Entity()] +#[ORM\Table(name: 'comment', schema: 'chill_ticket')] +#[Serializer\DiscriminatorMap(typeProperty: 'type', mapping: ['ticket_comment' => Comment::class])] +class Comment implements TrackCreationInterface, TrackUpdateInterface +{ + use TrackCreationTrait; + use TrackUpdateTrait; + + #[ORM\Id] + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: false)] + #[ORM\GeneratedValue(strategy: 'AUTO')] + #[Serializer\Groups(['read'])] + private ?int $id = null; + + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::BOOLEAN, nullable: false, options: ['default' => false])] + #[Serializer\Groups(['read'])] + private bool $deleted = false; + + public function __construct( + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])] + #[Serializer\Groups(['read'])] + private string $content, + #[ORM\ManyToOne(targetEntity: Ticket::class, inversedBy: 'comments')] + #[JoinColumn(nullable: false)] + private Ticket $ticket, + ) { + $ticket->addComment($this); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getContent(): string + { + return $this->content; + } + + public function getTicket(): Ticket + { + return $this->ticket; + } + + public function setContent(string $content): void + { + $this->content = $content; + } + + public function isDeleted(): bool + { + return $this->deleted; + } + + public function setDeleted(bool $deleted): void + { + $this->deleted = $deleted; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Entity/EmergencyStatusEnum.php b/src/Bundle/ChillTicketBundle/src/Entity/EmergencyStatusEnum.php new file mode 100644 index 000000000..c9fbe6c63 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Entity/EmergencyStatusEnum.php @@ -0,0 +1,30 @@ +<?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\TicketBundle\Entity; + +/** + * Represents the emergency status of a ticket (yes or no). + */ +enum EmergencyStatusEnum: string +{ + case YES = 'yes'; + case NO = 'no'; + + public static function fromValue(string $value): EmergencyStatusEnum + { + return match ($value) { + self::YES->value => self::YES, + self::NO->value => self::NO, + default => throw new \InvalidArgumentException(sprintf('Value "%s" is not valid', $value)), + }; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Entity/EmergencyStatusHistory.php b/src/Bundle/ChillTicketBundle/src/Entity/EmergencyStatusHistory.php new file mode 100644 index 000000000..74501be6f --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Entity/EmergencyStatusHistory.php @@ -0,0 +1,85 @@ +<?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\TicketBundle\Entity; + +use Chill\MainBundle\Doctrine\Model\TrackCreationInterface; +use Chill\MainBundle\Doctrine\Model\TrackCreationTrait; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation as Serializer; + +/** + * Represents the history of an emergency status associated with a ticket. + * + * This entity is used to track the changes in a ticket's emergency status over time. + * Implements the TrackCreationInterface for tracking entity lifecycle creation. + */ +#[ORM\Entity] +#[ORM\Table(name: 'emergency_status_history', schema: 'chill_ticket')] +#[Serializer\DiscriminatorMap(typeProperty: 'type', mapping: ['ticket_emergency_status_history' => EmergencyStatusHistory::class])] +class EmergencyStatusHistory implements TrackCreationInterface +{ + use TrackCreationTrait; + + #[ORM\Id] + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: false)] + #[ORM\GeneratedValue(strategy: 'AUTO')] + #[Serializer\Groups(['read'])] + private ?int $id = null; + + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true, options: ['default' => null])] + #[Serializer\Groups(['read'])] + private ?\DateTimeImmutable $endDate = null; + + public function __construct( + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, nullable: false, enumType: EmergencyStatusEnum::class)] + #[Serializer\Groups(['read'])] + private EmergencyStatusEnum $emergencyStatus, + #[ORM\ManyToOne(targetEntity: Ticket::class)] + #[ORM\JoinColumn(nullable: false)] + private Ticket $ticket, + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: false)] + #[Serializer\Groups(['read'])] + private \DateTimeImmutable $startDate = new \DateTimeImmutable('now'), + ) { + $ticket->addEmergencyStatusHistory($this); + } + + public function getEndDate(): ?\DateTimeImmutable + { + return $this->endDate; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getEmergencyStatus(): EmergencyStatusEnum + { + return $this->emergencyStatus; + } + + public function getStartDate(): \DateTimeImmutable + { + return $this->startDate; + } + + public function getTicket(): Ticket + { + return $this->ticket; + } + + public function setEndDate(?\DateTimeImmutable $endDate): void + { + $this->endDate = $endDate; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Entity/InputHistory.php b/src/Bundle/ChillTicketBundle/src/Entity/InputHistory.php new file mode 100644 index 000000000..a43bf7a1f --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Entity/InputHistory.php @@ -0,0 +1,96 @@ +<?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\TicketBundle\Entity; + +use Chill\MainBundle\Entity\User; +use Chill\PersonBundle\Entity\Person; +use Chill\ThirdPartyBundle\Entity\ThirdParty; +use Doctrine\ORM\Mapping as ORM; + +/** + * History of input for a single ticket. + * + * An input is someone who triggered the opening of the ticket. Typically, it's the "caller" of a call. + */ +#[ORM\Entity()] +#[ORM\Table(name: 'input_history', schema: 'chill_ticket')] +class InputHistory +{ + #[ORM\Id] + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: false)] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private ?int $id = null; + + #[ORM\ManyToOne(targetEntity: Person::class)] + #[ORM\JoinColumn(nullable: true)] + private ?Person $person = null; + + #[ORM\ManyToOne(targetEntity: ThirdParty::class)] + #[ORM\JoinColumn(nullable: true)] + private ?ThirdParty $thirdParty = null; + + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true, options: ['default' => null])] + private ?\DateTimeImmutable $endDate = null; + + #[ORM\ManyToOne(targetEntity: User::class)] + #[ORM\JoinColumn(nullable: true)] + private ?User $removedBy = null; + + public function __construct( + Person|ThirdParty $input, + #[ORM\ManyToOne(targetEntity: Ticket::class)] + #[ORM\JoinColumn(nullable: false)] + private Ticket $ticket, + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: false)] + private \DateTimeImmutable $startDate, + ) { + if ($input instanceof Person) { + $this->person = $input; + } else { + $this->thirdParty = $input; + } + } + + public function getId(): ?int + { + return $this->id; + } + + public function getEndDate(): ?\DateTimeImmutable + { + return $this->endDate; + } + + public function getRemovedBy(): ?User + { + return $this->removedBy; + } + + public function getStartDate(): \DateTimeImmutable + { + return $this->startDate; + } + + public function getTicket(): Ticket + { + return $this->ticket; + } + + public function getInput(): Person|ThirdParty + { + if (null !== $this->person) { + return $this->person; + } + + return $this->thirdParty; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Entity/Motive.php b/src/Bundle/ChillTicketBundle/src/Entity/Motive.php new file mode 100644 index 000000000..a22898b14 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Entity/Motive.php @@ -0,0 +1,233 @@ +<?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\TicketBundle\Entity; + +use Chill\DocStoreBundle\Entity\StoredObject; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\Common\Collections\ReadableCollection; +use Doctrine\Common\Collections\Selectable; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation as Serializer; + +#[ORM\Entity()] +#[ORM\Table(name: 'motive', schema: 'chill_ticket')] +#[Serializer\DiscriminatorMap(typeProperty: 'type', mapping: ['ticket_motive' => Motive::class])] +class Motive +{ + #[ORM\Id] + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: false)] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private ?int $id = null; + + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['default' => '[]'])] + private array $label = []; + + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::BOOLEAN, nullable: false, options: ['default' => true])] + private bool $active = true; + + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, nullable: true, enumType: EmergencyStatusEnum::class)] + private ?EmergencyStatusEnum $makeTicketEmergency = null; + + /** + * @var Collection<int, StoredObject> + */ + #[ORM\ManyToMany(targetEntity: StoredObject::class, fetch: 'EAGER')] + #[ORM\JoinTable(name: 'motive_stored_objects', schema: 'chill_ticket')] + private Collection $storedObjects; + + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::JSON, nullable: false, options: ['jsonb' => true, 'default' => '[]'])] + private array $supplementaryComments = []; + + #[ORM\ManyToOne(targetEntity: Motive::class, inversedBy: 'children')] + private ?Motive $parent = null; + + /** + * @var Collection<int, Motive>&Selectable<int, Motive> + */ + #[ORM\OneToMany(mappedBy: 'parent', targetEntity: Motive::class)] + private Collection&Selectable $children; + + #[ORM\Column(name: 'ordering', type: \Doctrine\DBAL\Types\Types::FLOAT, nullable: true, options: ['default' => '0.0'])] + private float $ordering = 0; + + public function __construct() + { + $this->storedObjects = new ArrayCollection(); + $this->children = new ArrayCollection(); + } + + public function addStoredObject(StoredObject $storedObject): void + { + if (!$this->storedObjects->contains($storedObject)) { + $this->storedObjects[] = $storedObject; + } + } + + public function removeStoredObject(StoredObject $storedObject): void + { + $this->storedObjects->removeElement($storedObject); + } + + public function getStoredObjects(): ReadableCollection + { + return $this->storedObjects; + } + + public function isActive(): bool + { + return $this->active; + } + + public function setActive(bool $active): void + { + $this->active = $active; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getLabel(): array + { + return $this->label; + } + + public function setLabel(array $label): void + { + $this->label = $label; + } + + public function getMakeTicketEmergency(): ?EmergencyStatusEnum + { + return $this->makeTicketEmergency; + } + + public function setMakeTicketEmergency(?EmergencyStatusEnum $makeTicketEmergency): void + { + $this->makeTicketEmergency = $makeTicketEmergency; + } + + public function isMakeTicketEmergency(): bool + { + return null !== $this->makeTicketEmergency; + } + + /** + * Get the supplementary comments. + * + * @return array<array{label: string}> + */ + public function getSupplementaryComments(): array + { + return $this->supplementaryComments; + } + + /** + * @param array{label: string} $supplementaryComments + */ + public function addSupplementaryComment(array $supplementaryComments): void + { + $this->supplementaryComments[] = $supplementaryComments; + } + + public function setSupplementaryComment(array $supplementaryComments): void + { + foreach ($this->supplementaryComments as $key => $supplementaryComment) { + unset($this->supplementaryComments[$key]); + } + foreach (array_values($supplementaryComments) as $key => $supplementaryComment) { + $this->supplementaryComments[$key] = $supplementaryComment; + } + } + + public function isParent(): bool + { + return $this->children->count() > 0; + } + + public function isChild(): bool + { + return null !== $this->parent; + } + + public function setParent(?Motive $parent): void + { + if (null !== $parent) { + $parent->addChild($this); + } else { + $this->parent->removeChild($this); + } + + $this->parent = $parent; + } + + /** + * @internal use @see{setParent} instead + */ + public function addChild(Motive $child): void + { + if (!$this->children->contains($child)) { + $this->children->add($child); + } + } + + /** + * @internal use @see{setParent} with null as argument instead + */ + public function removeChild(Motive $child): void + { + $this->children->removeElement($child); + } + + public function getChildren(): ReadableCollection&Selectable + { + return $this->children; + } + + public function getParent(): ?Motive + { + return $this->parent; + } + + /** + * Get the descendants of the current entity. + * + * This method collects all descendant entities recursively, starting from the current entity + * and including all of its children and their descendants. + * + * @return ReadableCollection&Selectable A collection containing the current entity and all its descendants + */ + public function getDescendants(): ReadableCollection&Selectable + { + $collection = new ArrayCollection([$this]); + + foreach ($this->getChildren() as $child) { + foreach ($child->getDescendants() as $descendant) { + $collection->add($descendant); + } + } + + return $collection; + } + + public function getOrdering(): float + { + return $this->ordering; + } + + public function setOrdering(float $ordering): void + { + $this->ordering = $ordering; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Entity/MotiveHistory.php b/src/Bundle/ChillTicketBundle/src/Entity/MotiveHistory.php new file mode 100644 index 000000000..e3de710be --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Entity/MotiveHistory.php @@ -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\TicketBundle\Entity; + +use Chill\MainBundle\Doctrine\Model\TrackCreationInterface; +use Chill\MainBundle\Doctrine\Model\TrackCreationTrait; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation as Serializer; + +/** + * Represents the history of a motive associated with a ticket. + * + * This entity is used to track the changes in a ticket's motive over time. + * Implements the TrackCreationInterface for tracking entity lifecycle creation. + */ +#[ORM\Entity] +#[ORM\Table(name: 'motives_history', schema: 'chill_ticket')] +#[Serializer\DiscriminatorMap(typeProperty: 'type', mapping: ['ticket_motive_history' => MotiveHistory::class])] +class MotiveHistory implements TrackCreationInterface +{ + use TrackCreationTrait; + + #[ORM\Id] + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: false)] + #[ORM\GeneratedValue(strategy: 'AUTO')] + #[Serializer\Groups(['read'])] + private ?int $id = null; + + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true, options: ['default' => null])] + #[Serializer\Groups(['read'])] + private ?\DateTimeImmutable $endDate = null; + + public function __construct( + #[ORM\ManyToOne(targetEntity: Motive::class)] + #[ORM\JoinColumn(nullable: false)] + #[Serializer\Groups(['read', 'read:children-to-parent'])] + private Motive $motive, + #[ORM\ManyToOne(targetEntity: Ticket::class)] + #[ORM\JoinColumn(nullable: false)] + private Ticket $ticket, + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: false)] + #[Serializer\Groups(['read'])] + private \DateTimeImmutable $startDate = new \DateTimeImmutable('now'), + ) { + $ticket->addMotiveHistory($this); + } + + public function getEndDate(): ?\DateTimeImmutable + { + return $this->endDate; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getMotive(): Motive + { + return $this->motive; + } + + public function getStartDate(): \DateTimeImmutable + { + return $this->startDate; + } + + public function getTicket(): Ticket + { + return $this->ticket; + } + + public function setEndDate(?\DateTimeImmutable $endDate): void + { + $this->endDate = $endDate; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Entity/PersonHistory.php b/src/Bundle/ChillTicketBundle/src/Entity/PersonHistory.php new file mode 100644 index 000000000..042b7395b --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Entity/PersonHistory.php @@ -0,0 +1,103 @@ +<?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\TicketBundle\Entity; + +use Chill\MainBundle\Doctrine\Model\TrackCreationInterface; +use Chill\MainBundle\Doctrine\Model\TrackCreationTrait; +use Chill\MainBundle\Entity\User; +use Chill\PersonBundle\Entity\Person; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation as Serializer; + +/** + * Represents a history entity associated with a person and a ticket. + * Tracks the start date, end date, and the user who removed the entry (if applicable). + */ +#[ORM\Entity] +#[ORM\Table(name: 'person_history', schema: 'chill_ticket')] +#[Serializer\DiscriminatorMap(typeProperty: 'type', mapping: ['ticket_person_history' => PersonHistory::class])] +class PersonHistory implements TrackCreationInterface +{ + use TrackCreationTrait; + + #[ORM\Id] + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: false)] + #[ORM\GeneratedValue(strategy: 'AUTO')] + #[Serializer\Groups(['read'])] + private ?int $id = null; + + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true, options: ['default' => null])] + #[Serializer\Groups(['read'])] + private ?\DateTimeImmutable $endDate = null; + + #[ORM\ManyToOne(targetEntity: User::class)] + #[ORM\JoinColumn(nullable: true)] + #[Serializer\Groups(['read'])] + private ?User $removedBy = null; + + public function __construct( + #[ORM\ManyToOne(targetEntity: Person::class, fetch: 'EAGER')] + #[Serializer\Groups(['read'])] + private Person $person, + #[ORM\ManyToOne(targetEntity: Ticket::class)] + #[ORM\JoinColumn(nullable: false)] + private Ticket $ticket, + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: false)] + #[Serializer\Groups(['read'])] + private \DateTimeImmutable $startDate, + ) { + // keep ticket instance in sync with this + $this->ticket->addPersonHistory($this); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getPerson(): Person + { + return $this->person; + } + + public function getEndDate(): ?\DateTimeImmutable + { + return $this->endDate; + } + + public function getTicket(): Ticket + { + return $this->ticket; + } + + public function getStartDate(): \DateTimeImmutable + { + return $this->startDate; + } + + public function getRemovedBy(): ?User + { + return $this->removedBy; + } + + public function setEndDate(?\DateTimeImmutable $endDate): self + { + $this->endDate = $endDate; + + return $this; + } + + public function setRemovedBy(?User $removedBy): void + { + $this->removedBy = $removedBy; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Entity/StateEnum.php b/src/Bundle/ChillTicketBundle/src/Entity/StateEnum.php new file mode 100644 index 000000000..ba5d9a1ee --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Entity/StateEnum.php @@ -0,0 +1,30 @@ +<?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\TicketBundle\Entity; + +/** + * Represents the state of a ticket (open or closed). + */ +enum StateEnum: string +{ + case OPEN = 'open'; + case CLOSED = 'closed'; + + public static function fromValue(string $value): self + { + return match ($value) { + 'open' => self::OPEN, + 'closed' => self::CLOSED, + default => throw new \InvalidArgumentException('Invalid state value'), + }; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Entity/StateHistory.php b/src/Bundle/ChillTicketBundle/src/Entity/StateHistory.php new file mode 100644 index 000000000..e6ddf1f88 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Entity/StateHistory.php @@ -0,0 +1,85 @@ +<?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\TicketBundle\Entity; + +use Chill\MainBundle\Doctrine\Model\TrackCreationInterface; +use Chill\MainBundle\Doctrine\Model\TrackCreationTrait; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation as Serializer; + +/** + * Represents the history of a state associated with a ticket. + * + * This entity is used to track the changes in a ticket's state over time. + * Implements the TrackCreationInterface for tracking entity lifecycle creation. + */ +#[ORM\Entity] +#[ORM\Table(name: 'state_history', schema: 'chill_ticket')] +#[Serializer\DiscriminatorMap(typeProperty: 'type', mapping: ['ticket_state_history' => StateHistory::class])] +class StateHistory implements TrackCreationInterface +{ + use TrackCreationTrait; + + #[ORM\Id] + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: false)] + #[ORM\GeneratedValue(strategy: 'AUTO')] + #[Serializer\Groups(['read'])] + private ?int $id = null; + + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true, options: ['default' => null])] + #[Serializer\Groups(['read'])] + private ?\DateTimeImmutable $endDate = null; + + public function __construct( + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::STRING, nullable: false, enumType: StateEnum::class)] + #[Serializer\Groups(['read'])] + private StateEnum $state, + #[ORM\ManyToOne(targetEntity: Ticket::class)] + #[ORM\JoinColumn(nullable: false)] + private Ticket $ticket, + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: false)] + #[Serializer\Groups(['read'])] + private \DateTimeImmutable $startDate = new \DateTimeImmutable('now'), + ) { + $ticket->addStateHistory($this); + } + + public function getEndDate(): ?\DateTimeImmutable + { + return $this->endDate; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getState(): StateEnum + { + return $this->state; + } + + public function getStartDate(): \DateTimeImmutable + { + return $this->startDate; + } + + public function getTicket(): Ticket + { + return $this->ticket; + } + + public function setEndDate(?\DateTimeImmutable $endDate): void + { + $this->endDate = $endDate; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Entity/Ticket.php b/src/Bundle/ChillTicketBundle/src/Entity/Ticket.php new file mode 100644 index 000000000..092aa5a35 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Entity/Ticket.php @@ -0,0 +1,336 @@ +<?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\TicketBundle\Entity; + +use Chill\MainBundle\Doctrine\Model\TrackCreationInterface; +use Chill\MainBundle\Doctrine\Model\TrackCreationTrait; +use Chill\MainBundle\Doctrine\Model\TrackUpdateInterface; +use Chill\MainBundle\Doctrine\Model\TrackUpdateTrait; +use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Entity\UserGroup; +use Chill\PersonBundle\Entity\Person; +use Chill\ThirdPartyBundle\Entity\ThirdParty; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\Common\Collections\ReadableCollection; +use Doctrine\ORM\Mapping as ORM; + +/** + * This entity represents a `Ticket` in the application and provides functionality for managing its associated + * histories, comments, and current data such as addressees, inputs, and motives. + * + * Most of the associated data are handle through "histories", with a many-to-one relationship (a ticket may have multiple) + * history. The history contains the associated data (see below) and start dates and end dates. + * + * There are histories for: + * + * - association between the ticket and persons: @see{PersonHistory}; + * - association between the ticket and motive: @see{MotiveHistory}; + * - association between the ticket and addresses: @see{AddresseeHistory}; + * - association between the ticket and input: @see{InputHistory}; + * - association between the ticket and state: @see{StateHistory}; + */ +#[ORM\Entity] +#[ORM\Table(name: 'ticket', schema: 'chill_ticket')] +class Ticket implements TrackCreationInterface, TrackUpdateInterface +{ + use TrackCreationTrait; + use TrackUpdateTrait; + + #[ORM\Id] + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::INTEGER, nullable: false)] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private ?int $id = null; + + /** + * @var Collection<int, AddresseeHistory> + */ + #[ORM\OneToMany(targetEntity: AddresseeHistory::class, mappedBy: 'ticket')] + private Collection $addresseeHistory; + + /** + * @var Collection<int, Comment> + */ + #[ORM\OneToMany(targetEntity: Comment::class, mappedBy: 'ticket')] + private Collection $comments; + + #[ORM\Column(type: \Doctrine\DBAL\Types\Types::TEXT, nullable: false, options: ['default' => ''])] + private string $externalRef = ''; + + /** + * @var Collection<int, InputHistory> + */ + #[ORM\OneToMany(targetEntity: InputHistory::class, mappedBy: 'ticket')] + private Collection $inputHistories; + + /** + * @var Collection<int, MotiveHistory> + */ + #[ORM\OneToMany(targetEntity: MotiveHistory::class, mappedBy: 'ticket')] + private Collection $motiveHistories; + + /** + * @var Collection<int, PersonHistory> + */ + #[ORM\OneToMany(targetEntity: PersonHistory::class, mappedBy: 'ticket')] + private Collection $personHistories; + + /** + * @var Collection<int, StateHistory> + */ + #[ORM\OneToMany(targetEntity: StateHistory::class, mappedBy: 'ticket', cascade: ['persist', 'remove'])] + private Collection $stateHistories; + + /** + * @var Collection<int, EmergencyStatusHistory> + */ + #[ORM\OneToMany(targetEntity: EmergencyStatusHistory::class, mappedBy: 'ticket', cascade: ['persist', 'remove'])] + private Collection $emergencyStatusHistories; + + /** + * @var Collection<int, CallerHistory> + */ + #[ORM\OneToMany(targetEntity: CallerHistory::class, mappedBy: 'ticket', cascade: ['persist', 'remove'])] + private Collection $callerHistories; + + public function __construct() + { + $this->addresseeHistory = new ArrayCollection(); + $this->comments = new ArrayCollection(); + $this->motiveHistories = new ArrayCollection(); + $this->personHistories = new ArrayCollection(); + $this->inputHistories = new ArrayCollection(); + $this->stateHistories = new ArrayCollection(); + $this->emergencyStatusHistories = new ArrayCollection(); + $this->callerHistories = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getExternalRef(): string + { + return $this->externalRef; + } + + public function setExternalRef(string $externalRef): void + { + $this->externalRef = $externalRef; + } + + /** + * @return list<Person> + */ + public function getPersons(): array + { + return $this->personHistories + ->filter(fn (PersonHistory $personHistory) => null === $personHistory->getEndDate()) + ->map(fn (PersonHistory $personHistory) => $personHistory->getPerson()) + ->getValues(); + } + + /** + * @internal use @see{Comment::__construct} instead + */ + public function addComment(Comment $comment): void + { + $this->comments->add($comment); + } + + /** + * Add a PersonHistory. + * + * @internal use @see{PersonHistory::__construct} instead + */ + public function addPersonHistory(PersonHistory $personHistory): void + { + $this->personHistories->add($personHistory); + } + + /** + * @internal use @see{MotiveHistory::__construct} instead + */ + public function addMotiveHistory(MotiveHistory $motiveHistory): void + { + $this->motiveHistories->add($motiveHistory); + } + + /** + * @internal use @see{AddresseHistory::__construct} instead + */ + public function addAddresseeHistory(AddresseeHistory $addresseeHistory): void + { + $this->addresseeHistory->add($addresseeHistory); + } + + /** + * @return list<UserGroup|User> + */ + public function getCurrentAddressee(): array + { + $addresses = []; + + foreach ($this->addresseeHistory + ->filter(fn (AddresseeHistory $addresseeHistory) => null === $addresseeHistory->getEndDate()) as $addressHistory) { + $addresses[] = $addressHistory->getAddressee(); + } + + return $addresses; + } + + /** + * @return ReadableCollection<int, Comment> + */ + public function getComments(): ReadableCollection + { + return $this->comments; + } + + /** + * @return list<ThirdParty|Person> + */ + public function getCurrentInputs(): array + { + $inputs = []; + + foreach ($this->inputHistories + ->filter(fn (InputHistory $inputHistory) => null === $inputHistory->getEndDate()) as $inputHistory + ) { + $inputs[] = $inputHistory->getInput(); + } + + return $inputs; + } + + public function getMotive(): ?Motive + { + foreach ($this->motiveHistories as $motiveHistory) { + if (null === $motiveHistory->getEndDate()) { + return $motiveHistory->getMotive(); + } + } + + return null; + } + + /** + * @return ReadableCollection<int, MotiveHistory> + */ + public function getMotiveHistories(): ReadableCollection + { + return $this->motiveHistories; + } + + /** + * @return ReadableCollection<int, PersonHistory> + */ + public function getPersonHistories(): ReadableCollection + { + return $this->personHistories; + } + + /** + * @return ReadableCollection<int, AddresseeHistory> + */ + public function getAddresseeHistories(): ReadableCollection + { + return $this->addresseeHistory; + } + + /** + * @internal use @see{StateHistory::__construct} instead + */ + public function addStateHistory(StateHistory $stateHistory): void + { + $this->stateHistories->add($stateHistory); + } + + public function getState(): ?StateEnum + { + foreach ($this->stateHistories as $stateHistory) { + if (null === $stateHistory->getEndDate()) { + return $stateHistory->getState(); + } + } + + return null; + } + + /** + * @return ReadableCollection<int, StateHistory> + */ + public function getStateHistories(): ReadableCollection + { + return $this->stateHistories; + } + + /** + * @internal use @see{EmergencyStatusHistory::__construct} instead + */ + public function addEmergencyStatusHistory(EmergencyStatusHistory $emergencyStatusHistory): void + { + $this->emergencyStatusHistories->add($emergencyStatusHistory); + } + + public function getEmergencyStatus(): ?EmergencyStatusEnum + { + foreach ($this->emergencyStatusHistories as $emergencyStatusHistory) { + if (null === $emergencyStatusHistory->getEndDate()) { + return $emergencyStatusHistory->getEmergencyStatus(); + } + } + + return null; + } + + /** + * @return ReadableCollection<int, EmergencyStatusHistory> + */ + public function getEmergencyStatusHistories(): ReadableCollection + { + return $this->emergencyStatusHistories; + } + + /** + * @internal use @see{CallerHistory::__construct} instead + */ + public function addCallerHistory(CallerHistory $callerHistory): void + { + $this->callerHistories->add($callerHistory); + } + + /** + * Get the current caller (Person or ThirdParty) associated with this ticket. + * + * @return Person|ThirdParty|null + */ + public function getCaller() + { + foreach ($this->callerHistories as $callerHistory) { + if (null === $callerHistory->getEndDate()) { + return $callerHistory->getCaller(); + } + } + + return null; + } + + /** + * @return ReadableCollection<int, CallerHistory> + */ + public function getCallerHistories(): ReadableCollection + { + return $this->callerHistories; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Event/EmergencyStatusUpdateEvent.php b/src/Bundle/ChillTicketBundle/src/Event/EmergencyStatusUpdateEvent.php new file mode 100644 index 000000000..934c38dde --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Event/EmergencyStatusUpdateEvent.php @@ -0,0 +1,26 @@ +<?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\TicketBundle\Event; + +use Chill\TicketBundle\Entity\EmergencyStatusEnum; +use Chill\TicketBundle\Entity\Ticket; + +final class EmergencyStatusUpdateEvent extends TicketUpdateEvent +{ + public function __construct( + Ticket $ticket, + public EmergencyStatusEnum $previousEmergencyStatus, + public EmergencyStatusEnum $newEmergencyStatus, + ) { + parent::__construct(TicketUpdateKindEnum::TOGGLE_EMERGENCY, $ticket); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Event/EventSubscriber/GeneratePostUpdateTicketEventSubscriber.php b/src/Bundle/ChillTicketBundle/src/Event/EventSubscriber/GeneratePostUpdateTicketEventSubscriber.php new file mode 100644 index 000000000..7b8889c1c --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Event/EventSubscriber/GeneratePostUpdateTicketEventSubscriber.php @@ -0,0 +1,52 @@ +<?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\TicketBundle\Event\EventSubscriber; + +use Chill\TicketBundle\Event\TicketUpdateEvent; +use Chill\TicketBundle\Messenger\PostTicketUpdateMessage; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpKernel\Event\TerminateEvent; +use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\Component\Messenger\MessageBusInterface; + +/** + * Subscribe to TicketUpdateEvents and dispatch a message for each one when the kernel terminates. + */ +final class GeneratePostUpdateTicketEventSubscriber implements EventSubscriberInterface +{ + /** + * @var list<PostTicketUpdateMessage> + */ + private array $toDispatch = []; + + public function __construct(private readonly MessageBusInterface $messageBus) {} + + public static function getSubscribedEvents(): array + { + return [ + TicketUpdateEvent::class => ['onTicketUpdate', 0], + KernelEvents::TERMINATE => ['onKernelTerminate', 8096], + ]; + } + + public function onTicketUpdate(TicketUpdateEvent $event): void + { + $this->toDispatch[] = new PostTicketUpdateMessage($event->ticket, $event->updateKind); + } + + public function onKernelTerminate(TerminateEvent $event): void + { + foreach ($this->toDispatch as $message) { + $this->messageBus->dispatch($message); + } + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Event/MotiveUpdateEvent.php b/src/Bundle/ChillTicketBundle/src/Event/MotiveUpdateEvent.php new file mode 100644 index 000000000..59aadf054 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Event/MotiveUpdateEvent.php @@ -0,0 +1,31 @@ +<?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\TicketBundle\Event; + +use Chill\TicketBundle\Entity\Motive; +use Chill\TicketBundle\Entity\Ticket; + +final class MotiveUpdateEvent extends TicketUpdateEvent +{ + public function __construct( + Ticket $ticket, + public ?Motive $previousMotive = null, + public ?Motive $newMotive = null, + ) { + parent::__construct(TicketUpdateKindEnum::UPDATE_MOTIVE, $ticket); + } + + public function hasChanges(): bool + { + return null !== $this->newMotive || null !== $this->previousMotive; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Event/PersonsUpdateEvent.php b/src/Bundle/ChillTicketBundle/src/Event/PersonsUpdateEvent.php new file mode 100644 index 000000000..6d1672dc3 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Event/PersonsUpdateEvent.php @@ -0,0 +1,38 @@ +<?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\TicketBundle\Event; + +use Chill\PersonBundle\Entity\Person; +use Chill\TicketBundle\Entity\Ticket; + +class PersonsUpdateEvent extends TicketUpdateEvent +{ + public function __construct(Ticket $ticket) + { + parent::__construct(TicketUpdateKindEnum::UPDATE_PERSONS, $ticket); + } + + /** + * @var list<Person> + */ + public $personsAdded = []; + + /** + * @var list<Person> + */ + public $personsRemoved = []; + + public function hasChanges(): bool + { + return count($this->personsAdded) > 0 || count($this->personsRemoved) > 0; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Event/PostTicketUpdateEvent.php b/src/Bundle/ChillTicketBundle/src/Event/PostTicketUpdateEvent.php new file mode 100644 index 000000000..d4e8fd738 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Event/PostTicketUpdateEvent.php @@ -0,0 +1,29 @@ +<?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\TicketBundle\Event; + +use Chill\TicketBundle\Entity\Ticket; + +/** + * Event triggered asynchronously after a ticket has been updated. + * + * This event is trigged by PostTicketUpdateMessageHandler, using Messenger component. + * + * To use a synchronous event, see @see{TicketUpdateEvent} + */ +class PostTicketUpdateEvent +{ + public function __construct( + public readonly TicketUpdateKindEnum $updateKind, + public readonly Ticket $ticket, + ) {} +} diff --git a/src/Bundle/ChillTicketBundle/src/Event/TicketUpdateEvent.php b/src/Bundle/ChillTicketBundle/src/Event/TicketUpdateEvent.php new file mode 100644 index 000000000..e3b1ac175 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Event/TicketUpdateEvent.php @@ -0,0 +1,22 @@ +<?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\TicketBundle\Event; + +use Chill\TicketBundle\Entity\Ticket; + +abstract class TicketUpdateEvent +{ + public function __construct( + public readonly TicketUpdateKindEnum $updateKind, + public readonly Ticket $ticket, + ) {} +} diff --git a/src/Bundle/ChillTicketBundle/src/Event/TicketUpdateKindEnum.php b/src/Bundle/ChillTicketBundle/src/Event/TicketUpdateKindEnum.php new file mode 100644 index 000000000..eda7a3075 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Event/TicketUpdateKindEnum.php @@ -0,0 +1,23 @@ +<?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\TicketBundle\Event; + +enum TicketUpdateKindEnum: string +{ + case UPDATE_ADDRESSEE = 'UPDATE_ADDRESSEE'; + case ADD_COMMENT = 'ADD_COMMENT'; + case TOGGLE_EMERGENCY = 'TOGGLE_EMERGENCY'; + case TOGGLE_STATE = 'TOGGLE_STATE'; + case UPDATE_MOTIVE = 'UPDATE_MOTIVE'; + case UPDATE_CALLER = 'UPDATE_CALLER'; + case UPDATE_PERSONS = 'UPDATE_PERSONS'; +} diff --git a/src/Bundle/ChillTicketBundle/src/Form/MotiveType.php b/src/Bundle/ChillTicketBundle/src/Form/MotiveType.php new file mode 100644 index 000000000..61251cb40 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Form/MotiveType.php @@ -0,0 +1,71 @@ +<?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\TicketBundle\Form; + +use Chill\MainBundle\Form\Type\ChillCollectionType; +use Chill\MainBundle\Form\Type\TranslatableStringFormType; +use Chill\TicketBundle\Entity\EmergencyStatusEnum; +use Chill\TicketBundle\MotiveDTO; +use Symfony\Component\Form\AbstractType; +use Symfony\Component\Form\Extension\Core\Type\CheckboxType; +use Symfony\Component\Form\Extension\Core\Type\EnumType; +use Symfony\Component\Form\Extension\Core\Type\NumberType; +use Symfony\Component\Form\Extension\Core\Type\TextType; +use Symfony\Component\Form\FormBuilderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class MotiveType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('label', TranslatableStringFormType::class, [ + 'label' => 'Label', + 'required' => true, + ]) + ->add('active', CheckboxType::class, [ + 'label' => 'Active', + 'required' => false, + ]) + ->add('makeTicketEmergency', EnumType::class, [ + 'class' => EmergencyStatusEnum::class, + 'label' => 'emergency?', + 'required' => false, + 'placeholder' => 'Choose an option...', + ]) + ->add('supplementaryComments', ChillCollectionType::class, [ + 'entry_type' => TextType::class, + 'allow_add' => true, + 'allow_delete' => true, + 'by_reference' => true, + 'label' => 'Supplementary comments', + 'required' => false, + ]) + ->add('ordering', NumberType::class, [ + 'scale' => 4, + 'label' => 'Ordering', + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => MotiveDTO::class, + ]); + } + + public function getBlockPrefix(): string + { + return 'chill_ticketbundle_motive'; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Menu/AdminMenuBuilder.php b/src/Bundle/ChillTicketBundle/src/Menu/AdminMenuBuilder.php new file mode 100644 index 000000000..b12dab2bf --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Menu/AdminMenuBuilder.php @@ -0,0 +1,45 @@ +<?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\TicketBundle\Menu; + +use Chill\MainBundle\Routing\LocalMenuBuilderInterface; +use Knp\Menu\MenuItem; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; + +class AdminMenuBuilder implements LocalMenuBuilderInterface +{ + public function __construct(protected AuthorizationCheckerInterface $authorizationChecker) {} + + public function buildMenu($menuId, MenuItem $menu, array $parameters): void + { + if (!$this->authorizationChecker->isGranted('ROLE_ADMIN')) { + return; + } + + $menu->addChild('Tickets', [ + 'route' => 'chill_ticket_admin_index', + ]) + ->setAttribute('class', 'list-group-item-header') + ->setExtras([ + 'order' => 7500, + ]); + + $menu->addChild('admin.ticket.motive.menu', [ + 'route' => 'chill_crud_motive_index', + ])->setExtras(['order' => 7510]); + } + + public static function getMenuIds(): array + { + return ['admin_section', 'admin_ticket']; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Menu/PersonMenuBuilder.php b/src/Bundle/ChillTicketBundle/src/Menu/PersonMenuBuilder.php new file mode 100644 index 000000000..ce780f036 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Menu/PersonMenuBuilder.php @@ -0,0 +1,69 @@ +<?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\TicketBundle\Menu; + +use Chill\PersonBundle\Entity\Person; +use Chill\PersonBundle\Security\Authorization\PersonVoter; +use Chill\TicketBundle\Repository\TicketRepositoryInterface; +use Chill\MainBundle\Routing\LocalMenuBuilderInterface; +use Knp\Menu\MenuItem; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +/** + * Add menu entrie to person menu. + * + * Menu entries added : + * + * - person details ; + * - accompanying period (if `visible`) + * + * @implements LocalMenuBuilderInterface<array{person: Person}> + */ +class PersonMenuBuilder implements LocalMenuBuilderInterface +{ + public function __construct( + private readonly AuthorizationCheckerInterface $authorizationChecker, + private readonly TranslatorInterface $translator, + private readonly TicketRepositoryInterface $ticketRepository, + ) {} + + /** + * @param array{person: Person} $parameters + * + * @return void + */ + public function buildMenu($menuId, MenuItem $menu, array $parameters) + { + /** @var Person $person */ + $person = $parameters['person']; + + if ($this->authorizationChecker->isGranted(PersonVoter::SEE, $person)) { + $menu->addChild($this->translator->trans('chill_ticket.list.title_menu'), [ + 'route' => 'chill_person_ticket_list', + 'routeParameters' => [ + 'id' => $person->getId(), + ], + ]) + ->setExtras([ + 'order' => 150, + 'counter' => 0 < ($nbTickets = $this->ticketRepository->countOpenedByPerson($person)) + ? $nbTickets : null, + ]); + } + } + + public static function getMenuIds(): array + { + return ['person']; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Menu/SectionMenuBuilder.php b/src/Bundle/ChillTicketBundle/src/Menu/SectionMenuBuilder.php new file mode 100644 index 000000000..554aacc77 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Menu/SectionMenuBuilder.php @@ -0,0 +1,29 @@ +<?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\TicketBundle\Menu; + +use Chill\MainBundle\Routing\LocalMenuBuilderInterface; +use Knp\Menu\MenuItem; + +class SectionMenuBuilder implements LocalMenuBuilderInterface +{ + public function buildMenu($menuId, MenuItem $menu, array $parameters) + { + $menu->addChild('Tickets', ['route' => 'chill_ticket_ticket_list']) + ->setExtras(['order' => 250]); + } + + public static function getMenuIds(): array + { + return ['section']; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Messenger/Handler/PostTicketUpdateMessageHandler.php b/src/Bundle/ChillTicketBundle/src/Messenger/Handler/PostTicketUpdateMessageHandler.php new file mode 100644 index 000000000..88f082b58 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Messenger/Handler/PostTicketUpdateMessageHandler.php @@ -0,0 +1,40 @@ +<?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\TicketBundle\Messenger\Handler; + +use Chill\TicketBundle\Event\PostTicketUpdateEvent; +use Chill\TicketBundle\Messenger\PostTicketUpdateMessage; +use Chill\TicketBundle\Repository\TicketRepositoryInterface; +use Symfony\Component\Messenger\Attribute\AsMessageHandler; +use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException; +use Symfony\Component\Messenger\Handler\MessageHandlerInterface; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; + +#[AsMessageHandler] +final readonly class PostTicketUpdateMessageHandler implements MessageHandlerInterface +{ + public function __construct( + private EventDispatcherInterface $eventDispatcher, + private TicketRepositoryInterface $ticketRepository, + ) {} + + public function __invoke(PostTicketUpdateMessage $event): void + { + $ticket = $this->ticketRepository->find($event->ticketId); + + if (null === $ticket) { + throw new UnrecoverableMessageHandlingException('Ticket not found'); + } + + $this->eventDispatcher->dispatch(new PostTicketUpdateEvent($event->updateKind, $ticket)); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Messenger/PostTicketUpdateMessage.php b/src/Bundle/ChillTicketBundle/src/Messenger/PostTicketUpdateMessage.php new file mode 100644 index 000000000..ac6f9c2d5 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Messenger/PostTicketUpdateMessage.php @@ -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\TicketBundle\Messenger; + +use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Event\TicketUpdateKindEnum; + +final readonly class PostTicketUpdateMessage +{ + public readonly int $ticketId; + + public function __construct( + Ticket $ticket, + public TicketUpdateKindEnum $updateKind, + ) { + $this->ticketId = $ticket->getId(); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/MotiveDTO.php b/src/Bundle/ChillTicketBundle/src/MotiveDTO.php new file mode 100644 index 000000000..e84565ee2 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/MotiveDTO.php @@ -0,0 +1,67 @@ +<?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\TicketBundle; + +use Chill\TicketBundle\Entity\EmergencyStatusEnum; +use Chill\TicketBundle\Entity\Motive; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Symfony\Component\Validator\Constraints as Assert; + +class MotiveDTO +{ + public function __construct( + #[Assert\NotBlank()] + public array $label = [], + public bool $active = true, + public Collection $supplementaryComments = new ArrayCollection(), + public ?EmergencyStatusEnum $makeTicketEmergency = null, + public float $ordering = 0.0, + ) { + if ($this->supplementaryComments->isEmpty()) { + $this->supplementaryComments = new ArrayCollection(); + } + } + + public static function fromMotive(Motive $motive): self + { + $supplementaryCommentsCollection = new ArrayCollection(); + + foreach ($motive->getSupplementaryComments() as $comment) { + $supplementaryCommentsCollection->add($comment['label']); + } + + return new self( + label: $motive->getLabel(), + active: $motive->isActive(), + supplementaryComments: $supplementaryCommentsCollection, + makeTicketEmergency: $motive->getMakeTicketEmergency(), + ordering: $motive->getOrdering(), + ); + } + + public function applyToMotive(Motive $motive): void + { + $motive->setLabel($this->label); + $motive->setActive($this->active); + $motive->setMakeTicketEmergency($this->makeTicketEmergency); + $motive->setOrdering($this->ordering); + + $supplementaryCommentsArray = []; + + foreach ($this->supplementaryComments as $supplementaryComment) { + $supplementaryCommentsArray[] = ['label' => $supplementaryComment]; + } + + $motive->setSupplementaryComment($supplementaryCommentsArray); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Repository/MotiveRepository.php b/src/Bundle/ChillTicketBundle/src/Repository/MotiveRepository.php new file mode 100644 index 000000000..234a7fd70 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Repository/MotiveRepository.php @@ -0,0 +1,55 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\TicketBundle\Repository; + +use Chill\DocStoreBundle\Entity\StoredObject; +use Chill\DocStoreBundle\Repository\AssociatedEntityToStoredObjectInterface; +use Chill\TicketBundle\Entity\Motive; +use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\Persistence\ManagerRegistry; + +/** + * @template-extends ServiceEntityRepository<Motive> + */ +class MotiveRepository extends ServiceEntityRepository implements AssociatedEntityToStoredObjectInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Motive::class); + } + + public function findAssociatedEntityToStoredObject(StoredObject $storedObject): ?Motive + { + $query = $this->createQueryBuilder('m'); + $query->select('m') + ->where(':stored_object MEMBER OF m.storedObjects') + ->setParameter('stored_object', $storedObject) + ->setMaxResults(1) + ; + + return $query->getQuery()->getOneOrNullResult(); + } + + /** + * @return list<Motive> + */ + public function findByLabel(string $label, string $lang): array + { + $query = $this->createQueryBuilder('m'); + $query->select('m') + ->where($query->expr()->like('JSON_EXTRACT(m.label, :lang)', ':label')) + ->setParameter('label', $label) + ->setParameter('lang', $lang); + + return $query->getQuery()->getResult(); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Repository/PersonTicketACLAwareRepository.php b/src/Bundle/ChillTicketBundle/src/Repository/PersonTicketACLAwareRepository.php new file mode 100644 index 000000000..21b61eb18 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Repository/PersonTicketACLAwareRepository.php @@ -0,0 +1,60 @@ +<?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\TicketBundle\Repository; + +use Chill\PersonBundle\Entity\Person; +use Chill\ThirdPartyBundle\Entity\ThirdParty; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Query\ResultSetMappingBuilder; + +final readonly class PersonTicketACLAwareRepository implements PersonTicketACLAwareRepositoryInterface +{ + public function __construct(private EntityManagerInterface $em) {} + + public function findPersonPreviouslyAssociatedWithCaller(Person|ThirdParty $caller, int $start = 0, int $limit = 100): array + { + $resultSetMappingBuilder = new ResultSetMappingBuilder($this->em); + $resultSetMappingBuilder->addRootEntityFromClassMetadata(Person::class, 'p'); + + $callerClause = match ($caller instanceof Person) { + true => 'caller_history.person_id = :callerId', + false => 'caller_history.thirdparty_id = :callerId', + }; + + $query = <<<SQL + SELECT DISTINCT {$resultSetMappingBuilder->generateSelectClause()} FROM chill_person_person p + WHERE p.id IN ( + SELECT person_id + FROM chill_ticket.person_history person_history + WHERE person_history.endDate IS NULL + AND EXISTS ( + SELECT 1 FROM chill_ticket.caller_history + WHERE + caller_history.ticket_id = person_history.ticket_id + AND caller_history.endDate IS NULL + AND {$callerClause} + ) + ORDER BY person_history.startDate DESC, p.id ASC + ) + OFFSET :start LIMIT :limit; + SQL; + + $nql = $this->em->createNativeQuery($query, $resultSetMappingBuilder); + $nql + ->setParameter('callerId', $caller->getId()) + ->setParameter('start', $start) + ->setParameter('limit', $limit); + + + return $nql->getResult(); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Repository/PersonTicketACLAwareRepositoryInterface.php b/src/Bundle/ChillTicketBundle/src/Repository/PersonTicketACLAwareRepositoryInterface.php new file mode 100644 index 000000000..dd1a1d014 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Repository/PersonTicketACLAwareRepositoryInterface.php @@ -0,0 +1,29 @@ +<?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\TicketBundle\Repository; + +use Chill\PersonBundle\Entity\Person; +use Chill\ThirdPartyBundle\Entity\ThirdParty; + +/** + * Interface for repository operations related to accessing and managing + * person tickets within an ACL-aware context. + */ +interface PersonTicketACLAwareRepositoryInterface +{ + /** + * Find all the Person entity that were previously associated with a ticket with the same caller. + * + * @return list<Person> + */ + public function findPersonPreviouslyAssociatedWithCaller(Person|ThirdParty $caller, int $start = 0, int $limit = 100): array; +} diff --git a/src/Bundle/ChillTicketBundle/src/Repository/TicketACLAwareRepository.php b/src/Bundle/ChillTicketBundle/src/Repository/TicketACLAwareRepository.php new file mode 100644 index 000000000..442fc077e --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Repository/TicketACLAwareRepository.php @@ -0,0 +1,238 @@ +<?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\TicketBundle\Repository; + +use Chill\PersonBundle\Entity\Person\PersonCenterHistory; +use Chill\TicketBundle\Entity\AddresseeHistory; +use Chill\TicketBundle\Entity\EmergencyStatusHistory; +use Chill\TicketBundle\Entity\MotiveHistory; +use Chill\TicketBundle\Entity\PersonHistory; +use Chill\TicketBundle\Entity\StateHistory; +use Chill\TicketBundle\Entity\Ticket; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\QueryBuilder; + +final readonly class TicketACLAwareRepository implements TicketACLAwareRepositoryInterface +{ + public function __construct(private EntityManagerInterface $em) {} + + public function findTickets(array $params, int $start = 0, int $limit = 100): array + { + $query = $this->buildQuery($params)->select('t'); + + // order by emergency. As the value can be 'yes' or 'no', we simply order by 'yes' first + $query->addSelect(sprintf("'SELECT u.emergencyStatus FROM %s u WHERE u.ticket = t AND u.endDate IS NULL' AS HIDDEN emergencyStatus", EmergencyStatusHistory::class)); + $query->addOrderBy('emergencyStatus', 'DESC'); + + // most recent tickets first + $query->addOrderBy('t.createdAt', 'DESC'); + + return $query->getQuery() + ->setFirstResult($start) + ->setMaxResults($limit) + ->getResult(); + } + + public function countTickets(array $params): int + { + return $this->buildQuery($params)->select('COUNT(t)')->getQuery()->getSingleScalarResult(); + } + + private function buildQuery(array $params): QueryBuilder + { + $qb = $this->em->createQueryBuilder(); + $qb->from(Ticket::class, 't'); + // counter for all the loops + $i = 0; + + if (array_key_exists('byTicketId', $params)) { + $qb->andWhere($qb->expr()->in('t.id', ':byTicketId')); + $qb->setParameter('byTicketId', $params['byTicketId']); + + return $qb; + } + + if (array_key_exists('byPerson', $params)) { + $or = $qb->expr()->orX(); + + foreach ($params['byPerson'] as $person) { + $or->add( + $qb->expr()->exists(sprintf( + 'SELECT 1 FROM %s tp_%d WHERE tp_%d.ticket = t AND tp_%d.person = :person_%d AND tp_%d.endDate IS NULL', + PersonHistory::class, + ++$i, + $i, + $i, + $i, + $i, + )) + ); + $qb->setParameter(sprintf('person_%d', $i), $person); + } + $qb->andWhere($or); + } + + if (array_key_exists('byCurrentState', $params)) { + $qb->andWhere( + $qb->expr()->exists(sprintf( + 'SELECT 1 FROM %s tp_state_%d WHERE tp_state_%d.ticket = t + AND tp_state_%d.state IN (:currentState) AND tp_state_%d.endDate IS NULL', + StateHistory::class, + ++$i, + $i, + $i, + $i, + )) + ); + $qb->setParameter('currentState', $params['byCurrentState']); + } + + if (array_key_exists('byCurrentStateEmergency', $params)) { + $qb->andWhere( + $qb->expr()->exists(sprintf( + 'SELECT 1 FROM %s tp_state_emergency_%d WHERE tp_state_emergency_%d.ticket = t + AND tp_state_emergency_%d.emergencyStatus IN (:currentStateEmergency) AND tp_state_emergency_%d.endDate IS NULL', + EmergencyStatusHistory::class, + ++$i, + $i, + $i, + $i, + )) + ); + $qb->setParameter('currentStateEmergency', $params['byCurrentStateEmergency']); + } + + if (array_key_exists('byMotives', $params)) { + $byMotives = $qb->expr()->orX(); + foreach ($params['byMotives'] as $motive) { + $motivesWithDescendants = $motive->getDescendants()->toArray(); + $byMotives->add( + $qb->expr()->exists(sprintf( + 'SELECT 1 FROM %s tp_motive_%d WHERE tp_motive_%d.ticket = t + AND tp_motive_%d.motive IN (:motives_%d) AND tp_motive_%d.endDate IS NULL + ', + MotiveHistory::class, + ++$i, + $i, + $i, + $i, + $i, + )) + ); + $qb->setParameter(sprintf('motives_%d', $i), $motivesWithDescendants); + } + $qb->andWhere($byMotives); + } + + if (array_key_exists('byCreatedAfter', $params)) { + $qb->andWhere('t.createdAt >= :opening_after'); + $qb->setParameter('opening_after', $params['byCreatedAfter']); + } + + if (array_key_exists('byCreatedBefore', $params)) { + $qb->andWhere('t.createdAt <= :opening_before'); + $qb->setParameter('opening_before', $params['byCreatedBefore']); + } + + if (array_key_exists('byCreator', $params)) { + $qb->andWhere('t.createdBy IN (:creators)'); + $qb->setParameter('creators', $params['byCreator']); + } + + if (array_key_exists('byAddressee', $params)) { + $orx = $qb->expr()->orX(); + foreach ($params['byAddressee'] as $addressee) { + $orx->add( + $qb->expr()->exists(sprintf( + 'SELECT 1 FROM %s tp_addressee_%d WHERE tp_addressee_%d.ticket = t + AND tp_addressee_%d.endDate IS NULL AND tp_addressee_%d.addresseeUser = :addressee_%d', + AddresseeHistory::class, + ++$i, + $i, + $i, + $i, + $addresseeParam = $i, + )) + ); + $orx->add( + $qb->expr()->exists(sprintf( + 'SELECT 1 FROM %s tp_addressee_%d JOIN tp_addressee_%d.addresseeGroup group_%d WHERE tp_addressee_%d.ticket = t + AND tp_addressee_%d.endDate IS NULL AND :addressee_%d MEMBER OF group_%d.users', + AddresseeHistory::class, + ++$i, + $i, + $i, + $i, + $i, + $addresseeParam, + $i, + )) + ); + $qb->setParameter(sprintf('addressee_%d', $addresseeParam), $addressee); + } + $qb->andWhere($orx); + } + + if (array_key_exists('byAddresseeGroup', $params)) { + $addresseeGroupOr = $qb->expr()->orX(); + foreach ($params['byAddresseeGroup'] as $addresseeGroup) { + $addresseeGroupOr->add( + $qb->expr()->exists(sprintf( + 'SELECT 1 FROM %s tp_addressee_%d JOIN tp_addressee_%d.addresseeGroup group_%d WHERE tp_addressee_%d.ticket = t + AND tp_addressee_%d.endDate IS NULL AND tp_addressee_%d.addresseeGroup = :addressee_%d', + AddresseeHistory::class, + ++$i, + $i, + $i, + $i, + $i, + $i, + $i, + )) + ); + $qb->setParameter(sprintf('addressee_%d', $i), $addresseeGroup); + } + $qb->andWhere($addresseeGroupOr); + } + + if (array_key_exists('byPersonCenter', $params)) { + $orx = $qb->expr()->orX(); + foreach ($params['byPersonCenter'] as $userCenter) { + $orx->add( + $qb->expr()->exists(sprintf( + 'SELECT 1 FROM %s ticket_person_%d + JOIN %s person_center_%d WITH person_center_%d.person = ticket_person_%d.person AND TRUE = OVERLAPSI(ticket_person_%d.startDate, ticket_person_%d.startDate),(person_center_%d.startDate, person_center_%d.endDate) + WHERE ticket_person_%d.ticket = t AND ticket_person_%d.endDate IS NULL AND person_center_%d.center = :center_%d', + PersonHistory::class, + ++$i, + PersonCenterHistory::class, + $i, + $i, + $i, + $i, + $i, + $i, + $i, + $i, + $i, + $i, + $i, + )) + ); + $qb->setParameter(sprintf('center_%d', $i), $userCenter); + } + $qb->andWhere($orx); + } + + return $qb; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Repository/TicketACLAwareRepositoryInterface.php b/src/Bundle/ChillTicketBundle/src/Repository/TicketACLAwareRepositoryInterface.php new file mode 100644 index 000000000..1887e7ffd --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Repository/TicketACLAwareRepositoryInterface.php @@ -0,0 +1,45 @@ +<?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\TicketBundle\Repository; + +use Chill\MainBundle\Entity\Center; +use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Entity\UserGroup; +use Chill\PersonBundle\Entity\Person; +use Chill\TicketBundle\Entity\EmergencyStatusEnum; +use Chill\TicketBundle\Entity\Motive; +use Chill\TicketBundle\Entity\StateEnum; +use Chill\TicketBundle\Entity\Ticket; + +/** + * Repository to find tickets, taking care of permissions. + * + * @phpstan-type TicketACLAwareRepositoryParam array{byPerson?: list<Person>, byCurrentState?: list<StateEnum>, byCurrentStateEmergency?: list<EmergencyStatusEnum>, byMotives?: list<Motive>, byCreatedBefore?: \DateTimeImmutable, byCreatedAfter?: \DateTimeImmutable, byAddressee?: list<User>, byAddresseeGroup?: list<UserGroup>, byCreator?: list<User>, byTicketId?: int, byPersonCenter?: list<Center>} + */ +interface TicketACLAwareRepositoryInterface +{ + /** + * Find tickets. + * + * @param TicketACLAwareRepositoryParam $params + * + * @return list<Ticket> + */ + public function findTickets(array $params, int $start = 0, int $limit = 100): array; + + /** + * Find tickets. + * + * @param TicketACLAwareRepositoryParam $params + */ + public function countTickets(array $params): int; +} diff --git a/src/Bundle/ChillTicketBundle/src/Repository/TicketRepository.php b/src/Bundle/ChillTicketBundle/src/Repository/TicketRepository.php new file mode 100644 index 000000000..d519a77cb --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Repository/TicketRepository.php @@ -0,0 +1,80 @@ +<?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\TicketBundle\Repository; + +use Chill\PersonBundle\Entity\Person; +use Chill\TicketBundle\Entity\Ticket; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\Persistence\ObjectRepository; + +final readonly class TicketRepository implements TicketRepositoryInterface +{ + private ObjectRepository $repository; + + public function __construct(private EntityManagerInterface $objectManager) + { + $this->repository = $objectManager->getRepository($this->getClassName()); + } + + public function find($id): ?Ticket + { + return $this->repository->find($id); + } + + public function findAll(): array + { + return $this->repository->findAll(); + } + + /** + * @return list<Ticket> + */ + public function findAllOrdered(): array + { + return $this->objectManager->createQuery('SELECT t FROM '.$this->getClassName().' t ORDER BY t.id DESC')->getResult(); + } + + public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array + { + return $this->repository->findBy($criteria, $orderBy, $limit, $offset); + } + + public function findOneBy(array $criteria): ?Ticket + { + return $this->repository->findOneBy($criteria); + } + + public function getClassName() + { + return Ticket::class; + } + + public function findOneByExternalRef(string $extId): ?Ticket + { + return $this->repository->findOneBy(['externalRef' => $extId]); + } + + /** + * Count tickets associated with a person where endDate is null. + */ + public function countOpenedByPerson(Person $person): int + { + return (int) $this->objectManager->createQuery( + 'SELECT COUNT(DISTINCT t.id) FROM '.$this->getClassName().' t + JOIN t.personHistories ph + WHERE ph.person = :person + AND ph.endDate IS NULL' + ) + ->setParameter('person', $person) + ->getSingleScalarResult(); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Repository/TicketRepositoryInterface.php b/src/Bundle/ChillTicketBundle/src/Repository/TicketRepositoryInterface.php new file mode 100644 index 000000000..c1e4cb188 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Repository/TicketRepositoryInterface.php @@ -0,0 +1,31 @@ +<?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\TicketBundle\Repository; + +use Chill\TicketBundle\Entity\Ticket; +use Chill\PersonBundle\Entity\Person; +use Doctrine\Persistence\ObjectRepository; + +/** + * @extends ObjectRepository<Ticket> + */ +interface TicketRepositoryInterface extends ObjectRepository +{ + public function findOneByExternalRef(string $extId): ?Ticket; + + /** + * @return list<Ticket> + */ + public function findAllOrdered(): array; + + public function countOpenedByPerson(Person $person): int; +} diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/page/ticket/banner.scss b/src/Bundle/ChillTicketBundle/src/Resources/public/page/ticket/banner.scss new file mode 100644 index 000000000..76893b424 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/page/ticket/banner.scss @@ -0,0 +1,24 @@ +@import '~ChillMainAssets/module/bootstrap/shared'; + +div.banner { + div#header-ticket-main { + background: none repeat scroll 0 0 #ae986fFF; + color: $white; + padding-top: 1em; + padding-bottom: 1em; + } + div#header-ticket-details { + background: none repeat scroll 0 0 #d3c7b1FF; + color: $white; + padding-top: 1em; + padding-bottom: 1em; + div.contact { + display: flex; + align-content: center; + & > * { + margin-right: 1em; + } + } + } +} + diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/page/ticket/index.ts b/src/Bundle/ChillTicketBundle/src/Resources/public/page/ticket/index.ts new file mode 100644 index 000000000..704996bc9 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/page/ticket/index.ts @@ -0,0 +1 @@ +import "./banner.scss"; diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/types.ts b/src/Bundle/ChillTicketBundle/src/Resources/public/types.ts new file mode 100644 index 000000000..4bf36974f --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/types.ts @@ -0,0 +1,215 @@ +import { + DateTime, + TranslatableString, + User, + UserGroupOrUser, +} from "ChillMainAssets/types"; +import { Person } from "ChillPersonAssets/types"; +import { Thirdparty } from "../../../../ChillThirdPartyBundle/Resources/public/types"; +import { StoredObject } from "ChillDocStoreAssets/types"; + +interface MotiveBase { + type: "ticket_motive"; + id: number; + active: boolean; + label: TranslatableString; + makeTicketEmergency: TicketEmergencyState; +} + +/** + * Represent a motive with basic information and parent motive. + * + * Match the "read" and "read:children-to-parent" serializer groups. + */ +export interface MotiveWithParent extends MotiveBase { + parent: MotiveWithParent | null; +} + +/** + * Represents a motive for a ticket, including details like emergency status, stored objects, and supplementary comments. + * + * Match the "read:extended" serializer group in MotiveNormalizer. + */ +export interface Motive extends MotiveBase { + makeTicketEmergency: TicketEmergencyState; + storedObjects: StoredObject[]; + supplementaryComments: { label: string }[]; + children: Motive[]; +} + +export type TicketState = "open" | "closed" | "close"; + +export type TicketEmergencyState = "yes" | "no"; + +interface TicketHistory<T extends string, D extends object> { + event_type: T; + at: DateTime; + by: User; + data: D; +} + +export interface PersonHistory { + type: "ticket_person_history"; + id: number; + startDate: DateTime; + endDate: null | DateTime; + person: Person; + removedBy: null; + createdBy: User | null; + createdAt: DateTime | null; +} + +export interface MotiveHistory { + type: "ticket_motive_history"; + id: number; + startDate: null; + endDate: null | DateTime; + motive: MotiveWithParent; + createdBy: User | null; + createdAt: DateTime | null; +} + +export interface Comment { + type: "ticket_comment"; + id: number; + content: string; + createdBy: User | null; + createdAt: DateTime | null; + updatedBy: User | null; + updatedAt: DateTime | null; + deleted: boolean; + supplementaryComments: { label: string }[]; +} + +export interface AddresseeHistory { + type: "ticket_addressee_history"; + id: number; + startDate: DateTime | null; + addressee: UserGroupOrUser; + endDate: DateTime | null; + removedBy: User | null; + createdBy: User | null; + createdAt: DateTime | null; + updatedBy: User | null; + updatedAt: DateTime | null; +} + +export interface AddresseeState { + addressees: UserGroupOrUser[]; +} + +export interface PersonsState { + persons: Person[]; +} +export interface CallerState { + new_caller: Person | Thirdparty | null; +} + +export interface StateChange { + new_state: TicketState; +} + +export interface EmergencyChange { + new_emergency: TicketEmergencyState; +} + +export interface CreateTicketState { + by: User; +} + +export type AddCommentEvent = TicketHistory<"add_comment", Comment>; +export type SetMotiveEvent = TicketHistory<"set_motive", MotiveHistory>; +export type AddPersonEvent = TicketHistory<"add_person", PersonHistory>; +export type AddresseesStateEvent = TicketHistory< + "addressees_state", + AddresseeState +>; +export type CreateTicketEvent = TicketHistory< + "create_ticket", + CreateTicketState +>; +export type PersonStateEvent = TicketHistory<"persons_state", PersonsState>; +export type ChangeStateEvent = TicketHistory<"state_change", StateChange>; +export type EmergencyStateEvent = TicketHistory< + "emergency_change", + EmergencyChange +>; +export type CallerStateEvent = TicketHistory<"set_caller", CallerState>; + +export type TicketHistoryLine = + | AddPersonEvent + | CreateTicketEvent + | AddCommentEvent + | SetMotiveEvent + | AddresseesStateEvent + | PersonStateEvent + | ChangeStateEvent + | EmergencyStateEvent + | CallerStateEvent; + +interface BaseTicket< + T extends "ticket_ticket:simple" | "ticket_ticket:extended" = + "ticket_ticket:simple", +> { + type_extended: T; + type: "ticket_ticket"; + id: number; + externalRef: string; + createdAt: DateTime | null; + currentAddressees: UserGroupOrUser[]; + currentPersons: Person[]; + currentMotive: MotiveWithParent | null; + currentState: TicketState | null; + emergency: TicketEmergencyState | null; + caller: Person | Thirdparty | null; +} + +export interface TicketSimple extends BaseTicket<"ticket_ticket:simple"> { + type_extended: "ticket_ticket:simple"; +} + +export interface Ticket extends BaseTicket<"ticket_ticket:extended"> { + type_extended: "ticket_ticket:extended"; + createdBy: User | null; + updatedAt: DateTime | null; + updatedBy: User | null; + history: TicketHistoryLine[]; +} + +export interface TicketFilters { + byPerson: Person[]; + byCreator: User[]; + byAddressee: UserGroupOrUser[]; + byCurrentState: TicketState[]; + byCurrentStateEmergency: TicketEmergencyState[]; + byMotives: Motive[]; + byCreatedAfter: string; + byCreatedBefore: string; + byResponseTimeExceeded: boolean; + byAddresseeToMe: boolean; + byTicketId: number | null; +} + +export interface TicketFilterParams { + byPerson?: number[]; + byCreator?: number[]; + byAddressee?: number[]; + byAddresseeGroup?: number[]; + byCurrentState?: TicketState[]; + byCurrentStateEmergency?: TicketEmergencyState[]; + byMotives?: number[]; + byCreatedAfter?: string; + byCreatedBefore?: string; + byResponseTimeExceeded?: string; + byAddresseeToMe?: boolean; + byTicketId?: number | null; +} + +export interface TicketInitForm { + content: string; + motive?: MotiveWithParent | null; + addressees: UserGroupOrUser[]; + persons: Person[]; + caller: Person | null; + emergency: TicketEmergencyState; +} diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/App.vue b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/App.vue new file mode 100644 index 000000000..c9f2352b1 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/App.vue @@ -0,0 +1,204 @@ +<template> + <banner-component :ticket="ticket" /> + <div class="container-xxl pt-1" style="padding-bottom: 55px" v-if="!loading"> + <div class="row"> + <div class="col"> + <label class="form-label pe-2" for="showAllHistory"> + {{ trans(CHILL_TICKET_LIST_SHOW_ALL_HISTORY) }} + </label> + <toggle-component v-model="showAllHistory" id="showAllHistory" /> + </div> + <div class="col d-flex justify-content-end"> + <previous-tickets-component :key="refreshKey" /> + </div> + </div> + <ticket-history-list-component + :history="ticketHistory" + :key="ticketHistory.length" + /> + </div> + + <div v-else class="text-center p-4"> + <div class="spinner-border" role="status"> + <span class="visually-hidden"> + {{ trans(CHILL_TICKET_LIST_LOADING_TICKET_DETAILS) }} + </span> + </div> + <div>{{ trans(CHILL_TICKET_LIST_LOADING_TICKET_DETAILS) }}</div> + </div> + <action-toolbar-component :key="refreshKey" v-if="!loading" /> + <Modal + v-if="showTicketInitFormModal" + :show="showTicketInitFormModal" + :modal-dialog-class="modalDialogClass" + @close="closeModal" + > + <template #header> + <h3 class="modal-title"> + {{ trans(CHILL_TICKET_TICKET_INIT_FORM_TITLE) }} + </h3> + </template> + + <template #body> + <ticket-init-form-component + v-model="ticketForm" + :ticket="ticket" + :motives="motives" + :suggested-persons="suggestedPersons" + @submit="handleFormSubmit" + /> + </template> + <template #footer> + <button class="btn btn-primary" @click="handleFormSubmit"> + {{ trans(CHILL_TICKET_TICKET_INIT_FORM_SUBMIT) }} + </button> + </template> + </Modal> +</template> + +<script setup lang="ts"> +import { useToast } from "vue-toast-notification"; +import { computed, onMounted, onUnmounted, ref } from "vue"; +import { useStore } from "vuex"; + +// Types +import { Ticket, Motive, TicketInitForm } from "../../types"; +import { Person } from "ChillPersonAssets/types"; + +// Components +import Modal from "../../../../../../ChillMainBundle/Resources/public/vuejs/_components/Modal.vue"; +import PreviousTicketsComponent from "./components/PreviousTicketsComponent.vue"; +import TicketHistoryListComponent from "../TicketList/components/TicketHistoryListComponent.vue"; +import ActionToolbarComponent from "./components/ActionToolbarComponent.vue"; +import BannerComponent from "./components/BannerComponent.vue"; +import TicketInitFormComponent from "./components/TicketInitFormComponent.vue"; +import ToggleComponent from "../TicketList/components/ToggleComponent.vue"; + +// Translations +import { + trans, + CHILL_TICKET_TICKET_INIT_FORM_SUBMIT, + CHILL_TICKET_TICKET_INIT_FORM_TITLE, + CHILL_TICKET_TICKET_INIT_FORM_SUCCESS, + CHILL_TICKET_TICKET_INIT_FORM_ERROR, + CHILL_TICKET_TICKET_INIT_FORM_WARNING, + CHILL_TICKET_LIST_LOADING_TICKET_DETAILS, + CHILL_TICKET_LIST_SHOW_ALL_HISTORY, +} from "translator"; + +const store = useStore(); +const toast = useToast(); + +store.commit("setTicket", JSON.parse(window.initialTicket) as Ticket); +const showAllHistory = ref(false); +const ticket = computed(() => store.getters.getTicket as Ticket); +const ticketHistory = computed(() => + showAllHistory.value + ? store.getters.getTicketHistory + : store.getters.getTicketHistoryComments, +); +const motives = computed(() => store.getters.getMotives as Motive[]); +const suggestedPersons = computed(() => store.getters.getPersons as Person[]); +const showTicketInitFormModal = ref(false); +const loading = ref(true); +const refreshKey = ref(0); + +// ticketForm réactif pour v-model +const ticketForm = ref({ + content: "", + addressees: ticket.value?.currentAddressees, + motive: ticket.value?.currentMotive, + persons: ticket.value?.currentPersons, + caller: ticket.value?.caller, + emergency: ticket.value?.emergency, +} as TicketInitForm); + +const modalDialogClass = ref( + window.innerWidth < 990 ? "modal-fullscreen" : "modal-lg", +); + +function updateModalClass() { + modalDialogClass.value = + window.innerWidth < 990 ? "modal-fullscreen" : "modal-lg"; +} +window.addEventListener("resize", updateModalClass); + +async function handleFormSubmit() { + try { + if (!ticketForm.value.motive || ticketForm.value.content.trim() === "") { + toast.warning(trans(CHILL_TICKET_TICKET_INIT_FORM_WARNING)); + return; + } + if (ticketForm.value.motive) { + await store.dispatch("createMotive", ticketForm.value.motive); + } + if (ticketForm.value.content.trim() !== "") { + await store.dispatch("createComment", ticketForm.value.content); + } + + // Ne pas mettre à jour emergency si le motif force l'état d'urgence + if ( + ticketForm.value.motive && + !ticketForm.value.motive.makeTicketEmergency + ) { + await store.dispatch("setEmergency", ticketForm.value.emergency); + } + + await store.dispatch("setAddressees", ticketForm.value.addressees); + await store.dispatch("setPersons", ticketForm.value.persons); + + // Forcer le rafraîchissement des composants + refreshKey.value++; + + toast.success(trans(CHILL_TICKET_TICKET_INIT_FORM_SUCCESS)); + closeModal(); + } catch (error) { + toast.error( + (error as string) || trans(CHILL_TICKET_TICKET_INIT_FORM_ERROR), + ); + } +} + +function closeModal() { + showTicketInitFormModal.value = false; +} + +onMounted(() => { + try { + store.dispatch("getCurrentUser"); + store.dispatch("fetchMotives"); + store.dispatch("fetchUserGroups"); + store.dispatch("fetchUsers"); + store.dispatch("getSuggestedPersons"); + showTicketInitFormModal.value = store.getters.isIncompleteTicket; + } catch (error) { + toast.error(error as string); + } finally { + loading.value = false; + } +}); + +onUnmounted(() => { + window.removeEventListener("resize", updateModalClass); +}); +</script> + +<style lang="scss" scoped> +.loading-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(255, 255, 255, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + font-size: 2rem; + color: #333; +} +.form-label { + font-weight: bold; +} +</style> diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/ActionToolbarComponent.vue b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/ActionToolbarComponent.vue new file mode 100644 index 000000000..919487f77 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/ActionToolbarComponent.vue @@ -0,0 +1,371 @@ +<template> + <teleport to="#actionToolbar"> + <div class="footer-ticket-details" v-if="activeTab"> + <div class="tab-content p-2"> + <button + type="button" + class="btn btn-link p-0" + style=" + position: absolute; + right: 0.5rem; + font-size: 2rem; + line-height: 1; + color: #888; + text-decoration: none; + " + @click="closeAllActions" + aria-label="Fermer" + title="Fermer" + > + <span aria-hidden="true">×</span> + </button> + <div v-if="activeTabTitle"> + <label class="col-form-label"> + {{ activeTabTitle }} + </label> + </div> + + <form class="p-2" @submit.prevent="submitAction"> + <comment-editor-component + v-model="content" + :motive="motive" + v-if="activeTab === 'add_comment'" + /> + <addressee-selector-component + v-model="addressees" + :suggested="userGroups" + v-if="activeTab === 'addressees_state'" + /> + + <motive-selector-component + v-model="motive" + :motives="motives" + open-direction="top" + v-if="activeTab === 'set_motive'" + /> + <div v-if="activeTab === 'persons_state'"> + <div class="row"> + <label class="col col-form-label"> + {{ trans(CHILL_TICKET_TICKET_SET_PERSONS_TITLE_CALLER) }} + </label> + <label class="col col-form-label"> + {{ trans(CHILL_TICKET_TICKET_SET_PERSONS_TITLE_PERSON) }} + </label> + </div> + <div class="row"> + <div class="col"> + <persons-selector-component + v-model="caller" + :suggested="[]" + :multiple="false" + :types="['person', 'thirdparty']" + :label="trans(CHILL_TICKET_TICKET_SET_PERSONS_CALLER_LABEL)" + /> + </div> + <div class="col"> + <persons-selector-component + v-model="persons" + :suggested="suggestedPersons" + :multiple="true" + :types="['person']" + :label="trans(CHILL_TICKET_TICKET_SET_PERSONS_USER_LABEL)" + /> + </div> + </div> + </div> + + <ul class="record_actions sticky-form-buttons"> + <li> + <button class="btn btn-save" type="submit"> + {{ trans(CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_SAVE) }} + </button> + </li> + </ul> + </form> + </div> + </div> + <div class="footer-ticket-main"> + <div class="d-flex w-100"> + <ul class="nav nav-tabs flex-grow-1 justify-content-start"> + <li v-if="hasReturnPath" class="nav-item p-2 go-back"> + <a :href="returnPath" class="btn btn-cancel"> + <span class="hide-on-sm"> + {{ trans(CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_CANCEL) }} + </span> + </a> + </li> + </ul> + <ul class="nav nav-tabs flex-grow-1 justify-content-center"> + <template v-for="btn in actionButtons" :key="btn.key"> + <li class="nav-item p-2"> + <button + type="button" + :class="`btn ${activeTab === btn.key ? 'btn-primary' : 'btn-light'}`" + @click=" + activeTab === btn.key + ? (activeTab = '') + : (activeTab = btn.key) + " + :disabled="btn.disabled.value" + > + <i :class="actionIcons[btn.key]" /> + <span class="hide-on-sm ms-2">{{ trans(btn.label) }}</span> + </button> + </li> + </template> + </ul> + <ul class="nav nav-tabs flex-grow-1 justify-content-end"> + <li class="nav-item p-2"> + <button + type="button" + class="btn bg-chill-green text-white" + @click="isOpen ? closeTicket() : reopenTicket()" + > + <i :class="isOpen ? 'fa fa-lock' : 'fa fa-unlock'"></i> + <span class="hide-on-sm ms-2"> + {{ + isOpen + ? trans(CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_CLOSE) + : trans(CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_REOPEN) + }} + </span> + </button> + </li> + </ul> + </div> + </div> + </teleport> +</template> + +<script setup lang="ts"> +import { computed, ref, watch } from "vue"; +import { useStore } from "vuex"; +import { useToast } from "vue-toast-notification"; + +// Component +import MotiveSelectorComponent from "./Motive/MotiveSelectorComponent.vue"; +import AddresseeSelectorComponent from "./Addressee/AddresseeSelectorComponent.vue"; +import CommentEditorComponent from "./Comment/CommentEditorComponent.vue"; +import PersonsSelectorComponent from "./Person/PersonsSelectorComponent.vue"; + +// Translations +import { + trans, + CHILL_TICKET_TICKET_ADD_ADDRESSEE_TITLE, + CHILL_TICKET_TICKET_ADD_ADDRESSEE_ERROR, + CHILL_TICKET_TICKET_ADD_ADDRESSEE_SUCCESS, + CHILL_TICKET_TICKET_ADD_COMMENT_TITLE, + CHILL_TICKET_TICKET_ADD_COMMENT_ERROR, + CHILL_TICKET_TICKET_ADD_COMMENT_SUCCESS, + CHILL_TICKET_TICKET_SET_MOTIVE_TITLE, + CHILL_TICKET_TICKET_SET_MOTIVE_ERROR, + CHILL_TICKET_TICKET_SET_MOTIVE_SUCCESS, + CHILL_TICKET_TICKET_SET_PERSONS_TITLE_PERSON, + CHILL_TICKET_TICKET_SET_PERSONS_TITLE_CALLER, + CHILL_TICKET_TICKET_SET_PERSONS_TITLE, + CHILL_TICKET_TICKET_SET_PERSONS_SUCCESS, + CHILL_TICKET_TICKET_SET_PERSONS_USER_LABEL, + CHILL_TICKET_TICKET_SET_PERSONS_CALLER_LABEL, + CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_SAVE, + CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_CLOSE, + CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_CLOSE_SUCCESS, + CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_CLOSE_ERROR, + CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_REOPEN, + CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_REOPEN_SUCCESS, + CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_REOPEN_ERROR, + CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_CANCEL, + CHILL_TICKET_TICKET_SET_PERSONS_ERROR, +} from "translator"; + +// Types +import { + UserGroup, + UserGroupOrUser, +} from "../../../../../../../ChillMainBundle/Resources/public/types"; +import { Comment, Motive, MotiveWithParent, Ticket } from "../../../types"; +import { Person } from "ChillPersonAssets/types"; + +const store = useStore(); +const toast = useToast(); + +const activeTab = ref("" as string); +const actionIcons = ref(store.getters.getActionIcons); + +const activeTabTitle = computed((): string => { + switch (activeTab.value) { + case "add_comment": + return trans(CHILL_TICKET_TICKET_ADD_COMMENT_TITLE); + case "set_motive": + return trans(CHILL_TICKET_TICKET_SET_MOTIVE_TITLE); + case "addressees_state": + return trans(CHILL_TICKET_TICKET_ADD_ADDRESSEE_TITLE); + default: + return ""; + } +}); + +const actionButtons = [ + { + key: "set_motive", + label: CHILL_TICKET_TICKET_SET_MOTIVE_TITLE, + icon: computed(() => actionIcons.value["set_motive"]), + disabled: computed(() => !isOpen.value), + }, + { + key: "add_comment", + label: CHILL_TICKET_TICKET_ADD_COMMENT_TITLE, + icon: computed(() => actionIcons.value["add_comment"]), + disabled: computed(() => !isOpen.value), + }, + { + key: "addressees_state", + label: CHILL_TICKET_TICKET_ADD_ADDRESSEE_TITLE, + icon: computed(() => actionIcons.value["addressees_state"]), + disabled: computed(() => !isOpen.value), + }, + { + key: "persons_state", + label: CHILL_TICKET_TICKET_SET_PERSONS_TITLE, + icon: computed(() => actionIcons.value["persons_state"]), + disabled: computed(() => !isOpen.value), + }, +]; + +const ticket = computed(() => store.getters.getTicket as Ticket); +const isOpen = computed(() => store.getters.isOpen); +const motives = computed(() => store.getters.getMotives as Motive[]); +const userGroups = computed(() => store.getters.getUserGroups as UserGroup[]); +const suggestedPersons = computed(() => store.getters.getPersons as Person[]); + +const hasReturnPath = computed((): boolean => { + const params = new URL(document.location.toString()).searchParams; + return params.has("returnPath"); +}); + +const returnPath = computed((): string => { + const params = new URL(document.location.toString()).searchParams; + const returnPath = params.get("returnPath"); + + if (null === returnPath) { + throw new Error( + "there isn't any returnPath, please check the existence before", + ); + } + + return returnPath; +}); + +const motive = ref(ticket.value.currentMotive as MotiveWithParent | null); +const content = ref("" as Comment["content"]); +const addressees = ref(ticket.value.currentAddressees as UserGroupOrUser[]); +const persons = ref(ticket.value.currentPersons as Person[]); +const caller = ref(ticket.value.caller as Person); + +async function submitAction() { + switch (activeTab.value) { + case "add_comment": + if (!content.value) { + toast.error(trans(CHILL_TICKET_TICKET_ADD_COMMENT_ERROR)); + } else { + await store.dispatch("createComment", content.value); + content.value = ""; + activeTab.value = ""; + toast.success(trans(CHILL_TICKET_TICKET_ADD_COMMENT_SUCCESS)); + } + break; + case "set_motive": + if (!motive.value) { + toast.error(trans(CHILL_TICKET_TICKET_SET_MOTIVE_ERROR)); + } else { + await store.dispatch("createMotive", motive.value); + activeTab.value = ""; + toast.success(trans(CHILL_TICKET_TICKET_SET_MOTIVE_SUCCESS)); + } + break; + case "addressees_state": + if (addressees.value.length) { + try { + await store.dispatch( + "setAddressees", + addressees.value as UserGroupOrUser[], + ); + activeTab.value = ""; + toast.success(trans(CHILL_TICKET_TICKET_ADD_ADDRESSEE_SUCCESS)); + } catch (error) { + console.error(trans(CHILL_TICKET_TICKET_ADD_ADDRESSEE_ERROR), error); + toast.error(trans(CHILL_TICKET_TICKET_ADD_ADDRESSEE_ERROR)); + } + } else { + await store.dispatch("setAddressees", [] as UserGroupOrUser[]); + toast.success(trans(CHILL_TICKET_TICKET_ADD_ADDRESSEE_SUCCESS)); + } + break; + case "persons_state": + if (persons.value.length) { + await store.dispatch("setPersons", persons.value); + try { + await store.dispatch("fetchTicketList", { + byPerson: persons.value.map((person) => person.id), + }); + activeTab.value = ""; + toast.success(trans(CHILL_TICKET_TICKET_SET_PERSONS_SUCCESS)); + } catch (error) { + console.error(trans(CHILL_TICKET_TICKET_SET_PERSONS_ERROR), error); + toast.error(trans(CHILL_TICKET_TICKET_SET_PERSONS_ERROR)); + } + } else { + store.dispatch("setPersons", [] as Person[]); + toast.success(trans(CHILL_TICKET_TICKET_SET_PERSONS_SUCCESS)); + } + break; + } +} + +async function closeTicket() { + try { + await store.dispatch("setTicketState", "close"); + closeAllActions(); + toast.success(trans(CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_CLOSE_SUCCESS)); + } catch (error) { + console.error(error); + toast.success(trans(CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_CLOSE_ERROR)); + } +} + +async function reopenTicket() { + try { + await store.dispatch("setTicketState", "open"); + toast.success(trans(CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_REOPEN_SUCCESS)); + } catch (error) { + console.error(error); + toast.error(trans(CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_REOPEN_ERROR)); + } +} + +function closeAllActions() { + activeTab.value = ""; +} + +watch(caller, async (newCaller) => { + await store.dispatch("setCaller", newCaller); + await store.dispatch("getSuggestedPersons"); +}); +</script> + +<style lang="scss" scoped> +.go-back { + margin-right: auto; +} + +.sticky-form-buttons { + margin-top: 0px; + background: none; +} +div.footer-ticket-main { + background: none repeat scroll 0 0 #cabb9f; +} + +div.footer-ticket-details { + background: none repeat scroll 0 0 #efe2ca; +} +</style> diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Addressee/AddresseeComponent.vue b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Addressee/AddresseeComponent.vue new file mode 100644 index 000000000..edd9503c9 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Addressee/AddresseeComponent.vue @@ -0,0 +1,42 @@ +<template> + <div class="col-12"> + <ul class="addressees-list" v-if="addressees.length > 0"> + <li v-for="addressee in addressees" :key="addressee.id"> + <span + v-if="addressee.type === 'user_group'" + class="badge-user-group" + :style="`background-color: ${addressee.backgroundColor}; color: ${addressee.foregroundColor};`" + > + {{ localizeString(addressee.label) }} + </span> + <span v-else-if="addressee.type === 'user'" class="badge-user"> + <user-render-box-badge :user="addressee" + /></span> + </li> + </ul> + </div> +</template> + +<script setup lang="ts"> +// Components +import UserRenderBoxBadge from "ChillMainAssets/vuejs/_components/Entity/UserRenderBoxBadge.vue"; + +// Types +import { UserGroupOrUser } from "../../../../../../../../ChillMainBundle/Resources/public/types"; + +// Utils +import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper"; + +defineProps<{ addressees: UserGroupOrUser[] }>(); +</script> + +<style lang="scss" scoped> +ul.addressees-list { + list-style-type: none; + margin: 0; + padding: 0; + & > li { + display: inline-block; + } +} +</style> diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Addressee/AddresseeSelectorComponent.vue b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Addressee/AddresseeSelectorComponent.vue new file mode 100644 index 000000000..db7b3aa67 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Addressee/AddresseeSelectorComponent.vue @@ -0,0 +1,100 @@ +<template> + <div + :class="{ + 'opacity-50': disabled, + }" + :style="disabled ? 'pointer-events: none;' : ''" + > + <pick-entity + uniqid="ticket-addressee-selector" + :types="['user', 'user_group']" + :picked="selectedEntities" + :suggested="suggestedValues" + :multiple="true" + :removable-if-set="true" + :display-picked="true" + :label="label" + @add-new-entity="addNewEntity" + @remove-entity="removeEntity" + /> + </div> +</template> + +<script lang="ts" setup> +import { ref, watch, defineProps, defineEmits } from "vue"; + +// Components +import PickEntity from "ChillMainAssets/vuejs/PickEntity/PickEntity.vue"; + +// Types +import { Entities, EntitiesOrMe } from "ChillPersonAssets/types"; + +// Translations +import { + CHILL_TICKET_TICKET_ADD_ADDRESSEE_USER_LABEL, + trans, +} from "translator"; + +const props = withDefaults( + defineProps<{ + modelValue: Entities[]; + suggested: Entities[]; + label?: string; + disabled?: boolean; + }>(), + { + label: trans(CHILL_TICKET_TICKET_ADD_ADDRESSEE_USER_LABEL), + disabled: false, + }, +); + +const emit = defineEmits<(e: "update:modelValue", value: Entities[]) => void>(); + +const selectedEntities = ref<Entities[]>([...props.modelValue]); +const suggestedValues = ref<Entities[]>([...props.suggested]); +watch( + () => [props.suggested, props.modelValue], + () => { + // Mise à jour des entités sélectionnées + selectedEntities.value = [...(props.modelValue as Entities[])]; + + // Filtrage des suggestions + suggestedValues.value = props.suggested.filter((suggested: Entities) => { + return !props.modelValue.some((selected: Entities) => { + if (suggested.type == "user_group" && selected.type == "user_group") { + switch (selected.excludeKey) { + case "level": + return suggested.excludeKey === "level"; + case "": + return ( + suggested.excludeKey === "" && suggested.id === selected.id + ); + default: + return false; + } + } else { + return ( + suggested.type === selected.type && suggested.id === selected.id + ); + } + }); + }); + }, + { immediate: true, deep: true }, +); + +function addNewEntity({ entity }: { entity: EntitiesOrMe }) { + selectedEntities.value.push(entity as Entities); + emit("update:modelValue", selectedEntities.value); +} + +function removeEntity({ entity }: { entity: EntitiesOrMe }) { + const index = selectedEntities.value.findIndex( + (selectedEntity: Entities) => selectedEntity === entity, + ); + if (index !== -1) { + selectedEntities.value.splice(index, 1); + } + emit("update:modelValue", selectedEntities.value); +} +</script> diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/BannerComponent.vue b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/BannerComponent.vue new file mode 100644 index 000000000..28a72d4b4 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/BannerComponent.vue @@ -0,0 +1,218 @@ +<template> + <Teleport to="#header-ticket-main"> + <div class="container-xxl text-primary"> + <div class="row"> + <div class="col-md-12 col-sm-12 ps-md-5 ps-xxl-0"> + <div class="small text-muted"> + {{ motiveHierarchyLabel(ticket.currentMotive) }} + </div> + <h1 class="responsive-title-h1"> + {{ getTicketTitle(ticket) }} + <peloton-component + :stored-objects=" + currentMotive ? currentMotive.storedObjects : null + " + /> + </h1> + + <h2 v-if="ticket.caller" class="responsive-title-h2"> + {{ ticket.caller.text }} + </h2> + </div> + + <div class="col-md-12 col-sm-12"> + <div class="d-flex justify-content-end"> + <emergency-toggle-component + v-model="isEmergency" + :disabled="!isOpen" + /> + <state-toggle-component v-model="isOpen" /> + </div> + <div class="d-flex justify-content-end"> + <p class="created-at-timespan" v-if="since"> + {{ + trans(CHILL_TICKET_TICKET_BANNER_SINCE, { + time: since, + }) + }} + </p> + </div> + </div> + </div> + </div> + </Teleport> + <Teleport to="#header-ticket-details"> + <div class="container-xxl"> + <div class="row justify-content-between"> + <div + class="col-4 d-flex flex-column align-items-start" + v-if="ticket.caller" + > + <h3 class="text-primary responsive-title-h3"> + {{ + trans(CHILL_TICKET_TICKET_BANNER_CALLER, { + count: ticket.caller ? 1 : 0, + }) + }} + </h3> + <person-component :entities="caller" /> + </div> + <div + class="col-4 d-flex flex-column align-items-start" + v-if="ticket.currentPersons.length" + > + <h3 class="text-primary responsive-title-h3"> + {{ + trans(CHILL_TICKET_TICKET_BANNER_PERSON, { + count: ticket.currentPersons.length, + }) + }} + </h3> + <person-component :entities="ticket.currentPersons" /> + </div> + <div + class="col-4 d-flex flex-column align-items-start" + v-if="ticket.currentAddressees.length" + > + <h3 class="text-primary responsive-title-h3"> + {{ + trans(CHILL_TICKET_TICKET_BANNER_SPEAKER, { + count: ticket.currentAddressees.length, + }) + }} + </h3> + <addressee-component :addressees="ticket.currentAddressees" /> + </div> + </div> + </div> + </Teleport> +</template> + +<style lang="scss"> +.created-at-timespan { + font-weight: 600; + font-variant: all-petite-caps; +} +</style> + +<script setup lang="ts"> +import { computed, ComputedRef, ref } from "vue"; +import { useToast } from "vue-toast-notification"; + +// Components +import PelotonComponent from "./PelotonComponent.vue"; +import AddresseeComponent from "./Addressee/AddresseeComponent.vue"; +import EmergencyToggleComponent from "./Emergency/EmergencyToggleComponent.vue"; +import StateToggleComponent from "./State/StateToggleComponent.vue"; +import PersonComponent from "./Person/PersonComponent.vue"; + +// Types +import { Motive, Ticket } from "../../../types"; +import { Person } from "ChillPersonAssets/types"; + +import { + trans, + CHILL_TICKET_TICKET_BANNER_SINCE, + CHILL_TICKET_TICKET_BANNER_SPEAKER, + CHILL_TICKET_TICKET_BANNER_CALLER, + CHILL_TICKET_TICKET_BANNER_PERSON, + CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_REOPEN_SUCCESS, + CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_REOPEN_ERROR, + CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_CLOSE_SUCCESS, + CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_CLOSE_ERROR, + CHILL_TICKET_TICKET_BANNER_EMERGENCY_SUCCESS, + CHILL_TICKET_TICKET_BANNER_EMERGENCY_ERROR, + CHILL_TICKET_TICKET_BANNER_NO_EMERGENCY_ERROR, + CHILL_TICKET_TICKET_BANNER_NO_EMERGENCY_SUCCESS, +} from "translator"; + +// Store +import { useStore } from "vuex"; + +// Utils +import { getTicketTitle, motiveHierarchyLabel } from "../utils/utils"; +import { + isThirdparty, + Thirdparty, +} from "../../../../../../../ChillThirdPartyBundle/Resources/public/types"; + +const props = defineProps<{ + ticket: Ticket; +}>(); + +const store = useStore(); +const toast = useToast(); +const today = ref(new Date()); + +const currentMotive: ComputedRef<Motive | null> = computed(() => + store.getters.getMotiveById(props.ticket.currentMotive?.id), +); + +setInterval(() => { + today.value = new Date(); +}, 5000); + +const isOpen = computed({ + get: () => store.getters.isOpen as boolean, + set: async (value: boolean) => { + try { + await store.dispatch("setTicketState", value ? "open" : "close"); + toast.success( + trans( + value + ? CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_REOPEN_SUCCESS + : CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_CLOSE_SUCCESS, + ), + ); + } catch (error) { + console.error(error); + toast.error( + trans( + value + ? CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_REOPEN_ERROR + : CHILL_TICKET_TICKET_ACTIONS_TOOLBAR_CLOSE_ERROR, + ), + ); + } + }, +}); +const isEmergency = computed({ + get: () => store.getters.isEmergency as boolean, + set: async (value: boolean) => { + try { + await store.dispatch("setEmergency", value ? "yes" : "no"); + toast.success( + trans( + value + ? CHILL_TICKET_TICKET_BANNER_EMERGENCY_SUCCESS + : CHILL_TICKET_TICKET_BANNER_NO_EMERGENCY_SUCCESS, + ), + ); + } catch (error) { + console.error(error); + toast.error( + trans( + value + ? CHILL_TICKET_TICKET_BANNER_EMERGENCY_ERROR + : CHILL_TICKET_TICKET_BANNER_NO_EMERGENCY_ERROR, + ), + ); + } + }, +}); +const caller = computed<Person[] | Thirdparty[]>(() => { + if (null === props.ticket.caller) { + return []; + } + + if (isThirdparty(props.ticket.caller)) { + return [props.ticket.caller]; + } else { + return [props.ticket.caller]; + } +}); + +const since = computed(() => { + return store.getters.getSinceCreated(today.value); +}); +</script> diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Comment/CommentComponent.vue b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Comment/CommentComponent.vue new file mode 100644 index 000000000..2e63a5bdc --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Comment/CommentComponent.vue @@ -0,0 +1,133 @@ +<template> + <div class="col-12" v-if="!commentHistory.deleted"> + <blockquote class="chill-user-quote"> + <button + class="btn btn-sm bg-chill-red float-end text-white" + @click="deleteComment" + > + <i class="bi bi-trash"></i> + </button> + <button + class="btn btn-sm btn-edit mx-2 float-end text-white" + @click="editCommentModal = true" + style="top: 0.5rem; right: 0.5rem" + v-if="canBeEdited && isOpen" + /> + + <p v-html="convertMarkdownToHtml(commentHistory.content)"></p> + </blockquote> + </div> + <div class="col-12" v-else> + <span class="ms-2 d-block text-center"> + {{ trans(CHILL_TICKET_TICKET_MASK_COMMENT_HINT) }} + <button class="btn btn-primary btn-sm ms-2" @click="restoreComment"> + {{ trans(CANCEL) }} + </button> + </span> + </div> + <Modal + v-if="editCommentModal" + :show="editCommentModal" + modal-dialog-class="modal-xl" + @close="editCommentModal = false" + > + <template #header> + <h5 class="modal-title"> + {{ trans(CHILL_TICKET_TICKET_EDIT_COMMENT_TITLE) }} + </h5> + </template> + <template #body> + <comment-editor v-model="editedComment" /> + </template> + <template #footer> + <button class="btn btn-save" @click="saveComment"> + {{ trans(SAVE) }} + </button> + </template> + </Modal> +</template> + +<script setup lang="ts"> +import { computed, ref } from "vue"; +import { useStore } from "vuex"; +import { marked } from "marked"; +import DOMPurify from "dompurify"; + +// Types +import { Comment } from "../../../../types"; + +// Components +import CommentEditor from "ChillMainAssets/vuejs/_components/CommentEditor/CommentEditor.vue"; +import Modal from "../../../../../../../../ChillMainBundle/Resources/public/vuejs/_components/Modal.vue"; + +import { + trans, + SAVE, + CHILL_TICKET_TICKET_EDIT_COMMENT_TITLE, + CHILL_TICKET_TICKET_EDIT_COMMENT_SUCCESS, + CHILL_TICKET_TICKET_MASK_COMMENT_SUCCESS, + CHILL_TICKET_TICKET_MASK_COMMENT_HINT, + CHILL_TICKET_TICKET_VISIBLE_COMMENT_SUCCESS, + CANCEL, +} from "translator"; +import { useToast } from "vue-toast-notification"; + +const props = defineProps<{ commentHistory: Comment }>(); +const toast = useToast(); +const store = useStore(); +const isOpen = computed(() => store.getters.isOpen as boolean); +const canBeEdited = computed(() => + store.getters["canBeEdited"](props.commentHistory), +); +const editCommentModal = ref<boolean>(false); +const editedComment = ref<string>(props.commentHistory.content); + +const saveComment = () => { + store.dispatch("editComment", { + id: props.commentHistory.id, + content: editedComment.value, + }); + editCommentModal.value = false; + toast.success(trans(CHILL_TICKET_TICKET_EDIT_COMMENT_SUCCESS)); +}; + +const deleteComment = () => { + store.dispatch("deleteComment", props.commentHistory.id); + store.commit("addRemovedCommentIds", props.commentHistory.id); + editCommentModal.value = false; + toast.success(trans(CHILL_TICKET_TICKET_MASK_COMMENT_SUCCESS)); +}; +const restoreComment = () => { + store.dispatch("restoreComment", props.commentHistory.id); + editCommentModal.value = false; + toast.success(trans(CHILL_TICKET_TICKET_VISIBLE_COMMENT_SUCCESS)); +}; +const preprocess = (markdown: string): string => { + return markdown; +}; + +const postprocess = (html: string): string => { + DOMPurify.addHook("afterSanitizeAttributes", (node: Element) => { + if ("target" in node) { + node.setAttribute("target", "_blank"); + node.setAttribute("rel", "noopener noreferrer"); + } + if ( + !node.hasAttribute("target") && + (node.hasAttribute("xlink:href") || node.hasAttribute("href")) + ) { + node.setAttribute("xlink:show", "new"); + } + }); + + return DOMPurify.sanitize(html); +}; + +const convertMarkdownToHtml = (markdown: string): string => { + marked.use({ hooks: { postprocess, preprocess } }); + const rawHtml = marked(markdown) as string; + return rawHtml; +}; +</script> + +<style lang="scss" scoped></style> diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Comment/CommentEditorComponent.vue b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Comment/CommentEditorComponent.vue new file mode 100644 index 000000000..d34e2ddd0 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Comment/CommentEditorComponent.vue @@ -0,0 +1,88 @@ +<template> + <div class="row"> + <div class="col-12"> + <comment-editor v-model="content" /> + </div> + <div class="col-12" v-if="motive"> + <div + class="input-group mb-2" + v-for="(supplementaryComments, index) in motive.supplementaryComments" + :key="index" + > + <div class="input-group-prepend d-flex align-items-center px-1"> + <span class="badge rounded-pill bg-chill-red"> + {{ supplementaryComments.label }} + </span> + </div> + + <input + type="text" + v-model="supplementaryCommentsInput[index]" + class="form-control" + /> + </div> + </div> + </div> +</template> + +<script setup lang="ts"> +import { reactive, ref, watch, computed, ComputedRef } from "vue"; + +// Components +import CommentEditor from "ChillMainAssets/vuejs/_components/CommentEditor/CommentEditor.vue"; + +// Types +import { Motive, MotiveWithParent } from "../../../../types"; + +// Utils +import { StoredObject } from "ChillDocStoreAssets/types"; +import { useStore } from "vuex"; + +const props = defineProps<{ + modelValue?: string; + motive?: MotiveWithParent | null; +}>(); + +const store = useStore(); +const supplementaryCommentsInput = reactive<string[]>([]); + +const emit = defineEmits<{ + "update:modelValue": [value: string]; + "show-peloton-modal": [storedObjects: StoredObject[]]; +}>(); + +const content = ref(props.modelValue); +const motive: ComputedRef<Motive | null> = computed(() => + store.getters.getMotiveById(props.motive?.id), +); + +function aggregateSupplementaryComments() { + let supplementaryText = " \n\n "; + if (props.motive && motive.value && motive.value.supplementaryComments) { + motive.value.supplementaryComments.forEach( + (item: { label: string }, index: number) => { + if (supplementaryCommentsInput[index]) { + supplementaryText += + `**${item.label}**: ${supplementaryCommentsInput[index]}` + + " \n\n "; + } + }, + ); + } + return (content.value || "") + supplementaryText; +} + +watch( + supplementaryCommentsInput, + () => { + emit("update:modelValue", aggregateSupplementaryComments()); + }, + { deep: true }, +); + +watch(content, () => { + emit("update:modelValue", aggregateSupplementaryComments()); +}); +</script> + +<style lang="scss" scoped></style> diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Emergency/EmergencyComponent.vue b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Emergency/EmergencyComponent.vue new file mode 100644 index 000000000..22234fec8 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Emergency/EmergencyComponent.vue @@ -0,0 +1,16 @@ +<template> + <span + class="badge rounded-pill me-1" + :class="{ + 'bg-warning': new_emergency === 'yes', + 'bg-secondary': new_emergency === 'no', + }" + > + {{ trans(CHILL_TICKET_TICKET_BANNER_EMERGENCY) }} + </span> +</template> +<script setup lang="ts"> +import { trans, CHILL_TICKET_TICKET_BANNER_EMERGENCY } from "translator"; + +defineProps<{ new_emergency: string }>(); +</script> diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Emergency/EmergencyToggleComponent.vue b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Emergency/EmergencyToggleComponent.vue new file mode 100644 index 000000000..3c2022cb2 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Emergency/EmergencyToggleComponent.vue @@ -0,0 +1,69 @@ +<template> + <span class="d-block d-sm-inline-block ms-sm-3 ms-md-0"> + <button + class="badge rounded-pill me-1" + :class="{ + 'bg-warning': modelValue, + 'bg-secondary': !modelValue, + 'no-pointer': props.disabled, + }" + @click="toggleEmergency" + :disabled="props.disabled" + type="button" + > + {{ trans(CHILL_TICKET_TICKET_BANNER_EMERGENCY) }} + </button> + </span> +</template> +<style scoped> +.no-pointer { + cursor: not-allowed !important; +} +</style> + +<script lang="ts" setup> +import { trans, CHILL_TICKET_TICKET_BANNER_EMERGENCY } from "translator"; + +// Props +const props = defineProps<{ + modelValue: boolean; + disabled?: boolean; +}>(); + +// Emits +const emit = defineEmits<{ + "update:modelValue": [value: boolean]; +}>(); + +// Methods +function toggleEmergency() { + const newValue = !props.modelValue; + emit("update:modelValue", newValue); +} +</script> + +<style lang="scss" scoped> +a.flag-toggle { + color: white; + cursor: pointer; + &:hover { + color: white; + text-decoration: underline; + border-radius: 20px; + } + i { + margin: auto 0.4em; + } + span.on { + font-weight: bolder; + } +} +button.badge { + &.bg-secondary { + opacity: 0.5; + &:hover { + opacity: 0.7; + } + } +} +</style> diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Motive/MotiveComponent.vue b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Motive/MotiveComponent.vue new file mode 100644 index 000000000..1690a5aa3 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Motive/MotiveComponent.vue @@ -0,0 +1,50 @@ +<template> + <div class="col-12"> + <span class="badge-motive"> + <div> + {{ localizeString(props.motiveHistory.motive.label) }} + </div> + </span> + <peloton-component + :stored-objects="motive ? motive.storedObjects : null" + pelotonBtnClass="float-end" + /> + </div> +</template> + +<script lang="ts" setup> +import { computed, ComputedRef } from "vue"; +import { useStore } from "vuex"; + +//utils +import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper"; + +// Types +import { Motive, MotiveHistory } from "../../../../types"; + +//Components +import PelotonComponent from "../PelotonComponent.vue"; + +const props = defineProps<{ motiveHistory: MotiveHistory }>(); + +const store = useStore(); +const motive: ComputedRef<Motive | null> = computed(() => + store.getters.getMotiveById(props.motiveHistory.motive.id), +); +</script> + +<style lang="scss" scoped> +span.badge-motive { + margin: 0.2rem 0.1rem; + display: inline-block; + padding: 0 0.5em !important; + background-color: #fff; + color: #2c2d2f; + border: 1px solid #dee2e6; + border-bottom-width: 1px; + border-bottom-style: solid; + border-bottom-width: 2px; + border-bottom-style: solid; + border-radius: 6px; +} +</style> diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Motive/MotiveSelectorComponent.vue b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Motive/MotiveSelectorComponent.vue new file mode 100644 index 000000000..91e5f89c4 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Motive/MotiveSelectorComponent.vue @@ -0,0 +1,193 @@ +<template> + <div class="row"> + <div class="col-12"> + <div class="input-group mb-2"> + <vue-multiselect + name="selectMotive" + id="selectMotive" + label="displayLabel" + :custom-label="(value: Motive) => localizeString(value.label)" + track-by="id" + :open-direction="openDirection" + :multiple="false" + :searchable="true" + :internalSearch="false" + :placeholder="trans(CHILL_TICKET_TICKET_SET_MOTIVE_LABEL)" + :options="options" + :loading="isLoading" + v-model="motive" + class="form-control" + @remove="(value: Motive) => $emit('remove', value)" + @search-change="search" + :disabled="disabled" + > + <template + #option="{ + option, + }: { + option: Motive & { + isChild?: boolean; + isParent?: boolean; + level?: number; + breadcrumb: string[]; + }; + }" + > + <span + :data-select="trans(MULTISELECT_SELECT_LABEL)" + :data-selected="trans(MULTISELECT_SELECTED_LABEL)" + :data-deselect="trans(MULTISELECT_DESELECT_LABEL)" + > + <span v-for="(crumb, idx) in option.breadcrumb" :key="idx"> + <template v-if="idx < option.breadcrumb.length - 1"> + <i>{{ crumb }}</i> > + </template> + <template v-else> + {{ crumb }} + </template> + </span> + </span> + </template> + </vue-multiselect> + <div class="input-group-append"> + <peloton-component + :stored-objects="motive ? motive.storedObjects : null" + /> + </div> + </div> + </div> + </div> +</template> + +<script setup lang="ts"> +import { computed, ref } from "vue"; +import VueMultiselect from "vue-multiselect"; + +// Types +import { Motive, MotiveWithParent } from "../../../../types"; + +// Translations +import { + trans, + CHILL_TICKET_TICKET_SET_MOTIVE_LABEL, + MULTISELECT_SELECT_LABEL, + MULTISELECT_DESELECT_LABEL, + MULTISELECT_SELECTED_LABEL, +} from "translator"; + +// Component +import PelotonComponent from "../PelotonComponent.vue"; + +// Utils +import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper"; +import { useStore } from "vuex"; + +const props = defineProps({ + modelValue: { + type: Object as () => MotiveWithParent | Motive | null, + default: null, + }, + motives: { + type: Array as () => Motive[], + default: () => [], + }, + openDirection: { + type: String, + default: "bottom", + }, + allowParentSelection: { + type: Boolean, + default: false, + }, + disabled: { + type: Boolean, + default: false, + }, +}); +const store = useStore(); + +const emit = defineEmits<{ + (e: "update:modelValue", value: Motive | null): void; + (e: "remove", value: Motive): void; +}>(); + +const motive = computed<Motive | null>({ + get() { + return store.getters.getMotiveById(props.modelValue?.id); + }, + set(value: Motive | null) { + emit("update:modelValue", value); + }, +}); +type MotiveOptions = Motive & { + isChild?: boolean; + isParent?: boolean; + level?: number; + displayLabel: string; + breadcrumb: string[]; +}; + +const searchQuery = ref<string>(""); +const isLoading = ref<boolean>(false); + +const allMotiveOptions = computed<MotiveOptions[]>(() => { + const result: MotiveOptions[] = []; + + const processMotiveRecursively = ( + motive: Motive, + isChild = false, + level = 0, + parentBreadcrumb: string[] = [], + ) => { + const hasChildren = motive.children && motive.children.length > 0; + const displayLabel = localizeString(motive.label); + const breadcrumb = [...parentBreadcrumb, displayLabel]; + + if (props.allowParentSelection || !hasChildren) { + result.push({ + ...motive, + isChild, + isParent: hasChildren, + level, + breadcrumb, + displayLabel, + }); + } + + if (hasChildren) { + motive.children.forEach((childMotive) => { + processMotiveRecursively(childMotive, true, level + 1, breadcrumb); + }); + } + }; + + props.motives.forEach((motive) => { + processMotiveRecursively(motive); + }); + + return result; +}); + +const options = computed<MotiveOptions[]>(() => { + if (!searchQuery.value.trim()) { + return allMotiveOptions.value; + } + return allMotiveOptions.value.filter((m) => + m.breadcrumb.some((crumb) => + crumb.toLowerCase().includes(searchQuery.value.trim().toLowerCase()), + ), + ); +}); + +function search(query: string) { + isLoading.value = true; + searchQuery.value = query; + isLoading.value = false; +} +</script> + +<style lang="scss" scoped> +.form-control { + padding: 0 !important; +} +</style> diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/PelotonComponent.vue b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/PelotonComponent.vue new file mode 100644 index 000000000..13bf284f5 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/PelotonComponent.vue @@ -0,0 +1,291 @@ +<template> + <button + :class="['input-group-text', 'btn', 'btn-primary', pelotonBtnClass]" + type="button" + @click="handleClick" + :disabled="!storedObjects?.length" + > + <i class="fa fa-sitemap"></i> + </button> + <Modal + v-if="showPelotonsModal" + :show="showPelotonsModal" + modal-dialog-class="modal-xl" + @close="closeModal" + > + <template #header> + <div class="dropdown-container"> + <select + v-model="selectedStoredObject" + class="form-select" + @change="fetchDocument" + style="max-width: 400px" + > + <option + v-for="storedObject in storedObjects" + :key="storedObject.id" + :value="storedObject" + > + {{ storedObject.title }} + </option> + </select> + </div> + </template> + <template #body> + <div v-if="selectedStoredObject && documentUrl" class="document-viewer"> + <div v-if="documentType === 'image'" class="image-container"> + <img + :src="documentUrl" + class="img-fluid" + style="max-width: 100%; height: auto" + /> + </div> + + <div v-else-if="documentType === 'pdf'" class="pdf-container"> + <iframe + :src="documentUrl" + width="100%" + height="100%" + style="border: 1px solid #ccc" + ></iframe> + <noscript> + <p> + {{ trans(CHILL_TICKET_PELOTON_IFRAME_NOT_SUPPORTED) }} + <a :href="documentUrl" target="_blank"> + {{ trans(CHILL_TICKET_PELOTON_CLICK_TO_OPEN_PDF) }} + </a> + </p> + </noscript> + </div> + + <div v-else class="unsupported-document"> + <p>{{ trans(CHILL_TICKET_PELOTON_UNSUPPORTED_TYPE) }}</p> + <a :href="documentUrl" target="_blank" class="btn btn-primary"> + {{ trans(CHILL_TICKET_PELOTON_OPEN_NEW_TAB) }} + </a> + </div> + </div> + + <div v-if="isLoading" class="loading-indicator"> + <div class="spinner-border" role="status"> + <span class="visually-hidden">{{ + trans(CHILL_TICKET_PELOTON_LOADING) + }}</span> + </div> + <p>{{ trans(CHILL_TICKET_PELOTON_LOADING_DOCUMENT) }}</p> + </div> + + <div v-if="error" class="alert alert-danger"> + {{ error }} + </div> + </template> + </Modal> +</template> + +<script setup lang="ts"> +import { ref, watch } from "vue"; + +// Component +import Modal from "../../../../../../../ChillMainBundle/Resources/public/vuejs/_components/Modal.vue"; + +// Translations +import { + CHILL_TICKET_PELOTON_LOADING_DOCUMENT, + CHILL_TICKET_PELOTON_LOADING, + CHILL_TICKET_PELOTON_ERROR_LOADING, + CHILL_TICKET_PELOTON_UNSUPPORTED_TYPE, + CHILL_TICKET_PELOTON_OPEN_NEW_TAB, + CHILL_TICKET_PELOTON_IFRAME_NOT_SUPPORTED, + CHILL_TICKET_PELOTON_CLICK_TO_OPEN_PDF, + trans, +} from "translator"; +// Type +import { StoredObject, StoredObjectVersion } from "ChillDocStoreAssets/types"; + +// Utils +import { + download_info_link, + is_object_ready, +} from "ChillDocStoreAssets/vuejs/StoredObjectButton/helpers"; + +const props = withDefaults( + defineProps<{ + storedObjects: StoredObject[] | null; + pelotonBtnClass?: string; + }>(), + { + pelotonBtnClass: "", + }, +); + +const selectedStoredObject = ref<StoredObject | null>(null); +const documentUrl = ref<string>(""); +const documentType = ref<string>(""); +const isLoading = ref<boolean>(false); +const error = ref<string>(""); +const showPelotonsModal = ref<boolean>(false); + +async function fetchDocument() { + if (!selectedStoredObject.value) { + cleanupPrevious(); + documentType.value = ""; + error.value = ""; + return; + } + + isLoading.value = true; + error.value = ""; + cleanupPrevious(); + + try { + const document = await is_object_ready(selectedStoredObject.value); + + const algo = "AES-CBC"; + const atVersionToDownload = selectedStoredObject.value + .currentVersion as StoredObjectVersion; + const downloadInfo = await download_info_link( + selectedStoredObject.value, + selectedStoredObject.value.currentVersion, + ); + const rawResponse = await window.fetch(downloadInfo.url); + + if (!rawResponse.ok) { + throw new Error( + "error while downloading raw file " + + rawResponse.status + + " " + + rawResponse.statusText, + ); + } + + let blob: Blob; + if (atVersionToDownload.iv.length === 0) { + blob = await rawResponse.blob(); + } else { + const rawBuffer = await rawResponse.arrayBuffer(); + try { + const key = await window.crypto.subtle.importKey( + "jwk", + atVersionToDownload.keyInfos, + { name: algo }, + false, + ["decrypt"], + ); + const iv = Uint8Array.from(atVersionToDownload.iv); + const decrypted = await window.crypto.subtle.decrypt( + { name: algo, iv: iv }, + key, + rawBuffer, + ); + + blob = new Blob([decrypted], { type: document.type }); + + documentUrl.value = URL.createObjectURL(blob); + } catch (e) { + console.error("encounter error while keys and decrypt operations"); + console.error(e); + throw e; + } + } + + documentUrl.value = URL.createObjectURL(blob); + + const mimeType = blob.type; + if (mimeType.startsWith("image/")) { + documentType.value = "image"; + } else if (mimeType === "application/pdf") { + documentType.value = "pdf"; + } else { + documentType.value = "other"; + } + } catch (err) { + console.error(trans(CHILL_TICKET_PELOTON_ERROR_LOADING), err); + error.value = trans(CHILL_TICKET_PELOTON_ERROR_LOADING); + } finally { + isLoading.value = false; + } +} +// Nettoyer les URLs blob précédentes +function cleanupPrevious() { + if (documentUrl.value) { + URL.revokeObjectURL(documentUrl.value); + documentUrl.value = ""; + } +} +function handleClick() { + fetchDocument(); + showPelotonsModal.value = true; +} + +function closeModal() { + showPelotonsModal.value = false; +} + +watch( + () => props.storedObjects, + (newStoredObjects) => { + selectedStoredObject.value = newStoredObjects ? newStoredObjects[0] : null; + }, + { immediate: true }, +); +</script> + +<style lang="scss" scoped> +.dropdown-container { + .form-label { + font-weight: 500; + margin-bottom: 0.5rem; + } +} + +.document-viewer { + height: 80vh; + + .image-container { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + + img { + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + border-radius: 0.25rem; + } + } + + .pdf-container { + height: 100%; + + iframe { + border-radius: 0.25rem; + height: 100% !important; + } + } + + .unsupported-document { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + + p { + margin-bottom: 1rem; + color: #6c757d; + } + } +} + +.loading-indicator { + text-align: center; + padding: 2rem; + + .spinner-border { + margin-bottom: 1rem; + } + + p { + color: #6c757d; + margin: 0; + } +} +</style> diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Person/PersonComponent.vue b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Person/PersonComponent.vue new file mode 100644 index 000000000..744417e25 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Person/PersonComponent.vue @@ -0,0 +1,36 @@ +<template> + <div class="col-12"> + <ul class="persons-list" v-if="entities.length > 0"> + <li v-for="entity in entities" :key="entity.text"> + <on-the-fly + :type="entity.type" + :id="entity.id" + :buttonText="entity.text" + :displayBadge="true" + action="show" + ></on-the-fly> + </li> + </ul> + </div> +</template> + +<script setup lang="ts"> +import OnTheFly from "ChillMainAssets/vuejs/OnTheFly/components/OnTheFly.vue"; + +// Types +import { Person } from "ChillPersonAssets/types"; +import { Thirdparty } from "src/Bundle/ChillThirdPartyBundle/Resources/public/types"; + +defineProps<{ entities: Person[] | Thirdparty[] }>(); +</script> + +<style lang="scss" scoped> +ul.persons-list { + list-style-type: none; + margin: 0; + padding: 0; + & > li { + display: inline-block; + } +} +</style> diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Person/PersonsSelectorComponent.vue b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Person/PersonsSelectorComponent.vue new file mode 100644 index 000000000..c1b90d0f7 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Person/PersonsSelectorComponent.vue @@ -0,0 +1,150 @@ +<template> + <div + :class="{ + 'opacity-50': disabled, + }" + :style="disabled ? 'pointer-events: none;' : ''" + > + <pick-entity + uniqid="ticket-person-selector" + :types="types" + :picked="pickedEntities" + :suggested="suggestedValues" + :multiple="multiple" + :removable-if-set="true" + :display-picked="true" + :label="label" + @add-new-entity="addNewEntity" + @remove-entity="removeEntity" + /> + </div> +</template> + +<script setup lang="ts"> +import { ref, watch, defineProps, defineEmits, computed } from "vue"; + +// Components +import PickEntity from "ChillMainAssets/vuejs/PickEntity/PickEntity.vue"; + +// Types +import { Entities, EntitiesOrMe, EntityType } from "ChillPersonAssets/types"; + +const props = withDefaults( + defineProps<{ + modelValue: EntitiesOrMe[] | EntitiesOrMe | null; + suggested: Entities[]; + multiple: boolean; + types: EntityType[]; + label: string; + disabled?: boolean; + }>(), + { + disabled: false, + }, +); + +const emit = defineEmits<{ + "update:modelValue": [value: EntitiesOrMe[] | EntitiesOrMe | null]; +}>(); + +const multiple = props.multiple; +const types = props.types; +const label = props.label; + +const selectedEntities = ref<Entities[]>( + multiple + ? [...((props.modelValue as Entities[]) || [])] + : props.modelValue + ? [props.modelValue as Entities] + : [], +); +const suggestedValues = ref<Entities[]>([...props.suggested]); + +// Entités sélectionnées pour le composant PickEntity +const pickedEntities = computed(() => + multiple ? selectedEntities.value : selectedEntities.value.slice(0, 1), +); + +watch( + () => [props.suggested, props.modelValue], + () => { + // Mise à jour des entités sélectionnées + selectedEntities.value = multiple + ? [...((props.modelValue as Entities[]) || [])] + : props.modelValue + ? [props.modelValue as Entities] + : []; + + // Filtrage des suggestions + if (multiple) { + suggestedValues.value = props.suggested.filter( + (suggested: Entities) => + !(props.modelValue as Entities[])?.some( + (selected: Entities) => + suggested.id === selected.id && suggested.type === selected.type, + ), + ); + } else { + const currentEntity = props.modelValue as Entities | null; + suggestedValues.value = props.suggested.filter( + (suggested: Entities) => + !( + currentEntity && + suggested.id === currentEntity.id && + suggested.type === currentEntity.type + ), + ); + } + }, + { immediate: true, deep: true }, +); + +function addNewEntity({ entity }: { entity: EntitiesOrMe }) { + if (multiple) { + selectedEntities.value.push(entity as Entities); + emit("update:modelValue", selectedEntities.value); + } else { + selectedEntities.value = [entity as Entities]; + emit("update:modelValue", entity); + } +} + +function removeEntity({ entity }: { entity: EntitiesOrMe }) { + if (multiple) { + const index = selectedEntities.value.findIndex( + (selectedEntity: Entities) => selectedEntity === entity, + ); + if (index !== -1) { + selectedEntities.value.splice(index, 1); + } + emit("update:modelValue", selectedEntities.value); + } else { + selectedEntities.value = []; + emit("update:modelValue", null); + } +} +</script> + +<style scoped lang="scss"> +ul.person-list { + list-style-type: none; + + & > li { + display: inline-block; + border: 1px solid transparent; + border-radius: 6px; + + button.remove-person { + opacity: 10%; + } + } + + & > li:hover { + border: 1px solid white; + + button.remove-person { + opacity: 100%; + } + } +} +</style> diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/PreviousTicketsComponent.vue b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/PreviousTicketsComponent.vue new file mode 100644 index 000000000..3b3ab18d0 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/PreviousTicketsComponent.vue @@ -0,0 +1,131 @@ +<template> + <div @click="handleClick"> + <button type="button" class="btn btn-light position-relative"> + {{ trans(CHILL_TICKET_TICKET_PREVIOUS_TICKETS) }} + + <span + class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-chill-green" + > + {{ previousTickets.length }} + <span class="visually-hidden">Tickets</span> + </span> + </button> + </div> + + <!-- Modal for ticket list --> + <Modal + v-if="showPreviousTicketModal" + :show="showPreviousTicketModal" + modal-dialog-class="modal-lg" + @close="closeModal" + > + <template #header> + <h3 class="modal-title"> + {{ + trans(CHILL_TICKET_LIST_TITLE_PREVIOUS_TICKETS, { + name: + currentPersons.length > 0 + ? currentPersons.map((person: Person) => person.text).join(", ") + : undefined, + }) + }} + </h3> + </template> + + <template #body> + <ticket-list-component + :hasMoreTickets="pagination.next !== null" + :tickets="previousTickets" + :title="trans(CHILL_TICKET_LIST_NO_TICKETS)" + @fetchNextPage="fetchNextPage" + @view-ticket="handleViewTicket" + @edit-ticket="handleEditTicket" + /> + </template> + </Modal> +</template> + +<script setup lang="ts"> +import { ref, onMounted, computed } from "vue"; +import { useStore } from "vuex"; + +// Components +import Modal from "../../../../../../../ChillMainBundle/Resources/public/vuejs/_components/Modal.vue"; +import TicketListComponent from "../../TicketList/components/TicketListComponent.vue"; + +// Translations +import { + trans, + CHILL_TICKET_LIST_NO_TICKETS, + CHILL_TICKET_LIST_ERROR_LOADING_TICKET, + CHILL_TICKET_TICKET_PREVIOUS_TICKETS, + CHILL_TICKET_LIST_TITLE_PREVIOUS_TICKETS, +} from "translator"; + +// Types +import { Person } from "ChillPersonAssets/types"; +import { TicketSimple } from "../../../types"; + +// Utils +import { localizedUrl } from "ChillMainAssets/lib/localizationHelper/localizationHelper"; +import { Pagination } from "ChillMainAssets/lib/api/apiMethods"; + +const store = useStore(); +const showPreviousTicketModal = ref(false); +const showTicketHistoryModal = ref(false); +const selectedTicketId = ref<number | null>(null); + +const pagination = computed(() => store.getters.getPagination as Pagination); +const currentPersons = computed( + () => store.getters.getCurrentPersons as Person[], +); +const previousTickets = computed( + () => store.getters.getPreviousTicketList as TicketSimple[], +); + +onMounted(async () => { + try { + if (currentPersons.value.length) { + await store.dispatch("fetchTicketList", { + byPerson: currentPersons.value.map((person) => person.id), + }); + } + } catch (error) { + console.error(trans(CHILL_TICKET_LIST_ERROR_LOADING_TICKET), error); + } +}); + +function handleClick() { + showPreviousTicketModal.value = true; +} + +function closeModal() { + showPreviousTicketModal.value = false; +} + +async function handleViewTicket(ticketId: number) { + selectedTicketId.value = ticketId; + showTicketHistoryModal.value = true; + + try { + await store.dispatch("fetchTicket", ticketId); + } catch (error) { + console.error(trans(CHILL_TICKET_LIST_ERROR_LOADING_TICKET), error); + } +} + +const fetchNextPage = async () => { + if (pagination.value.next) { + await store.dispatch("fetchTicketListByUrl", pagination.value.next); + } +}; + +function handleEditTicket(ticketId: number) { + const returnPath = localizedUrl(`/ticket/ticket/list`); + window.location.href = localizedUrl( + `/ticket/ticket/${ticketId}/edit?returnPath=${returnPath}`, + ); +} +</script> + +<style lang="scss" scoped></style> diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/State/StateComponent.vue b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/State/StateComponent.vue new file mode 100644 index 000000000..f3f7bd5c4 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/State/StateComponent.vue @@ -0,0 +1,31 @@ +<template> + <span + class="badge rounded-pill me-1" + :class="{ + 'bg-chill-red': props.new_state == 'closed', + 'bg-chill-green': props.new_state == 'open', + }" + > + <template v-if="props.new_state == 'open'"> + {{ trans(CHILL_TICKET_TICKET_BANNER_OPEN) }} + </template> + <template v-else-if="props.new_state == 'closed'"> + {{ trans(CHILL_TICKET_TICKET_BANNER_CLOSED) }} + </template> + </span> +</template> + +<script setup lang="ts"> +// Types +import { StateChange } from "../../../../types"; + +// Translations +import { + trans, + CHILL_TICKET_TICKET_BANNER_OPEN, + CHILL_TICKET_TICKET_BANNER_CLOSED, +} from "translator"; +const props = defineProps<StateChange>(); +</script> + +<style scoped lang="scss"></style> diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/State/StateToggleComponent.vue b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/State/StateToggleComponent.vue new file mode 100644 index 000000000..c7c993743 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/State/StateToggleComponent.vue @@ -0,0 +1,68 @@ +<template> + <span class="d-block d-sm-inline-block ms-sm-3 ms-md-0"> + <button + class="badge rounded-pill me-1" + :class="{ + 'bg-chill-green': modelValue, + 'bg-chill-red': !modelValue, + }" + @click="toggleEmergency" + > + {{ + modelValue + ? trans(CHILL_TICKET_TICKET_BANNER_OPEN) + : trans(CHILL_TICKET_TICKET_BANNER_CLOSED) + }} + </button> + </span> +</template> + +<script lang="ts" setup> +import { + trans, + CHILL_TICKET_TICKET_BANNER_OPEN, + CHILL_TICKET_TICKET_BANNER_CLOSED, +} from "translator"; + +// Props +const props = defineProps<{ + modelValue: boolean; +}>(); + +// Emits +const emit = defineEmits<{ + "update:modelValue": [value: boolean]; +}>(); + +// Methods +function toggleEmergency() { + const newValue = !props.modelValue; + emit("update:modelValue", newValue); +} +</script> + +<style lang="scss" scoped> +a.flag-toggle { + color: white; + cursor: pointer; + &:hover { + color: white; + text-decoration: underline; + border-radius: 20px; + } + i { + margin: auto 0.4em; + } + span.on { + font-weight: bolder; + } +} +button.badge { + &.bg-secondary { + opacity: 0.5; + &:hover { + opacity: 0.7; + } + } +} +</style> diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Ticket/TicketSelector.vue b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Ticket/TicketSelector.vue new file mode 100644 index 000000000..b83d0b7e2 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/Ticket/TicketSelector.vue @@ -0,0 +1,56 @@ +<template> + <div class="input-group mb-3"> + <input + v-model="ticketId" + type="number" + class="form-control" + :placeholder="trans(CHILL_TICKET_LIST_FILTER_TICKET_ID)" + :disabled="disabled" + @input=" + ticketId = isNaN(Number(($event.target as HTMLInputElement).value)) + ? null + : Number(($event.target as HTMLInputElement).value) + " + /> + <span class="input-group-text" v-if="ticketId !== null"> + <i + class="fa fa-times chill-red" + style="cursor: pointer" + @click="ticketId = null" + title="clear" + ></i> + </span> + </div> +</template> + +<script setup lang="ts"> +import { ref, watch } from "vue"; +// Translation +import { trans, CHILL_TICKET_LIST_FILTER_TICKET_ID } from "translator"; +const props = withDefaults( + defineProps<{ + modelValue: number | null; + disabled?: boolean; + }>(), + { + disabled: false, + }, +); + +const ticketId = ref<number | null>(props.modelValue); + +watch( + () => props.modelValue, + (newVal) => { + if (newVal === null) { + ticketId.value = null; + } + }, +); + +const emit = defineEmits(["update:modelValue"]); + +watch(ticketId, (ticketId) => { + emit("update:modelValue", ticketId ? ticketId : null); +}); +</script> diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/TicketInitFormComponent.vue b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/TicketInitFormComponent.vue new file mode 100644 index 000000000..463209f1a --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/components/TicketInitFormComponent.vue @@ -0,0 +1,156 @@ +<template> + <div class="card mb-4"> + <div class="card-body"> + <div class="mb-3"> + <label class="form-label pe-2" for="emergency"> + {{ trans(CHILL_TICKET_LIST_FILTER_EMERGENCY) }} + </label> + <emergency-toggle-component v-model="isEmergency" class="float-end" /> + </div> + + <!-- Sélection du motif --> + <div class="mb-3"> + <label class="form-label">{{ + trans(CHILL_TICKET_TICKET_SET_MOTIVE_TITLE) + }}</label> + <motive-selector-component + v-model="ticketForm.motive" + :motives="motives" + /> + </div> + + <!-- Sélection des personnes --> + <div class="row mb-3"> + <div class="col-md-6"> + <label class="form-label"> + {{ trans(CHILL_TICKET_TICKET_SET_PERSONS_TITLE_CALLER) }} + </label> + <persons-selector-component + v-model="ticketForm.caller" + :suggested="[]" + :multiple="false" + :types="['person', 'thirdparty']" + :label="trans(CHILL_TICKET_TICKET_SET_PERSONS_CALLER_LABEL)" + /> + </div> + <div class="col-md-6"> + <label class="form-label"> + {{ trans(CHILL_TICKET_TICKET_SET_PERSONS_TITLE_PERSON) }} + </label> + <persons-selector-component + v-model="ticketForm.persons" + :suggested="suggestedPersons" + :multiple="true" + :types="['person']" + :label="trans(CHILL_TICKET_TICKET_SET_PERSONS_USER_LABEL)" + /> + </div> + </div> + + <!-- Éditeur de commentaire --> + <div class="mb-3"> + <label class="form-label">{{ + trans(CHILL_TICKET_TICKET_ADD_COMMENT_TITLE) + }}</label> + <comment-editor-component + v-model="ticketForm.content" + :motive="ticketForm.motive ? ticketForm.motive : null" + /> + </div> + + <!-- Attribution des tickets --> + <div class="mb-3"> + <label class="form-label"> + {{ trans(CHILL_TICKET_TICKET_ADD_ADDRESSEE_TITLE) }} + </label> + <addressee-selector-component + v-model="ticketForm.addressees" + :suggested="userGroups" + /> + </div> + </div> + </div> +</template> + +<script setup lang="ts"> +import { computed, reactive, ref, watch } from "vue"; +import { useStore } from "vuex"; + +// Components +import MotiveSelectorComponent from "./Motive/MotiveSelectorComponent.vue"; +import CommentEditorComponent from "./Comment/CommentEditorComponent.vue"; +import PersonsSelectorComponent from "./Person/PersonsSelectorComponent.vue"; +import AddresseeSelectorComponent from "./Addressee/AddresseeSelectorComponent.vue"; +import EmergencyToggleComponent from "./Emergency/EmergencyToggleComponent.vue"; +// Types +import { Motive, TicketInitForm } from "../../../types"; +import { Person } from "ChillPersonAssets/types"; + +// Translations +import { + trans, + CHILL_TICKET_TICKET_SET_MOTIVE_TITLE, + CHILL_TICKET_TICKET_ADD_COMMENT_TITLE, + CHILL_TICKET_TICKET_SET_PERSONS_TITLE_CALLER, + CHILL_TICKET_TICKET_SET_PERSONS_TITLE_PERSON, + CHILL_TICKET_TICKET_SET_PERSONS_CALLER_LABEL, + CHILL_TICKET_TICKET_SET_PERSONS_USER_LABEL, + CHILL_TICKET_LIST_FILTER_EMERGENCY, + CHILL_TICKET_TICKET_ADD_ADDRESSEE_TITLE, +} from "translator"; +import { UserGroup } from "ChillMainAssets/types"; + +const props = defineProps<{ + modelValue: TicketInitForm; + motives: Motive[]; + suggestedPersons: Person[]; +}>(); + +const emit = defineEmits<{ + "update:modelValue": [value: TicketInitForm]; +}>(); + +const store = useStore(); + +const ticketForm = reactive({ ...props.modelValue }); +const isEmergency = ref<boolean>( + props.modelValue.emergency == "yes" ? true : false, +); + +const userGroups = computed(() => store.getters.getUserGroups as UserGroup[]); + +watch( + () => ticketForm.caller, + async (newCaller) => { + await store.dispatch("setCaller", newCaller); + await store.dispatch("getSuggestedPersons"); + }, +); + +watch( + ticketForm, + (newVal) => { + emit("update:modelValue", { + ...newVal, + emergency: isEmergency.value ? "yes" : "no", + }); + }, + { deep: true }, +); +</script> + +<style lang="scss" scoped> +.card { + border: 1px solid #dee2e6; + border-radius: 0.375rem; +} + +.card-header { + background-color: #f8f9fa; + border-bottom: 1px solid #dee2e6; +} + +.gap-2 { + gap: 0.5rem; +} +</style> diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/index.ts b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/index.ts new file mode 100644 index 000000000..501bf7c20 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/index.ts @@ -0,0 +1,26 @@ +import App from "./App.vue"; +import { createApp } from "vue"; + +import VueToast from "vue-toast-notification"; +import "vue-toast-notification/dist/theme-sugar.css"; +import "./scss/reactive.scss"; + +import { store } from "./store"; + +declare global { + interface Window { + initialTicket: string; + ticketPersonPerTicket: "one" | "multi"; + } +} + +const _app = createApp({ + template: "<app></app>", +}); + +_app + .use(store) + .use(VueToast) + .provide("toast", _app.config.globalProperties.$toast) + .component("app", App) + .mount("#ticketRoot"); diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/scss/reactive.scss b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/scss/reactive.scss new file mode 100644 index 000000000..dbda9bb70 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/scss/reactive.scss @@ -0,0 +1,42 @@ + +.responsive-title-h1 { + font-size: 2.5rem; + @media (max-width: 1200px) { + font-size: 2rem; + } + @media (max-width: 900px) { + font-size: 1.7rem; + } + @media (max-width: 768px) { + font-size: 1.3rem; + } +} +.responsive-title-h2 { + font-size: 2rem; + @media (max-width: 1200px) { + font-size: 1.5rem; + } + @media (max-width: 900px) { + font-size: 1.2rem; + } + @media (max-width: 768px) { + font-size: 1rem; + } +} +.responsive-title-h3 { + font-size: 1.5rem; + @media (max-width: 1200px) { + font-size: 1.2rem; + } + @media (max-width: 900px) { + font-size: 1rem; + } + @media (max-width: 768px) { + font-size: 1rem; + } +} +.hide-on-sm { + @media (max-width: 900px) { + display: none !important; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/store/index.ts b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/store/index.ts new file mode 100644 index 000000000..c0d102dc1 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/store/index.ts @@ -0,0 +1,33 @@ +import { createStore } from "vuex"; +import { State as MotiveStates, moduleMotive } from "./modules/motive"; +import { State as TicketStates, moduleTicket } from "./modules/ticket"; +import { State as CommentStates, moduleComment } from "./modules/comment"; +import { State as AddresseeStates, moduleAddressee } from "./modules/addressee"; +import { State as PersonsState, modulePersons } from "./modules/persons"; +import { + State as TicketListState, + moduleTicketList, +} from "./modules/ticket_list"; +import { State as UserState, moduleUser } from "./modules/user"; + +export interface RootState { + motive: MotiveStates; + ticket: TicketStates; + comment: CommentStates; + addressee: AddresseeStates; + persons: PersonsState; + ticketList: TicketListState; + user: UserState; +} + +export const store = createStore({ + modules: { + motive: moduleMotive, + ticket: moduleTicket, + comment: moduleComment, + addressee: moduleAddressee, + persons: modulePersons, + ticketList: moduleTicketList, + user: moduleUser, + }, +}); diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/store/modules/addressee.ts b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/store/modules/addressee.ts new file mode 100644 index 000000000..f5efc9fd5 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/store/modules/addressee.ts @@ -0,0 +1,82 @@ +import { + fetchResults, + makeFetch, +} from "../../../../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods"; + +import type { RootState } from ".."; +import type { Module } from "vuex"; + +import { + ApiException, + User, + UserGroup, + UserGroupOrUser, +} from "../../../../../../../../ChillMainBundle/Resources/public/types"; + +export interface State { + userGroups: UserGroup[]; + users: User[]; +} + +export const moduleAddressee: Module<State, RootState> = { + state: () => ({ + userGroups: [] as UserGroup[], + users: [] as User[], + }), + getters: { + getUserGroups(state) { + return state.userGroups; + }, + getUsers(state) { + return state.users; + }, + }, + mutations: { + setUserGroups(state, userGroups) { + state.userGroups = userGroups; + }, + setUsers(state, users) { + state.users = users; + }, + }, + actions: { + fetchUserGroups({ commit }) { + try { + fetchResults("/api/1.0/main/user-group.json").then((results) => { + commit("setUserGroups", results); + }); + } catch (e: unknown) { + const error = e as ApiException; + throw error.name; + } + }, + fetchUsers({ commit }) { + try { + fetchResults("/api/1.0/main/user.json").then((results) => { + commit("setUsers", results); + }); + } catch (e: unknown) { + const error = e as ApiException; + throw error.name; + } + }, + + async setAddressees({ commit, rootState }, addressees: UserGroupOrUser[]) { + try { + const result = await makeFetch( + "POST", + `/api/1.0/ticket/${rootState.ticket.ticket.id}/addressees/set`, + { + addressees: addressees.map((addressee) => { + return { id: addressee.id, type: addressee.type }; + }), + }, + ); + commit("setTicket", result); + } catch (e: unknown) { + const error = e as ApiException; + throw error.name; + } + }, + }, +}; diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/store/modules/comment.ts b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/store/modules/comment.ts new file mode 100644 index 000000000..932705fd3 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/store/modules/comment.ts @@ -0,0 +1,92 @@ +import { makeFetch } from "../../../../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods"; + +import { Module } from "vuex"; +import { RootState } from ".."; + +import { Comment, Ticket } from "../../../../types"; +import { ApiException } from "../../../../../../../../ChillMainBundle/Resources/public/types"; + +export interface State { + comments: Comment[]; + removedCommentIds: Comment["id"][]; +} + +export const moduleComment: Module<State, RootState> = { + state: () => ({ + comments: [] as Comment[], + removedCommentIds: [] as Comment["id"][], + }), + getters: { + canBeEdited: + (state: State, getters: unknown, rootState: RootState) => + (comment: Comment) => { + return ( + comment.createdBy?.username === rootState.user.currentUser?.username + ); + }, + getRemovedCommentIds(state) { + return state.removedCommentIds; + }, + }, + mutations: { + addRemovedCommentIds(state, commentId: number) { + state.removedCommentIds.push(commentId); + }, + }, + actions: { + async createComment({ commit, rootState }, content: Comment["content"]) { + try { + const result: Ticket = await makeFetch( + "POST", + `/api/1.0/ticket/${rootState.ticket.ticket.id}/comment/add`, + { content }, + ); + commit("setTicket", result); + } catch (e: unknown) { + const error = e as ApiException; + throw error.name; + } + }, + async editComment( + { commit }, + { id, content }: { id: Comment["id"]; content: Comment["content"] }, + ) { + try { + const result: Comment = await makeFetch( + "POST", + `/api/1.0/ticket/comment/${id}/edit`, + { content }, + ); + commit("setTicketHistoryComment", result); + } catch (e: unknown) { + const error = e as ApiException; + throw error.name; + } + }, + async deleteComment({ commit }, id: Comment["id"]) { + try { + const result: Comment = await makeFetch( + "POST", + `/api/1.0/ticket/comment/${id}/delete`, + ); + commit("setTicketHistoryComment", result); + } catch (e: unknown) { + const error = e as ApiException; + throw error.name; + } + }, + + async restoreComment({ commit }, id: Comment["id"]) { + try { + const result: Comment = await makeFetch( + "POST", + `/api/1.0/ticket/comment/${id}/restore`, + ); + commit("setTicketHistoryComment", result); + } catch (e: unknown) { + const error = e as ApiException; + throw error.name; + } + }, + }, +}; diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/store/modules/motive.ts b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/store/modules/motive.ts new file mode 100644 index 000000000..355613fba --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/store/modules/motive.ts @@ -0,0 +1,75 @@ +import { + fetchResults, + makeFetch, +} from "../../../../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods"; + +import { Module } from "vuex"; +import { RootState } from ".."; + +import { Motive } from "../../../../types"; +import { ApiException } from "../../../../../../../../ChillMainBundle/Resources/public/types"; + +export interface State { + motives: Motive[]; +} + +export const moduleMotive: Module<State, RootState> = { + state: () => ({ + motives: [] as Motive[], + }), + getters: { + getMotives(state) { + return state.motives; + }, + getMotiveById: (state) => (motiveId: number) => { + const findInChildren = (motives: Motive[]): Motive | null => { + for (const motive of motives) { + if (motive.id === motiveId) return motive; + const found = motive.children?.length + ? findInChildren(motive.children) + : null; + if (found) return found; + } + return null; + }; + return findInChildren(state.motives); + }, + }, + mutations: { + setMotives(state, motives) { + state.motives = motives; + }, + }, + actions: { + async fetchMotives({ commit }) { + try { + const results = (await fetchResults( + "/api/1.0/ticket/motive.json", + )) as Motive[]; + commit("setMotives", results); + } catch (e: unknown) { + const error = e as ApiException; + throw error.name; + } + }, + + async createMotive({ commit, rootState }, motive: Motive) { + try { + const result = await makeFetch( + "POST", + `/api/1.0/ticket/${rootState.ticket.ticket.id}/motive/set`, + { + motive: { + id: motive.id, + type: motive.type, + }, + }, + ); + commit("setTicket", result); + } catch (e: unknown) { + const error = e as ApiException; + throw error.name; + } + }, + }, +}; diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/store/modules/persons.ts b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/store/modules/persons.ts new file mode 100644 index 000000000..ae6a35545 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/store/modules/persons.ts @@ -0,0 +1,85 @@ +import { makeFetch } from "../../../../../../../../ChillMainBundle/Resources/public/lib/api/apiMethods"; +import { Person } from "../../../../../../../../ChillPersonBundle/Resources/public/types"; +import { ApiException } from "../../../../../../../../ChillMainBundle/Resources/public/types"; +import { Module } from "vuex"; +import { RootState } from ".."; +import { Ticket } from ".././../../../types"; +import { Thirdparty } from "src/Bundle/ChillThirdPartyBundle/Resources/public/types"; + +export interface State { + persons: Person[]; +} + +export const modulePersons: Module<State, RootState> = { + state: () => ({ + persons: [] as Person[], + }), + getters: { + getPersons(state) { + return state.persons; + }, + }, + mutations: { + setPersons(state, persons: Person[]) { + state.persons = persons; + }, + }, + actions: { + async setPersons({ commit, rootState }, persons: Person[]) { + const personData = persons.map((person: Person) => ({ + id: person.id, + type: person.type, + })); + try { + const result: Ticket = await makeFetch( + "POST", + `/api/1.0/ticket/${rootState.ticket.ticket.id}/persons/set`, + { persons: personData }, + ); + commit("setTicket", result); + + return Promise.resolve(); + } catch (e: unknown) { + const error = e as ApiException; + throw error.name; + } + }, + async setCaller({ commit, rootState }, caller: Person | null) { + try { + const callerData = caller + ? { + id: caller.id, + type: caller.type, + } + : null; + const result: Ticket = await makeFetch( + "POST", + `/api/1.0/ticket/ticket/${rootState.ticket.ticket.id}/set-caller`, + { caller: callerData }, + ); + commit("setTicket", result as Ticket); + } catch (e: unknown) { + const error = e as ApiException; + throw error.name; + } + }, + + async getSuggestedPersons({ commit, rootState }) { + try { + const ticketId = rootState.ticket.ticket.id; + const caller = rootState.ticket.ticket.caller; + const result: (Person | Thirdparty)[] = await makeFetch( + "GET", + `/api/1.0/ticket/ticket/${ticketId}/suggest-person`, + ); + if (caller && !result.some((person) => person.id === caller.id)) { + result.push(caller); + } + commit("setPersons", result); + } catch (e: unknown) { + const error = e as ApiException; + throw error.name; + } + }, + }, +}; diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/store/modules/ticket.ts b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/store/modules/ticket.ts new file mode 100644 index 000000000..228dc74eb --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/store/modules/ticket.ts @@ -0,0 +1,136 @@ +import { Module } from "vuex"; +import { RootState } from ".."; + +import { + Comment, + Ticket, + TicketEmergencyState, + TicketHistoryLine, + TicketState, +} from "../../../../types"; +import { makeFetch } from "ChillMainAssets/lib/api/apiMethods"; +import { ApiException } from "../../../../../../../../ChillMainBundle/Resources/public/types"; +import { getSinceCreated } from "../../utils/utils"; + +export interface State { + ticket: Ticket; + action_icons: object; +} + +export const moduleTicket: Module<State, RootState> = { + state: () => ({ + ticket: {} as Ticket, + action_icons: { + add_person: "fa fa-user-plus", + add_comment: "fa fa-comment", + set_motive: "fa fa-paint-brush", + addressees_state: "fa fa-paper-plane", + persons_state: "fa fa-user", + set_caller: "fa fa-phone", + state_change: "", + emergency_change: "", + }, + }), + getters: { + isOpen(state) { + return state.ticket.currentState === "open"; + }, + isEmergency(state) { + return state.ticket.emergency == "yes"; + }, + isIncompleteTicket(state) { + return ( + state.ticket.currentMotive === null || + !state.ticket.history.some((item) => item.event_type == "add_comment") + ); + }, + getTicket(state) { + state.ticket.history = state.ticket.history.sort((a, b) => + b.at.datetime.localeCompare(a.at.datetime), + ); + return state.ticket; + }, + getActionIcons(state) { + return state.action_icons; + }, + getSinceCreated: (state) => (currentTime: Date) => { + if (!state.ticket.createdAt) { + return ""; + } + return getSinceCreated(state.ticket.createdAt.datetime, currentTime); + }, + getTicketHistory: (state) => { + return state.ticket.history; + }, + getTicketHistoryComments: (state) => { + return state.ticket.history.filter( + (history) => history.event_type == "add_comment", + ); + }, + getCurrentPersons(state) { + return state.ticket.currentPersons; + }, + getCaller: (state) => { + return state.ticket.caller; + }, + }, + + mutations: { + setTicket(state, ticket: Ticket) { + state.ticket = ticket; + }, + setTicketHistoryComment(state, ticketHistoryData: Comment) { + const index = state.ticket.history.findIndex( + (item: TicketHistoryLine) => + item.event_type == "add_comment" && + item.data.id === ticketHistoryData.id, + ); + + if (index !== -1) { + state.ticket.history[index].data = ticketHistoryData; + } + }, + }, + actions: { + async setTicketState({ commit, state }, ticketState: TicketState) { + try { + const result: Ticket = await makeFetch( + "POST", + `/api/1.0/ticket/ticket/${state.ticket.id}/${ticketState}`, + ); + commit("setTicket", result as Ticket); + } catch (e: unknown) { + const error = e as ApiException; + throw error.name; + } + }, + + async fetchTicket({ commit }, ticketId: number) { + try { + const ticket: Ticket = await makeFetch( + "GET", + `/api/1.0/ticket/ticket/${ticketId}`, + ); + commit("setTicket", ticket); + } catch (error) { + console.error( + "Erreur lors du chargement du ticket:", + error as ApiException, + ); + throw error; + } + }, + async setEmergency({ commit, state }, emergency: TicketEmergencyState) { + try { + const result: Ticket = await makeFetch( + "POST", + `/api/1.0/ticket/ticket/${state.ticket.id}/emergency/${emergency}`, + ); + commit("setTicket", result as Ticket); + } catch (e: unknown) { + const error = e as ApiException; + throw error.name; + } + }, + }, +}; diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/store/modules/ticket_list.ts b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/store/modules/ticket_list.ts new file mode 100644 index 000000000..dfaa1a9a3 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/store/modules/ticket_list.ts @@ -0,0 +1,138 @@ +import { Module } from "vuex"; +import { RootState } from ".."; +import { TicketFilterParams, TicketSimple } from "../../../../types"; +import { + makeFetch, + Pagination, + PaginationResponse, +} from "ChillMainAssets/lib/api/apiMethods"; +import { + ApiException, + User, +} from "../../../../../../../../ChillMainBundle/Resources/public/types"; + +export interface State { + ticket_list: TicketSimple[]; + pagination: Pagination; + count: number; + user: User; +} + +export const moduleTicketList: Module<State, RootState> = { + state: () => ({ + ticket_list: [], + pagination: { + first: 0, + items_per_page: 50, + more: false, + next: null, + previous: null, + }, + count: 0, + user: {} as User, + }), + getters: { + getTicketList(state): TicketSimple[] { + return state.ticket_list; + }, + getPreviousTicketList(state, getters, rootState): TicketSimple[] { + return state.ticket_list.filter( + (ticket) => ticket.id !== rootState.ticket.ticket.id, + ); + }, + getPagination(state) { + return state.pagination; + }, + getUser(state): User { + return state.user; + }, + getCount(state): number { + return state.count; + }, + }, + mutations: { + setTicketList(state, ticketList: TicketSimple[]) { + state.ticket_list = ticketList; + }, + setPagination(state, pagination: State["pagination"]) { + state.pagination = pagination; + }, + setUser(state, user: User) { + state.user = user; + }, + setCount(state, count: number) { + state.count = count; + }, + }, + actions: { + async fetchTicketList( + { commit }, + ticketFilterParams: TicketFilterParams | null, + ) { + try { + let params = ""; + if (ticketFilterParams) { + const filteredParams = Object.fromEntries( + Object.entries(ticketFilterParams).filter( + ([, value]) => + value !== null && + value !== null && + (value === true || + (typeof value === "number" && !isNaN(value)) || + (typeof value === "string" && value !== "") || + (Array.isArray(value) && value.length > 0)), + ), + ); + params = new URLSearchParams( + filteredParams as Record<string, string>, + ).toString(); + } + + const { results, pagination, count } = (await makeFetch( + "GET", + `/api/1.0/ticket/ticket/list/?${params}`, + )) as PaginationResponse<TicketSimple>; + + commit("setTicketList", results); + commit("setCount", count); + commit("setPagination", pagination); + } catch (e: unknown) { + const error = e as ApiException; + console.error( + "Erreur lors du chargement de la liste des tickets:", + error, + ); + } + }, + async fetchConnectedUser({ commit }) { + try { + const user = await makeFetch("GET", "/api/1.0/main/whoami.json"); + commit("setUser", user); + return user; + } catch (error) { + console.error( + "Erreur lors de la récupération de l'utilisateur connecté:", + error as ApiException, + ); + throw error; + } + }, + async fetchTicketListByUrl({ commit, state }, url: string) { + try { + const { results, pagination, count } = (await makeFetch( + "GET", + url, + )) as PaginationResponse<TicketSimple>; + commit("setTicketList", [...state.ticket_list, ...results]); + commit("setCount", count); + commit("setPagination", pagination); + } catch (e: unknown) { + const error = e as ApiException; + console.error( + "Erreur lors du chargement de la liste des tickets par URL:", + error, + ); + } + }, + }, +}; diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/store/modules/user.ts b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/store/modules/user.ts new file mode 100644 index 000000000..06492f027 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/store/modules/user.ts @@ -0,0 +1,41 @@ +import { Module } from "vuex"; +import { RootState } from "../index"; +import { ApiException, User } from "ChillMainAssets/types"; +import { makeFetch } from "ChillMainAssets/lib/api/apiMethods"; + +export interface State { + currentUser: User | null; +} + +export const moduleUser: Module<State, RootState> = { + state: () => ({ + currentUser: null, + }), + + getters: { + currentUser(state) { + return state.currentUser; + }, + }, + + mutations: { + setCurrentUser(state, user: User) { + state.currentUser = user; + }, + }, + + actions: { + async getCurrentUser({ commit }) { + try { + const userData: User = await makeFetch( + "GET", + "/api/1.0/main/whoami.json", + ); + commit("setCurrentUser", userData); + } catch (e: unknown) { + const error = e as ApiException; + throw error.name; + } + }, + }, +}; diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/utils/utils.ts b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/utils/utils.ts new file mode 100644 index 000000000..5ff6f995a --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketApp/utils/utils.ts @@ -0,0 +1,103 @@ +import { ISOToDatetime } from "../../../../../../../../Bundle/ChillMainBundle/Resources/public/chill/js/date"; +import { + trans, + CHILL_TICKET_TICKET_BANNER_DAYS, + CHILL_TICKET_TICKET_BANNER_HOURS, + CHILL_TICKET_TICKET_BANNER_MINUTES, + CHILL_TICKET_TICKET_BANNER_SECONDS, + CHILL_TICKET_TICKET_BANNER_AND, + CHILL_TICKET_TICKET_BANNER_NO_MOTIVE, +} from "translator"; +import { MotiveWithParent, Ticket, TicketSimple } from "../../../types"; +import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper"; + +/** + * Calcule et formate le temps écoulé depuis une date de création + * @param createdAt La date de création au format ISO + * @param currentTime La date actuelle + * @returns Une chaîne formatée représentant le temps écoulé + */ +export function getSinceCreated(createdAt: string, currentTime: Date): string { + if (!createdAt) { + return ""; + } + const date = ISOToDatetime(createdAt); + if (!date) { + return ""; + } + + const timeDiff = Math.abs(currentTime.getTime() - date.getTime()); + const daysDiff = Math.floor(timeDiff / (1000 * 3600 * 24)); + const hoursDiff = Math.floor((timeDiff % (1000 * 3600 * 24)) / (1000 * 3600)); + const minutesDiff = Math.floor((timeDiff % (1000 * 3600)) / (1000 * 60)); + const secondsDiff = Math.floor((timeDiff % (1000 * 60)) / 1000); + + const parts: string[] = []; + if (daysDiff > 0) { + parts.push(trans(CHILL_TICKET_TICKET_BANNER_DAYS, { count: daysDiff })); + } + if (hoursDiff > 0 || daysDiff > 0) { + parts.push( + trans(CHILL_TICKET_TICKET_BANNER_HOURS, { + count: hoursDiff, + }), + ); + } + if (minutesDiff > 0 || hoursDiff > 0 || daysDiff > 0) { + parts.push( + trans(CHILL_TICKET_TICKET_BANNER_MINUTES, { + count: minutesDiff, + }), + ); + } + if (parts.length === 0) { + return trans(CHILL_TICKET_TICKET_BANNER_SECONDS, { + count: secondsDiff, + }); + } + if (parts.length > 1) { + const last = parts.pop(); + return ( + parts.join(", ") + + " " + + trans(CHILL_TICKET_TICKET_BANNER_AND) + + " " + + last + ); + } + return parts[0]; +} + +export function formatDateTime( + dateTime: string, + dateStyle: string, + timeStyle: string, +): string { + return new Date(dateTime).toLocaleString("fr-FR", { + dateStyle: dateStyle as "short" | "medium" | "long" | "full", + timeStyle: timeStyle as "short" | "medium" | "long" | "full", + }); +} +export function getTicketTitle(ticket: Ticket | TicketSimple): string { + if (ticket.currentMotive) { + return `#${ticket.id} ${localizeString(ticket.currentMotive.label)}`; + } + return `#${ticket.id} ${trans(CHILL_TICKET_TICKET_BANNER_NO_MOTIVE)}`; +} + +export function motiveHierarchyLabel(motive: MotiveWithParent | null): string { + if (null === motive) { + return "Aucun motif"; + } + const str = []; + let m: MotiveWithParent | null = motive; + do { + str.push(localizeString(m.label)); + m = m.parent; + } while (m !== null); + str.reverse(); + if (str.length > 1) { + str.pop(); + } + return str.join(" > "); +} diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketList/App.vue b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketList/App.vue new file mode 100644 index 000000000..df27fbde5 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketList/App.vue @@ -0,0 +1,128 @@ +<template> + <div class="container-fluid"> + <div class="row"> + <div class="col-12 mb-4"> + <ticket-filter-list-component + :resultCount="resultCount" + :available-persons="availablePersons" + :available-motives="availableMotives" + :ticket-filter-params="ticketFilterParams" + @filters-changed="handleFiltersChanged" + /> + </div> + <div class="col-12"> + <!-- Loading state --> + <div + v-if="isLoading" + class="d-flex justify-content-center align-items-center" + style="height: 200px" + > + <div class="text-center"> + <div class="spinner-border mb-3" role="status"> + <span class="visually-hidden">{{ + trans(CHILL_TICKET_LIST_LOADING_TICKET) + }}</span> + </div> + <div class="text-muted"> + {{ trans(CHILL_TICKET_LIST_LOADING_TICKET) }} + </div> + </div> + </div> + + <!-- Ticket list --> + <ticket-list-component + v-else + :tickets="ticketList" + :hasMoreTickets="pagination.next !== null" + @fetchNextPage="fetchNextPage" + /> + </div> + </div> + </div> +</template> + +<script setup lang="ts"> +import { ref, computed, onMounted } from "vue"; +import { useStore } from "vuex"; + +// Types +import { TicketSimple, Motive, TicketFilterParams } from "../../types"; +import type { Person } from "ChillPersonAssets/types"; + +// Components +import TicketListComponent from "./components/TicketListComponent.vue"; +import TicketFilterListComponent from "./components/TicketFilterListComponent.vue"; +import { Pagination } from "ChillMainAssets/lib/api/apiMethods"; + +// Translations +import { trans, CHILL_TICKET_LIST_LOADING_TICKET } from "translator"; + +const store = useStore(); +const ticketFilterParams = window.ticketFilterParams + ? window.ticketFilterParams + : null; + +const isLoading = ref(false); +const ticketList = computed( + () => store.getters.getTicketList as TicketSimple[], +); +const resultCount = computed(() => store.getters.getCount as number); +const pagination = computed(() => store.getters.getPagination as Pagination); +const availablePersons = ref<Person[]>([]); +const availableMotives = computed(() => store.getters.getMotives as Motive[]); + +const handleFiltersChanged = async (filters: TicketFilterParams) => { + isLoading.value = true; + try { + await store.dispatch("fetchTicketList", filters); + } finally { + isLoading.value = false; + } +}; + +const fetchNextPage = async () => { + if (pagination.value.next) { + await store.dispatch("fetchTicketListByUrl", pagination.value.next); + } +}; + +onMounted(async () => { + isLoading.value = true; + const filters: TicketFilterParams = { + byPerson: ticketFilterParams?.byPerson + ? ticketFilterParams.byPerson.map((person) => person.id) + : [], + byCreator: ticketFilterParams?.byCreator + ? ticketFilterParams.byCreator.map((creator) => creator.id) + : [], + byAddressee: ticketFilterParams?.byAddressee + ? ticketFilterParams.byAddressee.map((addressee) => addressee.id) + : [], + byCurrentState: ticketFilterParams?.byCurrentState ?? ["open"], + byCurrentStateEmergency: ticketFilterParams?.byCurrentStateEmergency ?? [], + byMotives: ticketFilterParams?.byMotives + ? ticketFilterParams.byMotives.map((motive) => motive.id) + : [], + byCreatedAfter: ticketFilterParams?.byCreatedAfter ?? "", + byCreatedBefore: ticketFilterParams?.byCreatedBefore ?? "", + byResponseTimeExceeded: ticketFilterParams?.byResponseTimeExceeded + ? "true" + : "", + byAddresseeToMe: ticketFilterParams?.byAddresseeToMe ?? false, + byTicketId: ticketFilterParams?.byTicketId ?? null, + }; + try { + await store.dispatch("fetchTicketList", filters); + store.dispatch("getCurrentUser"); + store.dispatch("fetchMotives"); + } finally { + isLoading.value = false; + } +}); +</script> + +<style lang="scss" scoped> +.container-fluid { + padding: 1rem; +} +</style> diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketList/components/DateAndTimeSelectorComponent.vue b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketList/components/DateAndTimeSelectorComponent.vue new file mode 100644 index 000000000..83f5e41ec --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketList/components/DateAndTimeSelectorComponent.vue @@ -0,0 +1,65 @@ +<template> + <label :for="dateId" class="form-label col-12"> + {{ label }} + </label> + <div class="d-flex gap-2"> + <div class="input-group"> + <input + type="date" + :id="dateId" + v-model="modelDate" + class="form-control" + :disabled="disabled" + /> + <input + type="time" + v-model="modelTime" + class="form-control" + :disabled="disabled" + style="max-width: 120px" + placeholder="hh:mm" + /> + <span class="input-group-text" v-if="modelDate"> + <i + class="fa fa-times chill-red" + style="cursor: pointer" + @click="clear" + title="clear" + ></i> + </span> + </div> + </div> +</template> + +<script setup lang="ts"> +import { computed } from "vue"; + +const props = defineProps<{ + label: string; + dateId: string; + modelValueDate: string; + modelValueTime: string; + defaultValueTime: string; + disabled?: boolean; +}>(); + +const emit = defineEmits<{ + (e: "update:modelValueDate", value: string): void; + (e: "update:modelValueTime", value: string): void; +}>(); + +const modelDate = computed({ + get: () => props.modelValueDate, + set: (val: string) => emit("update:modelValueDate", val), +}); + +const modelTime = computed({ + get: () => props.modelValueTime, + set: (val: string) => emit("update:modelValueTime", val), +}); + +function clear() { + emit("update:modelValueDate", ""); + emit("update:modelValueTime", props.defaultValueTime); +} +</script> diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketList/components/TicketFilterListComponent.vue b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketList/components/TicketFilterListComponent.vue new file mode 100644 index 000000000..7bbe08d3c --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketList/components/TicketFilterListComponent.vue @@ -0,0 +1,501 @@ +<template> + <div class="card"> + <div class="card-header"> + <h5 class="card-title mb-0"> + {{ trans(CHILL_TICKET_LIST_FILTER_TITLE) }} + </h5> + </div> + <div class="card-body"> + <form @submit.prevent="applyFilters"> + <div class="row"> + <!-- Filtre par usagé --> + <div class="col-md-6 mb-3"> + <label class="form-label" for="personSelector">{{ + trans(CHILL_TICKET_LIST_FILTER_PERSONS_CONCERNED) + }}</label> + <persons-selector + v-model="filters.byPerson" + :suggested="availablePersons" + :multiple="true" + :types="['person']" + id="personSelector" + :label="trans(CHILL_TICKET_LIST_FILTER_BY_PERSON)" + :disabled="ticketFilterParams?.byPerson ? true : false" + /> + </div> + + <div class="col-md-6 mb-3"> + <label class="form-label" for="userSelector">{{ + trans(CHILL_TICKET_LIST_FILTER_CREATORS) + }}</label> + <persons-selector + v-model="filters.byCreator" + :suggested="[]" + :multiple="true" + :types="['user']" + id="userSelector" + :label="trans(CHILL_TICKET_LIST_FILTER_BY_CREATOR)" + :disabled="ticketFilterParams?.byCreator ? true : false" + /> + </div> + + <div class="col-md-6 mb-3"> + <label class="form-label" for="addresseeSelector">{{ + trans(CHILL_TICKET_LIST_FILTER_ADDRESSEES) + }}</label> + <addressee-selector-component + v-model="filters.byAddressee" + :suggested="[]" + :label="trans(CHILL_TICKET_LIST_FILTER_BY_ADDRESSEES)" + id="addresseeSelector" + :disabled="ticketFilterParams?.byAddressee ? true : false" + /> + </div> + <div class="col-md-6 mb-3"> + <!-- Filtre par motifs --> + <div class="row"> + <label class="form-label" for="motiveSelector">{{ + trans(CHILL_TICKET_LIST_FILTER_BY_MOTIVES) + }}</label> + <motive-selector + v-model="selectedMotive" + :motives="availableMotives" + :allow-parent-selection="true" + @remove="(motive) => removeMotive(motive)" + id="motiveSelector" + :disabled="ticketFilterParams?.byMotives ? true : false" + /> + + <div class="mb-2" style="min-height: 2em"> + <div class="d-flex flex-wrap gap-2"> + <span + v-for="motive in filters.byMotives" + :key="motive.id" + class="badge bg-secondary d-flex align-items-center gap-1" + > + {{ getMotiveDisplayName(motive) }} + <button + type="button" + class="btn-close btn-close-white" + :aria-label="trans(CHILL_TICKET_LIST_FILTER_REMOVE)" + @click="removeMotive(motive)" + :disabled="ticketFilterParams?.byMotives ? true : false" + ></button> + </span> + </div> + </div> + </div> + <!-- Filtre par état actuel --> + <div class="d-flex gap-3"> + <div> + <label class="form-label pe-2" for="currentState"> + {{ trans(CHILL_TICKET_LIST_FILTER_CURRENT_STATE) }} + </label> + <toggle-component + v-model="isClosedToggled" + :on-label="trans(CHILL_TICKET_LIST_FILTER_CLOSED)" + :off-label="trans(CHILL_TICKET_LIST_FILTER_OPEN)" + :classColor="{ + on: 'bg-chill-red', + off: 'bg-chill-green', + }" + @update:model-value="handleStateToggle" + id="currentState" + :disabled="ticketFilterParams?.byCurrentState ? true : false" + /> + </div> + + <!-- Filtre par état d'urgence --> + <div> + <label class="form-label pe-2" for="emergency"> + {{ trans(CHILL_TICKET_LIST_FILTER_EMERGENCY) }} + </label> + <toggle-component + v-model="isEmergencyToggled" + :on-label="trans(CHILL_TICKET_LIST_FILTER_EMERGENCY)" + :off-label="trans(CHILL_TICKET_LIST_FILTER_EMERGENCY)" + :classColor="{ + on: 'bg-warning', + off: 'bg-secondary', + }" + @update:model-value="handleEmergencyToggle" + id="emergency" + :disabled=" + ticketFilterParams?.byCurrentStateEmergency ? true : false + " + /> + </div> + </div> + </div> + </div> + + <div class="row"> + <!-- Filtre pour temps de réponse dépassé --> + <div class="col-md-6 mb-3"> + <div class="form-check"> + <input + v-model="filters.byAddresseeToMe" + class="form-check-input" + type="checkbox" + id="stateMe" + :disabled="ticketFilterParams?.byAddresseeToMe ? true : false" + /> + <label class="form-check-label" for="stateMe"> + {{ trans(CHILL_TICKET_LIST_FILTER_TO_ME) }} + </label> + </div> + + <div class="form-check"> + <input + class="form-check-input" + type="checkbox" + v-model="filters.byResponseTimeExceeded" + @change="handleResponseTimeExceededChange" + id="responseTimeExceeded" + :disabled=" + ticketFilterParams?.byResponseTimeExceeded ? true : false + " + /> + <label class="form-check-label" for="responseTimeExceeded"> + {{ trans(CHILL_TICKET_LIST_FILTER_RESPONSE_TIME_EXCEEDED) }} + </label> + </div> + <small class="form-text text-muted"> + <i class="bi bi-exclamation-triangle"></i> + {{ trans(CHILL_TICKET_LIST_FILTER_RESPONSE_TIME_WARNING) }} + </small> + </div> + <!-- Filtre par numéro de ticket --> + <div class="col-md-6 mb-3"> + <label class="form-label pe-2" for="ticketSelector"> + {{ trans(CHILL_TICKET_LIST_FILTER_BY_TICKET_ID) }} + </label> + <ticket-selector + v-model="filters.byTicketId" + id="ticketSelector" + :disabled="ticketFilterParams?.byTicketId ? true : false" + /> + </div> + </div> + + <!-- Filtre par date de création --> + <div class="row"> + <div class="col-md-6 mb-3"> + <date-and-time-selector-component + :label="trans(CHILL_TICKET_LIST_FILTER_CREATED_AFTER)" + date-id="byCreatedAfter" + default-value-time="00:00" + :model-value-date="filters.byCreatedAfter" + :model-value-time="byCreatedAfterTime" + :disabled=" + filters.byResponseTimeExceeded || + ticketFilterParams?.byCreatedAfter + ? true + : false + " + @update:modelValueDate="filters.byCreatedAfter = $event" + @update:modelValueTime="byCreatedAfterTime = $event" + /> + </div> + + <div class="col-md-6 mb-3"> + <date-and-time-selector-component + :label="trans(CHILL_TICKET_LIST_FILTER_CREATED_BEFORE)" + date-id="byCreatedBefore" + default-value-time="23:59" + :model-value-date="filters.byCreatedBefore" + :model-value-time="byCreatedBeforeTime" + :disabled=" + filters.byResponseTimeExceeded || + ticketFilterParams?.byCreatedBefore + ? true + : false + " + @update:modelValueDate="filters.byCreatedBefore = $event" + @update:modelValueTime="byCreatedBeforeTime = $event" + /> + </div> + </div> + + <div class="row"> + <div class="col-12 d-flex align-items-end justify-content-end gap-2"> + <button + type="button" + @click="resetFilters" + class="btn btn-outline-secondary" + > + <i class="bi bi-arrow-clockwise"></i> + {{ trans(CHILL_TICKET_LIST_FILTER_RESET) }} + </button> + <button type="submit" class="btn btn-primary"> + <i class="bi bi-funnel"></i> + {{ trans(CHILL_TICKET_LIST_FILTER_APPLY) }} + </button> + </div> + </div> + <div class="row"> + <span + class="col-12 d-flex align-items-end justify-content-end form-text text-muted mt-1" + > + {{ resultCount !== 0 ? `${resultCount} ` : "" }} + {{ trans(CHILL_TICKET_LIST_FILTER_RESULT, { count: resultCount }) }} + </span> + </div> + </form> + </div> + </div> +</template> + +<script setup lang="ts"> +import { ref, computed, watch } from "vue"; +import type { Person } from "ChillPersonAssets/types"; +import { + type Motive, + type TicketFilterParams, + type TicketFilters, +} from "../../../types"; + +// Translation +import { + trans, + CHILL_TICKET_LIST_FILTER_TITLE, + CHILL_TICKET_LIST_FILTER_PERSONS_CONCERNED, + CHILL_TICKET_LIST_FILTER_BY_PERSON, + CHILL_TICKET_LIST_FILTER_CREATORS, + CHILL_TICKET_LIST_FILTER_BY_CREATOR, + CHILL_TICKET_LIST_FILTER_ADDRESSEES, + CHILL_TICKET_LIST_FILTER_BY_ADDRESSEES, + CHILL_TICKET_LIST_FILTER_BY_MOTIVES, + CHILL_TICKET_LIST_FILTER_BY_TICKET_ID, + CHILL_TICKET_LIST_FILTER_REMOVE, + CHILL_TICKET_LIST_FILTER_OPEN, + CHILL_TICKET_LIST_FILTER_CLOSED, + CHILL_TICKET_LIST_FILTER_TO_ME, + CHILL_TICKET_LIST_FILTER_EMERGENCY, + CHILL_TICKET_LIST_FILTER_CREATED_AFTER, + CHILL_TICKET_LIST_FILTER_CREATED_BEFORE, + CHILL_TICKET_LIST_FILTER_RESPONSE_TIME_EXCEEDED, + CHILL_TICKET_LIST_FILTER_RESPONSE_TIME_WARNING, + CHILL_TICKET_LIST_FILTER_RESET, + CHILL_TICKET_LIST_FILTER_APPLY, + CHILL_TICKET_LIST_FILTER_RESULT, + CHILL_TICKET_LIST_FILTER_CURRENT_STATE, +} from "translator"; + +// Components +import PersonsSelector from "../../TicketApp/components/Person/PersonsSelectorComponent.vue"; +import MotiveSelector from "../../TicketApp/components/Motive/MotiveSelectorComponent.vue"; +import AddresseeSelectorComponent from "../../TicketApp/components/Addressee/AddresseeSelectorComponent.vue"; +import ToggleComponent from "./ToggleComponent.vue"; +import TicketSelector from "../../TicketApp/components/Ticket/TicketSelector.vue"; +import DateAndTimeSelectorComponent from "./DateAndTimeSelectorComponent.vue"; + +// Props +const props = defineProps<{ + availablePersons?: Person[]; + availableMotives: Motive[]; + resultCount: number; + ticketFilterParams: TicketFilters | null; +}>(); + +// Emits +const emit = defineEmits<{ + "filters-changed": [filters: TicketFilterParams]; +}>(); + +const filtersInitValues: TicketFilters = { + byPerson: props.ticketFilterParams?.byPerson ?? [], + byCreator: props.ticketFilterParams?.byCreator ?? [], + byAddressee: props.ticketFilterParams?.byAddressee ?? [], + byCurrentState: props.ticketFilterParams?.byCurrentState ?? ["open"], + byCurrentStateEmergency: + props.ticketFilterParams?.byCurrentStateEmergency ?? [], + byMotives: props.ticketFilterParams?.byMotives ?? [], + byCreatedAfter: props.ticketFilterParams?.byCreatedAfter ?? "", + byCreatedBefore: props.ticketFilterParams?.byCreatedBefore ?? "", + byResponseTimeExceeded: + props.ticketFilterParams?.byResponseTimeExceeded ?? false, + byAddresseeToMe: props.ticketFilterParams?.byAddresseeToMe ?? false, + byTicketId: props.ticketFilterParams?.byTicketId ?? null, +}; + +// État réactif +const filters = ref<TicketFilters>({ ...filtersInitValues }); + +const byCreatedAfterTime = ref("00:00"); +const byCreatedBeforeTime = ref("23:59"); +const isClosedToggled = ref(false); +const isEmergencyToggled = ref(false); + +const availablePersons = ref<Person[]>(props.availablePersons || []); +const selectedMotive = ref<Motive | null>(); + +watch(selectedMotive, (newMotive) => { + if ( + newMotive && + !filters.value.byMotives.find((m) => m.id === newMotive.id) + ) { + filters.value.byMotives = [...filters.value.byMotives, newMotive]; + } +}); + +const selectedPersonIds = computed(() => + filters.value.byPerson.map((person) => person.id), +); + +const selectedUserAddresseesIds = computed(() => + filters.value.byAddressee + .filter((addressee) => addressee.type === "user") + .map((addressee) => addressee.id), +); + +const selectedGroupAddresseesIds = computed(() => + filters.value.byAddressee + .filter((addressee) => addressee.type === "user_group") + .map((addressee) => addressee.id), +); + +const selectedCreatorIds = computed(() => + filters.value.byCreator.map((creator) => creator.id), +); + +const selectedMotiveIds = computed(() => + filters.value.byMotives.map((motive) => motive.id), +); + +const handleStateToggle = (value: boolean) => { + if (value) { + filters.value.byCurrentState = ["closed"]; + } else { + filters.value.byCurrentState = ["open"]; + } +}; + +const handleEmergencyToggle = (value: boolean) => { + if (value) { + filters.value.byCurrentStateEmergency = ["yes"]; + } else { + filters.value.byCurrentStateEmergency = []; + } +}; + +const formatDateToISO = (dateString: string, timeString: string): string => { + const [hours, minutes] = timeString.split(":").map(Number); + const date = new Date(dateString); + date.setHours(hours, minutes, 0, 0); + return date.toISOString(); +}; + +const getMotiveDisplayName = (motive: Motive): string => { + if (typeof motive.label === "string") { + return motive.label; + } + if (typeof motive.label === "object" && motive.label !== null) { + const labels = Object.values(motive.label); + return labels[0] || `Motive ${motive.id}`; + } + return `Motive ${motive.id}`; +}; + +const removeMotive = (motiveToRemove: Motive): void => { + filters.value.byMotives = filters.value.byMotives.filter( + (m) => m.id !== motiveToRemove.id, + ); + if (selectedMotive.value && motiveToRemove.id == selectedMotive.value.id) { + selectedMotive.value = null; + } +}; + +const applyFilters = (): void => { + const apiFilters: TicketFilterParams = {}; + + if (selectedPersonIds.value.length > 0) { + apiFilters.byPerson = selectedPersonIds.value; + } + if (selectedCreatorIds.value.length > 0) { + apiFilters.byCreator = selectedCreatorIds.value; + } + if (selectedUserAddresseesIds.value.length > 0) { + apiFilters.byAddressee = selectedUserAddresseesIds.value; + } + if (selectedGroupAddresseesIds.value.length > 0) { + apiFilters.byAddresseeGroup = selectedGroupAddresseesIds.value; + } + if (filters.value.byCurrentState.length > 0) { + apiFilters.byCurrentState = filters.value.byCurrentState; + } + + if (filters.value.byCurrentStateEmergency.length > 0) { + apiFilters.byCurrentStateEmergency = filters.value.byCurrentStateEmergency; + } + + if (selectedMotiveIds.value.length > 0) { + apiFilters.byMotives = selectedMotiveIds.value; + } + + if (filters.value.byCreatedAfter) { + apiFilters.byCreatedAfter = formatDateToISO( + filters.value.byCreatedAfter, + byCreatedAfterTime.value, + ); + } + + if (filters.value.byCreatedBefore) { + apiFilters.byCreatedBefore = formatDateToISO( + filters.value.byCreatedBefore, + byCreatedBeforeTime.value, + ); + } + + if (filters.value.byResponseTimeExceeded) { + apiFilters.byResponseTimeExceeded = "true"; + } + if (filters.value.byAddresseeToMe) { + apiFilters.byAddresseeToMe = true; + } + if (filters.value.byAddresseeToMe) { + apiFilters.byAddresseeToMe = true; + } + if (filters.value.byTicketId) { + apiFilters.byTicketId = filters.value.byTicketId; + } + emit("filters-changed", apiFilters); +}; + +const resetFilters = (): void => { + filters.value = { ...filtersInitValues }; + selectedMotive.value = null; + isClosedToggled.value = false; + isEmergencyToggled.value = false; + byCreatedAfterTime.value = "00:00"; + byCreatedBeforeTime.value = "23:59"; + applyFilters(); +}; + +const handleResponseTimeExceededChange = (): void => { + if (filters.value.byResponseTimeExceeded) { + filters.value.byCreatedBefore = ""; + filters.value.byCreatedAfter = ""; + isClosedToggled.value = false; + } +}; +</script> + +<style scoped> +.form-text { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.badge .btn-close { + font-size: 0.65em; +} + +.form-label { + font-weight: bold; +} +.form-check-label { + font-weight: bold; +} +</style> diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketList/components/TicketHistoryListComponent.vue b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketList/components/TicketHistoryListComponent.vue new file mode 100644 index 000000000..51ff87f00 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketList/components/TicketHistoryListComponent.vue @@ -0,0 +1,168 @@ +<template> + <div + class="card my-2 bg-light" + v-for="history_line in filteredHistoryLines" + :key="history.indexOf(history_line)" + > + <div class="card-header"> + <div class="history-header row align-items-center gx-0"> + <div class="col d-flex align-items-center"> + <i :class="`${actionIcons[history_line.event_type]} me-1`"></i> + <span class="fw-bold">{{ explainSentence(history_line) }}</span> + <state-component + :new_state="history_line.data.new_state" + v-if="history_line.event_type == 'state_change'" + /> + <emergency-component + v-else-if="history_line.event_type == 'emergency_change'" + :new_emergency="history_line.data.new_emergency" + /> + </div> + <div class="col-auto d-flex justify-content-end align-items-center"> + <span class="badge-user d-flex"> + <user-render-box-badge :user="history_line.by" /> + </span> + <span class="fst-italic mx-2 d-flex"> + {{ formatDate(history_line.at) }} + </span> + </div> + </div> + </div> + <div class="card-body row" v-if="displayBody(history_line)"> + <person-component + :entities="history_line.data.persons" + v-if="history_line.event_type == 'persons_state'" + /> + <person-component + :entities=" + history_line.data.new_caller + ? ([history_line.data.new_caller] as Person[] | Thirdparty[]) + : [] + " + v-else-if="history_line.event_type == 'set_caller'" + /> + <motive-component + :motiveHistory="history_line.data" + v-else-if="history_line.event_type == 'set_motive'" + /> + <comment-component + :commentHistory="history_line.data" + v-else-if="history_line.event_type == 'add_comment'" + /> + <addressee-component + :addressees="history_line.data.addressees" + v-else-if="history_line.event_type == 'addressees_state'" + /> + </div> + </div> +</template> +<script setup lang="ts"> +import { computed, ref } from "vue"; +import { useStore } from "vuex"; + +// Types +import { DateTime } from "../../../../../../../ChillMainBundle/Resources/public/types"; +import { TicketHistoryLine } from "../../../types"; +import { Person } from "ChillPersonAssets/types"; +import { Thirdparty } from "src/Bundle/ChillThirdPartyBundle/Resources/public/types"; + +// Components +import PersonComponent from "../../TicketApp/components/Person/PersonComponent.vue"; +import MotiveComponent from "../../TicketApp/components/Motive/MotiveComponent.vue"; +import CommentComponent from "../../TicketApp/components/Comment/CommentComponent.vue"; +import AddresseeComponent from "../../TicketApp/components/Addressee/AddresseeComponent.vue"; +import StateComponent from "../../TicketApp/components/State/StateComponent.vue"; +import EmergencyComponent from "../../TicketApp/components/Emergency/EmergencyComponent.vue"; + +import UserRenderBoxBadge from "ChillMainAssets/vuejs/_components/Entity/UserRenderBoxBadge.vue"; + +// Utils +import { ISOToDatetime } from "../../../../../../../ChillMainBundle/Resources/public/chill/js/date"; + +// Translations +import { + trans, + CHILL_TICKET_TICKET_HISTORY_ADD_COMMENT, + CHILL_TICKET_TICKET_HISTORY_ADDRESSEES_STATE, + CHILL_TICKET_TICKET_HISTORY_PERSONS_STATE, + CHILL_TICKET_TICKET_HISTORY_SET_MOTIVE, + CHILL_TICKET_TICKET_HISTORY_CREATE_TICKET, + CHILL_TICKET_TICKET_HISTORY_STATE_CHANGE, + CHILL_TICKET_TICKET_HISTORY_EMERGENCY_CHANGE, + CHILL_TICKET_TICKET_HISTORY_SET_CALLER, +} from "translator"; + +const props = defineProps<{ history?: TicketHistoryLine[] }>(); +const history = props.history ?? []; +const store = useStore(); + +const actionIcons = ref<Record<string, string>>(store.getters.getActionIcons); +const removedCommentIds = ref<number[]>(store.getters.getRemovedCommentIds); + +const filteredHistoryLines = computed(() => + history.filter( + (line: TicketHistoryLine) => + line.event_type !== "add_person" && + !( + line.event_type == "add_comment" && + line.data.deleted && + !removedCommentIds.value.includes(line.data.id) + ), + ), +); + +function explainSentence(history: TicketHistoryLine): string { + switch (history.event_type) { + case "add_comment": + return trans(CHILL_TICKET_TICKET_HISTORY_ADD_COMMENT); + case "addressees_state": + return trans(CHILL_TICKET_TICKET_HISTORY_ADDRESSEES_STATE, { + count: history.data.addressees.length, + }); + case "persons_state": + return trans(CHILL_TICKET_TICKET_HISTORY_PERSONS_STATE, { + count: history.data.persons.length, + }); + case "set_motive": + return trans(CHILL_TICKET_TICKET_HISTORY_SET_MOTIVE); + case "create_ticket": + return trans(CHILL_TICKET_TICKET_HISTORY_CREATE_TICKET); + case "state_change": + return trans(CHILL_TICKET_TICKET_HISTORY_STATE_CHANGE); + case "emergency_change": + return trans(CHILL_TICKET_TICKET_HISTORY_EMERGENCY_CHANGE); + case "set_caller": + return trans(CHILL_TICKET_TICKET_HISTORY_SET_CALLER, { + id: history.data.new_caller?.id ?? "null", + }); + default: + return ""; + } +} + +function displayBody(history_line: TicketHistoryLine): boolean { + // Dont display body if no entities or if is change of state, emergency + const data = Object.values(history_line.data)[0]; + if (data == null) { + return false; + } else if (Array.isArray(data) && data.length === 0) { + return false; + } else if ( + ["state_change", "emergency_change"].includes(history_line.event_type) + ) { + return false; + } + return true; +} + +function formatDate(d: DateTime): string { + const date = ISOToDatetime(d.datetime); + + if (date === null) { + return ""; + } + + const month = date.toLocaleString("default", { month: "long" }); + return `${date.getDate()} ${month} ${date.getFullYear()}, ${date.toLocaleTimeString()}`; +} +</script> diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketList/components/TicketListComponent.vue b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketList/components/TicketListComponent.vue new file mode 100644 index 000000000..bbd9a63bd --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketList/components/TicketListComponent.vue @@ -0,0 +1,137 @@ +<template> + <div class="ticket-list-container"> + <div + v-if="tickets.length === 0" + class="chill-no-data-statement d-flex justify-content-center align-items-center" + > + <h3 class="text-muted fst-italic display-4"> + {{ trans(CHILL_TICKET_LIST_NO_TICKETS) }} + </h3> + </div> + <div + v-else + ref="ticketList" + style="overflow-y: scroll; flex: 1; min-height: 0; max-height: 100%" + > + <TicketListItemComponent + v-for="ticket in tickets" + :key="ticket.id" + :ticket="ticket" + @view-ticket="handleViewTicket" + @edit-ticket="handleEditTicket" + /> + <!-- Bouton pour charger plus de tickets --> + <div + v-if="hasMoreTickets" + class="text-center py-3" + style="margin: 12px 0" + > + <button class="btn btn-outline-primary" @click="loadMoreTickets"> + {{ trans(CHILL_TICKET_LIST_LOAD_MORE) }} + </button> + </div> + </div> + + <Modal + v-if="selectedTicketId !== null && ticketDetails" + :show="showTicketHistoryModal" + modal-dialog-class="modal-xl" + @close="closeHistoryModal" + > + <template #header> + <h3 class="modal-title"> + {{ getTicketTitle(ticketDetails) }} + </h3> + </template> + + <template #body> + <ticket-history-list-component + v-if="ticketDetails.history.length > 0" + :history="ticketDetails.history" + /> + <div v-else class="text-center p-4"> + <div class="spinner-border" role="status"> + <span class="visually-hidden">{{ + trans(CHILL_TICKET_LIST_LOADING_TICKET_DETAILS) + }}</span> + </div> + </div> + </template> + <template #footer> + <button + class="btn btn-edit" + @click="handleEditTicket(selectedTicketId)" + > + {{ trans(EDIT) }} + </button> + </template> + </Modal> + </div> +</template> + +<script setup lang="ts"> +import { computed, ref, onUnmounted } from "vue"; + +// Types +import { TicketSimple, Ticket } from "../../../types"; + +// Components +import TicketListItemComponent from "./TicketListItemComponent.vue"; +import TicketHistoryListComponent from "./TicketHistoryListComponent.vue"; +import Modal from "../../../../../../../ChillMainBundle/Resources/public/vuejs/_components/Modal.vue"; + +// Utils +import { getTicketTitle } from "../../TicketApp/utils/utils"; +import { localizedUrl } from "ChillMainAssets/lib/localizationHelper/localizationHelper"; + +// Translations +import { + trans, + EDIT, + CHILL_TICKET_LIST_NO_TICKETS, + CHILL_TICKET_LIST_LOAD_MORE, + CHILL_TICKET_LIST_LOADING_TICKET_DETAILS, +} from "translator"; +import { useStore } from "vuex"; + +defineProps<{ + tickets: TicketSimple[]; + hasMoreTickets: boolean; +}>(); + +const emit = defineEmits<{ + fetchNextPage: []; +}>(); + +const store = useStore(); +const selectedTicketId = ref<number | null>(null); +const showTicketHistoryModal = ref(false); +const ticketDetails = computed(() => store.getters.getTicket as Ticket | null); + +function closeHistoryModal() { + showTicketHistoryModal.value = false; + selectedTicketId.value = null; +} + +async function handleViewTicket(ticketId: number) { + await store.dispatch("fetchTicket", ticketId); + + selectedTicketId.value = ticketId; + showTicketHistoryModal.value = true; +} + +function handleEditTicket(ticketId: number) { + const returnPath = localizedUrl(`/ticket/ticket/list`); + window.location.href = localizedUrl( + `/ticket/ticket/${ticketId}/edit?returnPath=${returnPath}`, + ); +} + +function loadMoreTickets() { + emit("fetchNextPage"); +} + +onUnmounted(() => { + // Nettoyage si nécessaire +}); +</script> diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketList/components/TicketListItemComponent.vue b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketList/components/TicketListItemComponent.vue new file mode 100644 index 000000000..410905fa6 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketList/components/TicketListItemComponent.vue @@ -0,0 +1,132 @@ +<template> + <div class="card mb-3 text-primary border-primary"> + <div class="card-body"> + <div class="wrap-header"> + <div class="row align-items-top"> + <div class="col-6"> + <div class="small text-muted"> + {{ motiveHierarchyLabel(ticket.currentMotive) }} + </div> + </div> + <div class="col-6 text-end"> + <emergency-component + :new_emergency="ticket.emergency" + v-if="ticket.emergency == 'yes'" + /> + <state-component + v-if="ticket.currentState" + :new_state="ticket.currentState" + /> + </div> + </div> + </div> + <div class="row align-items-top"> + <div class="col-md-6 col-12"> + <span + class="h2" + style="color: var(--bs-chill-blue); font-variant: all-small-caps" + > + {{ getTicketTitle(ticket) }} + </span> + </div> + <div class="col-md-6 col-12 text-md-end text-start"> + <span + v-if="ticket.createdAt" + :title="formatDateTime(ticket.createdAt.datetime, 'long', 'long')" + style="font-style: italic" + > + {{ getSinceCreated(ticket.createdAt.datetime, new Date()) }} + </span> + </div> + </div> + </div> + + <div class="card-body pt-0"> + <div class="row" v-if="ticket.currentAddressees.length"> + <div class="col-12 title text-start"> + <h3>{{ trans(CHILL_TICKET_LIST_ADDRESSEES) }}</h3> + </div> + <div class="col-12 list"> + <addressee-component :addressees="ticket.currentAddressees" /> + </div> + </div> + <div class="row" v-if="ticket.currentPersons.length"> + <div class="col-12 title text-start"> + <h3>{{ trans(CHILL_TICKET_LIST_PERSONS) }}</h3> + </div> + <div class="col-12 list"> + <person-component :entities="ticket.currentPersons" /> + </div> + </div> + <div class="row" v-if="ticket.caller"> + <div class="col-12 title text-start"> + <h3>{{ trans(CHILL_TICKET_LIST_CALLERS) }}</h3> + </div> + <div class="col-12 list"> + <person-component + :entities=" + ticket.caller ? ([ticket.caller] as Person[] | Thirdparty[]) : [] + " + /> + </div> + </div> + </div> + <hr class="my-0" /> + <div class="card-footer bg-transparent border-0"> + <ul class="record_actions mb-0"> + <li> + <button + class="btn btn-view btn-outline-secondary" + type="button" + @click="$emit('view-ticket', ticket.id)" + /> + </li> + <li> + <button + class="btn btn-update btn-outline-primary" + type="button" + @click="emit('edit-ticket', ticket.id)" + /> + </li> + </ul> + </div> + </div> +</template> + +<script setup lang="ts"> +// Types +import { TicketSimple } from "../../../types"; +import { Person } from "ChillPersonAssets/types"; +import { Thirdparty } from "src/Bundle/ChillThirdPartyBundle/Resources/public/types"; + +// Utils +import { + getSinceCreated, + formatDateTime, + getTicketTitle, + motiveHierarchyLabel, +} from "../../TicketApp/utils/utils"; + +// Components +import EmergencyComponent from "../../TicketApp/components/Emergency/EmergencyComponent.vue"; +import StateComponent from "../../TicketApp/components/State/StateComponent.vue"; +import AddresseeComponent from "../../TicketApp/components/Addressee/AddresseeComponent.vue"; +import PersonComponent from "../../TicketApp/components/Person/PersonComponent.vue"; + +// Translation +import { + trans, + CHILL_TICKET_LIST_ADDRESSEES, + CHILL_TICKET_LIST_PERSONS, + CHILL_TICKET_LIST_CALLERS, +} from "translator"; + +defineProps<{ + ticket: TicketSimple; +}>(); + +const emit = defineEmits<{ + "edit-ticket": [ticketId: number]; + "view-ticket": [ticketId: number]; +}>(); +</script> diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketList/components/ToggleComponent.vue b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketList/components/ToggleComponent.vue new file mode 100644 index 000000000..65c3b1707 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketList/components/ToggleComponent.vue @@ -0,0 +1,152 @@ +<template> + <div class="toggle-wrapper"> + <input + :id="inputId" + type="checkbox" + class="toggle-input" + :checked="modelValue" + @change="handleChange" + :disabled="disabled" + /> + <label + :for="inputId" + class="toggle-label" + :class="[ + modelValue + ? classColor?.on || 'bg-chill-green' + : classColor?.off || 'bg-danger', + ]" + :style="{ + backgroundColor: classColor ? '' : !modelValue ? colorOff : colorOn, + height: '28px', + width: toggleWidth + 'px', + }" + > + <span + class="toggle-slider" + :style="{ + transform: modelValue ? `translateX(${toggleWidth - 26}px)` : 'none', + width: '24px', + height: '24px', + }" + > + </span> + <span + class="toggle-text" + :style="{ + color: 'white', + marginLeft: !modelValue ? '28px' : '0', + paddingLeft: modelValue ? '6px' : '0', + }" + > + {{ modelValue ? onLabel : offLabel }} + </span> + </label> + </div> +</template> + +<script setup lang="ts"> +import { computed } from "vue"; + +interface Props { + modelValue: boolean; + onLabel?: string; + offLabel?: string; + disabled?: boolean; + id?: string; + colorOn?: string; + colorOff?: string; + classColor?: { + on: string; + off: string; + }; +} + +type Emits = (e: "update:modelValue", value: boolean) => void; + +const props = withDefaults(defineProps<Props>(), { + onLabel: "ON", + offLabel: "OFF", + disabled: false, + id: "", + colorOn: "#4caf50", + colorOff: "#ccc", + classColor: () => ({ + on: "bg-chill-green", + off: "bg-chill-red", + }), +}); + +const emit = defineEmits<Emits>(); + +const inputId = computed( + () => props.id || `toggle-${Math.random().toString(36).substr(2, 9)}`, +); + +// Calcule la largeur du toggle basée sur le label le plus long +const toggleWidth = computed(() => { + const onLength = props.onLabel.length; + const offLength = props.offLabel.length; + const maxLength = Math.max(onLength, offLength); + + // Largeur minimale: 56px, puis 7px par caractère supplémentaire au-delà de 3 caractères + const baseWidth = 56; + const extraWidth = Math.max(0, maxLength - 3) * 7; + + return baseWidth + extraWidth; +}); + +const handleChange = (event: Event) => { + const target = event.target as HTMLInputElement; + emit("update:modelValue", target.checked); +}; +</script> + +<style scoped> +.toggle-wrapper { + display: inline-block; + position: relative; +} + +.toggle-input { + display: none; +} + +.toggle-label { + display: block; + border-radius: 14px; + position: relative; + cursor: pointer; + transition: background-color 0.3s ease; + user-select: none; +} + +.toggle-label:hover { + filter: brightness(0.95); +} + +.toggle-input:disabled + .toggle-label { + opacity: 0.6; + cursor: not-allowed; +} + +.toggle-slider { + position: absolute; + top: 2px; + left: 2px; + background-color: white; + border-radius: 50%; + transition: transform 0.3s ease; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.toggle-text { + font-size: 9px; + font-weight: bold; + letter-spacing: 0.5px; + transition: opacity 0.3s ease; +} +</style> diff --git a/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketList/index.ts b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketList/index.ts new file mode 100644 index 000000000..10aa5a051 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/public/vuejs/TicketList/index.ts @@ -0,0 +1,19 @@ +import App from "./App.vue"; +import { createApp } from "vue"; +import { store } from "../TicketApp/store"; +import VueToast from "vue-toast-notification"; +import "vue-toast-notification/dist/theme-sugar.css"; +import { TicketFilters } from "../../types"; + +declare global { + interface Window { + title: string; + ticketFilterParams: TicketFilters; + } +} + +const _app = createApp({ + template: "<app></app>", +}); + +_app.component("app", App).use(store).use(VueToast).mount("#ticketList"); diff --git a/src/Bundle/ChillTicketBundle/src/Resources/views/Admin/Motive/edit.html.twig b/src/Bundle/ChillTicketBundle/src/Resources/views/Admin/Motive/edit.html.twig new file mode 100644 index 000000000..73c005374 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/views/Admin/Motive/edit.html.twig @@ -0,0 +1,15 @@ +{% extends '@ChillMain/CRUD/Admin/index.html.twig' %} + +{% block title %} + {% include('@ChillMain/CRUD/_edit_title.html.twig') %} +{% endblock %} + +{% block js %} +{{ parent() }} +{% endblock %} + +{% block admin_content %} + {% embed '@ChillMain/CRUD/_edit_content.html.twig' %} + {% block content_form_actions_save_and_view %}{% endblock %} + {% endembed %} +{% endblock %} diff --git a/src/Bundle/ChillTicketBundle/src/Resources/views/Admin/Motive/index.html.twig b/src/Bundle/ChillTicketBundle/src/Resources/views/Admin/Motive/index.html.twig new file mode 100644 index 000000000..fe3959309 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/views/Admin/Motive/index.html.twig @@ -0,0 +1,68 @@ +{% extends '@ChillMain/Admin/layoutWithVerticalMenu.html.twig' %} + +{% block title %}{{ 'admin.motive.list.title'|trans }}{% endblock title %} + +{% block admin_content %} + + <h1>{{ 'admin.motive.list.title'|trans }}</h1> + + <table class="records_list table table-bordered border-dark"> + <thead> + <tr> + <th>{{ 'Label'|trans }}</th> + <th>{{ 'Active'|trans }}</th> + <th>{{ 'emergency?'|trans }}</th> + <th>{{ 'Ordering'|trans }}</th> + <th>{{ 'Supplementary comments'|trans }}</th> + <th> </th> + </tr> + </thead> + <tbody> + {% for entity in entities %} + <tr> + <td>{{ entity.label|localize_translatable_string }}</td> + <td style="text-align:center;"> + {%- if entity.isActive -%} + <i class="fa fa-check-square-o"></i> + {%- else -%} + <i class="fa fa-square-o"></i> + {%- endif -%} + </td> + <td style="text-align:center;"> + {%- if entity.makeTicketEmergency -%} + {{ entity.makeTicketEmergency.value|trans }} + {%- else -%} + - + {%- endif -%} + </td> + <td style="text-align:center;"> + {{ entity.ordering }} + </td> + <td style="text-align:center;"> + {{ entity.supplementaryComments|length }} + </td> + <td> + <ul class="record_actions"> + <li> + <a href="{{ path('chill_crud_motive_view', { 'id': entity.id }) }}" class="btn btn-show" title="{{ 'show'|trans }}"></a> + </li> + <li> + <a href="{{ path('chill_crud_motive_edit', { 'id': entity.id }) }}" class="btn btn-edit" title="{{ 'edit'|trans }}"></a> + </li> + </ul> + </td> + </tr> + {% endfor %} + </tbody> + </table> + + {{ chill_pagination(paginator) }} + + <ul class="record_actions sticky-form-buttons"> + <li> + <a href="{{ path('chill_crud_motive_new') }}" class="btn btn-create"> + {{ 'admin.motive.new.title'|trans }} + </a> + </li> + </ul> +{% endblock %} diff --git a/src/Bundle/ChillTicketBundle/src/Resources/views/Admin/Motive/new.html.twig b/src/Bundle/ChillTicketBundle/src/Resources/views/Admin/Motive/new.html.twig new file mode 100644 index 000000000..428c3fa75 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/views/Admin/Motive/new.html.twig @@ -0,0 +1,15 @@ +{% extends '@ChillMain/CRUD/Admin/index.html.twig' %} + +{% block title %} + {% include('@ChillMain/CRUD/_new_title.html.twig') %} +{% endblock %} + +{% block js %} +{{ parent() }} +{% endblock %} + +{% block admin_content %} + {% embed '@ChillMain/CRUD/_new_content.html.twig' %} + {% block content_form_actions_save_and_show %}{% endblock %} + {% endembed %} +{% endblock %} diff --git a/src/Bundle/ChillTicketBundle/src/Resources/views/Admin/Motive/view.html.twig b/src/Bundle/ChillTicketBundle/src/Resources/views/Admin/Motive/view.html.twig new file mode 100644 index 000000000..8ac3176ff --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/views/Admin/Motive/view.html.twig @@ -0,0 +1,68 @@ +{% extends '@ChillMain/Admin/layoutWithVerticalMenu.html.twig' %} + +{% block title %}{{ 'admin.motive.view.title'|trans }}{% endblock title %} + +{% block admin_content %} + + <h1>{{ 'admin.motive.view.title'|trans }}</h1> + + <table class="record_properties table table-bordered"> + <tbody> + <tr> + <th>{{ 'Id'|trans }}</th> + <td>{{ entity.id }}</td> + </tr> + <tr> + <th>{{ 'Label'|trans }}</th> + <td>{{ entity.label|localize_translatable_string }}</td> + </tr> + <tr> + <th>{{ 'Active'|trans }}</th> + <td style="text-align:center;"> + {%- if entity.isActive -%} + <i class="fa fa-check-square-o"></i> {{ 'Yes'|trans }} + {%- else -%} + <i class="fa fa-square-o"></i> {{ 'No'|trans }} + {%- endif -%} + </td> + </tr> + <tr> + <th>{{ 'emergency?'|trans }}</th> + <td> + {%- if entity.makeTicketEmergency -%} + {{ entity.makeTicketEmergency.value|trans }} + {%- else -%} + - + {%- endif -%} + </td> + </tr> + </tbody> + </table> + + {% if entity.supplementaryComments is not empty %} + <h2>{{ 'Supplementary comments'|trans }}</h2> + <div class="supplementary-comments"> + {% for comment in entity.supplementaryComments %} + <div class="card mb-3"> + <div class="card-body"> + <div class="comment-content"> + {{ comment.label|raw }} + </div> + </div> + </div> + {% endfor %} + </div> + {% else %} + <h2>{{ 'Supplementary comments'|trans }}</h2> + <p class="text-muted">{{ 'No supplementary comments'|trans }}</p> + {% endif %} + + <ul class="record_actions sticky-form-buttons"> + <li class="cancel"> + <a href="{{ path('chill_crud_motive_index') }}" class="btn btn-cancel">{{ 'Back to the list'|trans }}</a> + </li> + <li> + <a href="{{ path('chill_crud_motive_edit', { 'id': entity.id }) }}" class="btn btn-edit">{{ 'Edit'|trans }}</a> + </li> + </ul> +{% endblock %} diff --git a/src/Bundle/ChillTicketBundle/src/Resources/views/Admin/index.html.twig b/src/Bundle/ChillTicketBundle/src/Resources/views/Admin/index.html.twig new file mode 100644 index 000000000..8a5ec98f0 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/views/Admin/index.html.twig @@ -0,0 +1,13 @@ +{% extends "@ChillMain/Admin/layoutWithVerticalMenu.html.twig" %} + +{% block vertical_menu_content %} + {{ chill_menu('admin_ticket', { + 'layout': '@ChillMain/Admin/menu_admin_section.html.twig', + }) }} +{% endblock %} + +{% block layout_wvm_content %} + {% block admin_content %}<!-- block content empty --> + <h1>{{ 'Tickets configuration' |trans }}</h1> + {% endblock %} +{% endblock %} diff --git a/src/Bundle/ChillTicketBundle/src/Resources/views/Banner/banner.html.twig b/src/Bundle/ChillTicketBundle/src/Resources/views/Banner/banner.html.twig new file mode 100644 index 000000000..5a33f7fc2 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/views/Banner/banner.html.twig @@ -0,0 +1,8 @@ +<div class="banner banner-ticket "> + <div id="header-ticket-main" class="header-name"> + + </div> + <div id="header-ticket-details" class="header-details"> + + </div> +</div> diff --git a/src/Bundle/ChillTicketBundle/src/Resources/views/Person/list.html.twig b/src/Bundle/ChillTicketBundle/src/Resources/views/Person/list.html.twig new file mode 100644 index 000000000..bbd0f8903 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/views/Person/list.html.twig @@ -0,0 +1,32 @@ +{% extends "@ChillPerson/Person/layout.html.twig" %} +{% set ticketTitle = 'chill_ticket.list.title_with_name'|trans({'%name%': person|chill_entity_render_string }) %} +{% set activeRouteKey = 'chill_person_ticket_list' %} +{% set ticketFilterParams = { + 'byPerson': [person] +} %} +{% block title %}{{ ticketTitle }}{% endblock %} + +{% block css %} + {{ parent() }} + {{ encore_entry_link_tags('vue_ticket_list') }} +{% endblock %} + +{% block js %} + {{ parent() }} + <script type="text/javascript"> + window.ticketFilterParams = {{ ticketFilterParams|serialize('json', {'groups': ['read']})|raw }}; + </script> + {{ encore_entry_script_tags('vue_ticket_list') }} +{% endblock %} + +{% block content %} + <h1>{{ ticketTitle }}</h1> + + <div id="ticketList"></div> + + <ul class="record_actions sticky-form-buttons"> + <li> + <a href="{{ chill_path_add_return_path('chill_ticket_createticket__invoke') }}" class="btn btn-create">{{ 'Create'|trans }}</a> + </li> + </ul> +{% endblock %} diff --git a/src/Bundle/ChillTicketBundle/src/Resources/views/Ticket/edit.html.twig b/src/Bundle/ChillTicketBundle/src/Resources/views/Ticket/edit.html.twig new file mode 100644 index 000000000..34e23fd0d --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/views/Ticket/edit.html.twig @@ -0,0 +1,21 @@ +{% extends '@ChillTicket/layout.html.twig' %} + +{% block css %} + {{ parent() }} + {{ encore_entry_link_tags('vue_ticket_app') }} +{% endblock %} + +{% block js %} + {{ parent() }} + <script type="text/javascript"> + window.initialTicket = "{{ ticket|serialize('json', {'groups': 'read'})|escape('js') }}"; + window.ticketPersonPerTicket = "{{ personPerTicket|escape('js') }}"; + </script> + {{ encore_entry_script_tags('vue_ticket_app') }} +{% endblock %} + +{% block content %} + <div id="ticketRoot"></div> + <div class="sticky-form-buttons" id="actionToolbar" style="display: block;text-align: start;padding: inherit;"> + </div> +{% endblock %} diff --git a/src/Bundle/ChillTicketBundle/src/Resources/views/Ticket/list.html.twig b/src/Bundle/ChillTicketBundle/src/Resources/views/Ticket/list.html.twig new file mode 100644 index 000000000..b1597535a --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/views/Ticket/list.html.twig @@ -0,0 +1,24 @@ +{% extends '@ChillMain/layout.html.twig' %} +{% set ticketTitle = 'chill_ticket.list.title'|trans %} +{% block title %}{{ ticketTitle }}{% endblock %} + +{% block css %} + {{ parent() }} + {{ encore_entry_link_tags('vue_ticket_list') }} +{% endblock %} + +{% block js %} + {{ parent() }} + {{ encore_entry_script_tags('vue_ticket_list') }} +{% endblock %} + +{% block content %} + <h1>{{ ticketTitle }}</h1> + <div id="ticketList"></div> + + <ul class="record_actions sticky-form-buttons"> + <li> + <a href="{{ chill_path_add_return_path('chill_ticket_createticket__invoke') }}" class="btn btn-create">{{ 'Create'|trans }}</a> + </li> + </ul> +{% endblock %} diff --git a/src/Bundle/ChillTicketBundle/src/Resources/views/layout.html.twig b/src/Bundle/ChillTicketBundle/src/Resources/views/layout.html.twig new file mode 100644 index 000000000..8936d1c31 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Resources/views/layout.html.twig @@ -0,0 +1,17 @@ +{% extends '@ChillMain/layout.html.twig' %} + +{% block css %} + {{ encore_entry_link_tags('page_ticket') }} +{% endblock %} + +{% block js %} + {{ encore_entry_script_tags('page_ticket') }} +{% endblock %} + +{% block top_banner %} + {{ include('@ChillTicket/Banner/banner.html.twig') }} +{% endblock %} + +{% block wrapping_content %} + {% block content %}{% endblock %} +{% endblock %} diff --git a/src/Bundle/ChillTicketBundle/src/Security/Authorization/StoredObjectVoter/MotiveStoredObjectVoter.php b/src/Bundle/ChillTicketBundle/src/Security/Authorization/StoredObjectVoter/MotiveStoredObjectVoter.php new file mode 100644 index 000000000..64bfd763b --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Security/Authorization/StoredObjectVoter/MotiveStoredObjectVoter.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\TicketBundle\Security\Authorization\StoredObjectVoter; + +use Chill\DocStoreBundle\Entity\StoredObject; +use Chill\DocStoreBundle\Security\Authorization\StoredObjectRoleEnum; +use Chill\DocStoreBundle\Security\Authorization\StoredObjectVoterInterface; +use Chill\TicketBundle\Repository\MotiveRepository; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; + +final readonly class MotiveStoredObjectVoter implements StoredObjectVoterInterface +{ + public function __construct(private MotiveRepository $motiveRepository) {} + + public function supports(StoredObjectRoleEnum $attribute, StoredObject $subject): bool + { + return null !== $this->motiveRepository->findAssociatedEntityToStoredObject($subject); + } + + public function voteOnAttribute(StoredObjectRoleEnum $attribute, StoredObject $subject, TokenInterface $token): bool + { + return true; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Security/Voter/CommentVoter.php b/src/Bundle/ChillTicketBundle/src/Security/Voter/CommentVoter.php new file mode 100644 index 000000000..03622aa4b --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Security/Voter/CommentVoter.php @@ -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\TicketBundle\Security\Voter; + +use Chill\TicketBundle\Entity\Comment; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\AccessDecisionManagerInterface; +use Symfony\Component\Security\Core\Authorization\Voter\Voter; + +/** + * Check permissions on comments. + * + * - Adding a comment to the ticket requires to be allowed to write on the ticket; + * - Only the user who created the comment is allowed to edit it; + */ +final class CommentVoter extends Voter +{ + public const CREATE = 'CHILL_TICKET_COMMENT_CREATE'; + public const EDIT = 'CHILL_TICKET_COMMENT_EDIT'; + public const READ = 'CHILL_TICKET_COMMENT_READ'; + + private const ALL = [self::CREATE, self::EDIT, self::READ]; + + public function __construct(private readonly AccessDecisionManagerInterface $decisionManager) {} + + protected function supports(string $attribute, $subject): bool + { + return $subject instanceof Comment && in_array($attribute, self::ALL, true); + } + + protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool + { + /* @var Comment $subject */ + return match ($attribute) { + self::READ => !$subject->isDeleted() || $subject->getCreatedBy() === $token->getUser(), + self::CREATE => $this->decisionManager->decide($token, [TicketVoter::WRITE], $subject->getTicket()), + self::EDIT => $token->getUser() === $subject->getCreatedBy(), + default => throw new \Symfony\Component\Security\Core\Exception\LogicException('Invalid attribute'), + }; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Security/Voter/TicketVoter.php b/src/Bundle/ChillTicketBundle/src/Security/Voter/TicketVoter.php new file mode 100644 index 000000000..284dd55a8 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Security/Voter/TicketVoter.php @@ -0,0 +1,37 @@ +<?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\TicketBundle\Security\Voter; + +use Chill\TicketBundle\Entity\Ticket; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\Voter\Voter; + +/** + * Check permission on Ticket. + */ +final class TicketVoter extends Voter +{ + public const WRITE = 'CHILL_TICKET_TICKET_WRITE'; + public const READ = 'CHILL_TICKET_TICKET_READ'; + + private const ALL = [self::WRITE, self::READ]; + + protected function supports(string $attribute, $subject): bool + { + return $subject instanceof Ticket && in_array($attribute, self::ALL, true); + } + + protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool + { + return true; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/MotiveNormalizer.php b/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/MotiveNormalizer.php new file mode 100644 index 000000000..a9b33483b --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/MotiveNormalizer.php @@ -0,0 +1,103 @@ +<?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\TicketBundle\Serializer\Normalizer; + +use Chill\TicketBundle\Entity\Motive; +use Symfony\Component\Serializer\Exception\UnexpectedValueException; +use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * Normalizes a Motive object into an array format, supporting different serialization groups + * to customize the output depending on the context. + * + * There are several serialization groups available: + * - 'read': Basic information about the motive. + * - 'read:extended': Includes additional details like stored objects and supplementary comments. + * - 'read:parent-to-children': Normalizes children recursively without exposing parent to avoid cycles. + * - 'read:children-to-parent': Normalizes parent recursively without exposing children to avoid cycles. + */ +final class MotiveNormalizer implements NormalizerInterface, NormalizerAwareInterface +{ + use NormalizerAwareTrait; + + public const GROUP_PARENT_TO_CHILDREN = 'read:parent-to-children'; + public const GROUP_CHILDREN_TO_PARENT = 'read:children-to-parent'; + + public function normalize($object, ?string $format = null, array $context = []): array + { + if (!$object instanceof Motive) { + throw new UnexpectedValueException('Expected instance of '.Motive::class); + } + + $groups = $context[AbstractNormalizer::GROUPS] ?? []; + if (is_string($groups)) { + $groups = [$groups]; + } + + $data = []; + + if (in_array('read', $groups, true) || in_array('read:extended', $groups, true)) { + // Build base representation + $data = [ + 'type' => 'ticket_motive', + 'id' => $object->getId(), + 'label' => $object->getLabel(), + 'active' => $object->isActive(), + ]; + } + + if (in_array('read:extended', $groups, true)) { + $data['makeTicketEmergency'] = $object->getMakeTicketEmergency(); + $data['supplementaryComments'] = $object->getSupplementaryComments(); + // Normalize stored objects (delegated to their own normalizer when present) + $storedObjects = []; + foreach ($object->getStoredObjects() as $storedObject) { + $storedObjects[] = $this->normalizer->normalize($storedObject, $format, $context); + } + $data['storedObjects'] = $storedObjects; + + } + + if (in_array(self::GROUP_PARENT_TO_CHILDREN, $groups, true)) { + // Normalize children recursively (but we do not expose parent to avoid cycles) + $children = []; + foreach ($object->getChildren() as $child) { + $children[] = $this->normalizer->normalize($child, $format, $context); + } + $data['children'] = $children; + } elseif (in_array(self::GROUP_CHILDREN_TO_PARENT, $groups, true)) { + $data['parent'] = $this->normalizer->normalize($object->getParent(), $format, $context); + } + + return $data; + } + + public function supportsNormalization($data, ?string $format = null, array $context = []): bool + { + return $data instanceof Motive; + } + + /** + * Optimization hint for the Serializer (available since Symfony 5.3+). + * + * @return array<class-string, bool> + */ + public function getSupportedTypes(?string $format): array + { + return [ + Motive::class => true, + ]; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/SetAddresseesCommandDenormalizer.php b/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/SetAddresseesCommandDenormalizer.php new file mode 100644 index 000000000..86a843cd5 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/SetAddresseesCommandDenormalizer.php @@ -0,0 +1,56 @@ +<?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\TicketBundle\Serializer\Normalizer; + +use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Entity\UserGroup; +use Chill\TicketBundle\Action\Ticket\SetAddresseesCommand; +use Symfony\Component\Serializer\Exception\UnexpectedValueException; +use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface; +use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; + +final class SetAddresseesCommandDenormalizer implements DenormalizerInterface, DenormalizerAwareInterface +{ + use DenormalizerAwareTrait; + + public function denormalize($data, string $type, ?string $format = null, array $context = []) + { + if (null === $data) { + return null; + } + + if (!array_key_exists('addressees', $data)) { + throw new UnexpectedValueException("key 'addressees' does exists"); + } + + if (!is_array($data['addressees'])) { + throw new UnexpectedValueException("key 'addressees' must be an array"); + } + + $addresses = []; + foreach ($data['addressees'] as $address) { + $addresses[] = match ($address['type'] ?? '') { + 'user_group' => $this->denormalizer->denormalize($address, UserGroup::class, $format, $context), + 'user' => $this->denormalizer->denormalize($address, User::class, $format, $context), + default => throw new UnexpectedValueException('the type is not set or not supported'), + }; + } + + return new SetAddresseesCommand($addresses); + } + + public function supportsDenormalization($data, string $type, ?string $format = null) + { + return SetAddresseesCommand::class === $type && 'json' === $format; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/SetPersonsCommandDenormalizer.php b/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/SetPersonsCommandDenormalizer.php new file mode 100644 index 000000000..b0b29cbf7 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/SetPersonsCommandDenormalizer.php @@ -0,0 +1,51 @@ +<?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\TicketBundle\Serializer\Normalizer; + +use Chill\PersonBundle\Entity\Person; +use Chill\TicketBundle\Action\Ticket\SetPersonsCommand; +use Symfony\Component\Serializer\Exception\UnexpectedValueException; +use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface; +use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; + +class SetPersonsCommandDenormalizer implements DenormalizerInterface, DenormalizerAwareInterface +{ + use DenormalizerAwareTrait; + + public function denormalize($data, string $type, ?string $format = null, array $context = []) + { + if (null === $data) { + return null; + } + + if (!array_key_exists('persons', $data)) { + throw new UnexpectedValueException("key 'persons' does exists"); + } + + if (!is_array($data['persons'])) { + throw new UnexpectedValueException("key 'persons' must be an array"); + } + + $persons = []; + foreach ($data['persons'] as $person) { + $persons[] = $this->denormalizer->denormalize($person, Person::class, $format, $context); + } + + return new SetPersonsCommand($persons); + } + + public function supportsDenormalization($data, string $type, ?string $format = null) + { + return SetPersonsCommand::class === $type; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/TicketNormalizer.php b/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/TicketNormalizer.php new file mode 100644 index 000000000..30b057f21 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Serializer/Normalizer/TicketNormalizer.php @@ -0,0 +1,283 @@ +<?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\TicketBundle\Serializer\Normalizer; + +use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Entity\UserGroup; +use Chill\PersonBundle\Entity\Person; +use Chill\TicketBundle\Entity\AddresseeHistory; +use Chill\TicketBundle\Entity\CallerHistory; +use Chill\TicketBundle\Entity\Comment; +use Chill\TicketBundle\Entity\EmergencyStatusHistory; +use Chill\TicketBundle\Entity\MotiveHistory; +use Chill\TicketBundle\Entity\PersonHistory; +use Chill\TicketBundle\Entity\StateHistory; +use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Security\Voter\CommentVoter; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\Exception\UnexpectedValueException; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +final class TicketNormalizer implements NormalizerInterface, NormalizerAwareInterface +{ + use NormalizerAwareTrait; + + public function __construct(private Security $security) {} + + public function normalize($object, ?string $format = null, array $context = []) + { + if (!$object instanceof Ticket) { + throw new UnexpectedValueException(); + } + + $data = [ + 'type' => 'ticket_ticket', + 'id' => $object->getId(), + 'externalRef' => $object->getExternalRef(), + 'createdAt' => $this->normalizer->normalize($object->getCreatedAt(), $format, $context), + 'currentPersons' => $this->normalizer->normalize($object->getPersons(), $format, [ + 'groups' => 'read', + ]), + 'currentAddressees' => $this->normalizer->normalize($object->getCurrentAddressee(), $format, ['groups' => 'read']), + 'currentInputs' => $this->normalizer->normalize($object->getCurrentInputs(), $format, ['groups' => 'read']), + 'currentMotive' => $this->normalizer->normalize($object->getMotive(), $format, ['groups' => ['read', MotiveNormalizer::GROUP_CHILDREN_TO_PARENT]]), + 'currentState' => $object->getState()?->value ?? 'open', + 'emergency' => $object->getEmergencyStatus()?->value ?? 'no', + 'caller' => $this->normalizer->normalize($object->getCaller(), $format, ['groups' => 'read']), + ]; + + if ('read:simple' === $context['groups']) { + $data += ['type_extended' => 'ticket_ticket:simple']; + + return $data; + } + + $data += [ + 'type_extended' => 'ticket_ticket:extended', + 'history' => array_values($this->serializeHistory($object, $format, ['groups' => 'read'])), + 'updatedAt' => $this->normalizer->normalize($object->getUpdatedAt(), $format, $context), + 'updatedBy' => $this->normalizer->normalize($object->getUpdatedBy(), $format, $context), + 'createdBy' => $this->normalizer->normalize($object->getCreatedBy(), $format, $context), + ]; + + return $data; + } + + public function supportsNormalization($data, ?string $format = null) + { + return 'json' === $format && $data instanceof Ticket; + } + + private function serializeHistory(Ticket $ticket, string $format, array $context): array + { + $events = [ + ...array_map( + fn (StateHistory $stateHistory) => [ + 'event_type' => 'state_change', + 'at' => $stateHistory->getStartDate(), + 'by' => $stateHistory->getCreatedBy(), + 'data' => [ + 'new_state' => $stateHistory->getState()->value, + ], + ], + $ticket->getStateHistories()->toArray(), + ), + ...array_map( + fn (MotiveHistory $motiveHistory) => [ + 'event_type' => 'set_motive', + 'at' => $motiveHistory->getStartDate(), + 'by' => $motiveHistory->getCreatedBy(), + 'data' => $motiveHistory, + ], + $ticket->getMotiveHistories()->toArray() + ), + ...array_map( + fn (PersonHistory $personHistory) => [ + 'event_type' => 'add_person', + 'at' => $personHistory->getStartDate(), + 'by' => $personHistory->getCreatedBy(), + 'data' => $personHistory, + ], + $ticket->getPersonHistories()->toArray(), + ), + ...array_map( + fn (Comment $comment) => [ + 'event_type' => 'add_comment', + 'at' => $comment->getCreatedAt(), + 'by' => $comment->getCreatedBy(), + 'data' => $comment, + ], + $ticket->getComments()->filter(fn (Comment $comment) => $this->security->isGranted(CommentVoter::READ, $comment))->toArray(), + ), + ...$this->addresseesStates($ticket), + ...$this->personStates($ticket), + ...array_map( + fn (EmergencyStatusHistory $stateHistory) => [ + 'event_type' => 'emergency_change', + 'at' => $stateHistory->getStartDate(), + 'by' => $stateHistory->getCreatedBy(), + 'data' => [ + 'new_emergency' => $stateHistory->getEmergencyStatus()->value, + ], + ], + $ticket->getEmergencyStatusHistories()->toArray(), + ), + ...array_map( + fn (CallerHistory $stateHistory) => [ + 'event_type' => 'set_caller', + 'at' => $stateHistory->getStartDate(), + 'by' => $stateHistory->getCreatedBy(), + 'data' => [ + 'new_caller' => $this->normalizer->normalize($ticket->getCaller(), $format, ['groups' => ['read']]), + ], + ], + $ticket->getCallerHistories()->toArray(), + ), + ]; + + if (null !== $ticket->getCreatedBy() && null !== $ticket->getCreatedAt()) { + $events[] = + [ + 'event_type' => 'create_ticket', + 'at' => \DateTimeImmutable::createFromInterface($ticket->getCreatedAt()) + ->sub(new \DateInterval('PT1S')), // TODO hack to avoid collision with creation of the ticket event, + 'by' => $ticket->getCreatedBy(), + 'data' => [], + ]; + } + + usort( + $events, + static fn (array $a, array $b): int => $a['at'] <=> $b['at'] + ); + + return array_map( + fn ($data) => [ + 'event_type' => $data['event_type'], + 'at' => $this->normalizer->normalize($data['at'], $format, $context), + 'by' => $this->normalizer->normalize($data['by'], $format, $context), + 'data' => $this->normalizer->normalize($data['data'], $format, $this->contextByEventType($data['event_type'], $context)), + ], + $events + ); + } + + private function contextByEventType(string $eventType, array $context): array + { + return match($eventType) { + 'set_motive' => array_merge($context, ['groups' => ['read', MotiveNormalizer::GROUP_CHILDREN_TO_PARENT]]), + default => $context, + }; + } + + private function addresseesStates(Ticket $ticket): array + { + /** @var array{string, array{added: list<AddresseeHistory>, removed: list<AddresseeHistory>}} $changes */ + $changes = []; + $dateFormat = 'Y-m-d-m-Y-H-i-s'; + + foreach ($ticket->getAddresseeHistories() as $history) { + $changes[$history->getStartDate()->format($dateFormat)]['added'][] = $history; + if (null !== $history->getEndDate()) { + $changes[$history->getEndDate()->format($dateFormat)]['removed'][] = $history; + } + } + + ksort($changes); + + $currents = []; + $steps = []; + foreach ($changes as $change) { + $historiesAdded = $change['added'] ?? []; + $historiesRemoved = $change['removed'] ?? []; + + if (0 < count($historiesAdded)) { + $at = $historiesAdded[0]->getStartDate(); + $by = $historiesAdded[0]->getCreatedBy(); + } elseif (0 < count($historiesRemoved)) { + $at = $historiesRemoved[0]->getEndDate(); + $by = $historiesRemoved[0]->getRemovedBy(); + } else { + throw new \LogicException('it should have at least one history'); + } + + $removed = array_map(fn (AddresseeHistory $history) => $history->getAddressee(), $historiesRemoved); + $currents = array_filter($currents, fn (User|UserGroup $a) => !in_array($a, $removed, true)); + foreach ($historiesAdded as $history) { + $currents[] = $history->getAddressee(); + } + + $steps[] = [ + 'event_type' => 'addressees_state', + 'at' => $at, + 'by' => $by, + 'data' => [ + 'addressees' => array_values($currents), + ], + ]; + } + + return $steps; + } + + private function personStates(Ticket $ticket): array + { + /** @var array{string, array{added: list<PersonHistory>, removed: list<PersonHistory>}} $changes */ + $changes = []; + $dateFormat = 'Y-m-d-m-Y-H-i-s'; + + foreach ($ticket->getPersonHistories() as $history) { + $changes[$history->getStartDate()->format($dateFormat)]['added'][] = $history; + if (null !== $history->getEndDate()) { + $changes[$history->getEndDate()->format($dateFormat)]['removed'][] = $history; + } + } + + ksort($changes); + + $currents = []; + $steps = []; + foreach ($changes as $change) { + $historiesAdded = $change['added'] ?? []; + $historiesRemoved = $change['removed'] ?? []; + + if (0 < count($historiesAdded)) { + $at = $historiesAdded[0]->getStartDate(); + $by = $historiesAdded[0]->getCreatedBy(); + } elseif (0 < count($historiesRemoved)) { + $at = $historiesRemoved[0]->getEndDate(); + $by = $historiesRemoved[0]->getRemovedBy(); + } else { + throw new \LogicException('it should have at least one history'); + } + + $removed = array_map(fn (PersonHistory $history) => $history->getPerson(), $historiesRemoved); + $currents = array_filter($currents, fn (Person $a) => !in_array($a, $removed, true)); + foreach ($historiesAdded as $history) { + $currents[] = $history->getPerson(); + } + + $steps[] = [ + 'event_type' => 'persons_state', + 'at' => $at, + 'by' => $by, + 'data' => [ + 'persons' => array_values($currents), + ], + ]; + } + + return $steps; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Service/Import/ImportMotivesFromDirectory.php b/src/Bundle/ChillTicketBundle/src/Service/Import/ImportMotivesFromDirectory.php new file mode 100644 index 000000000..d61908366 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Service/Import/ImportMotivesFromDirectory.php @@ -0,0 +1,243 @@ +<?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\TicketBundle\Service\Import; + +use Chill\DocStoreBundle\Entity\StoredObject; +use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; +use Chill\TicketBundle\Entity\EmergencyStatusEnum; +use Chill\TicketBundle\Entity\Motive; +use Chill\TicketBundle\Repository\MotiveRepository; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Component\Yaml\Yaml; + +final readonly class ImportMotivesFromDirectory +{ + /** + * Cache en mémoire des Motive créés/trouvés pendant l'import. + * Clé: "$lang|lower(trim(label))" → valeur: Motive. + */ + private \ArrayObject $parentsAndMotives; + + public function __construct( + private EntityManagerInterface $entityManager, + private StoredObjectManagerInterface $storedObjectManager, + private MotiveRepository $motiveRepository, + ) { + // Objet mutable pour rester compatible avec la classe readonly + $this->parentsAndMotives = new \ArrayObject(); + } + + private function buildCacheKey(string $lang, string $label): string + { + return $lang.'|'.mb_strtolower(trim($label)); + } + + private function getFromCache(string $lang, string $label): ?Motive + { + $key = $this->buildCacheKey($lang, $label); + + if ($this->parentsAndMotives->offsetExists($key)) { + return $this->parentsAndMotives->offsetGet($key); + } + + return null; + } + + private function putInCache(string $lang, string $label, Motive $motive): void + { + $this->parentsAndMotives->offsetSet($this->buildCacheKey($lang, $label), $motive); + } + + /** + * Trouve un Motive par label/lang en utilisant d'abord le cache, puis le repository. + * Si inexistant, le crée, le persiste, l’ajoute au cache et le retourne. + * + * @param array $baseLabels tableau de labels (sera copié/ajusté pour la langue courante) + */ + private function findOrCreateMotiveByLabel(string $name, string $lang, array $baseLabels): Motive + { + $name = trim($name); + + if ('' === $name) { + throw new \InvalidArgumentException('Empty motive name provided to findOrCreateMotiveByLabel'); + } + + if (null !== ($cached = $this->getFromCache($lang, $name))) { + return $cached; + } + + $existing = $this->motiveRepository->findByLabel($name, $lang); + if (\count($existing) > 1) { + throw new \RuntimeException(sprintf('Multiple motives found with label "%s" for lang "%s".', $name, $lang)); + } + $motive = $existing[0] ?? new Motive(); + + // Préparer les labels: copie, trim, puis forcer la valeur de la langue courante + $labels = $baseLabels; + foreach ($labels as $k => $v) { + if (\is_string($v)) { + $labels[$k] = trim($v); + } + } + $labels[$lang] = $name; // forcer le label courant + $motive->setLabel($labels); + + if (!isset($existing[0])) { + $this->entityManager->persist($motive); + } + + $this->putInCache($lang, $name, $motive); + + return $motive; + } + + public function import(string $directory, string $lang): void + { + $directory = rtrim($directory, DIRECTORY_SEPARATOR); + if (!is_dir($directory)) { + throw new \InvalidArgumentException(sprintf('The given path "%s" is not a directory.', $directory)); + } + + $configPath = $directory.DIRECTORY_SEPARATOR.'motives.yaml'; + if (!is_file($configPath)) { + throw new \RuntimeException(sprintf('Missing motives.yaml in directory: %s', $directory)); + } + + $items = Yaml::parseFile($configPath); + if (!\is_array($items)) { + throw new \RuntimeException('The motives.yaml must contain a YAML array of items.'); + } + + foreach ($items as $index => $item) { + if (!\is_array($item)) { + throw new \RuntimeException(sprintf('Item at index %d is not an object/array.', $index)); + } + + // label: set as-is (expects an array) + if (!array_key_exists('label', $item) || !\is_array($item['label'])) { + throw new \RuntimeException(sprintf('Item %d: missing or invalid "label" (expected array).', $index)); + } + $labelArray = $item['label']; + // Trim all labels when they are strings + foreach ($labelArray as $k => $v) { + if (\is_string($v)) { + $labelArray[$k] = trim($v); + } + } + + $labelForSearch = $labelArray[$lang] ?? null; + if (!\is_string($labelForSearch) || '' === trim($labelForSearch)) { + throw new \RuntimeException(sprintf('Item %d: missing label for language "%s".', $index, $lang)); + } + + // Support parent > child notation in the current language label + $parentName = null; + $childName = trim($labelForSearch); + if (false !== strpos($childName, '>')) { + [$parentName, $childName] = array_map('trim', explode('>', $childName, 2)); + } + + // Find or create the current motive (child or standalone) en utilisant le cache + $motive = $this->getFromCache($lang, $childName) + ?? $this->findOrCreateMotiveByLabel($childName, $lang, $labelArray); + // S’assurer que le label courant reflète bien le nom de l’enfant/standalone + // (utile si l’entité existait déjà mais avec un label non trim) + $labelsForChild = $motive->getLabel(); + $labelsForChild[$lang] = $childName; + $motive->setLabel($labelsForChild); + + // If a parent is defined, ensure it exists and link it + if (null !== $parentName && '' !== $parentName) { + // Trouver/créer le parent via cache pour éviter les doublons + $parentLabel = $labelArray; + $parent = $this->getFromCache($lang, $parentName) + ?? $this->findOrCreateMotiveByLabel($parentName, $lang, $parentLabel); + + $motive->setParent($parent); + } + + // ordering: applies only to the current (child/standalone) motive when provided + if (array_key_exists('ordering', $item)) { + $ordering = $item['ordering']; + if (!\is_int($ordering) && !\is_float($ordering) && !(\is_string($ordering) && is_numeric($ordering))) { + throw new \RuntimeException(sprintf('Item %d: invalid "ordering" value (must be numeric).', $index)); + } + $motive->setOrdering((float) $ordering); + } + + // urgent: if true => YES, if explicitly false => NO, if absent => leave null + if (array_key_exists('urgent', $item)) { + $urgent = (bool) $item['urgent']; + $motive->setMakeTicketEmergency($urgent ? EmergencyStatusEnum::YES : EmergencyStatusEnum::NO); + } + + // supplementary informations (support both keys with/without underscore) + $suppKey = array_key_exists('supplementary_informations', $item) ? 'supplementary_informations' : (array_key_exists('supplementary informations', $item) ? 'supplementary informations' : null); + if (null !== $suppKey && \is_array($item[$suppKey])) { + $motive->setSupplementaryComment(array_map(fn (array $supplementaryDefinition) => ['label' => $supplementaryDefinition['label'][$lang]], $item[$suppKey])); + } + + // stored objects + $storedKey = array_key_exists('stored_objects', $item) ? 'stored_objects' : (array_key_exists('stored object', $item) ? 'stored object' : null); + if (null !== $storedKey && \is_array($item[$storedKey])) { + foreach ($item[$storedKey] as $docIndex => $doc) { + if (!\is_array($doc)) { + throw new \RuntimeException(sprintf('Item %d, stored object %d: invalid entry.', $index, $docIndex)); + } + $label = $doc['label'][$lang] ?? ($doc['label'] ?? null); + $filename = $doc['filename'] ?? null; + if (null === $filename || !\is_string($filename)) { + throw new \RuntimeException(sprintf('Item %d, stored object %d: missing filename.', $index, $docIndex)); + } + + $fullPath = $directory.DIRECTORY_SEPARATOR.$filename; + if (!is_file($fullPath)) { + throw new \RuntimeException(sprintf('Referenced file not found: %s', $fullPath)); + } + + $storedObject = new StoredObject(); + if (\is_string($label)) { + $storedObject->setTitle($label); + } elseif (\is_array($label) && isset($label['fr']) && \is_string($label['fr'])) { + $storedObject->setTitle($label['fr']); + } else { + $storedObject->setTitle(pathinfo($filename, PATHINFO_FILENAME)); + } + + $content = file_get_contents($fullPath); + if (false === $content) { + throw new \RuntimeException(sprintf('Unable to read file: %s', $fullPath)); + } + + $ext = strtolower(pathinfo($fullPath, PATHINFO_EXTENSION)); + $contentType = match ($ext) { + 'pdf' => 'application/pdf', + 'png' => 'image/png', + 'jpg', 'jpeg' => 'image/jpeg', + 'webp' => 'image/webp', + 'txt' => 'text/plain', + default => 'application/octet-stream', + }; + + $this->storedObjectManager->write($storedObject, $content, $contentType); + + $motive->addStoredObject($storedObject); + $this->entityManager->persist($storedObject); + } + } + + $this->entityManager->persist($motive); + } + + $this->entityManager->flush(); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Service/Ticket/SuggestPersonForTicket.php b/src/Bundle/ChillTicketBundle/src/Service/Ticket/SuggestPersonForTicket.php new file mode 100644 index 000000000..a454408dd --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Service/Ticket/SuggestPersonForTicket.php @@ -0,0 +1,30 @@ +<?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\TicketBundle\Service\Ticket; + +use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Repository\PersonTicketACLAwareRepositoryInterface; + +final readonly class SuggestPersonForTicket implements SuggestPersonForTicketInterface +{ + public function __construct(private PersonTicketACLAwareRepositoryInterface $personTicketACLAwareRepository) {} + + public function suggestPerson(Ticket $ticket, int $start = 0, int $limit = 20): array + { + $caller = $ticket->getCaller(); + if (null === $caller) { + return []; + } + + return $this->personTicketACLAwareRepository->findPersonPreviouslyAssociatedWithCaller($caller, $start, $limit); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Service/Ticket/SuggestPersonForTicketInterface.php b/src/Bundle/ChillTicketBundle/src/Service/Ticket/SuggestPersonForTicketInterface.php new file mode 100644 index 000000000..10d0eb31b --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Service/Ticket/SuggestPersonForTicketInterface.php @@ -0,0 +1,26 @@ +<?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\TicketBundle\Service\Ticket; + +use Chill\PersonBundle\Entity\Person; +use Chill\TicketBundle\Entity\Ticket; + +/** + * Suggest Person entities for a ticket. + */ +interface SuggestPersonForTicketInterface +{ + /** + * @return list<Person> + */ + public function suggestPerson(Ticket $ticket, int $start = 0, int $limit = 20): array; +} diff --git a/src/Bundle/ChillTicketBundle/src/Validation/Validator/SetPersonCommandConstraint.php b/src/Bundle/ChillTicketBundle/src/Validation/Validator/SetPersonCommandConstraint.php new file mode 100644 index 000000000..402a0d650 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Validation/Validator/SetPersonCommandConstraint.php @@ -0,0 +1,25 @@ +<?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\TicketBundle\Validation\Validator; + +use Symfony\Component\Validator\Constraint; + +#[\Attribute] +class SetPersonCommandConstraint extends Constraint +{ + public string $notMulti = 'ticket.set_persons.Only one person can be set.'; + + public function getTargets(): string + { + return Constraint::CLASS_CONSTRAINT; + } +} diff --git a/src/Bundle/ChillTicketBundle/src/Validation/Validator/SetPersonCommandConstraintValidator.php b/src/Bundle/ChillTicketBundle/src/Validation/Validator/SetPersonCommandConstraintValidator.php new file mode 100644 index 000000000..3b99a5188 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/Validation/Validator/SetPersonCommandConstraintValidator.php @@ -0,0 +1,47 @@ +<?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\TicketBundle\Validation\Validator; + +use Chill\TicketBundle\Action\Ticket\SetPersonsCommand; +use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\Validator\Exception\UnexpectedValueException; + +class SetPersonCommandConstraintValidator extends ConstraintValidator +{ + private readonly bool $isMulti; + + public function __construct(ParameterBagInterface $parameterBag) + { + $this->isMulti = 'multi' === $parameterBag->get('chill_ticket')['ticket']['person_per_ticket']; + } + + public function validate($value, Constraint $constraint) + { + if (!$value instanceof SetPersonsCommand) { + throw new UnexpectedValueException($value, SetPersonsCommand::class); + } + + if (!$constraint instanceof SetPersonCommandConstraint) { + throw new UnexpectedValueException($constraint, SetPersonCommandConstraint::class); + } + + if (!$this->isMulti) { + if (1 < count($value->persons)) { + $this->context->buildViolation($constraint->notMulti) + ->atPath('persons') + ->addViolation(); + } + } + } +} diff --git a/src/Bundle/ChillTicketBundle/src/config/routes.yaml b/src/Bundle/ChillTicketBundle/src/config/routes.yaml new file mode 100644 index 000000000..9823744ea --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/config/routes.yaml @@ -0,0 +1,3 @@ +chill_ticket_controller: + resource: '@ChillTicketBundle/Controller/' + type: annotation diff --git a/src/Bundle/ChillTicketBundle/src/config/services.yaml b/src/Bundle/ChillTicketBundle/src/config/services.yaml new file mode 100644 index 000000000..b7f6df2c2 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/config/services.yaml @@ -0,0 +1,51 @@ +services: + _defaults: + autoconfigure: true + autowire: true + + Chill\TicketBundle\Action\Ticket\Handler\: + resource: '../Action/Ticket/Handler/' + + Chill\TicketBundle\Action\Comment\Handler\: + resource: '../Action/Comment/Handler/' + + Chill\TicketBundle\Command\: + resource: '../Command/' + + Chill\TicketBundle\Controller\: + resource: '../Controller/' + tags: + - controller.service_arguments + + Chill\TicketBundle\Event\EventSubscriber\: + resource: '../Event/EventSubscriber/' + + Chill\TicketBundle\Repository\: + resource: '../Repository/' + + Chill\TicketBundle\Security\Voter\: + resource: '../Security/Voter/' + + Chill\TicketBundle\Security\Authorization\: + resource: '../Security/Authorization/' + + Chill\TicketBundle\Serializer\: + resource: '../Serializer/' + + Chill\TicketBundle\Service\: + resource: '../Service/' + + Chill\TicketBundle\Menu\: + resource: '../Menu/' + + Chill\TicketBundle\Messenger\Handler\: + resource: '../Messenger/Handler' + + Chill\TicketBundle\Validation\: + resource: '../Validation/' + + Chill\TicketBundle\DataFixtures\: + resource: '../DataFixtures/' + + Chill\TicketBundle\Form\: + resource: '../Form/' diff --git a/src/Bundle/ChillTicketBundle/src/migrations/Version20240416145919.php b/src/Bundle/ChillTicketBundle/src/migrations/Version20240416145919.php new file mode 100644 index 000000000..f627a4143 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/migrations/Version20240416145919.php @@ -0,0 +1,139 @@ +<?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\Ticket; + +use Doctrine\DBAL\Schema\Schema; +use Doctrine\Migrations\AbstractMigration; + +final class Version20240416145919 extends AbstractMigration +{ + public function getDescription(): string + { + return 'Create schema and tables for chill ticket'; + } + + public function up(Schema $schema): void + { + $this->addSql('CREATE SCHEMA chill_ticket'); + $this->addSql('CREATE SEQUENCE chill_ticket.addressee_history_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE chill_ticket.comment_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE chill_ticket.input_history_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE chill_ticket.motive_id_seq INCREMENT BY 1 MINVALUE 1 START 1000'); + $this->addSql('CREATE SEQUENCE chill_ticket.motives_history_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE chill_ticket.person_history_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE SEQUENCE chill_ticket.ticket_id_seq INCREMENT BY 1 MINVALUE 1 START 1'); + $this->addSql('CREATE TABLE chill_ticket.addressee_history (id INT NOT NULL, ticket_id INT NOT NULL, startDate TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, updatedAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, addresseeUser_id INT DEFAULT NULL, addresseeGroup_id INT DEFAULT NULL, createdBy_id INT DEFAULT NULL, updatedBy_id INT DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_434EBDBD4D06F00C ON chill_ticket.addressee_history (addresseeUser_id)'); + $this->addSql('CREATE INDEX IDX_434EBDBD776D9A84 ON chill_ticket.addressee_history (addresseeGroup_id)'); + $this->addSql('CREATE INDEX IDX_434EBDBD700047D2 ON chill_ticket.addressee_history (ticket_id)'); + $this->addSql('CREATE INDEX IDX_434EBDBD3174800F ON chill_ticket.addressee_history (createdBy_id)'); + $this->addSql('CREATE INDEX IDX_434EBDBD65FF1AEC ON chill_ticket.addressee_history (updatedBy_id)'); + $this->addSql('COMMENT ON COLUMN chill_ticket.addressee_history.startDate IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN chill_ticket.addressee_history.createdAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN chill_ticket.addressee_history.updatedAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('CREATE TABLE chill_ticket.comment (id INT NOT NULL, ticket_id INT NOT NULL, content TEXT DEFAULT \'\' NOT NULL, createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, updatedAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, createdBy_id INT DEFAULT NULL, updatedBy_id INT DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_79EBD416700047D2 ON chill_ticket.comment (ticket_id)'); + $this->addSql('CREATE INDEX IDX_79EBD4163174800F ON chill_ticket.comment (createdBy_id)'); + $this->addSql('CREATE INDEX IDX_79EBD41665FF1AEC ON chill_ticket.comment (updatedBy_id)'); + $this->addSql('COMMENT ON COLUMN chill_ticket.comment.createdAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN chill_ticket.comment.updatedAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('CREATE TABLE chill_ticket.input_history (id INT NOT NULL, person_id INT DEFAULT NULL, ticket_id INT NOT NULL, endDate TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, startDate TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, thirdParty_id INT DEFAULT NULL, removedBy_id INT DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_E2AA301F217BBB47 ON chill_ticket.input_history (person_id)'); + $this->addSql('CREATE INDEX IDX_E2AA301F3EA5CAB0 ON chill_ticket.input_history (thirdParty_id)'); + $this->addSql('CREATE INDEX IDX_E2AA301FB8346CCF ON chill_ticket.input_history (removedBy_id)'); + $this->addSql('CREATE INDEX IDX_E2AA301F700047D2 ON chill_ticket.input_history (ticket_id)'); + $this->addSql('COMMENT ON COLUMN chill_ticket.input_history.endDate IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN chill_ticket.input_history.startDate IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('CREATE TABLE chill_ticket.motive (id INT NOT NULL, label JSON DEFAULT \'[]\' NOT NULL, active BOOLEAN DEFAULT true NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE TABLE chill_ticket.motives_history (id INT NOT NULL, motive_id INT NOT NULL, ticket_id INT NOT NULL, endDate TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, startDate TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, createdBy_id INT DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_48995CFF9658649C ON chill_ticket.motives_history (motive_id)'); + $this->addSql('CREATE INDEX IDX_48995CFF700047D2 ON chill_ticket.motives_history (ticket_id)'); + $this->addSql('CREATE INDEX IDX_48995CFF3174800F ON chill_ticket.motives_history (createdBy_id)'); + $this->addSql('COMMENT ON COLUMN chill_ticket.motives_history.endDate IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN chill_ticket.motives_history.startDate IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN chill_ticket.motives_history.createdAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('CREATE TABLE chill_ticket.person_history (id INT NOT NULL, person_id INT DEFAULT NULL, ticket_id INT NOT NULL, endDate TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, startDate TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, removedBy_id INT DEFAULT NULL, createdBy_id INT DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_F2969246B8346CCF ON chill_ticket.person_history (removedBy_id)'); + $this->addSql('CREATE INDEX IDX_F2969246217BBB47 ON chill_ticket.person_history (person_id)'); + $this->addSql('CREATE INDEX IDX_F2969246700047D2 ON chill_ticket.person_history (ticket_id)'); + $this->addSql('CREATE INDEX IDX_F29692463174800F ON chill_ticket.person_history (createdBy_id)'); + $this->addSql('COMMENT ON COLUMN chill_ticket.person_history.endDate IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN chill_ticket.person_history.startDate IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN chill_ticket.person_history.createdAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('CREATE TABLE chill_ticket.ticket (id INT NOT NULL, externalRef TEXT DEFAULT \'\' NOT NULL, createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, updatedAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, createdBy_id INT DEFAULT NULL, updatedBy_id INT DEFAULT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_B0A5F7233174800F ON chill_ticket.ticket (createdBy_id)'); + $this->addSql('CREATE INDEX IDX_B0A5F72365FF1AEC ON chill_ticket.ticket (updatedBy_id)'); + $this->addSql('COMMENT ON COLUMN chill_ticket.ticket.createdAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('COMMENT ON COLUMN chill_ticket.ticket.updatedAt IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE chill_ticket.addressee_history ADD CONSTRAINT FK_434EBDBD4D06F00C FOREIGN KEY (addresseeUser_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_ticket.addressee_history ADD CONSTRAINT FK_434EBDBD776D9A84 FOREIGN KEY (addresseeGroup_id) REFERENCES chill_main_user_group (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_ticket.addressee_history ADD CONSTRAINT FK_434EBDBD700047D2 FOREIGN KEY (ticket_id) REFERENCES chill_ticket.ticket (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_ticket.addressee_history ADD CONSTRAINT FK_434EBDBD3174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_ticket.addressee_history ADD CONSTRAINT FK_434EBDBD65FF1AEC FOREIGN KEY (updatedBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_ticket.comment ADD CONSTRAINT FK_79EBD416700047D2 FOREIGN KEY (ticket_id) REFERENCES chill_ticket.ticket (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_ticket.comment ADD CONSTRAINT FK_79EBD4163174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_ticket.comment ADD CONSTRAINT FK_79EBD41665FF1AEC FOREIGN KEY (updatedBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_ticket.input_history ADD CONSTRAINT FK_E2AA301F217BBB47 FOREIGN KEY (person_id) REFERENCES chill_person_person (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_ticket.input_history ADD CONSTRAINT FK_E2AA301F3EA5CAB0 FOREIGN KEY (thirdParty_id) REFERENCES chill_3party.third_party (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_ticket.input_history ADD CONSTRAINT FK_E2AA301FB8346CCF FOREIGN KEY (removedBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_ticket.input_history ADD CONSTRAINT FK_E2AA301F700047D2 FOREIGN KEY (ticket_id) REFERENCES chill_ticket.ticket (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_ticket.motives_history ADD CONSTRAINT FK_48995CFF9658649C FOREIGN KEY (motive_id) REFERENCES chill_ticket.motive (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_ticket.motives_history ADD CONSTRAINT FK_48995CFF700047D2 FOREIGN KEY (ticket_id) REFERENCES chill_ticket.ticket (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_ticket.motives_history ADD CONSTRAINT FK_48995CFF3174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_ticket.person_history ADD CONSTRAINT FK_F2969246B8346CCF FOREIGN KEY (removedBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_ticket.person_history ADD CONSTRAINT FK_F2969246217BBB47 FOREIGN KEY (person_id) REFERENCES chill_person_person (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_ticket.person_history ADD CONSTRAINT FK_F2969246700047D2 FOREIGN KEY (ticket_id) REFERENCES chill_ticket.ticket (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_ticket.person_history ADD CONSTRAINT FK_F29692463174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_ticket.ticket ADD CONSTRAINT FK_B0A5F7233174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('ALTER TABLE chill_ticket.ticket ADD CONSTRAINT FK_B0A5F72365FF1AEC FOREIGN KEY (updatedBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP SEQUENCE chill_ticket.addressee_history_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE chill_ticket.comment_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE chill_ticket.input_history_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE chill_ticket.motive_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE chill_ticket.motives_history_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE chill_ticket.person_history_id_seq CASCADE'); + $this->addSql('DROP SEQUENCE chill_ticket.ticket_id_seq CASCADE'); + $this->addSql('ALTER TABLE chill_ticket.addressee_history DROP CONSTRAINT FK_434EBDBD4D06F00C'); + $this->addSql('ALTER TABLE chill_ticket.addressee_history DROP CONSTRAINT FK_434EBDBD776D9A84'); + $this->addSql('ALTER TABLE chill_ticket.addressee_history DROP CONSTRAINT FK_434EBDBD700047D2'); + $this->addSql('ALTER TABLE chill_ticket.addressee_history DROP CONSTRAINT FK_434EBDBD3174800F'); + $this->addSql('ALTER TABLE chill_ticket.addressee_history DROP CONSTRAINT FK_434EBDBD65FF1AEC'); + $this->addSql('ALTER TABLE chill_ticket.comment DROP CONSTRAINT FK_79EBD416700047D2'); + $this->addSql('ALTER TABLE chill_ticket.comment DROP CONSTRAINT FK_79EBD4163174800F'); + $this->addSql('ALTER TABLE chill_ticket.comment DROP CONSTRAINT FK_79EBD41665FF1AEC'); + $this->addSql('ALTER TABLE chill_ticket.input_history DROP CONSTRAINT FK_E2AA301F217BBB47'); + $this->addSql('ALTER TABLE chill_ticket.input_history DROP CONSTRAINT FK_E2AA301F3EA5CAB0'); + $this->addSql('ALTER TABLE chill_ticket.input_history DROP CONSTRAINT FK_E2AA301FB8346CCF'); + $this->addSql('ALTER TABLE chill_ticket.input_history DROP CONSTRAINT FK_E2AA301F700047D2'); + $this->addSql('ALTER TABLE chill_ticket.motives_history DROP CONSTRAINT FK_48995CFF9658649C'); + $this->addSql('ALTER TABLE chill_ticket.motives_history DROP CONSTRAINT FK_48995CFF700047D2'); + $this->addSql('ALTER TABLE chill_ticket.motives_history DROP CONSTRAINT FK_48995CFF3174800F'); + $this->addSql('ALTER TABLE chill_ticket.person_history DROP CONSTRAINT FK_F2969246B8346CCF'); + $this->addSql('ALTER TABLE chill_ticket.person_history DROP CONSTRAINT FK_F2969246217BBB47'); + $this->addSql('ALTER TABLE chill_ticket.person_history DROP CONSTRAINT FK_F2969246700047D2'); + $this->addSql('ALTER TABLE chill_ticket.person_history DROP CONSTRAINT FK_F29692463174800F'); + $this->addSql('ALTER TABLE chill_ticket.ticket DROP CONSTRAINT FK_B0A5F7233174800F'); + $this->addSql('ALTER TABLE chill_ticket.ticket DROP CONSTRAINT FK_B0A5F72365FF1AEC'); + $this->addSql('DROP TABLE chill_ticket.addressee_history'); + $this->addSql('DROP TABLE chill_ticket.comment'); + $this->addSql('DROP TABLE chill_ticket.input_history'); + $this->addSql('DROP TABLE chill_ticket.motive'); + $this->addSql('DROP TABLE chill_ticket.motives_history'); + $this->addSql('DROP TABLE chill_ticket.person_history'); + $this->addSql('DROP TABLE chill_ticket.ticket'); + $this->addSql('DROP SCHEMA chill_ticket CASCADE'); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/migrations/Version20240423212824.php b/src/Bundle/ChillTicketBundle/src/migrations/Version20240423212824.php new file mode 100644 index 000000000..3c025d763 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/migrations/Version20240423212824.php @@ -0,0 +1,40 @@ +<?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\Ticket; + +use Doctrine\DBAL\Schema\Schema; +use Doctrine\Migrations\AbstractMigration; + +final class Version20240423212824 extends AbstractMigration +{ + public function getDescription(): string + { + return 'Add endDate and removedBy columns on addressee history (ticket)'; + } + + public function up(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_ticket.addressee_history ADD endDate TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT null'); + $this->addSql('ALTER TABLE chill_ticket.addressee_history ADD removedBy_id INT DEFAULT NULL'); + $this->addSql('COMMENT ON COLUMN chill_ticket.addressee_history.endDate IS \'(DC2Type:datetime_immutable)\''); + $this->addSql('ALTER TABLE chill_ticket.addressee_history ADD CONSTRAINT FK_434EBDBDB8346CCF FOREIGN KEY (removedBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE INDEX IDX_434EBDBDB8346CCF ON chill_ticket.addressee_history (removedBy_id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_ticket.addressee_history DROP CONSTRAINT FK_434EBDBDB8346CCF'); + $this->addSql('DROP INDEX chill_ticket.IDX_434EBDBDB8346CCF'); + $this->addSql('ALTER TABLE chill_ticket.addressee_history DROP endDate'); + $this->addSql('ALTER TABLE chill_ticket.addressee_history DROP removedBy_id'); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/migrations/Version20250603085035.php b/src/Bundle/ChillTicketBundle/src/migrations/Version20250603085035.php new file mode 100644 index 000000000..a99f550c5 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/migrations/Version20250603085035.php @@ -0,0 +1,89 @@ +<?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\Ticket; + +use Doctrine\DBAL\Schema\Schema; +use Doctrine\Migrations\AbstractMigration; + +final class Version20250603085035 extends AbstractMigration +{ + public function getDescription(): string + { + return 'Add a state to tickets'; + } + + public function up(Schema $schema): void + { + $this->addSql(<<<'SQL' + CREATE SEQUENCE chill_ticket.state_history_id_seq INCREMENT BY 1 MINVALUE 1 START 1 + SQL); + $this->addSql(<<<'SQL' + CREATE TABLE chill_ticket.state_history (id INT NOT NULL, ticket_id INT NOT NULL, + endDate TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, state VARCHAR(255) NOT NULL, + startDate TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, + createdBy_id INT DEFAULT NULL, PRIMARY KEY(id)) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_B1DCD379700047D2 ON chill_ticket.state_history (ticket_id) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_B1DCD3793174800F ON chill_ticket.state_history (createdBy_id) + SQL); + $this->addSql(<<<'SQL' + COMMENT ON COLUMN chill_ticket.state_history.endDate IS '(DC2Type:datetime_immutable)' + SQL); + $this->addSql(<<<'SQL' + COMMENT ON COLUMN chill_ticket.state_history.startDate IS '(DC2Type:datetime_immutable)' + SQL); + $this->addSql(<<<'SQL' + COMMENT ON COLUMN chill_ticket.state_history.createdAt IS '(DC2Type:datetime_immutable)' + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_ticket.state_history ADD CONSTRAINT FK_B1DCD379700047D2 FOREIGN KEY (ticket_id) + REFERENCES chill_ticket.ticket (id) NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_ticket.state_history ADD CONSTRAINT FK_B1DCD3793174800F FOREIGN KEY (createdBy_id) + REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_ticket.state_history ADD CONSTRAINT ticket_state_not_overlaps + exclude using gist (ticket_id with =, tsrange(startdate, enddate) with &&) + deferrable initially deferred + SQL); + $this->addSql(<<<'SQL' + INSERT INTO chill_ticket.state_history (id, ticket_id, state, startDate, createdAt, createdBy_id) + SELECT nextval('chill_ticket.state_history_id_seq'), id, 'open', createdAt, createdAt, createdBy_id + FROM chill_ticket.ticket + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql(<<<'SQL' + DROP SEQUENCE chill_ticket.state_history_id_seq CASCADE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_ticket.state_history DROP CONSTRAINT FK_B1DCD379700047D2 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_ticket.state_history DROP CONSTRAINT FK_B1DCD3793174800F + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_ticket.state_history DROP CONSTRAINT ticket_state_not_overlaps + SQL); + $this->addSql(<<<'SQL' + DROP TABLE chill_ticket.state_history + SQL); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/migrations/Version20250620145414.php b/src/Bundle/ChillTicketBundle/src/migrations/Version20250620145414.php new file mode 100644 index 000000000..586bdebb4 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/migrations/Version20250620145414.php @@ -0,0 +1,82 @@ +<?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\Ticket; + +use Doctrine\DBAL\Schema\Schema; +use Doctrine\Migrations\AbstractMigration; + +final class Version20250620145414 extends AbstractMigration +{ + public function getDescription(): string + { + return 'Add emergency status to ticket'; + } + + public function up(Schema $schema): void + { + $this->addSql(<<<'SQL' + CREATE SEQUENCE chill_ticket.emergency_status_history_id_seq INCREMENT BY 1 MINVALUE 1 START 1 + SQL); + $this->addSql(<<<'SQL' + CREATE TABLE chill_ticket.emergency_status_history ( + id INT NOT NULL, + ticket_id INT NOT NULL, + endDate TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, + emergencyStatus VARCHAR(255) NOT NULL, + startDate TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, + createdBy_id INT DEFAULT NULL, PRIMARY KEY(id)) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_16CF4FDB700047D2 ON chill_ticket.emergency_status_history (ticket_id) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_16CF4FDB3174800F ON chill_ticket.emergency_status_history (createdBy_id) + SQL); + $this->addSql(<<<'SQL' + COMMENT ON COLUMN chill_ticket.emergency_status_history.endDate IS '(DC2Type:datetime_immutable)' + SQL); + $this->addSql(<<<'SQL' + COMMENT ON COLUMN chill_ticket.emergency_status_history.startDate IS '(DC2Type:datetime_immutable)' + SQL); + $this->addSql(<<<'SQL' + COMMENT ON COLUMN chill_ticket.emergency_status_history.createdAt IS '(DC2Type:datetime_immutable)' + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_ticket.emergency_status_history ADD CONSTRAINT FK_16CF4FDB700047D2 FOREIGN KEY (ticket_id) REFERENCES chill_ticket.ticket (id) NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_ticket.emergency_status_history ADD CONSTRAINT FK_16CF4FDB3174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_ticket.emergency_status_history ADD CONSTRAINT ticket_emergency_state_not_overlaps + exclude using gist (ticket_id with =, tsrange(startdate, enddate) with &&) + deferrable initially deferred + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql(<<<'SQL' + ALTER TABLE chill_ticket.emergency_status_history DROP CONSTRAINT FK_16CF4FDB700047D2 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_ticket.emergency_status_history DROP CONSTRAINT FK_16CF4FDB3174800F + SQL); + $this->addSql(<<<'SQL' + DROP TABLE chill_ticket.emergency_status_history + SQL); + $this->addSql(<<<'SQL' + DROP SEQUENCE chill_ticket.emergency_status_history_id_seq + SQL); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/migrations/Version20250620164517.php b/src/Bundle/ChillTicketBundle/src/migrations/Version20250620164517.php new file mode 100644 index 000000000..3022b2b23 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/migrations/Version20250620164517.php @@ -0,0 +1,37 @@ +<?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\Ticket; + +use Doctrine\DBAL\Schema\Schema; +use Doctrine\Migrations\AbstractMigration; + +final class Version20250620164517 extends AbstractMigration +{ + public function getDescription(): string + { + return 'Add makeTicketEmergency field to Motive entity to allow automatic emergency status changes when a motive is associated with a ticket'; + } + + public function up(Schema $schema): void + { + $this->addSql(<<<'SQL' + ALTER TABLE chill_ticket.motive ADD makeTicketEmergency VARCHAR(255) DEFAULT NULL + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql(<<<'SQL' + ALTER TABLE chill_ticket.motive DROP makeTicketEmergency + SQL); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/migrations/Version20250624105842.php b/src/Bundle/ChillTicketBundle/src/migrations/Version20250624105842.php new file mode 100644 index 000000000..1a49a4f5b --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/migrations/Version20250624105842.php @@ -0,0 +1,93 @@ +<?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\Ticket; + +use Doctrine\DBAL\Schema\Schema; +use Doctrine\Migrations\AbstractMigration; + +final class Version20250624105842 extends AbstractMigration +{ + public function getDescription(): string + { + return 'Add CallerHistory entity to associate a ticket with either a Person or a ThirdParty entity'; + } + + public function up(Schema $schema): void + { + $this->addSql(<<<'SQL' + CREATE SEQUENCE chill_ticket.caller_history_id_seq INCREMENT BY 1 MINVALUE 1 START 1 + SQL); + $this->addSql(<<<'SQL' + CREATE TABLE chill_ticket.caller_history (id INT NOT NULL, person_id INT DEFAULT NULL, ticket_id INT NOT NULL, endDate TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, startDate TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, createdAt TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, thirdParty_id INT DEFAULT NULL, createdBy_id INT DEFAULT NULL, PRIMARY KEY(id)) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_AD0DCE24217BBB47 ON chill_ticket.caller_history (person_id) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_AD0DCE243EA5CAB0 ON chill_ticket.caller_history (thirdParty_id) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_AD0DCE24700047D2 ON chill_ticket.caller_history (ticket_id) + SQL); + $this->addSql(<<<'SQL' + CREATE INDEX IDX_AD0DCE243174800F ON chill_ticket.caller_history (createdBy_id) + SQL); + $this->addSql(<<<'SQL' + COMMENT ON COLUMN chill_ticket.caller_history.endDate IS '(DC2Type:datetime_immutable)' + SQL); + $this->addSql(<<<'SQL' + COMMENT ON COLUMN chill_ticket.caller_history.startDate IS '(DC2Type:datetime_immutable)' + SQL); + $this->addSql(<<<'SQL' + COMMENT ON COLUMN chill_ticket.caller_history.createdAt IS '(DC2Type:datetime_immutable)' + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_ticket.caller_history ADD CONSTRAINT FK_AD0DCE24217BBB47 FOREIGN KEY (person_id) REFERENCES chill_person_person (id) NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_ticket.caller_history ADD CONSTRAINT FK_AD0DCE243EA5CAB0 FOREIGN KEY (thirdParty_id) REFERENCES chill_3party.third_party (id) NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_ticket.caller_history ADD CONSTRAINT FK_AD0DCE24700047D2 FOREIGN KEY (ticket_id) REFERENCES chill_ticket.ticket (id) NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_ticket.caller_history ADD CONSTRAINT FK_AD0DCE243174800F FOREIGN KEY (createdBy_id) REFERENCES users (id) NOT DEFERRABLE INITIALLY IMMEDIATE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_ticket.caller_history ADD CONSTRAINT caller_history_not_overlaps + exclude using gist (ticket_id with =, tsrange(startdate, enddate) with &&) + deferrable initially deferred + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql(<<<'SQL' + DROP SEQUENCE chill_ticket.caller_history_id_seq CASCADE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_ticket.caller_history DROP CONSTRAINT FK_AD0DCE24217BBB47 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_ticket.caller_history DROP CONSTRAINT FK_AD0DCE243EA5CAB0 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_ticket.caller_history DROP CONSTRAINT FK_AD0DCE24700047D2 + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE chill_ticket.caller_history DROP CONSTRAINT FK_AD0DCE243174800F + SQL); + $this->addSql(<<<'SQL' + DROP TABLE chill_ticket.caller_history + SQL); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/migrations/Version20250711115128.php b/src/Bundle/ChillTicketBundle/src/migrations/Version20250711115128.php new file mode 100644 index 000000000..260d3a396 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/migrations/Version20250711115128.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\Migrations\Ticket; + +use Doctrine\DBAL\Schema\Schema; +use Doctrine\Migrations\AbstractMigration; + +final class Version20250711115128 extends AbstractMigration +{ + public function getDescription(): string + { + return 'Add deleted column to comment table to support soft deletion of comments'; + } + + public function up(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_ticket.comment ADD deleted BOOLEAN DEFAULT false NOT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_ticket.comment DROP deleted'); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/migrations/Version20250711131126.php b/src/Bundle/ChillTicketBundle/src/migrations/Version20250711131126.php new file mode 100644 index 000000000..37ea00373 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/migrations/Version20250711131126.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\Migrations\Ticket; + +use Doctrine\DBAL\Schema\Schema; +use Doctrine\Migrations\AbstractMigration; + +final class Version20250711131126 extends AbstractMigration +{ + public function getDescription(): string + { + return 'Add supplementaryComments property to Motive entity as JSONB type to store list of comments with label'; + } + + public function up(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_ticket.motive ADD supplementaryComments JSONB DEFAULT \'[]\' NOT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_ticket.motive DROP supplementaryComments'); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/migrations/Version20250718124651.php b/src/Bundle/ChillTicketBundle/src/migrations/Version20250718124651.php new file mode 100644 index 000000000..b34c80d68 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/migrations/Version20250718124651.php @@ -0,0 +1,51 @@ +<?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\Ticket; + +use Doctrine\DBAL\Schema\Schema; +use Doctrine\Migrations\AbstractMigration; + +final class Version20250718124651 extends AbstractMigration +{ + public function getDescription(): string + { + return 'Create join table "chill_ticket.motive_stored_objects"'; + } + + public function up(Schema $schema): void + { + $this->addSql( + <<<'SQL' + CREATE TABLE chill_ticket.motive_stored_objects (motive_id INT NOT NULL, storedobject_id INT NOT NULL, PRIMARY KEY(motive_id, storedobject_id)) + SQL + ); + $this->addSql('CREATE INDEX IDX_4247C4849658649C ON chill_ticket.motive_stored_objects (motive_id)'); + $this->addSql('CREATE INDEX IDX_4247C484EE684399 ON chill_ticket.motive_stored_objects (storedobject_id)'); + $this->addSql( + <<<'SQL' + ALTER TABLE chill_ticket.motive_stored_objects ADD CONSTRAINT FK_4247C4849658649C FOREIGN KEY (motive_id) REFERENCES chill_ticket.motive (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE + SQL + ); + $this->addSql( + <<<'SQL' + ALTER TABLE chill_ticket.motive_stored_objects ADD CONSTRAINT FK_4247C484EE684399 FOREIGN KEY (storedobject_id) REFERENCES chill_doc.stored_object (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE + SQL + ); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_ticket.motive_stored_objects DROP CONSTRAINT FK_4247C4849658649C'); + $this->addSql('ALTER TABLE chill_ticket.motive_stored_objects DROP CONSTRAINT FK_4247C484EE684399'); + $this->addSql('DROP TABLE chill_ticket.motive_stored_objects'); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/migrations/Version20250924124214.php b/src/Bundle/ChillTicketBundle/src/migrations/Version20250924124214.php new file mode 100644 index 000000000..0af41c592 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/migrations/Version20250924124214.php @@ -0,0 +1,37 @@ +<?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\Ticket; + +use Doctrine\DBAL\Schema\Schema; +use Doctrine\Migrations\AbstractMigration; + +final class Version20250924124214 extends AbstractMigration +{ + public function getDescription(): string + { + return 'Add parent to motive'; + } + + public function up(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_ticket.motive ADD parent_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE chill_ticket.motive ADD CONSTRAINT FK_DE298BF8727ACA70 FOREIGN KEY (parent_id) REFERENCES chill_ticket.motive (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE INDEX IDX_DE298BF8727ACA70 ON chill_ticket.motive (parent_id)'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_ticket.motive DROP CONSTRAINT FK_DE298BF8727ACA70'); + $this->addSql('DROP INDEX chill_ticket.IDX_DE298BF8727ACA70'); + $this->addSql('ALTER TABLE chill_ticket.motive DROP parent_id'); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/migrations/Version20251022081554.php b/src/Bundle/ChillTicketBundle/src/migrations/Version20251022081554.php new file mode 100644 index 000000000..c024b334b --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/migrations/Version20251022081554.php @@ -0,0 +1,34 @@ +<?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\Ticket; + +use Doctrine\DBAL\Schema\Schema; +use Doctrine\Migrations\AbstractMigration; + +final class Version20251022081554 extends AbstractMigration +{ + public function getDescription(): string + { + return 'Add ordering property on motive entity'; + } + + public function up(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_ticket.motive ADD ordering DOUBLE PRECISION DEFAULT \'0.0\''); + $this->addSql('UPDATE chill_ticket.motive SET ordering = id * 100'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE chill_ticket.motive DROP ordering'); + } +} diff --git a/src/Bundle/ChillTicketBundle/src/translations/messages+intl-icu.fr.yml b/src/Bundle/ChillTicketBundle/src/translations/messages+intl-icu.fr.yml new file mode 100644 index 000000000..095595fea --- /dev/null +++ b/src/Bundle/ChillTicketBundle/src/translations/messages+intl-icu.fr.yml @@ -0,0 +1,185 @@ +restore: Restaurer +chill_ticket: + list: + title: "Tickets" + title_with_name: "{name, select, null {Tickets} undefined {Tickets} other {Tickets de {name}}}" + title_menu: "Tickets de l'usager" + show_all_history: "Afficher tout l'historique" + title_previous_tickets: "{name, select, other {Précédent ticket de {name}} undefined {Précédent ticket}}" + no_tickets: "Aucun ticket" + loading_ticket: "Chargement des tickets..." + loading_ticket_details: "Chargement de l'historique du ticket..." + error_loading_ticket: "Erreur lors du chargement des ticket" + load_more: "Voir plus..." + addressees: "Destinataires" + persons: "Usager concernés" + callers: "Appelants" + filter: + to_me: Tickets qui me sont destinés + in_alert: Tickets en alerte (délai de résolution dépassé) + created_between: Créés entre + state_change: État actuel + title: "Filtres des tickets" + persons_concerned: "Usagers concernés" + by_person: "Par personne" + creators: "Créateurs" + by_creator: "Par créateur" + addressees: "Par destinataire" + by_addressees: "Par destinataire" + by_motives: "Par motifs" + by_ticket_id: "Par numéro de ticket" + ticket_id: "Numéro de ticket" + current_state: "État actuel" + open: "Ouvert" + closed: "Clôturé" + emergency: "Urgent" + no_emergency: "Non urgent" + created_after: "Créé après" + created_before: "Créé avant" + response_time_exceeded: "Temps de réponse dépassé" + response_time_warning: 'Attention : ce filtre désactive les filtres "par dates" et affiche uniquement les tickets ouverts' + reset: "Réinitialiser" + apply: "Appliquer les filtres" + remove: "Supprimer" + result: "{count, plural, =0 {Aucun résultat} =1 {resultat} other {resultats}}" + + ticket: + init_form: + title: "Ajouter des informations sur le ticket" + submit: "Mettre à jour le ticket" + success: "Ticket mis à jour avec succès" + error: "Veuillez remplir tous les champs obligatoires" + warning: "Veuillez remplir au minimum le motif et le commentaire" + history: + add_comment: "Commentaire ajouté" + addressees_state: "{count, plural, =0 {Destinataire supprimé} =1 {Destinataire ajouté} other {Destinataire ajoutés}}" + persons_state: "{count, plural, =0 {Usagers supprimés} =1 {Usager concerné ajouté} other {Usagers concernés ajoutés}}" + set_caller: "{id, select, null {Appelant supprimé} other {Appelant ajouté}}" + set_motive: "Motif ajouté" + create_ticket: "Ticket créé" + state_change: "" + emergency_change: "" + mask_comment: "Supprimer" + previous_tickets: "Précédents tickets" + actions_toolbar: + cancel: "Annuler" + save: "Enregistrer" + close: "Clôturer" + close_success: "Ticket clôturé" + close_error: "Erreur lors de la clotûre du ticket" + reopen: "Rouvrir" + reopen_success: "Rouverture du ticket réussie" + reopen_error: "Erreur lors de la rouverture du ticket" + visible_comment: + success: "Commentaire restauré" + mask_comment: + success: "Commentaire supprimé" + hint: "Ce commentaire a été supprimé." + edit_comment: + title: "Éditer le commentaire" + success: "Commentaire modifié" + add_comment: + title: "Commentaire" + label: "Ajouter un commentaire" + success: "Commentaire enregistré" + content: "Ajouter un commentaire" + error: "Aucun commentaire ajouté" + set_motive: + title: "Motif" + label: "Choisir un motif" + success: "Motif enregistré" + error: "Aucun motif sélectionné" + add_addressee: + title: "Attribuer" + user_group_label: "Attribuer à un groupe" + user_label: "Attribuer à un ou plusieurs utilisateurs" + success: "Attribution effectuée" + error: "Aucun destinataire sélectionné" + set_persons: + title: "Appelant et usager(s)" + title_person: "Usager(s)" + title_caller: "Appelant" + user_label: "Ajouter un usager" + caller_label: "Ajouter un appelant" + success: "Appelants et usagers mis à jour" + error: "Aucun usager sélectionné" + banner: + person: "{count, plural, =0 {Aucun usager impliqué} =1 {Usager impliqué} other {Usagers impliqués}}" + speaker: "{count, plural, =0 {Aucun destinataire} =1 {Destinataire} other {Destinataires}}" + caller: "{count, plural, =0 {Aucun appelant} =1 {Appelant} other {Appelants}}" + open: "Ouvert" + closed: "Clôturé" + since: "Depuis {time}" + and: "et" + days: >- + {count, plural, + =0 {aucun jour} + =1 {1 jour} + other {# jours} + } + hours: >- + {count, plural, + =0 {aucune heure} + =1 {1 heure} + other {# heures} + } + minutes: >- + {count, plural, + =0 {aucune minute} + =1 {1 minute} + other {# minutes} + } + seconds: >- + {count, plural, + =0 {aucune seconde} + =1 {1 seconde} + other {# secondes} + } + no_motive: "Aucun motif" + emergency: "URGENT" + emergency_success: "Ticket marqué comme urgent" + emergency_error: "Erreur lors de la tentative de marquage du ticket comme urgent" + no_emergency_success: "Ticket marqué comme non urgent" + no_emergency_error: "Erreur lors de la tentative de marquage du ticket comme non urgent" + peloton: + loading: "Chargement..." + loading_document: "Chargement du document..." + error_loading: "Erreur lors du chargement du document." + error_not_ready: "Le document n'est pas prêt ou accessible." + unsupported_type: "Type de document non supporté pour l'affichage." + open_new_tab: "Ouvrir dans un nouvel onglet" + iframe_not_supported: "Votre navigateur ne supporte pas les iframes." + click_to_open_pdf: "Cliquez ici pour ouvrir le PDF" + +admin: + ticket: + motive: + menu: Motifs + motive: + list: + title: Liste des motifs + view: + title: Le motif + new: + title: Créer un motif +crud: + motive: + title_new: Nouveau motif + title_edit: Modifier le motif + new: + "Create a new motive": "Créer un nouveau motif" + +"Label": "Libellé" +"Active": "Actif" +"emergency?": "Urgent ?" +"Supplementary comments": "Commentaires supplémentaires" +"edit": "modifier" +"show": "voir" +"Yes": "Oui" +"No": "Non" +"Id": "ID" +"Date": "Date" +"User": "Utilisateur" +"No supplementary comments": "Aucun commentaire supplémentaire" +"Back to the list": "Retour à la liste" +"Edit": "Modifier" diff --git a/src/Bundle/ChillTicketBundle/tests/Action/Comment/Handler/UpdateCommentContentCommandHandlerTest.php b/src/Bundle/ChillTicketBundle/tests/Action/Comment/Handler/UpdateCommentContentCommandHandlerTest.php new file mode 100644 index 000000000..53cb003c3 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Action/Comment/Handler/UpdateCommentContentCommandHandlerTest.php @@ -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\TicketBundle\Tests\Action\Comment\Handler; + +use Chill\TicketBundle\Action\Comment\Handler\UpdateCommentContentCommandHandler; +use Chill\TicketBundle\Action\Comment\UpdateCommentContentCommand; +use Chill\TicketBundle\Entity\Comment; +use Chill\TicketBundle\Entity\Ticket; +use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; + +/** + * @internal + * + * @coversNothing + */ +class UpdateCommentContentCommandHandlerTest extends TestCase +{ + use ProphecyTrait; + + public function testUpdateCommentContent(): void + { + $handler = $this->buildCommand(); + + $ticket = new Ticket(); + $comment = new Comment('initial content', $ticket); + $command = new UpdateCommentContentCommand(content: 'updated content'); + + $handler->handle($comment, $command); + + self::assertEquals('updated content', $comment->getContent()); + } + + private function buildCommand(): UpdateCommentContentCommandHandler + { + $entityManager = $this->prophesize(EntityManagerInterface::class); + + return new UpdateCommentContentCommandHandler($entityManager->reveal()); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Action/Comment/Handler/UpdateCommentDeletedStatusCommandHandlerTest.php b/src/Bundle/ChillTicketBundle/tests/Action/Comment/Handler/UpdateCommentDeletedStatusCommandHandlerTest.php new file mode 100644 index 000000000..a128abc7b --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Action/Comment/Handler/UpdateCommentDeletedStatusCommandHandlerTest.php @@ -0,0 +1,55 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\TicketBundle\Tests\Action\Comment\Handler; + +use Chill\TicketBundle\Action\Comment\Handler\UpdateCommentDeletedStatusCommandHandler; +use Chill\TicketBundle\Action\Comment\UpdateCommentDeletedStatusCommand; +use Chill\TicketBundle\Entity\Comment; +use Chill\TicketBundle\Entity\Ticket; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; + +/** + * @internal + * + * @coversNothing + */ +class UpdateCommentDeletedStatusCommandHandlerTest extends TestCase +{ + use ProphecyTrait; + + public function testUpdateCommentDeletedStatus(): void + { + $handler = $this->buildCommand(); + + $ticket = new Ticket(); + $comment = new Comment('content', $ticket); + + // Initially, the comment should not be deleted + self::assertFalse($comment->isDeleted()); + + // Test setting deleted to true + $command = new UpdateCommentDeletedStatusCommand(delete: true); + $handler->handle($comment, $command); + self::assertTrue($comment->isDeleted()); + + // Test setting deleted back to false + $command = new UpdateCommentDeletedStatusCommand(delete: false); + $handler->handle($comment, $command); + self::assertFalse($comment->isDeleted()); + } + + private function buildCommand(): UpdateCommentDeletedStatusCommandHandler + { + return new UpdateCommentDeletedStatusCommandHandler(); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/AddCommentCommandHandlerTest.php b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/AddCommentCommandHandlerTest.php new file mode 100644 index 000000000..5ff18b436 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/AddCommentCommandHandlerTest.php @@ -0,0 +1,52 @@ +<?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\TicketBundle\Tests\Action\Ticket\Handler; + +use Chill\TicketBundle\Action\Ticket\AddCommentCommand; +use Chill\TicketBundle\Action\Ticket\Handler\AddCommentCommandHandler; +use Chill\TicketBundle\Entity\Comment; +use Chill\TicketBundle\Entity\Ticket; +use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; + +/** + * @internal + * + * @coversNothing + */ +class AddCommentCommandHandlerTest extends TestCase +{ + use ProphecyTrait; + + public function testAddComment(): void + { + $handler = $this->buildCommand(); + + $ticket = new Ticket(); + $command = new AddCommentCommand(content: 'test'); + + $handler->handle($ticket, $command); + + self::assertCount(1, $ticket->getComments()); + self::assertEquals('test', $ticket->getComments()[0]->getContent()); + } + + private function buildCommand(): AddCommentCommandHandler + { + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->persist(Argument::type(Comment::class))->shouldBeCalled(); + + return new AddCommentCommandHandler($entityManager->reveal()); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/AssociateByPhonenumberCommandHandlerTest.php b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/AssociateByPhonenumberCommandHandlerTest.php new file mode 100644 index 000000000..067443bd9 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/AssociateByPhonenumberCommandHandlerTest.php @@ -0,0 +1,96 @@ +<?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\TicketBundle\Tests\Action\Ticket\Handler; + +use Chill\MainBundle\Phonenumber\PhonenumberHelper; +use Chill\PersonBundle\Entity\Person; +use Chill\PersonBundle\Repository\PersonACLAwareRepositoryInterface; +use Chill\ThirdPartyBundle\Entity\ThirdParty; +use Chill\ThirdPartyBundle\Repository\ThirdPartyRepository; +use Chill\TicketBundle\Action\Ticket\AssociateByPhonenumberCommand; +use Chill\TicketBundle\Action\Ticket\Handler\AssociateByPhonenumberCommandHandler; +use Chill\TicketBundle\Entity\Ticket; +use Doctrine\ORM\EntityManagerInterface; +use libphonenumber\PhoneNumber; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Psr\Log\NullLogger; +use Symfony\Component\Cache\Adapter\ArrayAdapter; +use Symfony\Component\Clock\MockClock; +use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; + +/** + * @internal + * + * @coversNothing + */ +class AssociateByPhonenumberCommandHandlerTest extends TestCase +{ + use ProphecyTrait; + + private function getHandler( + PersonACLAwareRepositoryInterface $personACLAwareRepository, + ThirdPartyRepository $thirdPartyRepository, + ): AssociateByPhonenumberCommandHandler { + $entityManager = $this->prophesize(EntityManagerInterface::class); + $phonenumberHelper = new PhonenumberHelper( + new ArrayAdapter(), + new ParameterBag([ + 'chill_main.phone_helper' => [ + 'default_carrier_code' => 'BE', + ], + ]), + new NullLogger() + ); + + return new AssociateByPhonenumberCommandHandler( + $personACLAwareRepository, + $thirdPartyRepository, + $phonenumberHelper, + new MockClock(), + $entityManager->reveal() + ); + } + + public function testHandleWithPersonFoundByPhonenumber(): void + { + $person = new Person(); + + $personAclAwareRepository = $this->prophesize(PersonACLAwareRepositoryInterface::class); + $personAclAwareRepository->findByPhone(Argument::any())->willReturn([$person]); + $thirdPartyRepository = $this->prophesize(ThirdPartyRepository::class); + $thirdPartyRepository->findByPhonenumber(Argument::type(PhoneNumber::class))->willReturn([]); + + $handler = $this->getHandler($personAclAwareRepository->reveal(), $thirdPartyRepository->reveal()); + + $ticket = new Ticket(); + $handler($ticket, new AssociateByPhonenumberCommand('+3281136917')); + + self::assertSame($person, $ticket->getCaller()); + } + + public function testHandleWithThirdPartyFoundByPhonenumber(): void + { + $personAclAwareRepository = $this->prophesize(PersonACLAwareRepositoryInterface::class); + $personAclAwareRepository->findByPhone(Argument::any())->willReturn([]); + $thirdPartyRepository = $this->prophesize(ThirdPartyRepository::class); + $thirdPartyRepository->findByPhonenumber(Argument::type(PhoneNumber::class))->willReturn([$thirdParty = new ThirdParty()]); + + $handler = $this->getHandler($personAclAwareRepository->reveal(), $thirdPartyRepository->reveal()); + + $ticket = new Ticket(); + $handler($ticket, new AssociateByPhonenumberCommand('081136917')); + + self::assertSame($thirdParty, $ticket->getCaller()); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/ChangeEmergencyStateCommandHandlerTest.php b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/ChangeEmergencyStateCommandHandlerTest.php new file mode 100644 index 000000000..fe3e85f43 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/ChangeEmergencyStateCommandHandlerTest.php @@ -0,0 +1,145 @@ +<?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\TicketBundle\Tests\Action\Ticket\Handler; + +use Chill\TicketBundle\Action\Ticket\ChangeEmergencyStateCommand; +use Chill\TicketBundle\Action\Ticket\Handler\ChangeEmergencyStateCommandHandler; +use Chill\TicketBundle\Entity\EmergencyStatusEnum; +use Chill\TicketBundle\Entity\EmergencyStatusHistory; +use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Event\EmergencyStatusUpdateEvent; +use Chill\TicketBundle\Event\TicketUpdateEvent; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\Clock\MockClock; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; + +/** + * @internal + * + * @coversNothing + */ +final class ChangeEmergencyStateCommandHandlerTest extends TestCase +{ + use ProphecyTrait; + + public function testInvokeWithAlreadyYesEmergencyStatus(): void + { + $ticket = new Ticket(); + + // Create a YES emergency status history + new EmergencyStatusHistory(EmergencyStatusEnum::YES, $ticket); + + $eventDispatcher = $this->prophesize(EventDispatcherInterface::class); + $eventDispatcher->dispatch(Argument::type(EmergencyStatusUpdateEvent::class), TicketUpdateEvent::class)->shouldNotBeCalled(); + + $handler = new ChangeEmergencyStateCommandHandler(new MockClock(), $eventDispatcher->reveal()); + $command = new ChangeEmergencyStateCommand(EmergencyStatusEnum::YES); + + $result = $handler->__invoke($ticket, $command); + + // Assert that the ticket is returned unchanged + $this->assertSame($ticket, $result); + $this->assertSame(EmergencyStatusEnum::YES, $ticket->getEmergencyStatus()); + + // Assert that no new emergency status history was created + $emergencyStatusHistories = $ticket->getEmergencyStatusHistories(); + $this->assertCount(1, $emergencyStatusHistories); + } + + public function testInvokeWithYesEmergencyStatusToNo(): void + { + $ticket = new Ticket(); + + // Create a YES emergency status history + new EmergencyStatusHistory(EmergencyStatusEnum::YES, $ticket); + + $eventDispatcher = $this->prophesize(EventDispatcherInterface::class); + $eventDispatcher->dispatch( + Argument::that( + fn ($e) => $e instanceof EmergencyStatusUpdateEvent + && EmergencyStatusEnum::YES === $e->previousEmergencyStatus + && EmergencyStatusEnum::NO === $e->newEmergencyStatus + ), + TicketUpdateEvent::class + )->shouldBeCalled(); + + $handler = new ChangeEmergencyStateCommandHandler(new MockClock(), $eventDispatcher->reveal()); + $command = new ChangeEmergencyStateCommand(EmergencyStatusEnum::NO); + + $result = $handler->__invoke($ticket, $command); + + // Assert that the ticket is returned + $this->assertSame($ticket, $result); + + // Assert that the ticket emergency status is now NO + $this->assertSame(EmergencyStatusEnum::NO, $ticket->getEmergencyStatus()); + + // Assert that the old emergency status history was ended and a new one was created + $emergencyStatusHistories = $ticket->getEmergencyStatusHistories(); + $this->assertCount(2, $emergencyStatusHistories); + + // The first emergency status history should be ended + $yesEmergencyStatusHistory = $emergencyStatusHistories->first(); + $this->assertNotNull($yesEmergencyStatusHistory->getEndDate()); + $this->assertSame(EmergencyStatusEnum::YES, $yesEmergencyStatusHistory->getEmergencyStatus()); + + // The last emergency status history should be NO and active + $noEmergencyStatusHistory = $emergencyStatusHistories->last(); + $this->assertNull($noEmergencyStatusHistory->getEndDate()); + $this->assertSame(EmergencyStatusEnum::NO, $noEmergencyStatusHistory->getEmergencyStatus()); + } + + public function testInvokeWithNoEmergencyStatusToYes(): void + { + $ticket = new Ticket(); + + // Create a NO emergency status history + new EmergencyStatusHistory(EmergencyStatusEnum::NO, $ticket); + + $eventDispatcher = $this->prophesize(EventDispatcherInterface::class); + $eventDispatcher->dispatch( + Argument::that( + fn ($e) => $e instanceof EmergencyStatusUpdateEvent + && EmergencyStatusEnum::NO === $e->previousEmergencyStatus + && EmergencyStatusEnum::YES === $e->newEmergencyStatus + ), + TicketUpdateEvent::class + )->shouldBeCalled(); + + $handler = new ChangeEmergencyStateCommandHandler(new MockClock(), $eventDispatcher->reveal()); + $command = new ChangeEmergencyStateCommand(EmergencyStatusEnum::YES); + + $result = $handler->__invoke($ticket, $command); + + // Assert that the ticket is returned + $this->assertSame($ticket, $result); + + // Assert that the ticket emergency status is now YES + $this->assertSame(EmergencyStatusEnum::YES, $ticket->getEmergencyStatus()); + + // Assert that the old emergency status history was ended and a new one was created + $emergencyStatusHistories = $ticket->getEmergencyStatusHistories(); + $this->assertCount(2, $emergencyStatusHistories); + + // The first emergency status history should be ended + $noEmergencyStatusHistory = $emergencyStatusHistories->first(); + $this->assertNotNull($noEmergencyStatusHistory->getEndDate()); + $this->assertSame(EmergencyStatusEnum::NO, $noEmergencyStatusHistory->getEmergencyStatus()); + + // The last emergency status history should be YES and active + $yesEmergencyStatusHistory = $emergencyStatusHistories->last(); + $this->assertNull($yesEmergencyStatusHistory->getEndDate()); + $this->assertSame(EmergencyStatusEnum::YES, $yesEmergencyStatusHistory->getEmergencyStatus()); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/ChangeStateCommandHandlerTest.php b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/ChangeStateCommandHandlerTest.php new file mode 100644 index 000000000..a19c89479 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/ChangeStateCommandHandlerTest.php @@ -0,0 +1,118 @@ +<?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\TicketBundle\Tests\Action\Ticket\Handler; + +use Chill\TicketBundle\Action\Ticket\ChangeStateCommand; +use Chill\TicketBundle\Action\Ticket\Handler\ChangeStateCommandHandler; +use Chill\TicketBundle\Entity\StateEnum; +use Chill\TicketBundle\Entity\StateHistory; +use Chill\TicketBundle\Entity\Ticket; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\Clock\MockClock; + +/** + * @internal + * + * @coversNothing + */ +final class ChangeStateCommandHandlerTest extends TestCase +{ + use ProphecyTrait; + + public function testInvokeWithAlreadyClosedTicket(): void + { + $ticket = new Ticket(); + + // Create a closed state history + new StateHistory(StateEnum::CLOSED, $ticket); + + $handler = new ChangeStateCommandHandler(new MockClock()); + $command = new ChangeStateCommand(StateEnum::CLOSED); + + $result = $handler->__invoke($ticket, $command); + + // Assert that the ticket is returned unchanged + $this->assertSame($ticket, $result); + $this->assertSame(StateEnum::CLOSED, $ticket->getState()); + + // Assert that no new state history was created + $stateHistories = $ticket->getStateHistories(); + $this->assertCount(1, $stateHistories); + } + + public function testInvokeWithOpenTicketToClose(): void + { + $ticket = new Ticket(); + + // Create an open state history + new StateHistory(StateEnum::OPEN, $ticket); + + $handler = new ChangeStateCommandHandler(new MockClock()); + $command = new ChangeStateCommand(StateEnum::CLOSED); + + $result = $handler->__invoke($ticket, $command); + + // Assert that the ticket is returned + $this->assertSame($ticket, $result); + + // Assert that the ticket state is now closed + $this->assertSame(StateEnum::CLOSED, $ticket->getState()); + + // Assert that the old state history was ended and a new one was created + $stateHistories = $ticket->getStateHistories(); + $this->assertCount(2, $stateHistories); + + // The first state history should be ended + $openStateHistory = $stateHistories->first(); + $this->assertNotNull($openStateHistory->getEndDate()); + $this->assertSame(StateEnum::OPEN, $openStateHistory->getState()); + + // The last state history should be closed and active + $closedStateHistory = $stateHistories->last(); + $this->assertNull($closedStateHistory->getEndDate()); + $this->assertSame(StateEnum::CLOSED, $closedStateHistory->getState()); + } + + public function testInvokeWithClosedTicketToOpen(): void + { + $ticket = new Ticket(); + + // Create a closed state history + new StateHistory(StateEnum::CLOSED, $ticket); + + $handler = new ChangeStateCommandHandler(new MockClock()); + $command = new ChangeStateCommand(StateEnum::OPEN); + + $result = $handler->__invoke($ticket, $command); + + // Assert that the ticket is returned + $this->assertSame($ticket, $result); + + // Assert that the ticket state is now open + $this->assertSame(StateEnum::OPEN, $ticket->getState()); + + // Assert that the old state history was ended and a new one was created + $stateHistories = $ticket->getStateHistories(); + $this->assertCount(2, $stateHistories); + + // The first state history should be ended + $closedStateHistory = $stateHistories->first(); + $this->assertNotNull($closedStateHistory->getEndDate()); + $this->assertSame(StateEnum::CLOSED, $closedStateHistory->getState()); + + // The last state history should be open and active + $openStateHistory = $stateHistories->last(); + $this->assertNull($openStateHistory->getEndDate()); + $this->assertSame(StateEnum::OPEN, $openStateHistory->getState()); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/CreateTicketCommandHandlerTest.php b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/CreateTicketCommandHandlerTest.php new file mode 100644 index 000000000..b58d3f2a2 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/CreateTicketCommandHandlerTest.php @@ -0,0 +1,55 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\TicketBundle\Tests\Action\Ticket\Handler; + +use Chill\TicketBundle\Action\Ticket\CreateTicketCommand; +use Chill\TicketBundle\Action\Ticket\Handler\CreateTicketCommandHandler; +use Chill\TicketBundle\Entity\EmergencyStatusEnum; +use Chill\TicketBundle\Entity\StateEnum; +use Chill\TicketBundle\Entity\Ticket; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Clock\MockClock; + +/** + * @internal + * + * @coversNothing + */ +class CreateTicketCommandHandlerTest extends TestCase +{ + private function getHandler(): CreateTicketCommandHandler + { + return new CreateTicketCommandHandler(new MockClock()); + } + + public function testHandleWithoutReference(): void + { + $command = new CreateTicketCommand(); + $actual = ($this->getHandler())($command); + + self::assertInstanceOf(Ticket::class, $actual); + self::assertEquals('', $actual->getExternalRef()); + self::assertEquals(StateEnum::OPEN, $actual->getState()); + self::assertEquals(EmergencyStatusEnum::NO, $actual->getEmergencyStatus()); + } + + public function testHandleWithReference(): void + { + $command = new CreateTicketCommand($ref = 'external-ref'); + $actual = ($this->getHandler())($command); + + self::assertInstanceOf(Ticket::class, $actual); + self::assertEquals($ref, $actual->getExternalRef()); + self::assertEquals(StateEnum::OPEN, $actual->getState()); + self::assertEquals(EmergencyStatusEnum::NO, $actual->getEmergencyStatus()); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/ReplaceMotiveCommandHandlerTest.php b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/ReplaceMotiveCommandHandlerTest.php new file mode 100644 index 000000000..4a97ebb13 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/ReplaceMotiveCommandHandlerTest.php @@ -0,0 +1,220 @@ +<?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\TicketBundle\Tests\Action\Ticket\Handler; + +use Chill\TicketBundle\Action\Ticket\ChangeEmergencyStateCommand; +use Chill\TicketBundle\Action\Ticket\Handler\ChangeEmergencyStateCommandHandler; +use Chill\TicketBundle\Action\Ticket\Handler\ReplaceMotiveCommandHandler; +use Chill\TicketBundle\Action\Ticket\ReplaceMotiveCommand; +use Chill\TicketBundle\Entity\EmergencyStatusEnum; +use Chill\TicketBundle\Entity\Motive; +use Chill\TicketBundle\Entity\MotiveHistory; +use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Event\MotiveUpdateEvent; +use Chill\TicketBundle\Event\TicketUpdateEvent; +use Doctrine\ORM\EntityManagerInterface; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\Clock\MockClock; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; + +/** + * @internal + * + * @covers \Chill\TicketBundle\Action\Ticket\Handler\ReplaceMotiveCommandHandler + */ +final class ReplaceMotiveCommandHandlerTest extends KernelTestCase +{ + use ProphecyTrait; + + private function buildHandler( + EntityManagerInterface $entityManager, + ?ChangeEmergencyStateCommandHandler $changeEmergencyStateCommandHandler = null, + ?EventDispatcherInterface $eventDispatcher = null, + ): ReplaceMotiveCommandHandler { + $clock = new MockClock(); + + if (null === $changeEmergencyStateCommandHandler) { + $changeEmergencyStateCommandHandler = $this->prophesize(ChangeEmergencyStateCommandHandler::class)->reveal(); + } + if (null === $eventDispatcher) { + $eventDispatcher = $this->prophesize(EventDispatcherInterface::class)->reveal(); + } + + return new ReplaceMotiveCommandHandler($clock, $entityManager, $changeEmergencyStateCommandHandler, $eventDispatcher); + } + + public function testHandleOnTicketWithoutMotive(): void + { + $motive = new Motive(); + $ticket = new Ticket(); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->persist(Argument::that(static function ($arg) use ($motive): bool { + if (!$arg instanceof MotiveHistory) { + return false; + } + + return $arg->getMotive() === $motive; + }))->shouldBeCalled(); + + $eventDispatcher = $this->prophesize(EventDispatcherInterface::class); + $eventDispatcher->dispatch( + Argument::that(fn ($event) => $event instanceof MotiveUpdateEvent + && $event->newMotive === $motive + && null === $event->previousMotive + && $event->hasChanges()), + TicketUpdateEvent::class + )->shouldBeCalled(); + + $handler = $this->buildHandler($entityManager->reveal(), null, $eventDispatcher->reveal()); + + $handler->handle($ticket, new ReplaceMotiveCommand($motive)); + + self::assertSame($motive, $ticket->getMotive()); + } + + public function testHandleReplaceMotiveOnTicketWithExistingMotive(): void + { + $motive = new Motive(); + $ticket = new Ticket(); + $history = new MotiveHistory(new Motive(), $ticket); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->persist(Argument::that(static function ($arg) use ($motive): bool { + if (!$arg instanceof MotiveHistory) { + return false; + } + + return $arg->getMotive() === $motive; + }))->shouldBeCalled(); + + $previous = $history->getMotive(); + $eventDispatcher = $this->prophesize(EventDispatcherInterface::class); + $eventDispatcher->dispatch( + Argument::that(fn ($event) => $event instanceof MotiveUpdateEvent + && $event->newMotive === $motive + && $previous === $event->previousMotive + && $event->hasChanges()), + TicketUpdateEvent::class + )->shouldBeCalled(); + + $handler = $this->buildHandler($entityManager->reveal(), null, $eventDispatcher->reveal()); + + $handler->handle($ticket, new ReplaceMotiveCommand($motive)); + + self::assertSame($motive, $ticket->getMotive()); + self::assertCount(2, $ticket->getMotiveHistories()); + } + + public function testHandleReplaceMotiveOnTicketWithSameMotive(): void + { + $motive = new Motive(); + $ticket = new Ticket(); + $history = new MotiveHistory($motive, $ticket); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->persist(Argument::that(static function ($arg) use ($motive): bool { + if (!$arg instanceof MotiveHistory) { + return false; + } + + return $arg->getMotive() === $motive; + }))->shouldNotBeCalled(); + + $eventDispatcher = $this->prophesize(EventDispatcherInterface::class); + $eventDispatcher->dispatch(Argument::any(), TicketUpdateEvent::class)->shouldNotBeCalled(); + + $handler = $this->buildHandler($entityManager->reveal(), null, $eventDispatcher->reveal()); + + $handler->handle($ticket, new ReplaceMotiveCommand($motive)); + + self::assertSame($motive, $ticket->getMotive()); + self::assertCount(1, $ticket->getMotiveHistories()); + } + + public function testHandleUpdatesEmergencyStatusWhenMotiveHasMakeTicketEmergency(): void + { + // Create a motive with makeTicketEmergency set to YES + $motive = new Motive(); + $motive->setMakeTicketEmergency(EmergencyStatusEnum::YES); + + // Create a ticket with no emergency status + $ticket = new Ticket(); + + // Mock the entity manager + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->persist(Argument::type(MotiveHistory::class))->shouldBeCalled(); + + // Mock the ChangeEmergencyStateCommandHandler + $changeEmergencyStateCommandHandler = $this->prophesize(ChangeEmergencyStateCommandHandler::class); + $changeEmergencyStateCommandHandler->__invoke( + $ticket, + Argument::that(fn (ChangeEmergencyStateCommand $command) => EmergencyStatusEnum::YES === $command->newEmergencyStatus) + )->shouldBeCalled(); + + // Expect event dispatch for motive update + $eventDispatcher = $this->prophesize(EventDispatcherInterface::class); + $eventDispatcher->dispatch(Argument::type(MotiveUpdateEvent::class), TicketUpdateEvent::class)->shouldBeCalled(); + + // Create the handler with our mocks + $handler = $this->buildHandler( + $entityManager->reveal(), + $changeEmergencyStateCommandHandler->reveal(), + $eventDispatcher->reveal() + ); + + // Handle the command + $handler->handle($ticket, new ReplaceMotiveCommand($motive)); + + // Assert that the motive was set on the ticket + self::assertSame($motive, $ticket->getMotive()); + } + + public function testHandleDoesNotUpdateEmergencyStatusWhenMotiveHasNoMakeTicketEmergency(): void + { + // Create a motive with makeTicketEmergency set to null + $motive = new Motive(); + $motive->setMakeTicketEmergency(null); + + // Create a ticket with no emergency status + $ticket = new Ticket(); + + // Mock the entity manager + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->persist(Argument::type(MotiveHistory::class))->shouldBeCalled(); + + // Mock the ChangeEmergencyStateCommandHandler - it should NOT be called + $changeEmergencyStateCommandHandler = $this->prophesize(ChangeEmergencyStateCommandHandler::class); + $changeEmergencyStateCommandHandler->__invoke( + Argument::cetera() + )->shouldNotBeCalled(); + + // Expect event dispatch for motive update + $eventDispatcher = $this->prophesize(EventDispatcherInterface::class); + $eventDispatcher->dispatch(Argument::type(MotiveUpdateEvent::class), TicketUpdateEvent::class)->shouldBeCalled(); + + // Create the handler with our mocks + $handler = $this->buildHandler( + $entityManager->reveal(), + $changeEmergencyStateCommandHandler->reveal(), + $eventDispatcher->reveal() + ); + + // Handle the command + $handler->handle($ticket, new ReplaceMotiveCommand($motive)); + + // Assert that the motive was set on the ticket + self::assertSame($motive, $ticket->getMotive()); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/SetAddressesCommandHandlerTest.php b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/SetAddressesCommandHandlerTest.php new file mode 100644 index 000000000..3fc45db8a --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/SetAddressesCommandHandlerTest.php @@ -0,0 +1,112 @@ +<?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\TicketBundle\Tests\Action\Ticket\Handler; + +use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Entity\UserGroup; +use Chill\TicketBundle\Action\Ticket\Handler\SetAddresseesCommandHandler; +use Chill\TicketBundle\Action\Ticket\SetAddresseesCommand; +use Chill\TicketBundle\Entity\AddresseeHistory; +use Chill\TicketBundle\Entity\Ticket; +use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\Clock\MockClock; +use Symfony\Component\Security\Core\Security; + +/** + * @internal + * + * @coversNothing + */ +final class SetAddressesCommandHandlerTest extends TestCase +{ + use ProphecyTrait; + + public function testHandleOnEmptyAddresses(): void + { + $ticket = new Ticket(); + $command = new SetAddresseesCommand([$user1 = new User(), $group1 = new UserGroup()]); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->persist(Argument::that(fn ($arg) => $arg instanceof AddresseeHistory && $arg->getAddressee() === $user1))->shouldBeCalledOnce(); + $entityManager->persist(Argument::that(fn ($arg) => $arg instanceof AddresseeHistory && $arg->getAddressee() === $group1))->shouldBeCalledOnce(); + + $handler = $this->buildHandler($entityManager->reveal()); + + $handler->handle($ticket, $command); + + self::assertCount(2, $ticket->getCurrentAddressee()); + } + + public function testHandleExistingUserIsNotRemovedNorCreatingDouble(): void + { + $ticket = new Ticket(); + $user = new User(); + $history = new AddresseeHistory($user, new \DateTimeImmutable('1 month ago'), $ticket); + $command = new SetAddresseesCommand([$user]); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->persist(Argument::that(fn ($arg) => $arg instanceof AddresseeHistory && $arg->getAddressee() === $user))->shouldNotBeCalled(); + + $handler = $this->buildHandler($entityManager->reveal()); + + $handler->handle($ticket, $command); + + self::assertNull($history->getEndDate()); + self::assertCount(1, $ticket->getCurrentAddressee()); + } + + public function testHandleRemoveExistingAddressee(): void + { + $ticket = new Ticket(); + $user = new User(); + $group = new UserGroup(); + $history = new AddresseeHistory($user, new \DateTimeImmutable('1 month ago'), $ticket); + $command = new SetAddresseesCommand([$group]); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->persist(Argument::that(fn ($arg) => $arg instanceof AddresseeHistory && $arg->getAddressee() === $group))->shouldBeCalled(); + + $handler = $this->buildHandler($entityManager->reveal()); + + $handler->handle($ticket, $command); + + self::assertNotNull($history->getEndDate()); + self::assertContains($group, $ticket->getCurrentAddressee()); + } + + public function testAddingDoublingAddresseeDoesNotCreateDoubleHistories(): void + { + $ticket = new Ticket(); + $group = new UserGroup(); + $command = new SetAddresseesCommand([$group, $group]); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->persist(Argument::that(fn ($arg) => $arg instanceof AddresseeHistory && $arg->getAddressee() === $group))->shouldBeCalledOnce(); + + $handler = $this->buildHandler($entityManager->reveal()); + + $handler->handle($ticket, $command); + + self::assertCount(1, $ticket->getCurrentAddressee()); + } + + private function buildHandler(EntityManagerInterface $entityManager): SetAddresseesCommandHandler + { + $security = $this->prophesize(Security::class); + $security->getUser()->willReturn(new User()); + + return new SetAddresseesCommandHandler(new MockClock(), $entityManager, $security->reveal()); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/SetCallerCommandHandlerTest.php b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/SetCallerCommandHandlerTest.php new file mode 100644 index 000000000..e2e8a642b --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/SetCallerCommandHandlerTest.php @@ -0,0 +1,178 @@ +<?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\TicketBundle\Tests\Action\Ticket\Handler; + +use Chill\PersonBundle\Entity\Person; +use Chill\ThirdPartyBundle\Entity\ThirdParty; +use Chill\TicketBundle\Action\Ticket\Handler\SetCallerCommandHandler; +use Chill\TicketBundle\Action\Ticket\SetCallerCommand; +use Chill\TicketBundle\Entity\CallerHistory; +use Chill\TicketBundle\Entity\Ticket; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\Clock\ClockInterface; +use Symfony\Component\Clock\MockClock; + +/** + * @internal + * + * @coversNothing + */ +class SetCallerCommandHandlerTest extends TestCase +{ + use ProphecyTrait; + + private \DateTimeImmutable $now; + private ClockInterface $clock; + private SetCallerCommandHandler $handler; + + protected function setUp(): void + { + $this->now = new \DateTimeImmutable('2023-01-01 12:00:00'); + $this->clock = new MockClock($this->now); + $this->handler = new SetCallerCommandHandler($this->clock); + } + + public function testSetPersonAsCaller(): void + { + // Arrange + $ticket = new Ticket(); + $person = new Person(); + $command = new SetCallerCommand($person); + + // Act + $result = ($this->handler)($ticket, $command); + + // Assert + self::assertSame($ticket, $result); + self::assertSame($person, $ticket->getCaller()); + self::assertCount(1, $ticket->getCallerHistories()); + + $callerHistory = $ticket->getCallerHistories()->first(); + self::assertInstanceOf(CallerHistory::class, $callerHistory); + self::assertSame($person, $callerHistory->getPerson()); + self::assertNull($callerHistory->getThirdParty()); + self::assertEquals($this->now->getTimestamp(), $callerHistory->getStartDate()->getTimestamp()); + self::assertNull($callerHistory->getEndDate()); + } + + public function testSetThirdPartyAsCaller(): void + { + // Arrange + $ticket = new Ticket(); + $thirdParty = new ThirdParty(); + $command = new SetCallerCommand($thirdParty); + + // Act + $result = ($this->handler)($ticket, $command); + + // Assert + self::assertSame($ticket, $result); + self::assertSame($thirdParty, $ticket->getCaller()); + self::assertCount(1, $ticket->getCallerHistories()); + + $callerHistory = $ticket->getCallerHistories()->first(); + self::assertInstanceOf(CallerHistory::class, $callerHistory); + self::assertNull($callerHistory->getPerson()); + self::assertSame($thirdParty, $callerHistory->getThirdParty()); + self::assertEquals($this->now->getTimestamp(), $callerHistory->getStartDate()->getTimestamp()); + self::assertNull($callerHistory->getEndDate()); + } + + public function testChangeCallerFromPersonToThirdParty(): void + { + // Arrange + $ticket = new Ticket(); + $person = new Person(); + $thirdParty = new ThirdParty(); + + // Set initial person caller + $initialCallerHistory = new CallerHistory($person, $ticket, $this->now); + $initialCallerHistory->setPerson($person); + + // Create command to change to third party + $command = new SetCallerCommand($thirdParty); + + // Act + $this->clock->modify('+ 10 minutes'); + $result = ($this->handler)($ticket, $command); + + // Assert + self::assertSame($ticket, $result); + self::assertSame($thirdParty, $ticket->getCaller()); + self::assertCount(2, $ticket->getCallerHistories()); + + // Check that the first history is ended + $firstCallerHistory = $ticket->getCallerHistories()->first(); + self::assertSame($person, $firstCallerHistory->getPerson()); + self::assertEquals($this->clock->now()->getTimestamp(), $firstCallerHistory->getEndDate()->getTimestamp()); + + // Check that the new history is created correctly + $lastCallerHistory = $ticket->getCallerHistories()->last(); + self::assertNull($lastCallerHistory->getPerson()); + self::assertSame($thirdParty, $lastCallerHistory->getThirdParty()); + self::assertSame($this->clock->now()->getTimestamp(), $lastCallerHistory->getStartDate()->getTimestamp()); + self::assertNull($lastCallerHistory->getEndDate()); + } + + public function testRemoveCaller(): void + { + // Arrange + $ticket = new Ticket(); + $person = new Person(); + + // Set initial person caller + $initialCallerHistory = new CallerHistory($person, $ticket, $this->now); + + // Create command to remove caller + $command = new SetCallerCommand(null); + + // Act + $result = ($this->handler)($ticket, $command); + + // Assert + self::assertSame($ticket, $result); + self::assertNull($ticket->getCaller()); + self::assertCount(2, $ticket->getCallerHistories()); + + // Check that the history is ended + $callerHistory = $ticket->getCallerHistories()->first(); + self::assertSame($person, $callerHistory->getPerson()); + self::assertEquals($this->now->getTimestamp(), $callerHistory->getEndDate()->getTimestamp()); + } + + public function testNoChangeWhenCallerIsAlreadySet(): void + { + // Arrange + $ticket = new Ticket(); + $person = new Person(); + + // Set initial person caller + $initialCallerHistory = new CallerHistory($person, $ticket, $this->now); + + // Create command with the same person + $command = new SetCallerCommand($person); + + // Act + $result = ($this->handler)($ticket, $command); + + // Assert + self::assertSame($ticket, $result); + self::assertSame($person, $ticket->getCaller()); + self::assertCount(1, $ticket->getCallerHistories()); + + // Check that the history is unchanged + $callerHistory = $ticket->getCallerHistories()->first(); + self::assertSame($person, $callerHistory->getPerson()); + self::assertNull($callerHistory->getEndDate()); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/SetPersonsCommandHandlerTest.php b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/SetPersonsCommandHandlerTest.php new file mode 100644 index 000000000..6097ba993 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Action/Ticket/Handler/SetPersonsCommandHandlerTest.php @@ -0,0 +1,152 @@ +<?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\TicketBundle\Tests\Action\Ticket\Handler; + +use Chill\MainBundle\Entity\User; +use Chill\PersonBundle\Entity\Person; +use Chill\TicketBundle\Action\Ticket\Handler\SetPersonsCommandHandler; +use Chill\TicketBundle\Action\Ticket\SetPersonsCommand; +use Chill\TicketBundle\Entity\PersonHistory; +use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Event\PersonsUpdateEvent; +use Chill\TicketBundle\Event\TicketUpdateEvent; +use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\Clock\MockClock; +use Symfony\Component\Security\Core\Security; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; + +/** + * @internal + * + * @coversNothing + */ +final class SetPersonsCommandHandlerTest extends TestCase +{ + use ProphecyTrait; + + public function testHandleOnEmptyAddresses(): void + { + $ticket = new Ticket(); + $command = new SetPersonsCommand([$person1 = new Person(), $group1 = new Person()]); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->persist(Argument::that(fn ($arg) => $arg instanceof PersonHistory && $arg->getPerson() === $person1))->shouldBeCalledOnce(); + $entityManager->persist(Argument::that(fn ($arg) => $arg instanceof PersonHistory && $arg->getPerson() === $group1))->shouldBeCalledOnce(); + + $eventDispatcher = $this->prophesize(EventDispatcherInterface::class); + $eventDispatcher->dispatch( + Argument::that(fn ($arg) => $arg instanceof PersonsUpdateEvent + && in_array($person1, $arg->personsAdded, true) + && in_array($group1, $arg->personsAdded, true) + && [] === $arg->personsRemoved), + TicketUpdateEvent::class + )->shouldBeCalled(); + + $handler = $this->buildHandler($entityManager->reveal(), $eventDispatcher->reveal()); + + $handler->handle($ticket, $command); + + self::assertCount(2, $ticket->getPersons()); + } + + public function testHandleExistingPersonIsNotRemovedNorCreatingDouble(): void + { + $ticket = new Ticket(); + $person = new Person(); + $history = new PersonHistory($person, $ticket, new \DateTimeImmutable('1 month ago')); + $command = new SetPersonsCommand([$person]); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->persist(Argument::that(fn ($arg) => $arg instanceof PersonHistory && $arg->getPerson() === $person))->shouldNotBeCalled(); + + $eventDispatcher = $this->prophesize(EventDispatcherInterface::class); + $eventDispatcher->dispatch( + Argument::that( + fn ($arg) => $arg instanceof PersonsUpdateEvent + ), + TicketUpdateEvent::class + )->shouldNotBeCalled(); + + $handler = $this->buildHandler($entityManager->reveal(), $eventDispatcher->reveal()); + + $handler->handle($ticket, $command); + + self::assertNull($history->getEndDate()); + self::assertCount(1, $ticket->getPersons()); + } + + public function testHandleRemoveExistingPerson(): void + { + $ticket = new Ticket(); + $person = new Person(); + $person2 = new Person(); + $history = new PersonHistory($person, $ticket, new \DateTimeImmutable('1 month ago')); + $command = new SetPersonsCommand([$person2]); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->persist(Argument::that(fn ($arg) => $arg instanceof PersonHistory && $arg->getPerson() === $person2))->shouldBeCalled(); + + $eventDispatcher = $this->prophesize(EventDispatcherInterface::class); + $eventDispatcher->dispatch( + Argument::that( + fn ($arg) => $arg instanceof PersonsUpdateEvent + && in_array($person, $arg->personsRemoved, true) && 1 === count($arg->personsRemoved) + && in_array($person2, $arg->personsAdded, true) && 1 === count($arg->personsAdded) + ), + TicketUpdateEvent::class + )->shouldBeCalled(); + + $handler = $this->buildHandler($entityManager->reveal(), $eventDispatcher->reveal()); + + $handler->handle($ticket, $command); + + self::assertNotNull($history->getEndDate()); + self::assertContains($person2, $ticket->getPersons()); + } + + public function testAddingDoublingPersonsDoesNotCreateDoubleHistories(): void + { + $ticket = new Ticket(); + $person = new Person(); + $command = new SetPersonsCommand([$person, $person]); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->persist(Argument::that(fn ($arg) => $arg instanceof PersonHistory && $arg->getPerson() === $person))->shouldBeCalledOnce(); + + $eventDispatcher = $this->prophesize(EventDispatcherInterface::class); + $eventDispatcher->dispatch( + Argument::that( + fn ($arg) => $arg instanceof PersonsUpdateEvent + && in_array($person, $arg->personsAdded, true) && 1 === count($arg->personsAdded) + && [] === $arg->personsRemoved + ), + TicketUpdateEvent::class + )->shouldBeCalled(); + + $handler = $this->buildHandler($entityManager->reveal(), $eventDispatcher->reveal()); + + $handler->handle($ticket, $command); + + self::assertCount(1, $ticket->getPersons()); + } + + private function buildHandler(EntityManagerInterface $entityManager, EventDispatcherInterface $eventDispatcher): SetPersonsCommandHandler + { + $security = $this->prophesize(Security::class); + $security->getUser()->willReturn(new User()); + + return new SetPersonsCommandHandler(new MockClock(), $entityManager, $security->reveal(), $eventDispatcher); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/AddCommentControllerTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/AddCommentControllerTest.php new file mode 100644 index 000000000..f954fd084 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Controller/AddCommentControllerTest.php @@ -0,0 +1,106 @@ +<?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\TicketBundle\Tests\Controller; + +use Chill\TicketBundle\Action\Ticket\Handler\AddCommentCommandHandler; +use Chill\TicketBundle\Controller\AddCommentController; +use Chill\TicketBundle\Entity\Comment; +use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Security\Voter\CommentVoter; +use Doctrine\ORM\EntityManagerInterface; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Validator\Validator\ValidatorInterface; + +/** + * @internal + * + * @coversNothing + */ +class AddCommentControllerTest extends KernelTestCase +{ + use ProphecyTrait; + + private SerializerInterface $serializer; + private ValidatorInterface $validator; + + protected function setUp(): void + { + self::bootKernel(); + $this->validator = self::getContainer()->get(ValidatorInterface::class); + $this->serializer = self::getContainer()->get(SerializerInterface::class); + } + + public function testAddComment(): void + { + $controller = $this->buildController(willFlush: true); + + $ticket = new Ticket(); + $request = new Request(content: <<<'JSON' + {"content": "test"} + JSON); + + $response = $controller->__invoke($ticket, $request); + + self::assertEquals(201, $response->getStatusCode()); + } + + public function testAddCommentWithBlankContent(): void + { + $controller = $this->buildController(willFlush: false); + + $ticket = new Ticket(); + $request = new Request(content: <<<'JSON' + {"content": ""} + JSON); + + $response = $controller->__invoke($ticket, $request); + + self::assertEquals(422, $response->getStatusCode()); + + $request = new Request(content: <<<'JSON' + {"content": null} + JSON); + + $response = $controller->__invoke($ticket, $request); + + self::assertEquals(422, $response->getStatusCode()); + } + + private function buildController(bool $willFlush): AddCommentController + { + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + + if ($willFlush) { + $security->isGranted(CommentVoter::CREATE, Argument::type(Comment::class))->willReturn(true); + $entityManager->persist(Argument::type(Comment::class))->shouldBeCalled(); + $entityManager->flush()->shouldBeCalled(); + } + + $commandHandler = new AddCommentCommandHandler($entityManager->reveal()); + + return new AddCommentController( + $security->reveal(), + $this->serializer, + $this->validator, + $commandHandler, + $entityManager->reveal(), + ); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/ChangeEmergencyStateApiControllerTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/ChangeEmergencyStateApiControllerTest.php new file mode 100644 index 000000000..1a479bc64 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Controller/ChangeEmergencyStateApiControllerTest.php @@ -0,0 +1,144 @@ +<?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\TicketBundle\Tests\Controller; + +use Chill\TicketBundle\Action\Ticket\ChangeEmergencyStateCommand; +use Chill\TicketBundle\Action\Ticket\Handler\ChangeEmergencyStateCommandHandler; +use Chill\TicketBundle\Controller\ChangeEmergencyStateApiController; +use Chill\TicketBundle\Entity\EmergencyStatusEnum; +use Chill\TicketBundle\Entity\Ticket; +use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\SerializerInterface; + +/** + * @internal + * + * @coversNothing + */ +final class ChangeEmergencyStateApiControllerTest extends TestCase +{ + use ProphecyTrait; + + public function testSetEmergencyYesWithoutPermission(): void + { + $ticket = new Ticket(); + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(false); + + $changeEmergencyStateCommandHandler = $this->prophesize(ChangeEmergencyStateCommandHandler::class); + $entityManager = $this->prophesize(EntityManagerInterface::class); + $serializer = $this->prophesize(SerializerInterface::class); + + $controller = new ChangeEmergencyStateApiController( + $changeEmergencyStateCommandHandler->reveal(), + $security->reveal(), + $entityManager->reveal(), + $serializer->reveal(), + ); + + $this->expectException(AccessDeniedHttpException::class); + $controller->setEmergencyYes($ticket); + } + + public function testSetEmergencyYesWithPermission(): void + { + $ticket = new Ticket(); + + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $changeEmergencyStateCommandHandler = $this->prophesize(ChangeEmergencyStateCommandHandler::class); + $changeEmergencyStateCommandHandler->__invoke( + $ticket, + Argument::that(fn (ChangeEmergencyStateCommand $command) => EmergencyStatusEnum::YES === $command->newEmergencyStatus) + )->willReturn($ticket)->shouldBeCalled(); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->flush()->shouldBeCalled(); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->serialize($ticket, 'json', ['groups' => ['read']]) + ->willReturn('{}') + ->shouldBeCalled(); + + $controller = new ChangeEmergencyStateApiController( + $changeEmergencyStateCommandHandler->reveal(), + $security->reveal(), + $entityManager->reveal(), + $serializer->reveal(), + ); + + $response = $controller->setEmergencyYes($ticket); + + $this->assertInstanceOf(JsonResponse::class, $response); + } + + public function testSetEmergencyNoWithoutPermission(): void + { + $ticket = new Ticket(); + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(false); + + $changeEmergencyStateCommandHandler = $this->prophesize(ChangeEmergencyStateCommandHandler::class); + $entityManager = $this->prophesize(EntityManagerInterface::class); + $serializer = $this->prophesize(SerializerInterface::class); + + $controller = new ChangeEmergencyStateApiController( + $changeEmergencyStateCommandHandler->reveal(), + $security->reveal(), + $entityManager->reveal(), + $serializer->reveal(), + ); + + $this->expectException(AccessDeniedHttpException::class); + $controller->setEmergencyNo($ticket); + } + + public function testSetEmergencyNoWithPermission(): void + { + $ticket = new Ticket(); + + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $changeEmergencyStateCommandHandler = $this->prophesize(ChangeEmergencyStateCommandHandler::class); + $changeEmergencyStateCommandHandler->__invoke( + $ticket, + Argument::that(fn (ChangeEmergencyStateCommand $command) => EmergencyStatusEnum::NO === $command->newEmergencyStatus) + )->willReturn($ticket)->shouldBeCalled(); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->flush()->shouldBeCalled(); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->serialize($ticket, 'json', ['groups' => ['read']]) + ->willReturn('{}') + ->shouldBeCalled(); + + $controller = new ChangeEmergencyStateApiController( + $changeEmergencyStateCommandHandler->reveal(), + $security->reveal(), + $entityManager->reveal(), + $serializer->reveal(), + ); + + $response = $controller->setEmergencyNo($ticket); + + $this->assertInstanceOf(JsonResponse::class, $response); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/ChangeStateApiControllerTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/ChangeStateApiControllerTest.php new file mode 100644 index 000000000..ab8c2b3cb --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Controller/ChangeStateApiControllerTest.php @@ -0,0 +1,144 @@ +<?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\TicketBundle\Tests\Controller; + +use Chill\TicketBundle\Action\Ticket\ChangeStateCommand; +use Chill\TicketBundle\Action\Ticket\Handler\ChangeStateCommandHandler; +use Chill\TicketBundle\Controller\ChangeStateApiController; +use Chill\TicketBundle\Entity\StateEnum; +use Chill\TicketBundle\Entity\Ticket; +use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\SerializerInterface; + +/** + * @internal + * + * @coversNothing + */ +final class ChangeStateApiControllerTest extends TestCase +{ + use ProphecyTrait; + + public function testCloseWithoutPermission(): void + { + $ticket = new Ticket(); + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(false); + + $changeStateCommandHandler = $this->prophesize(ChangeStateCommandHandler::class); + $entityManager = $this->prophesize(EntityManagerInterface::class); + $serializer = $this->prophesize(SerializerInterface::class); + + $controller = new ChangeStateApiController( + $changeStateCommandHandler->reveal(), + $security->reveal(), + $entityManager->reveal(), + $serializer->reveal(), + ); + + $this->expectException(AccessDeniedHttpException::class); + $controller->close($ticket); + } + + public function testCloseWithPermission(): void + { + $ticket = new Ticket(); + + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $changeStateCommandHandler = $this->prophesize(ChangeStateCommandHandler::class); + $changeStateCommandHandler->__invoke( + $ticket, + Argument::that(fn (ChangeStateCommand $command) => StateEnum::CLOSED === $command->newState) + )->willReturn($ticket); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->flush()->shouldBeCalled(); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->serialize($ticket, 'json', ['groups' => ['read']]) + ->willReturn('{}') + ->shouldBeCalled(); + + $controller = new ChangeStateApiController( + $changeStateCommandHandler->reveal(), + $security->reveal(), + $entityManager->reveal(), + $serializer->reveal(), + ); + + $response = $controller->close($ticket); + + $this->assertInstanceOf(JsonResponse::class, $response); + } + + public function testOpenWithoutPermission(): void + { + $ticket = new Ticket(); + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(false); + + $changeStateCommandHandler = $this->prophesize(ChangeStateCommandHandler::class); + $entityManager = $this->prophesize(EntityManagerInterface::class); + $serializer = $this->prophesize(SerializerInterface::class); + + $controller = new ChangeStateApiController( + $changeStateCommandHandler->reveal(), + $security->reveal(), + $entityManager->reveal(), + $serializer->reveal(), + ); + + $this->expectException(AccessDeniedHttpException::class); + $controller->open($ticket); + } + + public function testOpenWithPermission(): void + { + $ticket = new Ticket(); + + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $changeStateCommandHandler = $this->prophesize(ChangeStateCommandHandler::class); + $changeStateCommandHandler->__invoke( + $ticket, + Argument::that(fn (ChangeStateCommand $command) => StateEnum::OPEN === $command->newState) + )->willReturn($ticket); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->flush()->shouldBeCalled(); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->serialize($ticket, 'json', ['groups' => ['read']]) + ->willReturn('{}') + ->shouldBeCalled(); + + $controller = new ChangeStateApiController( + $changeStateCommandHandler->reveal(), + $security->reveal(), + $entityManager->reveal(), + $serializer->reveal(), + ); + + $response = $controller->open($ticket); + + $this->assertInstanceOf(JsonResponse::class, $response); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/ReplaceMotiveControllerTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/ReplaceMotiveControllerTest.php new file mode 100644 index 000000000..28bf7dac5 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Controller/ReplaceMotiveControllerTest.php @@ -0,0 +1,92 @@ +<?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\TicketBundle\Tests\Controller; + +use Chill\TicketBundle\Action\Ticket\Handler\ReplaceMotiveCommandHandler; +use Chill\TicketBundle\Action\Ticket\ReplaceMotiveCommand; +use Chill\TicketBundle\Entity\Motive; +use PHPUnit\Framework\TestCase; +use Chill\TicketBundle\Controller\ReplaceMotiveController; +use Chill\TicketBundle\Entity\Ticket; +use Doctrine\ORM\EntityManagerInterface; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; +use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Validator\ConstraintViolationList; +use Symfony\Component\Validator\Validator\ValidatorInterface; + +/** + * @internal + * + * @coversNothing + */ +class ReplaceMotiveControllerTest extends TestCase +{ + use ProphecyTrait; + + private function buildController(Ticket $ticket, string $body, Motive $motive): ReplaceMotiveController + { + $command = new ReplaceMotiveCommand($motive); + + // Mock Security + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + // Mock EntityManager + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->flush()->shouldBeCalled(); + + $replaceMotiveCommandHandler = $this->prophesize(ReplaceMotiveCommandHandler::class); + $replaceMotiveCommandHandler->handle($ticket, $command)->shouldBeCalled(); + + // Mock Validator + $validator = $this->prophesize(ValidatorInterface::class); + $validator->validate($command) + ->shouldBeCalled() + ->willReturn(new ConstraintViolationList([])); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->deserialize($body, ReplaceMotiveCommand::class, 'json', [AbstractNormalizer::GROUPS => ['write']]) + ->willReturn($command); + $serializer->serialize($ticket, 'json', ['groups' => ['read']]) + ->shouldBeCalled() + ->willReturn('{"type": "ticket", "id": 1}'); + + return new ReplaceMotiveController( + $security->reveal(), + $replaceMotiveCommandHandler->reveal(), + $serializer->reveal(), + $validator->reveal(), + $entityManager->reveal() + ); + } + + public function testAddValidMotive(): void + { + $ticket = new Ticket(); + $motive = new Motive(); + + $payload = <<<'JSON' + {"motive": {"type": "ticket_motive", "id": 1}} + JSON; + + $request = new Request(content: $payload); + + $controller = $this->buildController($ticket, $payload, $motive); + + $response = $controller($ticket, $request); + + self::assertEquals(201, $response->getStatusCode()); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/SetAddresseesControllerTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/SetAddresseesControllerTest.php new file mode 100644 index 000000000..5ff4bfd77 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Controller/SetAddresseesControllerTest.php @@ -0,0 +1,212 @@ +<?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\TicketBundle\Tests\Controller; + +use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Entity\UserGroup; +use Chill\TicketBundle\Action\Ticket\Handler\SetAddresseesCommandHandler; +use Chill\TicketBundle\Action\Ticket\SetAddresseesCommand; +use Chill\TicketBundle\Controller\SetAddresseesController; +use Chill\TicketBundle\Entity\AddresseeHistory; +use Chill\TicketBundle\Entity\Ticket; +use Doctrine\ORM\EntityManagerInterface; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\Clock\MockClock; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Validator\ConstraintViolation; +use Symfony\Component\Validator\ConstraintViolationList; +use Symfony\Component\Validator\Validator\ValidatorInterface; + +/** + * @internal + * + * @coversNothing + */ +class SetAddresseesControllerTest extends KernelTestCase +{ + use ProphecyTrait; + + private SerializerInterface $serializer; + + protected function setUp(): void + { + self::bootKernel(); + $this->serializer = self::getContainer()->get(SerializerInterface::class); + } + + /** + * @dataProvider getContentData + */ + public function testSetAddresseesWithValidData(array $bodyAsArray): void + { + $controller = $this->buildController(true, true); + $request = new Request(content: json_encode(['addressees' => $bodyAsArray], JSON_THROW_ON_ERROR, 512)); + $ticket = new Ticket(); + + $response = $controller->setAddressees($ticket, $request); + + self::assertEquals(200, $response->getStatusCode()); + + $asArray = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR); + self::assertIsArray($asArray); + self::assertArrayHasKey('type', $asArray); + self::assertEquals('ticket_ticket', $asArray['type']); + } + + /** + * @dataProvider getContentData + */ + public function testSetAddresseesWithInvalidData(array $bodyAsArray): void + { + $controller = $this->buildController(false, false); + $request = new Request(content: json_encode(['addressees' => $bodyAsArray], JSON_THROW_ON_ERROR, 512)); + $ticket = new Ticket(); + + $response = $controller->setAddressees($ticket, $request); + + self::assertEquals(422, $response->getStatusCode()); + + $asArray = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR); + self::assertIsArray($asArray); + self::arrayHasKey('violations'); + self::assertGreaterThan(0, count($asArray['violations'])); + } + + public static function getContentData(): iterable + { + self::bootKernel(); + $entityManager = self::getContainer()->get(EntityManagerInterface::class); + + $userGroup = $entityManager->createQuery('SELECT ug FROM '.UserGroup::class.' ug ') + ->setMaxResults(1)->getOneOrNullResult(); + + if (null === $userGroup) { + throw new \RuntimeException('User group not existing in database'); + } + + $user = $entityManager->createQuery('SELECT u FROM '.User::class.' u') + ->setMaxResults(1)->getOneOrNullResult(); + + if (null === $user) { + throw new \RuntimeException('User not existing in database'); + } + + self::ensureKernelShutdown(); + + yield [[['type' => 'user', 'id' => $user->getId()]]]; + yield [[['type' => 'user', 'id' => $user->getId()], ['type' => 'user_group', 'id' => $userGroup->getId()]]]; + yield [[['type' => 'user_group', 'id' => $userGroup->getId()]]]; + } + + /** + * @dataProvider getContentDataUnique + */ + public function testAddAddresseeWithValidData(array $bodyAsArray): void + { + $controller = $this->buildController(true, true); + $request = new Request(content: json_encode(['addressee' => $bodyAsArray], JSON_THROW_ON_ERROR, 512)); + $ticket = new Ticket(); + + $response = $controller->addAddressee($ticket, $request); + + self::assertEquals(200, $response->getStatusCode()); + + $asArray = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR); + self::assertIsArray($asArray); + self::assertArrayHasKey('type', $asArray); + self::assertEquals('ticket_ticket', $asArray['type']); + } + + /** + * @throws \JsonException + * + * @dataProvider getContentDataUnique + */ + public function testAddAddresseeWithInvalidData(array $bodyAsArray): void + { + $controller = $this->buildController(false, false); + $request = new Request(content: json_encode(['addressee' => $bodyAsArray], JSON_THROW_ON_ERROR, 512)); + $ticket = new Ticket(); + + $response = $controller->addAddressee($ticket, $request); + + self::assertEquals(422, $response->getStatusCode()); + + $asArray = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR); + self::assertIsArray($asArray); + self::arrayHasKey('violations'); + self::assertGreaterThan(0, count($asArray['violations'])); + } + + public static function getContentDataUnique(): iterable + { + $entityManager = self::getContainer()->get(EntityManagerInterface::class); + + $userGroup = $entityManager->createQuery('SELECT ug FROM '.UserGroup::class.' ug ') + ->setMaxResults(1)->getOneOrNullResult(); + + if (null === $userGroup) { + throw new \RuntimeException('User group not existing in database'); + } + + $user = $entityManager->createQuery('SELECT u FROM '.User::class.' u') + ->setMaxResults(1)->getOneOrNullResult(); + + if (null === $user) { + throw new \RuntimeException('User not existing in database'); + } + + self::ensureKernelShutdown(); + + yield [['type' => 'user', 'id' => $user->getId()]]; + yield [['type' => 'user_group', 'id' => $userGroup->getId()]]; + } + + private function buildController(bool $willSave, bool $isValid): SetAddresseesController + { + $user = new User(); + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + $security->getUser()->willReturn($user); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + + if ($willSave) { + $entityManager->flush()->shouldBeCalled(); + $entityManager->persist(Argument::type(AddresseeHistory::class))->shouldBeCalled(); + } + + $validator = $this->prophesize(ValidatorInterface::class); + + if ($isValid) { + $validator->validate(Argument::type(SetAddresseesCommand::class))->willReturn(new ConstraintViolationList([])); + } else { + $validator->validate(Argument::type(SetAddresseesCommand::class))->willReturn( + new ConstraintViolationList([ + new ConstraintViolation('Fake constraint', 'fake message template', [], [], 'addresses', []), + ]) + ); + } + + return new SetAddresseesController( + $security->reveal(), + $entityManager->reveal(), + $this->serializer, + new SetAddresseesCommandHandler(new MockClock(), $entityManager->reveal(), $security->reveal()), + $validator->reveal() + ); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/SetCallerApiControllerTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/SetCallerApiControllerTest.php new file mode 100644 index 000000000..aad6342dd --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Controller/SetCallerApiControllerTest.php @@ -0,0 +1,200 @@ +<?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\TicketBundle\Tests\Controller; + +use Chill\PersonBundle\Entity\Person; +use Chill\ThirdPartyBundle\Entity\ThirdParty; +use Chill\TicketBundle\Action\Ticket\Handler\SetCallerCommandHandler; +use Chill\TicketBundle\Action\Ticket\SetCallerCommand; +use Chill\TicketBundle\Controller\SetCallerApiController; +use Chill\TicketBundle\Entity\Ticket; +use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\SerializerInterface; + +/** + * @internal + * + * @coversNothing + */ +final class SetCallerApiControllerTest extends TestCase +{ + use ProphecyTrait; + + public function testSetCallerWithoutPermission(): void + { + $ticket = new Ticket(); + $request = new Request(); + + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(false); + + $setCallerCommandHandler = $this->prophesize(SetCallerCommandHandler::class); + $entityManager = $this->prophesize(EntityManagerInterface::class); + $serializer = $this->prophesize(SerializerInterface::class); + + $controller = new SetCallerApiController( + $setCallerCommandHandler->reveal(), + $security->reveal(), + $entityManager->reveal(), + $serializer->reveal(), + ); + + $this->expectException(AccessDeniedHttpException::class); + $controller->setCaller($ticket, $request); + } + + public function testSetCallerWithInvalidRequestBody(): void + { + $ticket = new Ticket(); + $request = new Request([], [], [], [], [], [], 'invalid json'); + + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $setCallerCommandHandler = $this->prophesize(SetCallerCommandHandler::class); + $entityManager = $this->prophesize(EntityManagerInterface::class); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->deserialize('invalid json', SetCallerCommand::class, 'json') + ->willThrow(new \Exception('Invalid JSON')); + + $controller = new SetCallerApiController( + $setCallerCommandHandler->reveal(), + $security->reveal(), + $entityManager->reveal(), + $serializer->reveal(), + ); + + $this->expectException(BadRequestHttpException::class); + $controller->setCaller($ticket, $request); + } + + public function testSetCallerWithValidRequest(): void + { + $ticket = new Ticket(); + $request = new Request([], [], [], [], [], [], '{"caller": {"id": 123, "type": "person"}}'); + + $person = new Person(); + $command = new SetCallerCommand($person); + + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->deserialize('{"caller": {"id": 123, "type": "person"}}', SetCallerCommand::class, 'json') + ->willReturn($command); + $serializer->serialize($ticket, 'json', ['groups' => ['read']]) + ->willReturn('{}') + ->shouldBeCalled(); + + $setCallerCommandHandler = $this->prophesize(SetCallerCommandHandler::class); + $setCallerCommandHandler->__invoke($ticket, $command) + ->willReturn($ticket) + ->shouldBeCalled(); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->flush()->shouldBeCalled(); + + $controller = new SetCallerApiController( + $setCallerCommandHandler->reveal(), + $security->reveal(), + $entityManager->reveal(), + $serializer->reveal(), + ); + + $response = $controller->setCaller($ticket, $request); + + $this->assertInstanceOf(JsonResponse::class, $response); + } + + public function testSetCallerWithThirdParty(): void + { + $ticket = new Ticket(); + $request = new Request([], [], [], [], [], [], '{"caller": {"id": 456, "type": "thirdParty"}}'); + + $thirdParty = $this->prophesize(ThirdParty::class)->reveal(); + $command = new SetCallerCommand($thirdParty); + + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->deserialize('{"caller": {"id": 456, "type": "thirdParty"}}', SetCallerCommand::class, 'json') + ->willReturn($command); + $serializer->serialize($ticket, 'json', ['groups' => ['read']]) + ->willReturn('{}') + ->shouldBeCalled(); + + $setCallerCommandHandler = $this->prophesize(SetCallerCommandHandler::class); + $setCallerCommandHandler->__invoke($ticket, $command) + ->willReturn($ticket) + ->shouldBeCalled(); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->flush()->shouldBeCalled(); + + $controller = new SetCallerApiController( + $setCallerCommandHandler->reveal(), + $security->reveal(), + $entityManager->reveal(), + $serializer->reveal(), + ); + + $response = $controller->setCaller($ticket, $request); + + $this->assertInstanceOf(JsonResponse::class, $response); + } + + public function testSetCallerToNull(): void + { + $ticket = new Ticket(); + $request = new Request([], [], [], [], [], [], '{"caller": null}'); + + $command = new SetCallerCommand(null); + + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->deserialize('{"caller": null}', SetCallerCommand::class, 'json') + ->willReturn($command); + $serializer->serialize($ticket, 'json', ['groups' => ['read']]) + ->willReturn('{}') + ->shouldBeCalled(); + + $setCallerCommandHandler = $this->prophesize(SetCallerCommandHandler::class); + $setCallerCommandHandler->__invoke($ticket, $command) + ->willReturn($ticket) + ->shouldBeCalled(); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + $entityManager->flush()->shouldBeCalled(); + + $controller = new SetCallerApiController( + $setCallerCommandHandler->reveal(), + $security->reveal(), + $entityManager->reveal(), + $serializer->reveal(), + ); + + $response = $controller->setCaller($ticket, $request); + + $this->assertInstanceOf(JsonResponse::class, $response); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerByAddresseeGroupTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerByAddresseeGroupTest.php new file mode 100644 index 000000000..afbe251d9 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerByAddresseeGroupTest.php @@ -0,0 +1,215 @@ +<?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\TicketBundle\Tests\Controller; + +use Chill\MainBundle\Entity\UserGroup; +use Chill\MainBundle\Pagination\PaginatorFactoryInterface; +use Chill\MainBundle\Pagination\PaginatorInterface; +use Chill\MainBundle\Repository\CenterRepositoryInterface; +use Chill\MainBundle\Repository\UserGroupRepositoryInterface; +use Chill\MainBundle\Repository\UserRepositoryInterface; +use Chill\MainBundle\Serializer\Model\Collection; +use Chill\PersonBundle\Repository\PersonRepository; +use Chill\TicketBundle\Controller\TicketListApiController; +use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Repository\MotiveRepository; +use Chill\TicketBundle\Repository\TicketACLAwareRepositoryInterface; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\Clock\MockClock; +use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\SerializerInterface; + +/** + * @internal + * + * @covers \Chill\TicketBundle\Controller\TicketListApiController + */ +final class TicketListApiControllerByAddresseeGroupTest extends TestCase +{ + use ProphecyTrait; + + public function testListTicketWithSingleByAddresseeGroupFilter(): void + { + // Mocks + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $group = new UserGroup(); + + $groupRepository = $this->prophesize(UserGroupRepositoryInterface::class); + $groupRepository->find(10)->willReturn($group); + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + $tickets = [new Ticket(), new Ticket()]; + $ticketRepository->countTickets( + Argument::that(fn ($params) => isset($params['byAddresseeGroup']) && in_array($group, $params['byAddresseeGroup'], true)) + )->shouldBeCalled()->willReturn(2); + $ticketRepository->findTickets( + Argument::that(fn ($params) => isset($params['byAddresseeGroup']) && in_array($group, $params['byAddresseeGroup'], true)), + 0, + 10 + )->shouldBeCalled()->willReturn($tickets); + + $paginator = $this->prophesize(PaginatorInterface::class); + $paginator->getCurrentPageFirstItemNumber()->willReturn(0); + $paginator->getItemsPerPage()->willReturn(10); + + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $paginatorFactory->create(2)->willReturn($paginator->reveal()); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->serialize( + Argument::that(fn (Collection $collection) => $collection->getItems() === $tickets), + 'json', + ['groups' => 'read:simple'] + )->willReturn('{"items":[{},{}],"pagination":{}}'); + + $personRepository = $this->prophesize(PersonRepository::class); + $motiveRepository = $this->prophesize(MotiveRepository::class); + $userRepository = $this->prophesize(UserRepositoryInterface::class); + + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal(), + $motiveRepository->reveal(), + new MockClock(), + new ParameterBag(['chill_ticket' => ['ticket' => ['response_time_exceeded_delay' => 'PT12H']]]), + $userRepository->reveal(), + $groupRepository->reveal(), + $this->prophesize(CenterRepositoryInterface::class)->reveal(), + ); + + $request = new Request(query: ['byAddresseeGroup' => '10']); + + $response = $controller->listTicket($request); + + self::assertInstanceOf(JsonResponse::class, $response); + self::assertSame('{"items":[{},{}],"pagination":{}}', $response->getContent()); + } + + public function testListTicketWithMultipleByAddresseeGroupFilter(): void + { + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $group1 = new UserGroup(); + $group2 = new UserGroup(); + + $groupRepository = $this->prophesize(UserGroupRepositoryInterface::class); + $groupRepository->find(10)->willReturn($group1); + $groupRepository->find(20)->willReturn($group2); + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + $tickets = [new Ticket(), new Ticket()]; + $ticketRepository->countTickets( + Argument::that(fn ($params) => isset($params['byAddresseeGroup']) + && in_array($group1, $params['byAddresseeGroup'], true) + && in_array($group2, $params['byAddresseeGroup'], true)) + )->shouldBeCalled()->willReturn(2); + $ticketRepository->findTickets( + Argument::that(fn ($params) => isset($params['byAddresseeGroup']) + && in_array($group1, $params['byAddresseeGroup'], true) + && in_array($group2, $params['byAddresseeGroup'], true)), + 0, + 10 + )->shouldBeCalled()->willReturn($tickets); + + $paginator = $this->prophesize(PaginatorInterface::class); + $paginator->getCurrentPageFirstItemNumber()->willReturn(0); + $paginator->getItemsPerPage()->willReturn(10); + + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $paginatorFactory->create(2)->willReturn($paginator->reveal()); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->serialize( + Argument::that(fn (Collection $collection) => $collection->getItems() === $tickets), + 'json', + ['groups' => 'read:simple'] + )->willReturn('{"items":[{},{}],"pagination":{}}'); + + $personRepository = $this->prophesize(PersonRepository::class); + $motiveRepository = $this->prophesize(MotiveRepository::class); + $userRepository = $this->prophesize(UserRepositoryInterface::class); + + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal(), + $motiveRepository->reveal(), + new MockClock(), + new ParameterBag(['chill_ticket' => ['ticket' => ['response_time_exceeded_delay' => 'PT12H']]]), + $userRepository->reveal(), + $groupRepository->reveal(), + $this->prophesize(CenterRepositoryInterface::class)->reveal(), + ); + + $request = new Request(query: ['byAddresseeGroup' => '10,20']); + + $response = $controller->listTicket($request); + + self::assertInstanceOf(JsonResponse::class, $response); + self::assertSame('{"items":[{},{}],"pagination":{}}', $response->getContent()); + } + + public function testListTicketWithByAddresseeGroupFilterGroupNotFound(): void + { + self::expectException(BadRequestHttpException::class); + self::expectExceptionMessage('User group not found'); + + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $group1 = new UserGroup(); + + $groupRepository = $this->prophesize(UserGroupRepositoryInterface::class); + $groupRepository->find(10)->willReturn($group1); + $groupRepository->find(20)->willReturn(null); // not found + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $serializer = $this->prophesize(SerializerInterface::class); + $personRepository = $this->prophesize(PersonRepository::class); + $motiveRepository = $this->prophesize(MotiveRepository::class); + $userRepository = $this->prophesize(UserRepositoryInterface::class); + + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal(), + $motiveRepository->reveal(), + new MockClock(), + new ParameterBag(['chill_ticket' => ['ticket' => ['response_time_exceeded_delay' => 'PT12H']]]), + $userRepository->reveal(), + $groupRepository->reveal(), + $this->prophesize(CenterRepositoryInterface::class)->reveal(), + ); + + $request = new Request(query: ['byAddresseeGroup' => '10,20']); + + // should throw + $controller->listTicket($request); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerByAddresseeTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerByAddresseeTest.php new file mode 100644 index 000000000..62fdcb278 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerByAddresseeTest.php @@ -0,0 +1,312 @@ +<?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\TicketBundle\Tests\Controller; + +use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Pagination\PaginatorFactoryInterface; +use Chill\MainBundle\Pagination\PaginatorInterface; +use Chill\MainBundle\Repository\CenterRepositoryInterface; +use Chill\MainBundle\Repository\UserRepositoryInterface; +use Chill\MainBundle\Repository\UserGroupRepositoryInterface; +use Chill\MainBundle\Serializer\Model\Collection; +use Chill\PersonBundle\Repository\PersonRepository; +use Chill\TicketBundle\Controller\TicketListApiController; +use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Repository\MotiveRepository; +use Chill\TicketBundle\Repository\TicketACLAwareRepositoryInterface; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Clock\MockClock; +use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; + +/** + * @internal + * + * @covers \Chill\TicketBundle\Controller\TicketListApiController + */ +final class TicketListApiControllerByAddresseeTest extends TestCase +{ + use ProphecyTrait; + + public function testListTicketWithSingleByAddresseeFilter(): void + { + // Mock dependencies + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $user = new User(); + + $userRepository = $this->prophesize(UserRepositoryInterface::class); + $userRepository->find(1)->willReturn($user); + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + $tickets = [new Ticket(), new Ticket()]; + $ticketRepository->countTickets( + Argument::that(fn ($params) => isset($params['byAddressee']) && in_array($user, $params['byAddressee'])) + ) + ->shouldBeCalled() + ->willReturn(2); + $ticketRepository->findTickets( + Argument::that(fn ($params) => isset($params['byAddressee']) && in_array($user, $params['byAddressee'])), + 0, + 10 + )->shouldBeCalled()->willReturn($tickets); + + $paginator = $this->prophesize(PaginatorInterface::class); + $paginator->getCurrentPageFirstItemNumber()->willReturn(0); + $paginator->getItemsPerPage()->willReturn(10); + + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $paginatorFactory->create(2)->willReturn($paginator->reveal()); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->serialize( + Argument::that(fn (Collection $collection) => $collection->getItems() === $tickets), + 'json', + ['groups' => 'read:simple'] + )->willReturn('{"items":[{},{}],"pagination":{}}'); + + $personRepository = $this->prophesize(PersonRepository::class); + $motiveRepository = $this->prophesize(MotiveRepository::class); + + // Create controller + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal(), + $motiveRepository->reveal(), + new MockClock(), + new ParameterBag(['chill_ticket' => ['ticket' => ['response_time_exceeded_delay' => 'PT12H']]]), + $userRepository->reveal(), + $this->prophesize(UserGroupRepositoryInterface::class)->reveal(), + $this->prophesize(CenterRepositoryInterface::class)->reveal() + ); + + // Create request with byAddressee filter + $request = new Request( + query: ['byAddressee' => '1'] + ); + + // Call controller method + $response = $controller->listTicket($request); + + // Assert response + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertEquals('{"items":[{},{}],"pagination":{}}', $response->getContent()); + } + + public function testListTicketWithMultipleByAddresseeFilter(): void + { + // Mock dependencies + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $user1 = new User(); + $user2 = new User(); + + $userRepository = $this->prophesize(UserRepositoryInterface::class); + $userRepository->find(1)->willReturn($user1); + $userRepository->find(2)->willReturn($user2); + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + $tickets = [new Ticket(), new Ticket()]; + $ticketRepository->countTickets( + Argument::that(fn ($params) => isset($params['byAddressee']) + && in_array($user1, $params['byAddressee']) + && in_array($user2, $params['byAddressee'])) + ) + ->shouldBeCalled() + ->willReturn(2); + $ticketRepository->findTickets( + Argument::that(fn ($params) => isset($params['byAddressee']) + && in_array($user1, $params['byAddressee']) + && in_array($user2, $params['byAddressee'])), + 0, + 10 + )->shouldBeCalled()->willReturn($tickets); + + $paginator = $this->prophesize(PaginatorInterface::class); + $paginator->getCurrentPageFirstItemNumber()->willReturn(0); + $paginator->getItemsPerPage()->willReturn(10); + + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $paginatorFactory->create(2)->willReturn($paginator->reveal()); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->serialize( + Argument::that(fn (Collection $collection) => $collection->getItems() === $tickets), + 'json', + ['groups' => 'read:simple'] + )->willReturn('{"items":[{},{}],"pagination":{}}'); + + $personRepository = $this->prophesize(PersonRepository::class); + $motiveRepository = $this->prophesize(MotiveRepository::class); + + // Create controller + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal(), + $motiveRepository->reveal(), + new MockClock(), + new ParameterBag(['chill_ticket' => ['ticket' => ['response_time_exceeded_delay' => 'PT12H']]]), + $userRepository->reveal(), + $this->prophesize(UserGroupRepositoryInterface::class)->reveal(), + $this->prophesize(CenterRepositoryInterface::class)->reveal() + ); + + // Create request with multiple byAddressee filter + $request = new Request( + query: ['byAddressee' => '1,2'] + ); + + // Call controller method + $response = $controller->listTicket($request); + + // Assert response + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertEquals('{"items":[{},{}],"pagination":{}}', $response->getContent()); + } + + public function testListTicketWithByAddresseeFilterUserNotFound(): void + { + self::expectException(BadRequestHttpException::class); + self::expectExceptionMessage('User not found'); + + // Mock dependencies + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $user1 = new User(); + + $userRepository = $this->prophesize(UserRepositoryInterface::class); + $userRepository->find(1)->willReturn($user1); + $userRepository->find(2)->willReturn(null); // User not found + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $serializer = $this->prophesize(SerializerInterface::class); + $personRepository = $this->prophesize(PersonRepository::class); + $motiveRepository = $this->prophesize(MotiveRepository::class); + + // Create controller + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal(), + $motiveRepository->reveal(), + new MockClock(), + new ParameterBag(['chill_ticket' => ['ticket' => ['response_time_exceeded_delay' => 'PT12H']]]), + $userRepository->reveal(), + $this->prophesize(UserGroupRepositoryInterface::class)->reveal(), + $this->prophesize(CenterRepositoryInterface::class)->reveal() + ); + + // Create request with byAddressee filter with non-existent user + $request = new Request( + query: ['byAddressee' => '1,2'] + ); + + // Call controller method - should throw exception + $controller->listTicket($request); + } + + public function testListTicketWithByAddresseeAndByAddresseeToMeFilters(): void + { + // Mock dependencies + $currentUser = new User(); + $user1 = new User(); + + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + $security->getUser()->willReturn($currentUser); + + $userRepository = $this->prophesize(UserRepositoryInterface::class); + $userRepository->find(1)->willReturn($user1); + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + $tickets = [new Ticket(), new Ticket()]; + $ticketRepository->countTickets( + Argument::that(fn ($params) => isset($params['byAddressee']) + && in_array($currentUser, $params['byAddressee']) + && in_array($user1, $params['byAddressee'])) + ) + ->shouldBeCalled() + ->willReturn(2); + $ticketRepository->findTickets( + Argument::that(fn ($params) => isset($params['byAddressee']) + && in_array($currentUser, $params['byAddressee']) + && in_array($user1, $params['byAddressee'])), + 0, + 10 + )->shouldBeCalled()->willReturn($tickets); + + $paginator = $this->prophesize(PaginatorInterface::class); + $paginator->getCurrentPageFirstItemNumber()->willReturn(0); + $paginator->getItemsPerPage()->willReturn(10); + + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $paginatorFactory->create(2)->willReturn($paginator->reveal()); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->serialize( + Argument::that(fn (Collection $collection) => $collection->getItems() === $tickets), + 'json', + ['groups' => 'read:simple'] + )->willReturn('{"items":[{},{}],"pagination":{}}'); + + $personRepository = $this->prophesize(PersonRepository::class); + $motiveRepository = $this->prophesize(MotiveRepository::class); + + // Create controller + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal(), + $motiveRepository->reveal(), + new MockClock(), + new ParameterBag(['chill_ticket' => ['ticket' => ['response_time_exceeded_delay' => 'PT12H']]]), + $userRepository->reveal(), + $this->prophesize(UserGroupRepositoryInterface::class)->reveal(), + $this->prophesize(CenterRepositoryInterface::class)->reveal() + ); + + // Create request with both byAddressee and byAddresseeToMe filters + $request = new Request( + query: [ + 'byAddressee' => '1', + 'byAddresseeToMe' => '', + ] + ); + + // Call controller method + $response = $controller->listTicket($request); + + // Assert response + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertEquals('{"items":[{},{}],"pagination":{}}', $response->getContent()); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerByAddresseeToMeTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerByAddresseeToMeTest.php new file mode 100644 index 000000000..c0054bd90 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerByAddresseeToMeTest.php @@ -0,0 +1,112 @@ +<?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\TicketBundle\Tests\Controller; + +use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Pagination\PaginatorFactoryInterface; +use Chill\MainBundle\Repository\CenterRepositoryInterface; +use Chill\MainBundle\Repository\UserGroupRepositoryInterface; +use Chill\MainBundle\Pagination\PaginatorInterface; +use Chill\MainBundle\Repository\UserRepositoryInterface; +use Chill\MainBundle\Serializer\Model\Collection; +use Chill\PersonBundle\Repository\PersonRepository; +use Chill\TicketBundle\Controller\TicketListApiController; +use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Repository\MotiveRepository; +use Chill\TicketBundle\Repository\TicketACLAwareRepositoryInterface; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Clock\MockClock; +use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; + +/** + * @internal + * + * @covers \Chill\TicketBundle\Controller\TicketListApiController + */ +final class TicketListApiControllerByAddresseeToMeTest extends TestCase +{ + use ProphecyTrait; + + public function testListTicketWithByAddresseeToMeFilter(): void + { + // Mock dependencies + $user = new User(); + + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + $security->getUser()->willReturn($user); + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + $tickets = [new Ticket(), new Ticket()]; + $ticketRepository->countTickets( + Argument::that(fn ($params) => isset($params['byAddressee']) && in_array($user, $params['byAddressee'])) + ) + ->shouldBeCalled() + ->willReturn(2); + $ticketRepository->findTickets( + Argument::that(fn ($params) => isset($params['byAddressee']) && in_array($user, $params['byAddressee'])), + 0, + 10 + )->shouldBeCalled()->willReturn($tickets); + + $paginator = $this->prophesize(PaginatorInterface::class); + $paginator->getCurrentPageFirstItemNumber()->willReturn(0); + $paginator->getItemsPerPage()->willReturn(10); + + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $paginatorFactory->create(2)->willReturn($paginator->reveal()); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->serialize( + Argument::that(fn (Collection $collection) => $collection->getItems() === $tickets), + 'json', + ['groups' => 'read:simple'] + )->willReturn('{"items":[{},{}],"pagination":{}}'); + + $personRepository = $this->prophesize(PersonRepository::class); + $motiveRepository = $this->prophesize(MotiveRepository::class); + $userRepository = $this->prophesize(UserRepositoryInterface::class); + + // Create controller + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal(), + $motiveRepository->reveal(), + new MockClock(), + new ParameterBag(['chill_ticket' => ['ticket' => ['response_time_exceeded_delay' => 'PT12H']]]), + $userRepository->reveal(), + $this->prophesize(UserGroupRepositoryInterface::class)->reveal(), + $this->prophesize(CenterRepositoryInterface::class)->reveal() + ); + + // Create request with byAddresseeToMe filter + $request = new Request( + query: ['byAddresseeToMe' => ''] + ); + + // Call controller method + $response = $controller->listTicket($request); + + // Assert response + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertEquals('{"items":[{},{}],"pagination":{}}', $response->getContent()); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerByCreatedAfterTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerByCreatedAfterTest.php new file mode 100644 index 000000000..3efd0bb8e --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerByCreatedAfterTest.php @@ -0,0 +1,235 @@ +<?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\TicketBundle\Tests\Controller; + +use Chill\MainBundle\Pagination\PaginatorFactoryInterface; +use Chill\MainBundle\Pagination\PaginatorInterface; +use Chill\MainBundle\Repository\CenterRepositoryInterface; +use Chill\MainBundle\Repository\UserRepositoryInterface; +use Chill\MainBundle\Repository\UserGroupRepositoryInterface; +use Chill\MainBundle\Serializer\Model\Collection; +use Chill\PersonBundle\Repository\PersonRepository; +use Chill\TicketBundle\Controller\TicketListApiController; +use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Repository\MotiveRepository; +use Chill\TicketBundle\Repository\TicketACLAwareRepositoryInterface; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Clock\MockClock; +use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; + +/** + * @internal + * + * @covers \Chill\TicketBundle\Controller\TicketListApiController + */ +final class TicketListApiControllerByCreatedAfterTest extends TestCase +{ + use ProphecyTrait; + + public function testListTicketWithByCreatedAfterRFC3339Format(): void + { + // Mock dependencies + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + $tickets = [new Ticket(), new Ticket()]; + + // The date in RFC3339 format + $dateString = '2025-05-15T15:05:00+00:00'; + $date = \DateTimeImmutable::createFromFormat(\DateTimeImmutable::RFC3339, $dateString); + + $ticketRepository->countTickets( + Argument::that( + fn ($params) => isset($params['byCreatedAfter']) + && $params['byCreatedAfter'] == $date + ) + )->willReturn(2); + + $ticketRepository->findTickets( + Argument::that( + fn ($params) => isset($params['byCreatedAfter']) + && $params['byCreatedAfter'] == $date + ), + 0, + 10 + )->willReturn($tickets); + + $paginator = $this->prophesize(PaginatorInterface::class); + $paginator->getCurrentPageFirstItemNumber()->willReturn(0); + $paginator->getItemsPerPage()->willReturn(10); + + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $paginatorFactory->create(2)->willReturn($paginator->reveal()); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->serialize( + Argument::that(fn (Collection $collection) => $collection->getItems() === $tickets), + 'json', + ['groups' => 'read:simple'] + )->willReturn('{"items":[{},{}],"pagination":{}}'); + + $personRepository = $this->prophesize(PersonRepository::class); + $motiveRepository = $this->prophesize(MotiveRepository::class); + $userRepository = $this->prophesize(UserRepositoryInterface::class); + + // Create controller + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal(), + $motiveRepository->reveal(), + new MockClock(), + new ParameterBag(['chill_ticket' => ['ticket' => ['response_time_exceeded_delay' => 'PT12H']]]), + $userRepository->reveal(), + $this->prophesize(UserGroupRepositoryInterface::class)->reveal(), + $this->prophesize(CenterRepositoryInterface::class)->reveal() + ); + + // Create request with byCreatedAfter filter in RFC3339 format + $request = new Request( + query: ['byCreatedAfter' => $dateString] + ); + + // Call controller method + $response = $controller->listTicket($request); + + // Assert response + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertEquals('{"items":[{},{}],"pagination":{}}', $response->getContent()); + } + + public function testListTicketWithByCreatedAfterRFC3339ExtendedFormat(): void + { + // Mock dependencies + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + $tickets = [new Ticket(), new Ticket()]; + + // The date in RFC3339_EXTENDED format + $dateString = '2025-05-15T15:05:00.000+02:00'; + $date = \DateTimeImmutable::createFromFormat(\DateTimeImmutable::RFC3339_EXTENDED, $dateString); + + $ticketRepository->countTickets( + Argument::that( + fn ($params) => isset($params['byCreatedAfter']) + && $params['byCreatedAfter'] == $date + ) + )->willReturn(2); + + $ticketRepository->findTickets( + Argument::that( + fn ($params) => isset($params['byCreatedAfter']) + && $params['byCreatedAfter'] == $date + ), + 0, + 10 + )->willReturn($tickets); + + $paginator = $this->prophesize(PaginatorInterface::class); + $paginator->getCurrentPageFirstItemNumber()->willReturn(0); + $paginator->getItemsPerPage()->willReturn(10); + + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $paginatorFactory->create(2)->willReturn($paginator->reveal()); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->serialize( + Argument::that(fn (Collection $collection) => $collection->getItems() === $tickets), + 'json', + ['groups' => 'read:simple'] + )->willReturn('{"items":[{},{}],"pagination":{}}'); + + $personRepository = $this->prophesize(PersonRepository::class); + $motiveRepository = $this->prophesize(MotiveRepository::class); + $userRepository = $this->prophesize(UserRepositoryInterface::class); + + // Create controller + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal(), + $motiveRepository->reveal(), + new MockClock(), + new ParameterBag(['chill_ticket' => ['ticket' => ['response_time_exceeded_delay' => 'PT12H']]]), + $userRepository->reveal(), + $this->prophesize(UserGroupRepositoryInterface::class)->reveal(), + $this->prophesize(CenterRepositoryInterface::class)->reveal() + ); + + // Create request with byCreatedAfter filter in RFC3339_EXTENDED format + $request = new Request( + query: ['byCreatedAfter' => $dateString] + ); + + // Call controller method + $response = $controller->listTicket($request); + + // Assert response + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertEquals('{"items":[{},{}],"pagination":{}}', $response->getContent()); + } + + public function testListTicketWithByCreatedAfterInvalidFormat(): void + { + self::expectException(BadRequestHttpException::class); + self::expectExceptionMessage('Invalid date for byCreatedAfter'); + + // Mock dependencies + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $serializer = $this->prophesize(SerializerInterface::class); + $personRepository = $this->prophesize(PersonRepository::class); + $motiveRepository = $this->prophesize(MotiveRepository::class); + $userRepository = $this->prophesize(UserRepositoryInterface::class); + $centerRepository = $this->prophesize(CenterRepositoryInterface::class); + + // Create controller + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal(), + $motiveRepository->reveal(), + new MockClock(), + new ParameterBag(['chill_ticket' => ['ticket' => ['response_time_exceeded_delay' => 'PT12H']]]), + $userRepository->reveal(), + $this->prophesize(UserGroupRepositoryInterface::class)->reveal(), + $centerRepository->reveal(), + ); + + // Create request with byCreatedAfter filter in invalid format + $request = new Request( + query: ['byCreatedAfter' => '2025-05-15'] + ); + + // Call controller method + $controller->listTicket($request); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerByCreatedBeforeTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerByCreatedBeforeTest.php new file mode 100644 index 000000000..e02471866 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerByCreatedBeforeTest.php @@ -0,0 +1,235 @@ +<?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\TicketBundle\Tests\Controller; + +use Chill\MainBundle\Pagination\PaginatorFactoryInterface; +use Chill\MainBundle\Pagination\PaginatorInterface; +use Chill\MainBundle\Repository\CenterRepositoryInterface; +use Chill\MainBundle\Repository\UserGroupRepositoryInterface; +use Chill\MainBundle\Repository\UserRepositoryInterface; +use Chill\MainBundle\Serializer\Model\Collection; +use Chill\PersonBundle\Repository\PersonRepository; +use Chill\TicketBundle\Controller\TicketListApiController; +use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Repository\MotiveRepository; +use Chill\TicketBundle\Repository\TicketACLAwareRepositoryInterface; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Clock\MockClock; +use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; + +/** + * @internal + * + * @covers \Chill\TicketBundle\Controller\TicketListApiController + */ +final class TicketListApiControllerByCreatedBeforeTest extends TestCase +{ + use ProphecyTrait; + + public function testListTicketWithByCreatedBeforeRFC3339Format(): void + { + // Mock dependencies + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + $tickets = [new Ticket(), new Ticket()]; + + // The date in RFC3339 format + $dateString = '2025-05-15T15:05:00Z'; + $date = \DateTimeImmutable::createFromFormat(\DateTimeImmutable::RFC3339, $dateString); + + $ticketRepository->countTickets( + Argument::that( + fn ($params) => isset($params['byCreatedBefore']) + && $params['byCreatedBefore'] == $date + ) + )->willReturn(2); + + $ticketRepository->findTickets( + Argument::that( + fn ($params) => isset($params['byCreatedBefore']) + && $params['byCreatedBefore'] == $date + ), + 0, + 10 + )->willReturn($tickets); + + $paginator = $this->prophesize(PaginatorInterface::class); + $paginator->getCurrentPageFirstItemNumber()->willReturn(0); + $paginator->getItemsPerPage()->willReturn(10); + + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $paginatorFactory->create(2)->willReturn($paginator->reveal()); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->serialize( + Argument::that(fn (Collection $collection) => $collection->getItems() === $tickets), + 'json', + ['groups' => 'read:simple'] + )->willReturn('{"items":[{},{}],"pagination":{}}'); + + $personRepository = $this->prophesize(PersonRepository::class); + $motiveRepository = $this->prophesize(MotiveRepository::class); + $userRepository = $this->prophesize(UserRepositoryInterface::class); + + // Create controller + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal(), + $motiveRepository->reveal(), + new MockClock(), + new ParameterBag(['chill_ticket' => ['ticket' => ['response_time_exceeded_delay' => 'PT12H']]]), + $userRepository->reveal(), + $this->prophesize(UserGroupRepositoryInterface::class)->reveal(), + $this->prophesize(CenterRepositoryInterface::class)->reveal() + ); + + // Create request with byCreatedAfter filter in RFC3339 format + $request = new Request( + query: ['byCreatedBefore' => $dateString] + ); + + // Call controller method + $response = $controller->listTicket($request); + + // Assert response + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertEquals('{"items":[{},{}],"pagination":{}}', $response->getContent()); + } + + public function testListTicketWithByCreatedAfterRFC3339ExtendedFormat(): void + { + // Mock dependencies + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + $tickets = [new Ticket(), new Ticket()]; + + // The date in RFC3339_EXTENDED format + $dateString = '2025-05-15T15:05:00.000+02:00'; + $date = \DateTimeImmutable::createFromFormat(\DateTimeImmutable::RFC3339_EXTENDED, $dateString); + + $ticketRepository->countTickets( + Argument::that( + fn ($params) => isset($params['byCreatedBefore']) + && $params['byCreatedBefore'] == $date + ) + )->willReturn(2); + + $ticketRepository->findTickets( + Argument::that( + fn ($params) => isset($params['byCreatedBefore']) + && $params['byCreatedBefore'] == $date + ), + 0, + 10 + )->willReturn($tickets); + + $paginator = $this->prophesize(PaginatorInterface::class); + $paginator->getCurrentPageFirstItemNumber()->willReturn(0); + $paginator->getItemsPerPage()->willReturn(10); + + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $paginatorFactory->create(2)->willReturn($paginator->reveal()); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->serialize( + Argument::that(fn (Collection $collection) => $collection->getItems() === $tickets), + 'json', + ['groups' => 'read:simple'] + )->willReturn('{"items":[{},{}],"pagination":{}}'); + + $personRepository = $this->prophesize(PersonRepository::class); + $motiveRepository = $this->prophesize(MotiveRepository::class); + $userRepository = $this->prophesize(UserRepositoryInterface::class); + + // Create controller + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal(), + $motiveRepository->reveal(), + new MockClock(), + new ParameterBag(['chill_ticket' => ['ticket' => ['response_time_exceeded_delay' => 'PT12H']]]), + $userRepository->reveal(), + $this->prophesize(UserGroupRepositoryInterface::class)->reveal(), + $this->prophesize(CenterRepositoryInterface::class)->reveal() + ); + + // Create request with byCreatedAfter filter in RFC3339_EXTENDED format + $request = new Request( + query: ['byCreatedBefore' => $dateString] + ); + + // Call controller method + $response = $controller->listTicket($request); + + // Assert response + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertEquals('{"items":[{},{}],"pagination":{}}', $response->getContent()); + } + + public function testListTicketWithByCreatedBeforeInvalidFormat(): void + { + self::expectException(BadRequestHttpException::class); + self::expectExceptionMessage('Invalid date for byCreatedBefore'); + + // Mock dependencies + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $serializer = $this->prophesize(SerializerInterface::class); + $personRepository = $this->prophesize(PersonRepository::class); + $motiveRepository = $this->prophesize(MotiveRepository::class); + $userRepository = $this->prophesize(UserRepositoryInterface::class); + $centerRepository = $this->prophesize(CenterRepositoryInterface::class); + + // Create controller + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal(), + $motiveRepository->reveal(), + new MockClock(), + new ParameterBag(['chill_ticket' => ['ticket' => ['response_time_exceeded_delay' => 'PT12H']]]), + $userRepository->reveal(), + $this->prophesize(UserGroupRepositoryInterface::class)->reveal(), + $centerRepository->reveal(), + ); + + // Create request with byCreatedAfter filter in invalid format + $request = new Request( + query: ['byCreatedBefore' => '2025-05-15'] + ); + + // Call controller method + $controller->listTicket($request); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerByCreatorTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerByCreatorTest.php new file mode 100644 index 000000000..513e14014 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerByCreatorTest.php @@ -0,0 +1,207 @@ +<?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\TicketBundle\Tests\Controller; + +use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Pagination\PaginatorFactoryInterface; +use Chill\MainBundle\Pagination\PaginatorInterface; +use Chill\MainBundle\Repository\CenterRepositoryInterface; +use Chill\MainBundle\Repository\UserGroupRepositoryInterface; +use Chill\MainBundle\Repository\UserRepositoryInterface; +use Chill\MainBundle\Serializer\Model\Collection; +use Chill\PersonBundle\Repository\PersonRepository; +use Chill\TicketBundle\Controller\TicketListApiController; +use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Repository\MotiveRepository; +use Chill\TicketBundle\Repository\TicketACLAwareRepositoryInterface; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Clock\MockClock; +use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; + +/** + * @internal + * + * @covers \Chill\TicketBundle\Controller\TicketListApiController + */ +final class TicketListApiControllerByCreatorTest extends TestCase +{ + use ProphecyTrait; + + public function testListTicketWithSingleByCreatorFilter(): void + { + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $user = new User(); + + $userRepository = $this->prophesize(UserRepositoryInterface::class); + $userRepository->find(1)->willReturn($user); + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + $tickets = [new Ticket(), new Ticket()]; + $ticketRepository->countTickets( + Argument::that(fn ($params) => isset($params['byCreator']) && in_array($user, $params['byCreator'], true)) + )->shouldBeCalled()->willReturn(2); + $ticketRepository->findTickets( + Argument::that(fn ($params) => isset($params['byCreator']) && in_array($user, $params['byCreator'], true)), + 0, + 10 + )->shouldBeCalled()->willReturn($tickets); + + $paginator = $this->prophesize(PaginatorInterface::class); + $paginator->getCurrentPageFirstItemNumber()->willReturn(0); + $paginator->getItemsPerPage()->willReturn(10); + + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $paginatorFactory->create(2)->willReturn($paginator->reveal()); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->serialize( + Argument::that(fn (Collection $collection) => $collection->getItems() === $tickets), + 'json', + ['groups' => 'read:simple'] + )->willReturn('{"items":[{},{}],"pagination":{}}'); + + $personRepository = $this->prophesize(PersonRepository::class); + $motiveRepository = $this->prophesize(MotiveRepository::class); + + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal(), + $motiveRepository->reveal(), + new MockClock(), + new ParameterBag(['chill_ticket' => ['ticket' => ['response_time_exceeded_delay' => 'PT12H']]]), + $userRepository->reveal(), + $this->prophesize(UserGroupRepositoryInterface::class)->reveal(), + $this->prophesize(CenterRepositoryInterface::class)->reveal() + ); + + $request = new Request(query: ['byCreator' => '1']); + + $response = $controller->listTicket($request); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertEquals('{"items":[{},{}],"pagination":{}}', $response->getContent()); + } + + public function testListTicketWithMultipleByCreatorFilter(): void + { + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $user1 = new User(); + $user2 = new User(); + + $userRepository = $this->prophesize(UserRepositoryInterface::class); + $userRepository->find(1)->willReturn($user1); + $userRepository->find(2)->willReturn($user2); + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + $tickets = [new Ticket()]; + $ticketRepository->countTickets( + Argument::that(fn ($params) => isset($params['byCreator']) && in_array($user1, $params['byCreator'], true) && in_array($user2, $params['byCreator'], true)) + )->willReturn(1); + $ticketRepository->findTickets( + Argument::that(fn ($params) => isset($params['byCreator']) && in_array($user1, $params['byCreator'], true) && in_array($user2, $params['byCreator'], true)), + 0, + 10 + )->willReturn($tickets); + + $paginator = $this->prophesize(PaginatorInterface::class); + $paginator->getCurrentPageFirstItemNumber()->willReturn(0); + $paginator->getItemsPerPage()->willReturn(10); + + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $paginatorFactory->create(1)->willReturn($paginator->reveal()); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->serialize( + Argument::that(fn (Collection $collection) => $collection->getItems() === $tickets), + 'json', + ['groups' => 'read:simple'] + )->willReturn('{"items":[{}],"pagination":{}}'); + + $personRepository = $this->prophesize(PersonRepository::class); + $motiveRepository = $this->prophesize(MotiveRepository::class); + + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal(), + $motiveRepository->reveal(), + new MockClock(), + new ParameterBag(['chill_ticket' => ['ticket' => ['response_time_exceeded_delay' => 'PT12H']]]), + $userRepository->reveal(), + $this->prophesize(UserGroupRepositoryInterface::class)->reveal(), + $this->prophesize(CenterRepositoryInterface::class)->reveal() + ); + + $request = new Request(query: ['byCreator' => '1,2']); + $response = $controller->listTicket($request); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertEquals('{"items":[{}],"pagination":{}}', $response->getContent()); + } + + public function testListTicketWithByCreatorFilterUserNotFound(): void + { + $this->expectException(BadRequestHttpException::class); + + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $userRepository = $this->prophesize(UserRepositoryInterface::class); + $userRepository->find(99)->willReturn(null); + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + + $paginator = $this->prophesize(PaginatorInterface::class); + $paginator->getCurrentPageFirstItemNumber()->willReturn(0); + $paginator->getItemsPerPage()->willReturn(10); + + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $paginatorFactory->create(0)->willReturn($paginator->reveal()); + + $serializer = $this->prophesize(SerializerInterface::class); + $personRepository = $this->prophesize(PersonRepository::class); + $motiveRepository = $this->prophesize(MotiveRepository::class); + + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal(), + $motiveRepository->reveal(), + new MockClock(), + new ParameterBag(['chill_ticket' => ['ticket' => ['response_time_exceeded_delay' => 'PT12H']]]), + $userRepository->reveal(), + $this->prophesize(UserGroupRepositoryInterface::class)->reveal(), + $this->prophesize(CenterRepositoryInterface::class)->reveal() + ); + + $request = new Request(query: ['byCreator' => '99']); + $controller->listTicket($request); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerByPersonCenterTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerByPersonCenterTest.php new file mode 100644 index 000000000..a141029ba --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerByPersonCenterTest.php @@ -0,0 +1,240 @@ +<?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\TicketBundle\Tests\Controller; + +use Chill\MainBundle\Entity\Center; +use Chill\MainBundle\Pagination\PaginatorFactoryInterface; +use Chill\MainBundle\Pagination\PaginatorInterface; +use Chill\MainBundle\Repository\CenterRepositoryInterface; +use Chill\MainBundle\Repository\UserGroupRepositoryInterface; +use Chill\MainBundle\Repository\UserRepositoryInterface; +use Chill\MainBundle\Serializer\Model\Collection; +use Chill\PersonBundle\Repository\PersonRepository; +use Chill\TicketBundle\Controller\TicketListApiController; +use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Repository\MotiveRepository; +use Chill\TicketBundle\Repository\TicketACLAwareRepositoryInterface; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\Clock\MockClock; +use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\SerializerInterface; + +/** + * @internal + * + * @covers \Chill\TicketBundle\Controller\TicketListApiController + */ +final class TicketListApiControllerByPersonCenterTest extends TestCase +{ + use ProphecyTrait; + + public function testListTicketWithSingleCenter(): void + { + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $center = new Center(); + + $centerRepository = $this->prophesize(CenterRepositoryInterface::class); + $centerRepository->find(10)->willReturn($center); + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + $tickets = [new Ticket(), new Ticket()]; + $ticketRepository->countTickets( + Argument::that(fn ($params) => isset($params['byPersonCenter']) && in_array($center, $params['byPersonCenter'], true)) + )->shouldBeCalled()->willReturn(2); + $ticketRepository->findTickets( + Argument::that(fn ($params) => isset($params['byPersonCenter']) && in_array($center, $params['byPersonCenter'], true)), + 0, + 10 + )->shouldBeCalled()->willReturn($tickets); + + $paginator = $this->prophesize(PaginatorInterface::class); + $paginator->getCurrentPageFirstItemNumber()->willReturn(0); + $paginator->getItemsPerPage()->willReturn(10); + + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $paginatorFactory->create(2)->willReturn($paginator->reveal()); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->serialize( + Argument::that(fn (Collection $collection) => $collection->getItems() === $tickets), + 'json', + ['groups' => 'read:simple'] + )->willReturn('{"items":[{},{}],"pagination":{}}'); + + $personRepository = $this->prophesize(PersonRepository::class); + $motiveRepository = $this->prophesize(MotiveRepository::class); + $userRepository = $this->prophesize(UserRepositoryInterface::class); + + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal(), + $motiveRepository->reveal(), + new MockClock(), + new ParameterBag(['chill_ticket' => ['ticket' => ['response_time_exceeded_delay' => 'PT12H']]]), + $userRepository->reveal(), + $this->prophesize(UserGroupRepositoryInterface::class)->reveal(), + $centerRepository->reveal(), + ); + + $request = new Request(query: ['byPersonCenter' => '10']); + + $response = $controller->listTicket($request); + + self::assertInstanceOf(JsonResponse::class, $response); + self::assertSame('{"items":[{},{}],"pagination":{}}', $response->getContent()); + } + + public function testListTicketWithMultipleCenters(): void + { + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $center1 = new Center(); + $center2 = new Center(); + + $centerRepository = $this->prophesize(CenterRepositoryInterface::class); + $centerRepository->find(10)->willReturn($center1); + $centerRepository->find(20)->willReturn($center2); + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + $tickets = [new Ticket(), new Ticket()]; + $ticketRepository->countTickets( + Argument::that(fn ($params) => isset($params['byPersonCenter']) && in_array($center1, $params['byPersonCenter'], true) && in_array($center2, $params['byPersonCenter'], true)) + )->willReturn(2); + $ticketRepository->findTickets( + Argument::that(fn ($params) => isset($params['byPersonCenter']) && in_array($center1, $params['byPersonCenter'], true) && in_array($center2, $params['byPersonCenter'], true)), + 0, + 10 + )->willReturn($tickets); + + $paginator = $this->prophesize(PaginatorInterface::class); + $paginator->getCurrentPageFirstItemNumber()->willReturn(0); + $paginator->getItemsPerPage()->willReturn(10); + + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $paginatorFactory->create(2)->willReturn($paginator->reveal()); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->serialize( + Argument::that(fn (Collection $collection) => $collection->getItems() === $tickets), + 'json', + ['groups' => 'read:simple'] + )->willReturn('{"items":[{},{}],"pagination":{}}'); + + $personRepository = $this->prophesize(PersonRepository::class); + $motiveRepository = $this->prophesize(MotiveRepository::class); + $userRepository = $this->prophesize(UserRepositoryInterface::class); + + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal(), + $motiveRepository->reveal(), + new MockClock(), + new ParameterBag(['chill_ticket' => ['ticket' => ['response_time_exceeded_delay' => 'PT12H']]]), + $userRepository->reveal(), + $this->prophesize(UserGroupRepositoryInterface::class)->reveal(), + $centerRepository->reveal(), + ); + + $request = new Request(query: ['byPersonCenter' => '10,20']); + + $response = $controller->listTicket($request); + + self::assertInstanceOf(JsonResponse::class, $response); + self::assertSame('{"items":[{},{}],"pagination":{}}', $response->getContent()); + } + + public function testListTicketWithNonNumericCenterId(): void + { + self::expectException(BadRequestHttpException::class); + self::expectExceptionMessage('Only numbers are allowed in by center parameter'); + + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $serializer = $this->prophesize(SerializerInterface::class); + $personRepository = $this->prophesize(PersonRepository::class); + $motiveRepository = $this->prophesize(MotiveRepository::class); + $userRepository = $this->prophesize(UserRepositoryInterface::class); + $userGroupRepository = $this->prophesize(UserGroupRepositoryInterface::class); + $centerRepository = $this->prophesize(CenterRepositoryInterface::class); + + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal(), + $motiveRepository->reveal(), + new MockClock(), + new ParameterBag(['chill_ticket' => ['ticket' => ['response_time_exceeded_delay' => 'PT12H']]]), + $userRepository->reveal(), + $userGroupRepository->reveal(), + $centerRepository->reveal(), + ); + + $request = new Request(query: ['byPersonCenter' => 'foo']); + $controller->listTicket($request); + } + + public function testListTicketWithCenterNotFound(): void + { + self::expectException(BadRequestHttpException::class); + self::expectExceptionMessage('Center not found'); + + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $centerRepository = $this->prophesize(CenterRepositoryInterface::class); + $centerRepository->find(10)->willReturn(null); + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $serializer = $this->prophesize(SerializerInterface::class); + $personRepository = $this->prophesize(PersonRepository::class); + $motiveRepository = $this->prophesize(MotiveRepository::class); + $userRepository = $this->prophesize(UserRepositoryInterface::class); + + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal(), + $motiveRepository->reveal(), + new MockClock(), + new ParameterBag(['chill_ticket' => ['ticket' => ['response_time_exceeded_delay' => 'PT12H']]]), + $userRepository->reveal(), + $this->prophesize(UserGroupRepositoryInterface::class)->reveal(), + $centerRepository->reveal(), + ); + + $request = new Request(query: ['byPersonCenter' => '10']); + $controller->listTicket($request); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerByResponseTimeExceededTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerByResponseTimeExceededTest.php new file mode 100644 index 000000000..17241547e --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerByResponseTimeExceededTest.php @@ -0,0 +1,218 @@ +<?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\TicketBundle\Tests\Controller; + +use Chill\MainBundle\Pagination\PaginatorFactoryInterface; +use Chill\MainBundle\Pagination\PaginatorInterface; +use Chill\MainBundle\Repository\CenterRepositoryInterface; +use Chill\MainBundle\Repository\UserGroupRepositoryInterface; +use Chill\MainBundle\Repository\UserRepositoryInterface; +use Chill\MainBundle\Serializer\Model\Collection; +use Chill\PersonBundle\Repository\PersonRepository; +use Chill\TicketBundle\Controller\TicketListApiController; +use Chill\TicketBundle\Entity\StateEnum; +use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Repository\MotiveRepository; +use Chill\TicketBundle\Repository\TicketACLAwareRepositoryInterface; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Clock\MockClock; +use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; + +/** + * @internal + * + * @covers \Chill\TicketBundle\Controller\TicketListApiController + */ +final class TicketListApiControllerByResponseTimeExceededTest extends TestCase +{ + use ProphecyTrait; + + public function testListTicketWithByResponseTimeExceededOnly(): void + { + // Mock dependencies + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + $tickets = [new Ticket(), new Ticket()]; + + // Create a mock clock with a fixed time + $mockClock = new MockClock(); + $now = new \DateTimeImmutable('2025-05-15T15:05:00Z'); + $mockClock->modify($now->format(\DateTimeImmutable::RFC3339)); + + // Calculate the expected byCreatedBefore value (now - PT12H) + $expectedCreatedBefore = $now->sub(new \DateInterval('PT12H')); + + $ticketRepository->countTickets( + Argument::that( + fn ($params) => isset($params['byCurrentState']) + && $params['byCurrentState'] === [StateEnum::OPEN] + && isset($params['byCreatedBefore']) + && $params['byCreatedBefore'] == $expectedCreatedBefore + && !isset($params['byCreatedAfter']) + ) + )->willReturn(2); + + $ticketRepository->findTickets( + Argument::that( + fn ($params) => isset($params['byCurrentState']) + && $params['byCurrentState'] === [StateEnum::OPEN] + && isset($params['byCreatedBefore']) + && $params['byCreatedBefore'] == $expectedCreatedBefore + && !isset($params['byCreatedAfter']) + ), + 0, + 10 + )->willReturn($tickets); + + $paginator = $this->prophesize(PaginatorInterface::class); + $paginator->getCurrentPageFirstItemNumber()->willReturn(0); + $paginator->getItemsPerPage()->willReturn(10); + + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $paginatorFactory->create(2)->willReturn($paginator->reveal()); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->serialize( + Argument::that(fn (Collection $collection) => $collection->getItems() === $tickets), + 'json', + ['groups' => 'read:simple'] + )->willReturn('{"items":[{},{}],"pagination":{}}'); + + $personRepository = $this->prophesize(PersonRepository::class); + $motiveRepository = $this->prophesize(MotiveRepository::class); + $userRepository = $this->prophesize(UserRepositoryInterface::class); + + // Create controller + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal(), + $motiveRepository->reveal(), + $mockClock, + new ParameterBag(['chill_ticket' => ['ticket' => ['response_time_exceeded_delay' => 'PT12H']]]), + $userRepository->reveal(), + $this->prophesize(UserGroupRepositoryInterface::class)->reveal(), + $this->prophesize(CenterRepositoryInterface::class)->reveal() + ); + + // Create request with byResponseTimeExceeded parameter only + $request = new Request( + query: ['byResponseTimeExceeded' => ''] + ); + + // Call controller method + $response = $controller->listTicket($request); + + // Assert response + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertEquals('{"items":[{},{}],"pagination":{}}', $response->getContent()); + } + + public function testListTicketWithByResponseTimeExceededAndByCreatedAfter(): void + { + // Mock dependencies + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + $tickets = [new Ticket(), new Ticket()]; + + // Create a mock clock with a fixed time + $mockClock = new MockClock(); + $now = new \DateTimeImmutable('2025-05-15T15:05:00Z'); + $mockClock->modify($now->format(\DateTimeImmutable::RFC3339)); + + // Calculate the expected byCreatedBefore value (now - PT12H) + $expectedCreatedBefore = $now->sub(new \DateInterval('PT12H')); + + // The byCreatedAfter parameter should be ignored when byResponseTimeExceeded is present + $ticketRepository->countTickets( + Argument::that( + fn ($params) => isset($params['byCurrentState']) + && $params['byCurrentState'] === [StateEnum::OPEN] + && isset($params['byCreatedBefore']) + && $params['byCreatedBefore'] == $expectedCreatedBefore + && !isset($params['byCreatedAfter']) + ) + )->willReturn(2); + + $ticketRepository->findTickets( + Argument::that( + fn ($params) => isset($params['byCurrentState']) + && $params['byCurrentState'] === [StateEnum::OPEN] + && isset($params['byCreatedBefore']) + && $params['byCreatedBefore'] == $expectedCreatedBefore + && !isset($params['byCreatedAfter']) + ), + 0, + 10 + )->willReturn($tickets); + + $paginator = $this->prophesize(PaginatorInterface::class); + $paginator->getCurrentPageFirstItemNumber()->willReturn(0); + $paginator->getItemsPerPage()->willReturn(10); + + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $paginatorFactory->create(2)->willReturn($paginator->reveal()); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->serialize( + Argument::that(fn (Collection $collection) => $collection->getItems() === $tickets), + 'json', + ['groups' => 'read:simple'] + )->willReturn('{"items":[{},{}],"pagination":{}}'); + + $personRepository = $this->prophesize(PersonRepository::class); + $motiveRepository = $this->prophesize(MotiveRepository::class); + $userRepository = $this->prophesize(UserRepositoryInterface::class); + + // Create controller + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal(), + $motiveRepository->reveal(), + $mockClock, + new ParameterBag(['chill_ticket' => ['ticket' => ['response_time_exceeded_delay' => 'PT12H']]]), + $userRepository->reveal(), + $this->prophesize(UserGroupRepositoryInterface::class)->reveal(), + $this->prophesize(CenterRepositoryInterface::class)->reveal() + ); + + // Create request with both byCreatedAfter and byResponseTimeExceeded parameters + $request = new Request( + query: [ + 'byCreatedAfter' => '2025-05-15T12:00:00+00:00', + 'byResponseTimeExceeded' => '', + ] + ); + + // Call controller method + $response = $controller->listTicket($request); + + // Assert response + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertEquals('{"items":[{},{}],"pagination":{}}', $response->getContent()); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerByTicketIdTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerByTicketIdTest.php new file mode 100644 index 000000000..1bc10e078 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerByTicketIdTest.php @@ -0,0 +1,150 @@ +<?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\TicketBundle\Tests\Controller; + +use Chill\MainBundle\Pagination\PaginatorFactoryInterface; +use Chill\MainBundle\Pagination\PaginatorInterface; +use Chill\MainBundle\Repository\CenterRepositoryInterface; +use Chill\MainBundle\Repository\UserGroupRepositoryInterface; +use Chill\MainBundle\Repository\UserRepositoryInterface; +use Chill\MainBundle\Serializer\Model\Collection; +use Chill\PersonBundle\Repository\PersonRepository; +use Chill\TicketBundle\Controller\TicketListApiController; +use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Repository\MotiveRepository; +use Chill\TicketBundle\Repository\TicketACLAwareRepositoryInterface; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\Clock\MockClock; +use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\SerializerInterface; + +/** + * @internal + * + * @covers \Chill\TicketBundle\Controller\TicketListApiController + */ +final class TicketListApiControllerByTicketIdTest extends TestCase +{ + use ProphecyTrait; + + public function testListTicketWithByTicketIdFilter(): void + { + // Mock dependencies + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + $tickets = [new Ticket(), new Ticket()]; + $ticketRepository->countTickets( + Argument::that(fn ($params) => isset($params['byTicketId']) && 5 === $params['byTicketId']) + ) + ->shouldBeCalled() + ->willReturn(2); + $ticketRepository->findTickets( + Argument::that(fn ($params) => isset($params['byTicketId']) && 5 === $params['byTicketId']), + 0, + 10 + )->shouldBeCalled()->willReturn($tickets); + + $paginator = $this->prophesize(PaginatorInterface::class); + $paginator->getCurrentPageFirstItemNumber()->willReturn(0); + $paginator->getItemsPerPage()->willReturn(10); + + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $paginatorFactory->create(2)->willReturn($paginator->reveal()); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->serialize( + Argument::that(fn (Collection $collection) => $collection->getItems() === $tickets), + 'json', + ['groups' => 'read:simple'] + )->willReturn('{"items":[{},{}],"pagination":{}}'); + + $personRepository = $this->prophesize(PersonRepository::class); + $motiveRepository = $this->prophesize(MotiveRepository::class); + + // Needed for controller constructor but not used here + $userRepository = $this->prophesize(UserRepositoryInterface::class); + $userGroupRepository = $this->prophesize(UserGroupRepositoryInterface::class); + + // Create controller + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal(), + $motiveRepository->reveal(), + new MockClock(), + new ParameterBag(['chill_ticket' => ['ticket' => ['response_time_exceeded_delay' => 'PT12H']]]), + $userRepository->reveal(), + $userGroupRepository->reveal(), + $this->prophesize(CenterRepositoryInterface::class)->reveal() + ); + + // Create request with byTicketId filter + $request = new Request( + query: ['byTicketId' => '5'] + ); + + // Call controller method + $response = $controller->listTicket($request); + + // Assert response + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertEquals('{"items":[{},{}],"pagination":{}}', $response->getContent()); + } + + public function testListTicketWithInvalidByTicketIdThrows(): void + { + $this->expectException(BadRequestHttpException::class); + + // Mock dependencies + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $serializer = $this->prophesize(SerializerInterface::class); + $personRepository = $this->prophesize(PersonRepository::class); + $motiveRepository = $this->prophesize(MotiveRepository::class); + $userRepository = $this->prophesize(UserRepositoryInterface::class); + $userGroupRepository = $this->prophesize(UserGroupRepositoryInterface::class); + + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal(), + $motiveRepository->reveal(), + new MockClock(), + new ParameterBag(['chill_ticket' => ['ticket' => ['response_time_exceeded_delay' => 'PT12H']]]), + $userRepository->reveal(), + $userGroupRepository->reveal(), + $this->prophesize(CenterRepositoryInterface::class)->reveal() + ); + + // Use -1 to trigger the controller's validation error + $request = new Request(query: ['byTicketId' => '-1']); + + // This should throw BadRequestHttpException + $controller->listTicket($request); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerCurrentStateEmergencyTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerCurrentStateEmergencyTest.php new file mode 100644 index 000000000..cee3b6119 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerCurrentStateEmergencyTest.php @@ -0,0 +1,148 @@ +<?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\TicketBundle\Tests\Controller; + +use Chill\MainBundle\Pagination\PaginatorFactoryInterface; +use Chill\MainBundle\Pagination\PaginatorInterface; +use Chill\MainBundle\Repository\CenterRepositoryInterface; +use Chill\MainBundle\Repository\UserGroupRepositoryInterface; +use Chill\MainBundle\Repository\UserRepositoryInterface; +use Chill\MainBundle\Serializer\Model\Collection; +use Chill\PersonBundle\Repository\PersonRepository; +use Chill\TicketBundle\Controller\TicketListApiController; +use Chill\TicketBundle\Entity\EmergencyStatusEnum; +use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Repository\MotiveRepository; +use Chill\TicketBundle\Repository\TicketACLAwareRepositoryInterface; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Clock\MockClock; +use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; + +/** + * @internal + * + * @covers \Chill\TicketBundle\Controller\TicketListApiController + */ +final class TicketListApiControllerCurrentStateEmergencyTest extends TestCase +{ + use ProphecyTrait; + + public function testListTicketWithcurrentStateEmergencyFilter(): void + { + // Mock dependencies + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + $tickets = [new Ticket(), new Ticket()]; + $ticketRepository->countTickets( + Argument::that(fn ($params) => isset($params['byCurrentStateEmergency']) && in_array(EmergencyStatusEnum::YES, $params['byCurrentStateEmergency'])) + ) + ->willReturn(2); + $ticketRepository->findTickets( + Argument::that(fn ($params) => isset($params['byCurrentStateEmergency']) && in_array(EmergencyStatusEnum::YES, $params['byCurrentStateEmergency'])), + 0, + 10 + )->willReturn($tickets); + + $paginator = $this->prophesize(PaginatorInterface::class); + $paginator->getCurrentPageFirstItemNumber()->willReturn(0); + $paginator->getItemsPerPage()->willReturn(10); + + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $paginatorFactory->create(2)->willReturn($paginator->reveal()); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->serialize( + Argument::that(fn (Collection $collection) => $collection->getItems() === $tickets), + 'json', + ['groups' => 'read:simple'] + )->willReturn('{"items":[{},{}],"pagination":{}}'); + + $personRepository = $this->prophesize(PersonRepository::class); + $motiveRepository = $this->prophesize(MotiveRepository::class); + $userRepository = $this->prophesize(UserRepositoryInterface::class); + + // Create controller + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal(), + $motiveRepository->reveal(), + new MockClock(), + new ParameterBag(['chill_ticket' => ['ticket' => ['response_time_exceeded_delay' => 'PT12H']]]), + $userRepository->reveal(), + $this->prophesize(UserGroupRepositoryInterface::class)->reveal(), + $this->prophesize(CenterRepositoryInterface::class)->reveal() + ); + + // Create request with emergency filter + $request = new Request( + query: ['byCurrentStateEmergency' => 'yes,no'] + ); + + // Call controller method + $response = $controller->listTicket($request); + + // Assert response + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertEquals('{"items":[{},{}],"pagination":{}}', $response->getContent()); + } + + public function testListTicketWithCurrentStateEmergencyWithInvalidFilter(): void + { + self::expectException(BadRequestHttpException::class); + + // Mock dependencies + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $serializer = $this->prophesize(SerializerInterface::class); + $personRepository = $this->prophesize(PersonRepository::class); + $motiveRepository = $this->prophesize(MotiveRepository::class); + $userRepository = $this->prophesize(UserRepositoryInterface::class); + + // Create controller + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal(), + $motiveRepository->reveal(), + new MockClock(), + new ParameterBag(['chill_ticket' => ['ticket' => ['response_time_exceeded_delay' => 'PT12H']]]), + $userRepository->reveal(), + $this->prophesize(UserGroupRepositoryInterface::class)->reveal(), + $this->prophesize(CenterRepositoryInterface::class)->reveal() + ); + + // Create request with invalid emergency filter + $request = new Request( + query: ['byCurrentStateEmergency' => 'foo'] + ); + + // Call controller method + $response = $controller->listTicket($request); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerCurrentStateTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerCurrentStateTest.php new file mode 100644 index 000000000..5813c85ae --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerCurrentStateTest.php @@ -0,0 +1,148 @@ +<?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\TicketBundle\Tests\Controller; + +use Chill\MainBundle\Pagination\PaginatorFactoryInterface; +use Chill\MainBundle\Pagination\PaginatorInterface; +use Chill\MainBundle\Repository\CenterRepositoryInterface; +use Chill\MainBundle\Repository\UserGroupRepositoryInterface; +use Chill\MainBundle\Repository\UserRepositoryInterface; +use Chill\MainBundle\Serializer\Model\Collection; +use Chill\PersonBundle\Repository\PersonRepository; +use Chill\TicketBundle\Controller\TicketListApiController; +use Chill\TicketBundle\Entity\StateEnum; +use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Repository\MotiveRepository; +use Chill\TicketBundle\Repository\TicketACLAwareRepositoryInterface; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Clock\MockClock; +use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; + +/** + * @internal + * + * @covers \Chill\TicketBundle\Controller\TicketListApiController + */ +final class TicketListApiControllerCurrentStateTest extends TestCase +{ + use ProphecyTrait; + + public function testListTicketWithCurrentStateFilter(): void + { + // Mock dependencies + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + $tickets = [new Ticket(), new Ticket()]; + $ticketRepository->countTickets( + Argument::that(fn ($params) => isset($params['byCurrentState']) && in_array(StateEnum::OPEN, $params['byCurrentState'])) + ) + ->willReturn(2); + $ticketRepository->findTickets( + Argument::that(fn ($params) => isset($params['byCurrentState']) && in_array(StateEnum::OPEN, $params['byCurrentState'])), + 0, + 10 + )->willReturn($tickets); + + $paginator = $this->prophesize(PaginatorInterface::class); + $paginator->getCurrentPageFirstItemNumber()->willReturn(0); + $paginator->getItemsPerPage()->willReturn(10); + + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $paginatorFactory->create(2)->willReturn($paginator->reveal()); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->serialize( + Argument::that(fn (Collection $collection) => $collection->getItems() === $tickets), + 'json', + ['groups' => 'read:simple'] + )->willReturn('{"items":[{},{}],"pagination":{}}'); + + $personRepository = $this->prophesize(PersonRepository::class); + $motiveRepository = $this->prophesize(MotiveRepository::class); + $userRepository = $this->prophesize(UserRepositoryInterface::class); + + // Create controller + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal(), + $motiveRepository->reveal(), + new MockClock(), + new ParameterBag(['chill_ticket' => ['ticket' => ['response_time_exceeded_delay' => 'PT12H']]]), + $userRepository->reveal(), + $this->prophesize(UserGroupRepositoryInterface::class)->reveal(), + $this->prophesize(CenterRepositoryInterface::class)->reveal() + ); + + // Create request with person filter + $request = new Request( + query: ['byCurrentState' => 'open,closed'] + ); + + // Call controller method + $response = $controller->listTicket($request); + + // Assert response + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertEquals('{"items":[{},{}],"pagination":{}}', $response->getContent()); + } + + public function testListTicketWithCurrentStateWithInvalidFilter(): void + { + self::expectException(BadRequestHttpException::class); + + // Mock dependencies + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $serializer = $this->prophesize(SerializerInterface::class); + $personRepository = $this->prophesize(PersonRepository::class); + $motiveRepository = $this->prophesize(MotiveRepository::class); + $userRepository = $this->prophesize(UserRepositoryInterface::class); + + // Create controller + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal(), + $motiveRepository->reveal(), + new MockClock(), + new ParameterBag(['chill_ticket' => ['ticket' => ['response_time_exceeded_delay' => 'PT12H']]]), + $userRepository->reveal(), + $this->prophesize(UserGroupRepositoryInterface::class)->reveal(), + $this->prophesize(CenterRepositoryInterface::class)->reveal() + ); + + // Create request with person filter + $request = new Request( + query: ['byCurrentState' => 'foo'] + ); + + // Call controller method + $response = $controller->listTicket($request); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerMotivesTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerMotivesTest.php new file mode 100644 index 000000000..846bb9bfb --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerMotivesTest.php @@ -0,0 +1,263 @@ +<?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\TicketBundle\Tests\Controller; + +use Chill\MainBundle\Pagination\PaginatorFactoryInterface; +use Chill\MainBundle\Pagination\PaginatorInterface; +use Chill\MainBundle\Repository\CenterRepositoryInterface; +use Chill\MainBundle\Repository\UserGroupRepositoryInterface; +use Chill\MainBundle\Repository\UserRepositoryInterface; +use Chill\MainBundle\Serializer\Model\Collection; +use Chill\PersonBundle\Repository\PersonRepository; +use Chill\TicketBundle\Controller\TicketListApiController; +use Chill\TicketBundle\Entity\Motive; +use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Repository\MotiveRepository; +use Chill\TicketBundle\Repository\TicketACLAwareRepositoryInterface; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Clock\MockClock; +use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; + +/** + * @internal + * + * @covers \Chill\TicketBundle\Controller\TicketListApiController + */ +final class TicketListApiControllerMotivesTest extends TestCase +{ + use ProphecyTrait; + + public function testListTicketWithMultipleMotivesFilter(): void + { + // Mock dependencies + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $motive1 = new Motive(); + $motive2 = new Motive(); + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + $tickets = [new Ticket(), new Ticket()]; + $ticketRepository->countTickets( + Argument::that(fn ($params) => isset($params['byMotives']) && in_array($motive1, $params['byMotives']) && in_array($motive2, $params['byMotives'])) + ) + ->willReturn(2); + $ticketRepository->findTickets( + Argument::that(fn ($params) => isset($params['byMotives']) && in_array($motive1, $params['byMotives']) && in_array($motive2, $params['byMotives'])), + 0, + 10 + )->willReturn($tickets); + + $paginator = $this->prophesize(PaginatorInterface::class); + $paginator->getCurrentPageFirstItemNumber()->willReturn(0); + $paginator->getItemsPerPage()->willReturn(10); + + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $paginatorFactory->create(2)->willReturn($paginator->reveal()); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->serialize( + Argument::that(fn (Collection $collection) => $collection->getItems() === $tickets), + 'json', + ['groups' => 'read:simple'] + )->willReturn('{"items":[{},{}],"pagination":{}}'); + + $personRepository = $this->prophesize(PersonRepository::class); + $motiveRepository = $this->prophesize(MotiveRepository::class); + $motiveRepository->find(1)->willReturn($motive1); + $motiveRepository->find(2)->willReturn($motive2); + $userRepository = $this->prophesize(UserRepositoryInterface::class); + + // Create controller + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal(), + $motiveRepository->reveal(), + new MockClock(), + new ParameterBag(['chill_ticket' => ['ticket' => ['response_time_exceeded_delay' => 'PT12H']]]), + $userRepository->reveal(), + $this->prophesize(UserGroupRepositoryInterface::class)->reveal(), + $this->prophesize(CenterRepositoryInterface::class)->reveal() + ); + + // Create request with multiple motives filter + $request = new Request( + query: ['byMotives' => '1,2'] + ); + + // Call controller method + $response = $controller->listTicket($request); + + // Assert response + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertEquals('{"items":[{},{}],"pagination":{}}', $response->getContent()); + } + + public function testListTicketWithSingleMotiveFilter(): void + { + // Mock dependencies + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $motive = new Motive(); + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + $tickets = [new Ticket(), new Ticket()]; + $ticketRepository->countTickets( + Argument::that(fn ($params) => isset($params['byMotives']) && in_array($motive, $params['byMotives'])) + ) + ->willReturn(2); + $ticketRepository->findTickets( + Argument::that(fn ($params) => isset($params['byMotives']) && in_array($motive, $params['byMotives'])), + 0, + 10 + )->willReturn($tickets); + + $paginator = $this->prophesize(PaginatorInterface::class); + $paginator->getCurrentPageFirstItemNumber()->willReturn(0); + $paginator->getItemsPerPage()->willReturn(10); + + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $paginatorFactory->create(2)->willReturn($paginator->reveal()); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->serialize( + Argument::that(fn (Collection $collection) => $collection->getItems() === $tickets), + 'json', + ['groups' => 'read:simple'] + )->willReturn('{"items":[{},{}],"pagination":{}}'); + + $personRepository = $this->prophesize(PersonRepository::class); + $motiveRepository = $this->prophesize(MotiveRepository::class); + $motiveRepository->find(1)->willReturn($motive); + $userRepository = $this->prophesize(UserRepositoryInterface::class); + + // Create controller + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal(), + $motiveRepository->reveal(), + new MockClock(), + new ParameterBag(['chill_ticket' => ['ticket' => ['response_time_exceeded_delay' => 'PT12H']]]), + $userRepository->reveal(), + $this->prophesize(UserGroupRepositoryInterface::class)->reveal(), + $this->prophesize(CenterRepositoryInterface::class)->reveal() + ); + + // Create request with single motive filter + $request = new Request( + query: ['byMotives' => '1'] + ); + + // Call controller method + $response = $controller->listTicket($request); + + // Assert response + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertEquals('{"items":[{},{}],"pagination":{}}', $response->getContent()); + } + + public function testListTicketWithMotiveNotFound(): void + { + self::expectException(BadRequestHttpException::class); + self::expectExceptionMessage('Motive not found'); + + // Mock dependencies + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $serializer = $this->prophesize(SerializerInterface::class); + $personRepository = $this->prophesize(PersonRepository::class); + $motiveRepository = $this->prophesize(MotiveRepository::class); + $motiveRepository->find(999)->willReturn(null); + $userRepository = $this->prophesize(UserRepositoryInterface::class); + + // Create controller + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal(), + $motiveRepository->reveal(), + new MockClock(), + new ParameterBag(['chill_ticket' => ['ticket' => ['response_time_exceeded_delay' => 'PT12H']]]), + $userRepository->reveal(), + $this->prophesize(UserGroupRepositoryInterface::class)->reveal(), + $this->prophesize(CenterRepositoryInterface::class)->reveal() + ); + + // Create request with non-existent motive + $request = new Request( + query: ['byMotives' => '999'] + ); + + // Call controller method + $controller->listTicket($request); + } + + public function testListTicketWithNonIntegerMotiveParameter(): void + { + self::expectException(BadRequestHttpException::class); + self::expectExceptionMessage('Only numbers are allowed in by motives parameter'); + + // Mock dependencies + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $serializer = $this->prophesize(SerializerInterface::class); + $personRepository = $this->prophesize(PersonRepository::class); + $motiveRepository = $this->prophesize(MotiveRepository::class); + $userRepository = $this->prophesize(UserRepositoryInterface::class); + $centerRepository = $this->prophesize(CenterRepositoryInterface::class); + + // Create controller + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal(), + $motiveRepository->reveal(), + new MockClock(), + new ParameterBag(['chill_ticket' => ['ticket' => ['response_time_exceeded_delay' => 'PT12H']]]), + $userRepository->reveal(), + $this->prophesize(UserGroupRepositoryInterface::class)->reveal(), + $centerRepository->reveal(), + ); + + // Create request with non-integer motive parameter + $request = new Request( + query: ['byMotives' => 'abc'] + ); + + // Call controller method + $controller->listTicket($request); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerTest.php new file mode 100644 index 000000000..17facdea4 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Controller/TicketListApiControllerTest.php @@ -0,0 +1,251 @@ +<?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\TicketBundle\Tests\Controller; + +use Chill\MainBundle\Pagination\PaginatorFactoryInterface; +use Chill\MainBundle\Pagination\PaginatorInterface; +use Chill\MainBundle\Repository\CenterRepositoryInterface; +use Chill\MainBundle\Repository\UserRepositoryInterface; +use Chill\MainBundle\Repository\UserGroupRepositoryInterface; +use Chill\MainBundle\Serializer\Model\Collection; +use Chill\PersonBundle\Entity\Person; +use Chill\PersonBundle\Repository\PersonRepository; +use Chill\PersonBundle\Security\Authorization\PersonVoter; +use Chill\TicketBundle\Controller\TicketListApiController; +use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Repository\MotiveRepository; +use Chill\TicketBundle\Repository\TicketACLAwareRepositoryInterface; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Clock\MockClock; +use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; + +/** + * @internal + * + * @covers \Chill\TicketBundle\Controller\TicketListApiController + */ +final class TicketListApiControllerTest extends TestCase +{ + use ProphecyTrait; + + public function testListTicketNoParameter(): void + { + // Mock dependencies + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + $tickets = [new Ticket(), new Ticket()]; + $ticketRepository->countTickets([])->willReturn(2); + $ticketRepository->findTickets([], 0, 10)->willReturn($tickets); + + $paginator = $this->prophesize(PaginatorInterface::class); + $paginator->getCurrentPageFirstItemNumber()->willReturn(0); + $paginator->getItemsPerPage()->willReturn(10); + + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $paginatorFactory->create(2)->willReturn($paginator->reveal()); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->serialize( + Argument::that(fn (Collection $collection) => $collection->getItems() === $tickets), + 'json', + ['groups' => 'read:simple'] + )->willReturn('{"items":[{},{}],"pagination":{}}'); + + $personRepository = $this->prophesize(PersonRepository::class); + $motiveRepository = $this->prophesize(MotiveRepository::class); + $userRepository = $this->prophesize(UserRepositoryInterface::class); + + // Create controller + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal(), + $motiveRepository->reveal(), + new MockClock(), + new ParameterBag(['chill_ticket' => ['ticket' => ['response_time_exceeded_delay' => 'PT12H']]]), + $userRepository->reveal(), + $this->prophesize(UserGroupRepositoryInterface::class)->reveal(), + $this->prophesize(CenterRepositoryInterface::class)->reveal() + ); + + // Create request + $request = new Request(); + + // Call controller method + $response = $controller->listTicket($request); + + // Assert response + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertEquals('{"items":[{},{}],"pagination":{}}', $response->getContent()); + } + + public function testListTicketWithPersonFilter(): void + { + // Mock dependencies + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $person = new Person(); + $security->isGranted(PersonVoter::SEE, $person)->willReturn(true); + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + $tickets = [new Ticket(), new Ticket()]; + $ticketRepository->countTickets(Argument::that(fn ($params) => isset($params['byPerson']) && in_array($person, $params['byPerson'])))->willReturn(2); + $ticketRepository->findTickets( + Argument::that(fn ($params) => isset($params['byPerson']) && in_array($person, $params['byPerson'])), + 0, + 10 + )->willReturn($tickets); + + $paginator = $this->prophesize(PaginatorInterface::class); + $paginator->getCurrentPageFirstItemNumber()->willReturn(0); + $paginator->getItemsPerPage()->willReturn(10); + + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $paginatorFactory->create(2)->willReturn($paginator->reveal()); + + $serializer = $this->prophesize(SerializerInterface::class); + $serializer->serialize( + Argument::that(fn (Collection $collection) => $collection->getItems() === $tickets), + 'json', + ['groups' => 'read:simple'] + )->willReturn('{"items":[{},{}],"pagination":{}}'); + + $personRepository = $this->prophesize(PersonRepository::class); + $personRepository->find(123)->willReturn($person); + $motiveRepository = $this->prophesize(MotiveRepository::class); + $userRepository = $this->prophesize(UserRepositoryInterface::class); + + // Create controller + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal(), + $motiveRepository->reveal(), + new MockClock(), + new ParameterBag(['chill_ticket' => ['ticket' => ['response_time_exceeded_delay' => 'PT12H']]]), + $userRepository->reveal(), + $this->prophesize(UserGroupRepositoryInterface::class)->reveal(), + $this->prophesize(CenterRepositoryInterface::class)->reveal() + ); + + // Create request with person filter + $request = new Request( + query: ['byPerson' => '123'] + ); + + // Call controller method + $response = $controller->listTicket($request); + + // Assert response + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertEquals('{"items":[{},{}],"pagination":{}}', $response->getContent()); + } + + public function testListTicketWithoutUserRole(): void + { + // Mock dependencies + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(false); + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $serializer = $this->prophesize(SerializerInterface::class); + $personRepository = $this->prophesize(PersonRepository::class); + $motiveRepository = $this->prophesize(MotiveRepository::class); + $userRepository = $this->prophesize(UserRepositoryInterface::class); + + // Create controller + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal(), + $motiveRepository->reveal(), + new MockClock(), + new ParameterBag(['chill_ticket' => ['ticket' => ['response_time_exceeded_delay' => 'PT12H']]]), + $userRepository->reveal(), + $this->prophesize(UserGroupRepositoryInterface::class)->reveal(), + $this->prophesize(CenterRepositoryInterface::class)->reveal() + ); + + // Create request + $request = new Request(); + + // Expect exception + $this->expectException(AccessDeniedHttpException::class); + $this->expectExceptionMessage('Only users are allowed to list tickets.'); + + // Call controller method + $controller->listTicket($request); + } + + public function testListTicketWithPersonWithoutAccess(): void + { + // Mock dependencies + $security = $this->prophesize(Security::class); + $security->isGranted('ROLE_USER')->willReturn(true); + + $person = new Person(); + $security->isGranted(PersonVoter::SEE, $person)->willReturn(false); + + $ticketRepository = $this->prophesize(TicketACLAwareRepositoryInterface::class); + $paginatorFactory = $this->prophesize(PaginatorFactoryInterface::class); + $serializer = $this->prophesize(SerializerInterface::class); + + $personRepository = $this->prophesize(PersonRepository::class); + $personRepository->find(123)->willReturn($person); + $motiveRepository = $this->prophesize(MotiveRepository::class); + $userRepository = $this->prophesize(UserRepositoryInterface::class); + + // Create controller + $controller = new TicketListApiController( + $security->reveal(), + $ticketRepository->reveal(), + $paginatorFactory->reveal(), + $serializer->reveal(), + $personRepository->reveal(), + $motiveRepository->reveal(), + new MockClock(), + new ParameterBag(['chill_ticket' => ['ticket' => ['response_time_exceeded_delay' => 'PT12H']]]), + $userRepository->reveal(), + $this->prophesize(UserGroupRepositoryInterface::class)->reveal(), + $this->prophesize(CenterRepositoryInterface::class)->reveal() + ); + + // Create request with person filter + $request = new Request( + query: ['byPerson' => '123'] + ); + + // Expect exception + $this->expectException(AccessDeniedHttpException::class); + $this->expectExceptionMessage('Not allowed to see a person with id 123'); + + // Call controller method + $controller->listTicket($request); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/TicketListControllerTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/TicketListControllerTest.php new file mode 100644 index 000000000..65f237068 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Controller/TicketListControllerTest.php @@ -0,0 +1,34 @@ +<?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 Controller; + +use Chill\MainBundle\Test\PrepareClientTrait; +use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; + +/** + * @internal + * + * @coversNothing + */ +class TicketListControllerTest extends WebTestCase +{ + use PrepareClientTrait; + + public function testList(): void + { + $client = $this->getClientAuthenticated(); + + $client->request('GET', '/fr/ticket/ticket/list'); + + self::assertResponseIsSuccessful(); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/UpdateCommentControllerTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/UpdateCommentControllerTest.php new file mode 100644 index 000000000..599051702 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Controller/UpdateCommentControllerTest.php @@ -0,0 +1,134 @@ +<?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\TicketBundle\Tests\Controller; + +use Chill\TicketBundle\Action\Comment\Handler\UpdateCommentContentCommandHandlerInterface; +use Chill\TicketBundle\Controller\UpdateCommentController; +use Chill\TicketBundle\Entity\Comment; +use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Security\Voter\CommentVoter; +use Doctrine\ORM\EntityManagerInterface; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Validator\Validator\ValidatorInterface; + +/** + * @internal + * + * @coversNothing + */ +class UpdateCommentControllerTest extends KernelTestCase +{ + use ProphecyTrait; + + private SerializerInterface $serializer; + private ValidatorInterface $validator; + + protected function setUp(): void + { + self::bootKernel(); + $this->validator = self::getContainer()->get(ValidatorInterface::class); + $this->serializer = self::getContainer()->get(SerializerInterface::class); + } + + public function testUpdateComment(): void + { + $ticket = new Ticket(); + $comment = new Comment('initial content', $ticket); + $controller = $this->buildController(willFlush: true, isGranted: true, comment: $comment); + + $request = new Request(content: <<<'JSON' + {"content": "updated content"} + JSON); + + $response = $controller->__invoke($comment, $request); + + self::assertEquals(200, $response->getStatusCode()); + } + + public function testUpdateCommentWithBlankContent(): void + { + $ticket = new Ticket(); + $comment = new Comment('initial content', $ticket); + $controller = $this->buildController(willFlush: false, isGranted: true, comment: $comment); + + $request = new Request(content: <<<'JSON' + {"content": ""} + JSON); + + $response = $controller->__invoke($comment, $request); + + self::assertEquals(422, $response->getStatusCode()); + } + + public function testUpdateCommentWithNullContent(): void + { + $ticket = new Ticket(); + $comment = new Comment('initial content', $ticket); + $controller = $this->buildController(willFlush: false, isGranted: true, comment: $comment); + + $request = new Request(content: <<<'JSON' + {"content": null} + JSON); + + $response = $controller->__invoke($comment, $request); + + self::assertEquals(422, $response->getStatusCode()); + } + + public function testUpdateCommentWithoutAuthorization(): void + { + $ticket = new Ticket(); + $comment = new Comment('initial content', $ticket); + $controller = $this->buildController(willFlush: false, isGranted: false, comment: $comment); + + $request = new Request(content: <<<'JSON' + {"content": "updated content"} + JSON); + + $this->expectException(AccessDeniedHttpException::class); + $this->expectExceptionMessage('You are not allowed to edit this comment.'); + + $controller->__invoke($comment, $request); + } + + private function buildController(bool $willFlush, bool $isGranted, Comment $comment): UpdateCommentController + { + $security = $this->prophesize(Security::class); + $security->isGranted(CommentVoter::EDIT, $comment)->willReturn($isGranted); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + + if ($willFlush) { + $entityManager->flush()->shouldBeCalled(); + } + + $commandHandler = $this->prophesize(UpdateCommentContentCommandHandlerInterface::class); + + if ($isGranted && $willFlush) { + $commandHandler->handle($comment, Argument::any())->shouldBeCalled(); + } + + return new UpdateCommentController( + $security->reveal(), + $this->serializer, + $this->validator, + $commandHandler->reveal(), + $entityManager->reveal(), + ); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Controller/UpdateCommentDeletedStatusControllerTest.php b/src/Bundle/ChillTicketBundle/tests/Controller/UpdateCommentDeletedStatusControllerTest.php new file mode 100644 index 000000000..fb70db76d --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Controller/UpdateCommentDeletedStatusControllerTest.php @@ -0,0 +1,114 @@ +<?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\TicketBundle\Tests\Controller; + +use Chill\TicketBundle\Action\Comment\Handler\UpdateCommentDeletedStatusCommandHandlerInterface; +use Chill\TicketBundle\Controller\UpdateCommentDeletedStatusController; +use Chill\TicketBundle\Entity\Comment; +use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Security\Voter\CommentVoter; +use Doctrine\ORM\EntityManagerInterface; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\SerializerInterface; + +/** + * @internal + * + * @coversNothing + */ +class UpdateCommentDeletedStatusControllerTest extends KernelTestCase +{ + use ProphecyTrait; + + private SerializerInterface $serializer; + + protected function setUp(): void + { + self::bootKernel(); + $this->serializer = self::getContainer()->get(SerializerInterface::class); + } + + public function testDeleteComment(): void + { + $ticket = new Ticket(); + $comment = new Comment('content', $ticket); + $controller = $this->buildController(willFlush: true, isGranted: true, comment: $comment); + + $response = $controller->deleteComment($comment); + + self::assertEquals(200, $response->getStatusCode()); + } + + public function testRestoreComment(): void + { + $ticket = new Ticket(); + $comment = new Comment('content', $ticket); + $controller = $this->buildController(willFlush: true, isGranted: true, comment: $comment); + + $response = $controller->restoreComment($comment); + + self::assertEquals(200, $response->getStatusCode()); + } + + public function testDeleteCommentWithoutAuthorization(): void + { + $ticket = new Ticket(); + $comment = new Comment('content', $ticket); + $controller = $this->buildController(willFlush: false, isGranted: false, comment: $comment); + + $this->expectException(AccessDeniedHttpException::class); + $this->expectExceptionMessage('You are not allowed to edit this comment.'); + + $controller->deleteComment($comment); + } + + public function testRestoreCommentWithoutAuthorization(): void + { + $ticket = new Ticket(); + $comment = new Comment('content', $ticket); + $controller = $this->buildController(willFlush: false, isGranted: false, comment: $comment); + + $this->expectException(AccessDeniedHttpException::class); + $this->expectExceptionMessage('You are not allowed to edit this comment.'); + + $controller->restoreComment($comment); + } + + private function buildController(bool $willFlush, bool $isGranted, Comment $comment): UpdateCommentDeletedStatusController + { + $security = $this->prophesize(Security::class); + $security->isGranted(CommentVoter::EDIT, $comment)->willReturn($isGranted); + + $entityManager = $this->prophesize(EntityManagerInterface::class); + + if ($willFlush) { + $entityManager->flush()->shouldBeCalled(); + } + + $commandHandler = $this->prophesize(UpdateCommentDeletedStatusCommandHandlerInterface::class); + + if ($isGranted && $willFlush) { + $commandHandler->handle($comment, Argument::any())->shouldBeCalled(); + } + + return new UpdateCommentDeletedStatusController( + $security->reveal(), + $this->serializer, + $commandHandler->reveal(), + $entityManager->reveal(), + ); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Entity/CallerHistoryTest.php b/src/Bundle/ChillTicketBundle/tests/Entity/CallerHistoryTest.php new file mode 100644 index 000000000..36024fde5 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Entity/CallerHistoryTest.php @@ -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\TicketBundle\Tests\Entity; + +use Chill\PersonBundle\Entity\Person; +use Chill\ThirdPartyBundle\Entity\ThirdParty; +use Chill\TicketBundle\Entity\CallerHistory; +use Chill\TicketBundle\Entity\Ticket; +use PHPUnit\Framework\TestCase; + +/** + * @internal + * + * @coversNothing + */ +class CallerHistoryTest extends TestCase +{ + public function testConstructorWithPerson(): void + { + $ticket = new Ticket(); + + $callerHistory = new CallerHistory($person = new Person(), $ticket); + + self::assertSame($ticket, $callerHistory->getTicket()); + self::assertNull($callerHistory->getEndDate()); + self::assertSame($person, $callerHistory->getPerson()); + self::assertNull($callerHistory->getThirdParty()); + self::assertSame($person, $callerHistory->getCaller()); + } + + public function testConstructorWithThirdParty(): void + { + $ticket = new Ticket(); + + $callerHistory = new CallerHistory($thirdParty = new ThirdParty(), $ticket); + + self::assertSame($ticket, $callerHistory->getTicket()); + self::assertNull($callerHistory->getEndDate()); + self::assertNull($callerHistory->getPerson()); + self::assertSame($thirdParty, $callerHistory->getThirdParty()); + self::assertSame($thirdParty, $callerHistory->getCaller()); + } + + public function testSetEndDate(): void + { + $ticket = $this->createMock(Ticket::class); + $callerHistory = new CallerHistory(new ThirdParty(), $ticket); + + $endDate = new \DateTimeImmutable('2023-01-01'); + $callerHistory->setEndDate($endDate); + + self::assertSame($endDate, $callerHistory->getEndDate()); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Entity/MotiveTest.php b/src/Bundle/ChillTicketBundle/tests/Entity/MotiveTest.php new file mode 100644 index 000000000..c9bba7f6f --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Entity/MotiveTest.php @@ -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\TicketBundle\Tests\Entity; + +use Chill\TicketBundle\Entity\Motive; +use PHPUnit\Framework\TestCase; + +/** + * @internal + * + * @covers \Chill\TicketBundle\Entity\Motive::getWithDescendants + */ +final class MotiveTest extends TestCase +{ + public function testGetDescendantsOnLeafReturnsSelfOnly(): void + { + $leaf = new Motive(); + $leaf->setLabel(['fr' => 'Feuille']); + + $collection = $leaf->getDescendants(); + + self::assertCount(1, $collection); + self::assertSame($leaf, $collection->first()); + self::assertContains($leaf, $collection->toArray()); + } + + public function testGetWithDescendantsReturnsSelfAndAllDescendants(): void + { + $parent = new Motive(); + $parent->setLabel(['fr' => 'Parent']); + + $childA = new Motive(); + $childA->setLabel(['fr' => 'Enfant A']); + $childA->setParent($parent); + + $childB = new Motive(); + $childB->setLabel(['fr' => 'Enfant B']); + $childB->setParent($parent); + + $grandChildA1 = new Motive(); + $grandChildA1->setLabel(['fr' => 'Petit-enfant A1']); + $grandChildA1->setParent($childA); + + $descendants = $parent->getDescendants(); + $asArray = $descendants->toArray(); + + // It should contain the parent itself, both children and the grand child + self::assertCount(4, $descendants, 'Expected parent + 2 children + 1 grandchild'); + self::assertContains($parent, $asArray); + self::assertContains($childA, $asArray); + self::assertContains($childB, $asArray); + self::assertContains($grandChildA1, $asArray); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Entity/TicketCallerTest.php b/src/Bundle/ChillTicketBundle/tests/Entity/TicketCallerTest.php new file mode 100644 index 000000000..ad9b8ecaf --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Entity/TicketCallerTest.php @@ -0,0 +1,82 @@ +<?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\TicketBundle\Tests\Entity; + +use Chill\PersonBundle\Entity\Person; +use Chill\ThirdPartyBundle\Entity\ThirdParty; +use Chill\TicketBundle\Entity\CallerHistory; +use Chill\TicketBundle\Entity\Ticket; +use PHPUnit\Framework\TestCase; + +/** + * @internal + * + * @coversNothing + */ +class TicketCallerTest extends TestCase +{ + public function testGetCaller(): void + { + $ticket = new Ticket(); + + // Initially, there should be no caller + self::assertNull($ticket->getCaller()); + + // Create a person + $person = new Person(); + + // Create a caller history with the person + $callerHistory = new CallerHistory($person, $ticket); + + // The ticket should now return the person as the caller + self::assertSame($person, $ticket->getCaller()); + + // Create a third party + $thirdParty = new ThirdParty(); + + // Create a new caller history with the third party + $callerHistory2 = new CallerHistory($thirdParty, $ticket); + + // End the first caller history + $callerHistory->setEndDate(new \DateTimeImmutable()); + + // The ticket should now return the third party as the caller + self::assertSame($thirdParty, $ticket->getCaller()); + + // End the second caller history + $callerHistory2->setEndDate(new \DateTimeImmutable()); + + // The ticket should now return null as there is no active caller + self::assertNull($ticket->getCaller()); + } + + public function testGetCallerHistories(): void + { + $ticket = new Ticket(); + + // Initially, there should be no caller histories + self::assertCount(0, $ticket->getCallerHistories()); + + // Create a caller history + $callerHistory = new CallerHistory(new Person(), $ticket); + + // The ticket should now have one caller history + self::assertCount(1, $ticket->getCallerHistories()); + self::assertSame($callerHistory, $ticket->getCallerHistories()->first()); + + // Create another caller history + $callerHistory2 = new CallerHistory(new ThirdParty(), $ticket); + + // The ticket should now have two caller histories + self::assertCount(2, $ticket->getCallerHistories()); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Entity/TicketTest.php b/src/Bundle/ChillTicketBundle/tests/Entity/TicketTest.php new file mode 100644 index 000000000..975a8e2e2 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Entity/TicketTest.php @@ -0,0 +1,161 @@ +<?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\TicketBundle\Tests\Entity; + +use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Entity\UserGroup; +use Chill\PersonBundle\Entity\Person; +use Chill\TicketBundle\Entity\AddresseeHistory; +use Chill\TicketBundle\Entity\Motive; +use Chill\TicketBundle\Entity\MotiveHistory; +use Chill\TicketBundle\Entity\PersonHistory; +use Chill\TicketBundle\Entity\EmergencyStatusEnum; +use Chill\TicketBundle\Entity\EmergencyStatusHistory; +use Chill\TicketBundle\Entity\StateEnum; +use Chill\TicketBundle\Entity\StateHistory; +use Chill\TicketBundle\Entity\Ticket; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; + +/** + * @internal + * + * @coversNothing + */ +class TicketTest extends KernelTestCase +{ + public function testGetMotive(): void + { + $ticket = new Ticket(); + $motive = new Motive(); + + self::assertNull($ticket->getMotive()); + + $history = new MotiveHistory($motive, $ticket); + + self::assertSame($motive, $ticket->getMotive()); + self::assertCount(1, $ticket->getMotiveHistories()); + + // replace motive + $motive2 = new Motive(); + $history->setEndDate(new \DateTimeImmutable()); + $history2 = new MotiveHistory($motive2, $ticket); + + self::assertCount(2, $ticket->getMotiveHistories()); + self::assertSame($motive2, $ticket->getMotive()); + } + + public function testGetPerson(): void + { + $ticket = new Ticket(); + $person = new Person(); + + self::assertEquals([], $ticket->getPersons()); + + $history = new PersonHistory($person, $ticket, new \DateTimeImmutable('now')); + + self::assertCount(1, $ticket->getPersons()); + self::assertSame($person, $ticket->getPersons()[0]); + + $history->setEndDate(new \DateTimeImmutable('now')); + + self::assertCount(0, $ticket->getPersons()); + } + + public function testGetAddresse(): void + { + $ticket = new Ticket(); + $user = new User(); + $group = new UserGroup(); + + self::assertEquals([], $ticket->getCurrentAddressee()); + + $history = new AddresseeHistory($user, new \DateTimeImmutable('now'), $ticket); + + self::assertCount(1, $ticket->getCurrentAddressee()); + self::assertSame($user, $ticket->getCurrentAddressee()[0]); + + $history2 = new AddresseeHistory($group, new \DateTimeImmutable('now'), $ticket); + + self::assertCount(2, $ticket->getCurrentAddressee()); + self::assertContains($group, $ticket->getCurrentAddressee()); + + $history->setEndDate(new \DateTimeImmutable('now')); + + self::assertCount(1, $ticket->getCurrentAddressee()); + self::assertSame($group, $ticket->getCurrentAddressee()[0]); + } + + public function testGetAndSetExternalRef(): void + { + $ticket = new Ticket(); + $externalRef = 'REF-123'; + + // Set the external reference + $ticket->setExternalRef($externalRef); + + // Verify that getExternalRef returns the correct value + self::assertSame($externalRef, $ticket->getExternalRef()); + + // Change the external reference + $newExternalRef = 'REF-456'; + $ticket->setExternalRef($newExternalRef); + + // Verify that getExternalRef returns the updated value + self::assertSame($newExternalRef, $ticket->getExternalRef()); + } + + public function testGetState(): void + { + $ticket = new Ticket(); + + // Initially, the ticket has no state + self::assertNull($ticket->getState()); + + // Create a state history entry with the open state + $history = new StateHistory(StateEnum::OPEN, $ticket); + + // Verify that the ticket now has the open state + self::assertSame(StateEnum::OPEN, $ticket->getState()); + self::assertCount(1, $ticket->getStateHistories()); + + // Change the state to closed + $history->setEndDate(new \DateTimeImmutable()); + $history2 = new StateHistory(StateEnum::CLOSED, $ticket); + + // Verify that the ticket now has the closed state + self::assertCount(2, $ticket->getStateHistories()); + self::assertSame(StateEnum::CLOSED, $ticket->getState()); + } + + public function testGetEmergencyStatus(): void + { + $ticket = new Ticket(); + + // Initially, the ticket has no emergency status + self::assertNull($ticket->getEmergencyStatus()); + + // Create an emergency status history entry with the YES status + $history = new EmergencyStatusHistory(EmergencyStatusEnum::YES, $ticket); + + // Verify that the ticket now has the YES status + self::assertSame(EmergencyStatusEnum::YES, $ticket->getEmergencyStatus()); + self::assertCount(1, $ticket->getEmergencyStatusHistories()); + + // Change the emergency status to NO + $history->setEndDate(new \DateTimeImmutable()); + $history2 = new EmergencyStatusHistory(EmergencyStatusEnum::NO, $ticket); + + // Verify that the ticket now has the NO status + self::assertCount(2, $ticket->getEmergencyStatusHistories()); + self::assertSame(EmergencyStatusEnum::NO, $ticket->getEmergencyStatus()); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Event/EventSubscriber/GeneratePostUpdateTicketEventSubscriberTest.php b/src/Bundle/ChillTicketBundle/tests/Event/EventSubscriber/GeneratePostUpdateTicketEventSubscriberTest.php new file mode 100644 index 000000000..e6a982906 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Event/EventSubscriber/GeneratePostUpdateTicketEventSubscriberTest.php @@ -0,0 +1,58 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Chill\TicketBundle\tests\Event\EventSubscriber; + +use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Event\EventSubscriber\GeneratePostUpdateTicketEventSubscriber; +use Chill\TicketBundle\Event\TicketUpdateEvent; +use Chill\TicketBundle\Event\TicketUpdateKindEnum; +use Chill\TicketBundle\Messenger\PostTicketUpdateMessage; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Event\TerminateEvent; +use Symfony\Component\HttpKernel\KernelInterface; +use Symfony\Component\Messenger\Envelope; +use Symfony\Component\Messenger\MessageBusInterface; + +/** + * @covers \Chill\TicketBundle\Event\EventSubscriber\GeneratePostUpdateTicketEventSubscriber + * + * @internal + */ +class GeneratePostUpdateTicketEventSubscriberTest extends TestCase +{ + use ProphecyTrait; + + public function testOnTicketUpdate(): void + { + $ticket = new Ticket(); + $reflection = new \ReflectionClass(Ticket::class); + $idProperty = $reflection->getProperty('id'); + $idProperty->setValue($ticket, 1); + $event = new class (TicketUpdateKindEnum::UPDATE_MOTIVE, $ticket) extends TicketUpdateEvent {}; + + $messageBus = $this->prophesize(MessageBusInterface::class); + $messageBus->dispatch(Argument::that(fn ($arg) => $arg instanceof PostTicketUpdateMessage && TicketUpdateKindEnum::UPDATE_MOTIVE === $arg->updateKind && 1 === $arg->ticketId)) + ->will(fn ($args) => new Envelope($args[0])) + ->shouldBeCalled(); + + $eventSubscriber = new GeneratePostUpdateTicketEventSubscriber($messageBus->reveal()); + $eventSubscriber->onTicketUpdate($event); + + $kernel = $this->prophesize(KernelInterface::class); + $terminate = new TerminateEvent($kernel->reveal(), new Request(), new Response()); + $eventSubscriber->onKernelTerminate($terminate); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Messenger/Handler/PostTicketUpdateMessageHandlerTest.php b/src/Bundle/ChillTicketBundle/tests/Messenger/Handler/PostTicketUpdateMessageHandlerTest.php new file mode 100644 index 000000000..7d5f33e5d --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Messenger/Handler/PostTicketUpdateMessageHandlerTest.php @@ -0,0 +1,92 @@ +<?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\TicketBundle\tests\Messenger\Handler; + +use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Event\PostTicketUpdateEvent; +use Chill\TicketBundle\Event\TicketUpdateKindEnum; +use Chill\TicketBundle\Messenger\PostTicketUpdateMessage; +use Chill\TicketBundle\Messenger\Handler\PostTicketUpdateMessageHandler; +use Chill\TicketBundle\Repository\TicketRepositoryInterface; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\Messenger\Exception\UnrecoverableMessageHandlingException; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; + +/** + * @covers \Chill\TicketBundle\Messenger\Handler\PostTicketUpdateMessageHandler + * + * @internal + */ +class PostTicketUpdateMessageHandlerTest extends TestCase +{ + use ProphecyTrait; + + public function testDispatchesEventWhenTicketExists(): void + { + // Arrange: a Ticket with an ID + $ticket = new Ticket(); + $reflection = new \ReflectionClass(Ticket::class); + $idProperty = $reflection->getProperty('id'); + $idProperty->setValue($ticket, 123); + + $message = new PostTicketUpdateMessage($ticket, TicketUpdateKindEnum::UPDATE_MOTIVE); + + // Mock repository to return the Ticket when searching by id + $ticketRepository = $this->prophesize(TicketRepositoryInterface::class); + $ticketRepository->find(123)->willReturn($ticket)->shouldBeCalledOnce(); + + // Expect the dispatcher to dispatch a PostTicketUpdateEvent with correct data + $eventDispatcher = $this->prophesize(EventDispatcherInterface::class); + $eventDispatcher + ->dispatch(Argument::that(fn ($event) => $event instanceof PostTicketUpdateEvent + && TicketUpdateKindEnum::UPDATE_MOTIVE === $event->updateKind + && $event->ticket === $ticket)) + ->will(fn ($args) => $args[0]) + ->shouldBeCalledOnce(); + + $handler = new PostTicketUpdateMessageHandler($eventDispatcher->reveal(), $ticketRepository->reveal()); + + // Act + $handler($message); + + // Assert: expectations asserted by Prophecy + self::assertTrue(true); + } + + public function testThrowsWhenTicketNotFound(): void + { + // Arrange: a Ticket with an ID for the message, but repository will return null + $ticket = new Ticket(); + $reflection = new \ReflectionClass(Ticket::class); + $idProperty = $reflection->getProperty('id'); + $idProperty->setValue($ticket, 999); + + $message = new PostTicketUpdateMessage($ticket, TicketUpdateKindEnum::UPDATE_MOTIVE); + + $ticketRepository = $this->prophesize(TicketRepositoryInterface::class); + $ticketRepository->find(999)->willReturn(null)->shouldBeCalledOnce(); + + $eventDispatcher = $this->prophesize(EventDispatcherInterface::class); + $eventDispatcher->dispatch(Argument::any())->shouldNotBeCalled(); + + $handler = new PostTicketUpdateMessageHandler($eventDispatcher->reveal(), $ticketRepository->reveal()); + + // Assert: exception is thrown + $this->expectException(UnrecoverableMessageHandlingException::class); + $this->expectExceptionMessage('Ticket not found'); + + // Act + $handler($message); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Repository/PersonTicketACLAwareRepositoryTest.php b/src/Bundle/ChillTicketBundle/tests/Repository/PersonTicketACLAwareRepositoryTest.php new file mode 100644 index 000000000..654ba2467 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Repository/PersonTicketACLAwareRepositoryTest.php @@ -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 Repository; + +use Chill\PersonBundle\DataFixtures\Helper\RandomPersonHelperTrait; +use Chill\ThirdPartyBundle\Entity\ThirdParty; +use Chill\TicketBundle\Repository\PersonTicketACLAwareRepository; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; + +/** + * @internal + * + * @coversNothing + */ +class PersonTicketACLAwareRepositoryTest extends KernelTestCase +{ + use RandomPersonHelperTrait; + + private PersonTicketACLAwareRepository $repository; + private EntityManagerInterface $entityManager; + + protected function setUp(): void + { + self::bootKernel(); + $this->repository = self::getContainer()->get(PersonTicketACLAwareRepository::class); + $this->entityManager = self::getContainer()->get(EntityManagerInterface::class); + } + + public function testFindPersonPreviouslyAssociatedWithCallerWithPerson() + { + $person = $this->getRandomPerson(self::getContainer()->get(EntityManagerInterface::class)); + + $actual = $this->repository->findPersonPreviouslyAssociatedWithCaller($person); + + self::assertIsList($actual); + } + + public function testFindPersonPreviouslyAssociatedWithCallerWithThirdParty() + { + $thirdParty = $this->entityManager->createQuery( + sprintf('SELECT t FROM %s t', ThirdParty::class) + ) + ->setMaxResults(1) + ->getSingleResult(); + + if (null === $thirdParty) { + throw new \RuntimeException('the third party table seems to be empty'); + } + + $actual = $this->repository->findPersonPreviouslyAssociatedWithCaller($thirdParty); + + self::assertIsList($actual); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Repository/TicketACLAwareRepositoryTest.php b/src/Bundle/ChillTicketBundle/tests/Repository/TicketACLAwareRepositoryTest.php new file mode 100644 index 000000000..17684b06f --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Repository/TicketACLAwareRepositoryTest.php @@ -0,0 +1,211 @@ +<?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\TicketBundle\Tests\Repository; + +use Chill\MainBundle\Entity\Center; +use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Entity\UserGroup; +use Chill\PersonBundle\DataFixtures\Helper\RandomPersonHelperTrait; +use Chill\TicketBundle\Entity\EmergencyStatusEnum; +use Chill\TicketBundle\Entity\Motive; +use Chill\TicketBundle\Entity\StateEnum; +use Chill\TicketBundle\Repository\TicketACLAwareRepository; +use Doctrine\ORM\EntityManagerInterface; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; + +/** + * @internal + * + * @coversNothing + */ +class TicketACLAwareRepositoryTest extends KernelTestCase +{ + use RandomPersonHelperTrait; + + private TicketACLAwareRepository $repository; + private EntityManagerInterface $entityManager; + + protected function setUp(): void + { + self::bootKernel(); + $this->entityManager = self::getContainer()->get(EntityManagerInterface::class); + $this->repository = new TicketACLAwareRepository($this->entityManager); + } + + public function testFindNoParameters(): void + { + // Test the findTickets method with byPerson parameter + $actual = $this->repository->findTickets([]); + + // Only verify that the query executes successfully without checking results + self::assertIsList($actual); + } + + public function testFindTicketByPerson(): void + { + $person = $this->getRandomPerson($this->entityManager); + + // Test the findTickets method with byPerson parameter + $actual = $this->repository->findTickets(['byPerson' => [$person]]); + + // Only verify that the query executes successfully without checking results + self::assertIsList($actual); + } + + public function testCountTicketsByPerson(): void + { + $person = $this->getRandomPerson($this->entityManager); + + $result = $this->repository->countTickets(['byPerson' => [$person]]); + + self::assertIsInt($result); + } + + public function testCountTicketByCurrentStateSingleState(): void + { + $result = $this->repository->countTickets(['byCurrentState' => [StateEnum::OPEN]]); + + self::assertIsInt($result); + } + + public function testFindTicketByCurrentStateMultipleState(): void + { + $result = $this->repository->findTickets(['byCurrentState' => [StateEnum::OPEN, StateEnum::CLOSED]]); + + self::assertIsArray($result); + } + + public function testCountTicketByCurrentStateEmergencySingleState(): void + { + $result = $this->repository->countTickets(['byCurrentStateEmergency' => [EmergencyStatusEnum::YES]]); + + self::assertIsInt($result); + } + + public function testFindTicketByCurrentStateEmergencyMultipleState(): void + { + $result = $this->repository->findTickets(['byCurrentStateEmergency' => [EmergencyStatusEnum::YES, EmergencyStatusEnum::NO]]); + + self::assertIsArray($result); + } + + public function testFindTicketByMotives(): void + { + $motives = $this->entityManager->createQuery(sprintf('SELECT m FROM %s m', Motive::class)) + ->setMaxResults(2) + ->getResult(); + + if ([] === $motives) { + throw new \UnexpectedValueException('No motives found'); + } + + $results = $this->repository->findTickets(['byMotives' => $motives]); + + self::assertIsArray($results); + } + + public function testFindTicketByCreatedBefore(): void + { + $actual = $this->repository->findTickets(['byCreatedBefore' => (new \DateTimeImmutable('now'))->add(new \DateInterval('P1D'))]); + + self::assertIsArray($actual); + } + + public function testFindTicketByCreatedAfter(): void + { + $actual = $this->repository->findTickets(['byCreatedAfter' => (new \DateTimeImmutable('now'))->sub(new \DateInterval('P1M'))]); + + self::assertIsArray($actual); + } + + public function testFindByAddressee(): void + { + $users = $this->entityManager->createQuery('SELECT u FROM '.User::class.' u') + ->setMaxResults(2) + ->getResult(); + + if ([] === $users) { + throw new \UnexpectedValueException('No users found'); + } + + $actual = $this->repository->findTickets(['byAddressee' => $users]); + + self::assertIsArray($actual); + } + + public function testFindByCreator(): void + { + $users = $this->entityManager->createQuery('SELECT u FROM '.User::class.' u') + ->setMaxResults(2) + ->getResult(); + + if ([] === $users) { + throw new \UnexpectedValueException('No users found'); + } + + $actual = $this->repository->findTickets(['byCreator' => $users]); + + self::assertIsArray($actual); + } + + public function testCountByCreator(): void + { + $users = $this->entityManager->createQuery('SELECT u FROM '.User::class.' u') + ->setMaxResults(2) + ->getResult(); + + if ([] === $users) { + throw new \UnexpectedValueException('No users found'); + } + + $count = $this->repository->countTickets(['byCreator' => $users]); + + self::assertIsInt($count); + } + + public function testFindByAddresseeGroup(): void + { + $userGroups = $this->entityManager->createQuery('SELECT ug FROM '.UserGroup::class.' ug') + ->setMaxResults(2) + ->getResult(); + + if ([] === $userGroups) { + throw new \UnexpectedValueException('No users found'); + } + + $actual = $this->repository->findTickets(['byCreator' => $userGroups]); + + self::assertIsArray($actual); + } + + public function testFindByTicketid(): void + { + $actual = $this->repository->findTickets(['byTicketId' => 1]); + + self::assertIsArray($actual); + } + + public function testFindByPersonCenter(): void + { + $centers = $this->entityManager->createQuery('SELECT c FROM '.Center::class.' c') + ->setMaxResults(2) + ->getResult(); + + if ([] === $centers) { + throw new \UnexpectedValueException('No centers found'); + } + + $actual = $this->repository->findTickets(['byPersonCenter' => $centers]); + + self::assertIsArray($actual); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/MotiveNormalizerTest.php b/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/MotiveNormalizerTest.php new file mode 100644 index 000000000..1d9ecd10a --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/MotiveNormalizerTest.php @@ -0,0 +1,154 @@ +<?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\TicketBundle\Tests\Serializer\Normalizer; + +use Chill\DocStoreBundle\Entity\StoredObject; +use Chill\TicketBundle\Entity\EmergencyStatusEnum; +use Chill\TicketBundle\Entity\Motive; +use Chill\TicketBundle\Serializer\Normalizer\MotiveNormalizer; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * @internal + * + * @covers \Chill\TicketBundle\Serializer\Normalizer\MotiveNormalizer + */ +final class MotiveNormalizerTest extends TestCase +{ + public function testNormalizeReadBasic(): void + { + $motive = new Motive(); + $motive->setLabel(['fr' => 'Logement', 'en' => 'Housing']); + // active is true by default + + $normalizer = new MotiveNormalizer(); + $normalizer->setNormalizer($this->buildDummyNormalizer()); + + $actual = $normalizer->normalize($motive, 'json', ['groups' => 'read']); + + self::assertSame('ticket_motive', $actual['type']); + self::assertNull($actual['id']); + self::assertSame(['fr' => 'Logement', 'en' => 'Housing'], $actual['label']); + self::assertTrue($actual['active']); + // no extended fields here + self::assertArrayNotHasKey('makeTicketEmergency', $actual); + self::assertArrayNotHasKey('supplementaryComments', $actual); + self::assertArrayNotHasKey('storedObjects', $actual); + self::assertArrayNotHasKey('children', $actual); + } + + public function testNormalizeExtended(): void + { + $motive = new Motive(); + $motive->setLabel(['fr' => 'Financier']); + $motive->setMakeTicketEmergency(EmergencyStatusEnum::YES); + $motive->addSupplementaryComment(['label' => 'Justifier le revenu']); + $motive->addStoredObject(new StoredObject('pending')); + + $normalizer = new MotiveNormalizer(); + $normalizer->setNormalizer($this->buildDummyNormalizer()); + + $actual = $normalizer->normalize($motive, 'json', ['groups' => ['read', 'read:extended']]); + + self::assertSame('ticket_motive', $actual['type']); + self::assertSame(['fr' => 'Financier'], $actual['label']); + self::assertSame(EmergencyStatusEnum::YES, $actual['makeTicketEmergency']); + self::assertSame([ + ['label' => 'Justifier le revenu'], + ], $actual['supplementaryComments']); + self::assertSame([ + ['stored_object'], + ], $actual['storedObjects']); + } + + public function testNormalizeParentToChildren(): void + { + $parent = new Motive(); + $parent->setLabel(['fr' => 'Parent']); + $child1 = new Motive(); + $child1->setLabel(['fr' => 'Enfant 1']); + $child2 = new Motive(); + $child2->setLabel(['fr' => 'Enfant 2']); + + // build relation + $child1->setParent($parent); + $child2->setParent($parent); + + $normalizer = new MotiveNormalizer(); + $normalizer->setNormalizer($this->buildDummyNormalizer()); + + $actual = $normalizer->normalize($parent, 'json', ['groups' => [MotiveNormalizer::GROUP_PARENT_TO_CHILDREN]]); + + // children must be normalized by the injected normalizer and parent not exposed + self::assertArrayHasKey('children', $actual); + self::assertSame([ + ['motive' => 'normalized'], + ['motive' => 'normalized'], + ], $actual['children']); + self::assertArrayNotHasKey('parent', $actual); + } + + public function testNormalizeChildrenToParent(): void + { + $parent = new Motive(); + $parent->setLabel(['fr' => 'Parent']); + $child = new Motive(); + $child->setLabel(['fr' => 'Enfant']); + $child->setParent($parent); + + $normalizer = new MotiveNormalizer(); + $normalizer->setNormalizer($this->buildDummyNormalizer()); + + $actual = $normalizer->normalize($child, 'json', ['groups' => ['read', MotiveNormalizer::GROUP_CHILDREN_TO_PARENT]]); + + // parent must be normalized by the injected normalizer and children not exposed + self::assertArrayHasKey('parent', $actual); + self::assertSame(['motive' => 'normalized'], $actual['parent']); + self::assertArrayNotHasKey('children', $actual); + } + + public function testSupportsAndSupportedTypes(): void + { + $motive = new Motive(); + $normalizer = new MotiveNormalizer(); + + self::assertTrue($normalizer->supportsNormalization($motive, 'json')); + self::assertFalse($normalizer->supportsNormalization(new \stdClass(), 'json')); + + $supported = $normalizer->getSupportedTypes('json'); + self::assertArrayHasKey(Motive::class, $supported); + self::assertTrue($supported[Motive::class]); + } + + private function buildDummyNormalizer(): NormalizerInterface + { + return new class () implements NormalizerInterface { + public function normalize($object, ?string $format = null, array $context = []): array + { + if ($object instanceof StoredObject) { + return ['stored_object']; + } + if ($object instanceof Motive) { + return ['motive' => 'normalized']; + } + + return ['normalized']; + } + + public function supportsNormalization($data, ?string $format = null): bool + { + return true; + } + }; + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/SetAddresseesCommandDenormalizerTest.php b/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/SetAddresseesCommandDenormalizerTest.php new file mode 100644 index 000000000..a4b9bbf49 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/SetAddresseesCommandDenormalizerTest.php @@ -0,0 +1,66 @@ +<?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\TicketBundle\Tests\Serializer\Normalizer; + +use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Entity\UserGroup; +use Chill\TicketBundle\Action\Ticket\SetAddresseesCommand; +use Chill\TicketBundle\Serializer\Normalizer\SetAddresseesCommandDenormalizer; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; + +/** + * @internal + * + * @coversNothing + */ +class SetAddresseesCommandDenormalizerTest extends TestCase +{ + use ProphecyTrait; + + public function testSupportsDenormalization() + { + $denormalizer = new SetAddresseesCommandDenormalizer(); + + self::assertTrue($denormalizer->supportsDenormalization('', SetAddresseesCommand::class, 'json')); + self::assertFalse($denormalizer->supportsDenormalization('', stdClass::class, 'json')); + } + + public function testDenormalize() + { + $denormalizer = $this->buildDenormalizer(); + + $actual = $denormalizer->denormalize(['addressees' => [['type' => 'user'], ['type' => 'user_group']]], SetAddresseesCommand::class, 'json'); + + self::assertInstanceOf(SetAddresseesCommand::class, $actual); + self::assertIsArray($actual->addressees); + self::assertCount(2, $actual->addressees); + self::assertInstanceOf(User::class, $actual->addressees[0]); + self::assertInstanceOf(UserGroup::class, $actual->addressees[1]); + } + + private function buildDenormalizer(): SetAddresseesCommandDenormalizer + { + $normalizer = $this->prophesize(DenormalizerInterface::class); + $normalizer->denormalize(Argument::any(), User::class, 'json', Argument::any()) + ->willReturn(new User()); + $normalizer->denormalize(Argument::any(), UserGroup::class, 'json', Argument::any()) + ->willReturn(new UserGroup()); + + $denormalizer = new SetAddresseesCommandDenormalizer(); + $denormalizer->setDenormalizer($normalizer->reveal()); + + return $denormalizer; + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/TicketNormalizerTest.php b/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/TicketNormalizerTest.php new file mode 100644 index 000000000..764182871 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Serializer/Normalizer/TicketNormalizerTest.php @@ -0,0 +1,408 @@ +<?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\TicketBundle\Tests\Serializer\Normalizer; + +use Chill\MainBundle\Entity\User; +use Chill\MainBundle\Entity\UserGroup; +use Chill\PersonBundle\Entity\Person; +use Chill\TicketBundle\Entity\AddresseeHistory; +use Chill\TicketBundle\Entity\CallerHistory; +use Chill\TicketBundle\Entity\Comment; +use Chill\TicketBundle\Entity\EmergencyStatusEnum; +use Chill\TicketBundle\Entity\EmergencyStatusHistory; +use Chill\TicketBundle\Entity\Motive; +use Chill\TicketBundle\Entity\MotiveHistory; +use Chill\TicketBundle\Entity\PersonHistory; +use Chill\TicketBundle\Entity\StateEnum; +use Chill\TicketBundle\Entity\StateHistory; +use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Security\Voter\CommentVoter; +use Chill\TicketBundle\Serializer\Normalizer\TicketNormalizer; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Prophecy\Prophecy\ObjectProphecy; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\Security\Core\Security; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * @internal + * + * @coversNothing + */ +class TicketNormalizerTest extends KernelTestCase +{ + use ProphecyTrait; + + private ObjectProphecy $security; + + protected function setUp(): void + { + $this->security = $this->prophesize(Security::class); + } + + /** + * @dataProvider provideTickets + */ + public function testNormalize(Ticket $ticket, array $expected): void + { + $actual = $this->buildNormalizer()->normalize($ticket, 'json', ['groups' => 'read']); + self::assertEqualsCanonicalizing(array_keys($expected), array_keys($actual)); + + foreach (array_keys($expected) as $k) { + if ('history' === $k) { + continue; + } + self::assertEqualsCanonicalizing($expected[$k], $actual[$k], sprintf("assert the content of the '%s' key", $k)); + } + + self::assertArrayHasKey('history', $actual); + self::assertIsArray($actual['history']); + + foreach ($actual['history'] as $k => $eventType) { + self::assertEquals($expected['history'][$k]['event_type'], $eventType['event_type']); + } + } + + public static function provideTickets(): iterable + { + $t = new Ticket(); + + // added by action + new StateHistory(StateEnum::OPEN, $t, new \DateTimeImmutable('2024-06-16T00:00:00Z')); + + // those are added by doctrine listeners + $t->setCreatedAt(new \DateTimeImmutable('2024-06-16T00:00:00Z')); + $t->setCreatedBy(new User()); + $t->setUpdatedAt(new \DateTimeImmutable('2024-06-16T00:00:00Z')); + $t->setUpdatedBy(new User()); + + yield [ + // this a nearly empty ticket + $t, + [ + 'type' => 'ticket_ticket', + 'type_extended' => 'ticket_ticket:extended', + 'createdAt' => $t->getCreatedAt()?->getTimestamp(), + 'createdBy' => ['user'], + 'id' => null, + 'externalRef' => '', + 'currentPersons' => [], + 'currentAddressees' => [], + 'currentInputs' => [], + 'currentMotive' => null, + 'history' => [ + [ + 'event_type' => 'create_ticket', + ], + [ + 'event_type' => 'state_change', + ], + ], + 'currentState' => 'open', + 'updatedAt' => $t->getUpdatedAt()->getTimestamp(), + 'updatedBy' => ['user'], + 'emergency' => 'no', + 'caller' => null, + ], + ]; + + // ticket with more features + $ticket = new Ticket(); + + // added by action + new StateHistory(StateEnum::OPEN, $ticket, new \DateTimeImmutable('2024-06-16T00:00:00Z')); + new EmergencyStatusHistory(EmergencyStatusEnum::YES, $ticket, new \DateTimeImmutable('2024-06-16T00:00:10Z')); + + // those are added by doctrine listeners + $ticket->setCreatedAt(new \DateTimeImmutable('2024-06-16T00:00:00Z')); + $ticket->setCreatedBy(new User()); + $ticket->setUpdatedAt(new \DateTimeImmutable('2024-06-16T00:00:00Z')); + $ticket->setUpdatedBy(new User()); + $ticket->setExternalRef('2134'); + $personHistory = new PersonHistory(new Person(), $ticket, new \DateTimeImmutable('2024-04-01T12:00:00')); + $ticketHistory = new MotiveHistory(new Motive(), $ticket, new \DateTimeImmutable('2024-04-01T12:02:00')); + $comment = new Comment('blabla test', $ticket); + $comment->setCreatedAt(new \DateTimeImmutable('2024-04-01T12:04:00')); + $comment->setCreatedBy(new User()); + $addresseeHistory = new AddresseeHistory(new User(), new \DateTimeImmutable('2024-04-01T12:05:00'), $ticket); + $addresseeHistory->setEndDate(new \DateTimeImmutable('2024-04-01T12:06:00')); + new AddresseeHistory(new UserGroup(), new \DateTimeImmutable('2024-04-01T12:07:00'), $ticket); + + yield [ + $ticket, + [ + 'type' => 'ticket_ticket', + 'type_extended' => 'ticket_ticket:extended', + 'createdAt' => $ticket->getCreatedAt()?->getTimestamp(), + 'createdBy' => ['user'], + 'id' => null, + 'externalRef' => '2134', + 'currentPersons' => ['embedded'], + 'currentAddressees' => ['embedded'], + 'currentInputs' => [], + 'currentMotive' => ['type' => 'motive', 'id' => 0], + 'history' => [ + ['event_type' => 'add_person'], + ['event_type' => 'persons_state'], + ['event_type' => 'set_motive'], + ['event_type' => 'add_comment'], + ['event_type' => 'addressees_state'], + ['event_type' => 'addressees_state'], + ['event_type' => 'addressees_state'], + ['event_type' => 'create_ticket'], + ['event_type' => 'state_change'], + ['event_type' => 'emergency_change'], + ], + 'currentState' => 'open', + 'updatedAt' => $ticket->getUpdatedAt()->getTimestamp(), + 'updatedBy' => ['user'], + 'emergency' => 'yes', + 'caller' => null, + ], + ]; + + // ticket with caller + $ticket = new Ticket(); + $ticket->setCreatedAt(new \DateTimeImmutable('2024-06-16T00:00:00')); + $ticket->setUpdatedAt(new \DateTimeImmutable('2024-06-16T00:00:00')); + new CallerHistory(new Person(), $ticket, new \DateTimeImmutable('2024-04-01T12:00:00')); + + yield [ + $ticket, + [ + 'type' => 'ticket_ticket', + 'type_extended' => 'ticket_ticket:extended', + 'createdAt' => $ticket->getCreatedAt()?->getTimestamp(), + 'createdBy' => null, + 'id' => null, + 'externalRef' => '', + 'currentPersons' => [], + 'currentAddressees' => [], + 'currentInputs' => [], + 'currentMotive' => null, + 'history' => [ + ['event_type' => 'set_caller'], + ], + 'currentState' => 'open', + 'updatedAt' => $ticket->getUpdatedAt()->getTimestamp(), + 'updatedBy' => null, + 'emergency' => 'no', + 'caller' => ['person'], + ], + ]; + } + + public function testNormalizeTicketWithCommentNotAllowed(): void + { + $ticket = new Ticket(); + $comment = new Comment('Test comment', $ticket); + $comment->setCreatedAt(new \DateTimeImmutable('2024-04-01T12:04:00')); + $comment->setCreatedBy(new User()); + + $this->security->isGranted(CommentVoter::READ, $comment)->willReturn(false)->shouldBeCalled(); + + $actual = $this->buildNormalizer()->normalize($ticket, 'json', ['groups' => ['read']]); + + self::assertEmpty($actual['history']); + } + + public function testNormalizeTicketWithCommentAllowed(): void + { + $ticket = new Ticket(); + $comment = new Comment('Test comment', $ticket); + $comment->setCreatedAt(new \DateTimeImmutable('2024-04-01T12:04:00')); + $comment->setCreatedBy(new User()); + + $this->security->isGranted(CommentVoter::READ, $comment)->willReturn(true)->shouldBeCalled(); + + $actual = $this->buildNormalizer()->normalize($ticket, 'json', ['groups' => ['read']]); + + self::assertCount(1, $actual['history']); + self::assertEquals('add_comment', $actual['history'][0]['event_type']); + } + + public function testNormalizeReadSimple(): void + { + // Create a ticket with some data + $ticket = new Ticket(); + $ticket->setExternalRef('TEST-123'); + + // Add state history + new StateHistory(StateEnum::OPEN, $ticket, new \DateTimeImmutable('2024-06-16T00:00:00Z')); + + // Add emergency status + new EmergencyStatusHistory(EmergencyStatusEnum::YES, $ticket, new \DateTimeImmutable('2024-06-16T00:00:10Z')); + + // Add person + $personHistory = new PersonHistory(new Person(), $ticket, new \DateTimeImmutable('2024-04-01T12:00:00')); + + // Add motive + $motiveHistory = new MotiveHistory(new Motive(), $ticket, new \DateTimeImmutable('2024-04-01T12:02:00')); + + // Add comment + $comment = new Comment('Test comment', $ticket); + $comment->setCreatedAt(new \DateTimeImmutable('2024-04-01T12:04:00')); + $comment->setCreatedBy(new User()); + + // Add addressee + new AddresseeHistory(new User(), new \DateTimeImmutable('2024-04-01T12:05:00'), $ticket); + + // Add caller + new CallerHistory(new Person(), $ticket, new \DateTimeImmutable('2024-04-01T12:00:00')); + + // Set created/updated metadata + $ticket->setCreatedAt(new \DateTimeImmutable('2024-06-16T00:00:00Z')); + $ticket->setCreatedBy(new User()); + $ticket->setUpdatedAt(new \DateTimeImmutable('2024-06-16T00:00:00Z')); + $ticket->setUpdatedBy(new User()); + + // Normalize with read:simple group + $actual = $this->buildNormalizer()->normalize($ticket, 'json', ['groups' => 'read:simple']); + + // Expected keys in read:simple normalization + $expectedKeys = [ + 'type', + 'type_extended', + 'id', + 'externalRef', + 'currentPersons', + 'currentAddressees', + 'currentInputs', + 'currentMotive', + 'currentState', + 'emergency', + 'caller', + 'createdAt', + ]; + + // Keys that should not be present in read:simple normalization + $unexpectedKeys = [ + 'history', + 'updatedAt', + 'updatedBy', + 'createdBy', + ]; + + // Assert that all expected keys are present + foreach ($expectedKeys as $key) { + self::assertArrayHasKey($key, $actual, "Expected key '{$key}' is missing"); + } + + // Assert that none of the unexpected keys are present + foreach ($unexpectedKeys as $key) { + self::assertArrayNotHasKey($key, $actual, "Unexpected key '{$key}' is present"); + } + + // Assert specific values + self::assertEquals('ticket_ticket', $actual['type']); + self::assertEquals('ticket_ticket:simple', $actual['type_extended']); + self::assertEquals('TEST-123', $actual['externalRef']); + self::assertEquals('open', $actual['currentState']); + self::assertEquals('yes', $actual['emergency']); + } + + private function buildNormalizer(): TicketNormalizer + { + $this->security->isGranted(CommentVoter::READ, Argument::type(Comment::class)) + ->willReturn(true); + + + $normalizer = $this->prophesize(NormalizerInterface::class); + + // empty array + $normalizer->normalize( + Argument::that(fn ($arg) => is_array($arg) && 0 === count($arg)), + 'json', + Argument::type('array') + )->willReturn([]); + + // array of mixed objects + $normalizer->normalize( + Argument::that(fn ($arg) => is_array($arg) && 0 < count($arg) && is_object($arg[0] ?? null)), + 'json', + Argument::type('array') + )->will(fn ($args) => array_fill(0, count($args[0]), 'embedded')); + + // array of event type + $normalizer->normalize( + Argument::that(fn ($arg) => is_array($arg) && 0 < count($arg) && is_array($arg[0] ?? null) && array_key_exists('event_type', $arg[0] ?? null)), + 'json', + Argument::type('array') + )->will(function ($args): array { + $events = []; + + foreach ($args[0] as $event) { + $events[] = $event['event_type']; + } + + return $events; + }); + // array of persons + $normalizer->normalize( + Argument::that(fn ($arg) => is_array($arg) && 1 === count($arg) && array_key_exists('persons', $arg)), + 'json', + ['groups' => 'read'] + )->will(fn ($args): array => ['persons' => []]); + // array of addresses + $normalizer->normalize( + Argument::that(fn ($arg) => is_array($arg) && 1 === count($arg) && array_key_exists('addressees', $arg)), + 'json', + ['groups' => 'read'] + )->will(fn ($args): array => ['addressees' => []]); + // state data + $normalizer->normalize( + Argument::that(fn ($arg) => is_array($arg) && 1 === count($arg) && array_key_exists('new_state', $arg)), + 'json', + ['groups' => 'read'] + )->will(fn ($args): array => $args[0]); + $normalizer->normalize( + Argument::that(fn ($arg) => is_array($arg) && 1 === count($arg) && array_key_exists('new_emergency', $arg)), + 'json', + ['groups' => 'read'] + )->will(fn ($args): array => $args[0]); + $normalizer->normalize( + Argument::that(fn ($arg) => is_array($arg) && 1 === count($arg) && array_key_exists('new_caller', $arg)), + 'json', + ['groups' => 'read'] + )->will(fn ($args): array => ['new_caller' => ['dummy']]); + + // datetime + $normalizer->normalize(Argument::type(\DateTimeImmutable::class), 'json', Argument::type('array')) + ->will(fn ($args) => $args[0]->getTimestamp()); + // user + $normalizer->normalize(Argument::type(User::class), 'json', Argument::type('array')) + ->willReturn(['user']); + // person + $normalizer->normalize(Argument::type(Person::class), 'json', Argument::type('array')) + ->willReturn(['person']); + // motive + $normalizer->normalize(Argument::type(Motive::class), 'json', Argument::type('array'))->willReturn(['type' => 'motive', 'id' => 0]); + // person history + $normalizer->normalize(Argument::type(PersonHistory::class), 'json', Argument::type('array')) + ->willReturn(['personHistory']); + // motive history + $normalizer->normalize(Argument::type(MotiveHistory::class), 'json', Argument::type('array')) + ->willReturn(['motiveHistory']); + $normalizer->normalize(Argument::type(Comment::class), 'json', Argument::type('array')) + ->willReturn(['comment']); + $normalizer->normalize(Argument::type(AddresseeHistory::class), 'json', Argument::type('array')) + ->willReturn(['addresseeHistory']); + // null values + $normalizer->normalize(null, 'json', Argument::type('array'))->willReturn(null); + + $ticketNormalizer = new TicketNormalizer($this->security->reveal()); + $ticketNormalizer->setNormalizer($normalizer->reveal()); + + return $ticketNormalizer; + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Service/Import/ImportMotivesFromDirectoryTest.php b/src/Bundle/ChillTicketBundle/tests/Service/Import/ImportMotivesFromDirectoryTest.php new file mode 100644 index 000000000..8939940b7 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Service/Import/ImportMotivesFromDirectoryTest.php @@ -0,0 +1,245 @@ +<?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\TicketBundle\Tests\Service\Import; + +use Chill\DocStoreBundle\Entity\StoredObject; +use Chill\DocStoreBundle\Entity\StoredObjectVersion; +use Chill\DocStoreBundle\Service\StoredObjectManagerInterface; +use Chill\TicketBundle\Entity\Motive; +use Chill\TicketBundle\Repository\MotiveRepository; +use Chill\TicketBundle\Service\Import\ImportMotivesFromDirectory; +use Doctrine\ORM\EntityManagerInterface; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; + +/** + * @internal + * + * @coversNothing + */ +class ImportMotivesFromDirectoryTest extends TestCase +{ + use ProphecyTrait; + + public function testImportSmoke(): void + { + // 1) Prepare temporary directory with a minimal motives.yaml and a small file + $tmpBase = sys_get_temp_dir().DIRECTORY_SEPARATOR.'chill_ticket_import_'.bin2hex(random_bytes(6)); + $this->assertTrue(mkdir($tmpBase, 0777, true)); + + $filePath = $tmpBase.DIRECTORY_SEPARATOR.'file.txt'; + file_put_contents($filePath, 'hello world'); + + $yaml = <<<'YAML' +- label: + fr: "Test Motive" + ordering: 12.5 + urgent: true + supplementary_informations: + - label: + fr: "Some note" + stored_objects: + - label: + fr: "Doc title" + filename: "file.txt" +YAML; + file_put_contents($tmpBase.DIRECTORY_SEPARATOR.'motives.yaml', $yaml); + + // 2) Create mocks (Prophecy) for dependencies + $emProphecy = $this->prophesize(EntityManagerInterface::class); + // persist should be called for StoredObject and Motive at least once + $emProphecy->persist(Argument::type(StoredObject::class))->shouldBeCalled(); + $capturedMotive = null; + $emProphecy->persist(Argument::type(Motive::class)) + ->will(function ($args) use (&$capturedMotive) { + $capturedMotive = $args[0]; + }) + ->shouldBeCalled(); + $emProphecy->flush()->shouldBeCalled(); + + $somMock = $this->createMock(StoredObjectManagerInterface::class); + $somMock + ->expects($this->once()) + ->method('write') + ->willReturnCallback(function (StoredObject $so, string $content, ?string $contentType) { + $this->assertSame('text/plain', $contentType); + + return new StoredObjectVersion($so, 1, [], [], $contentType ?? 'application/octet-stream', 'file.txt'); + }); + + $repoProphecy = $this->prophesize(MotiveRepository::class); + $repoProphecy->findByLabel('Test Motive', 'fr')->willReturn([])->shouldBeCalled(); + + // 3) Run the importer + $importer = new ImportMotivesFromDirectory( + $emProphecy->reveal(), + $somMock, + $repoProphecy->reveal(), + ); + + $importer->import($tmpBase, 'fr'); + + // 4) Cleanup + @unlink($filePath); + @unlink($tmpBase.DIRECTORY_SEPARATOR.'motives.yaml'); + @rmdir($tmpBase); + + // Verify that ordering has been properly set on the created motive + $this->assertInstanceOf(Motive::class, $capturedMotive); + $this->assertSame(12.5, $capturedMotive->getOrdering()); + } + + public function testImportWithParentCreatesAndLinks(): void + { + // 1) Prepare temporary directory with a motives.yaml that declares a parent > child label + $tmpBase = sys_get_temp_dir().DIRECTORY_SEPARATOR.'chill_ticket_import_'.bin2hex(random_bytes(6)); + $this->assertTrue(mkdir($tmpBase, 0777, true)); + + $yaml = <<<'YAML' +- label: + fr: " Parent > Child " + ordering: 7 +YAML; + file_put_contents($tmpBase.DIRECTORY_SEPARATOR.'motives.yaml', $yaml); + + // 2) Create repository prophecy: both child and parent are initially missing + $repoProphecy = $this->prophesize(MotiveRepository::class); + $repoProphecy->findByLabel('Child', 'fr')->willReturn([])->shouldBeCalled(); + $repoProphecy->findByLabel('Parent', 'fr')->willReturn([])->shouldBeCalled(); + + // 3) Capture persisted motives to verify parent/child relation + $persistedMotives = []; + $emProphecy = $this->prophesize(EntityManagerInterface::class); + $emProphecy->persist(Argument::type(Motive::class)) + ->will(function ($args) use (&$persistedMotives) { + $persistedMotives[] = $args[0]; + }) + ->shouldBeCalled(); + $emProphecy->flush()->shouldBeCalled(); + + // StoredObjectManager is unused in this scenario; provide a dummy mock + $somMock = $this->createMock(StoredObjectManagerInterface::class); + + // 4) Run the importer + $importer = new ImportMotivesFromDirectory( + $emProphecy->reveal(), + $somMock, + $repoProphecy->reveal(), + ); + $importer->import($tmpBase, 'fr'); + + // 5) Assertions: we should have at least two motives persisted (parent and child) + $this->assertGreaterThanOrEqual(2, \count($persistedMotives)); + + // Identify child and parent + $child = null; + $parent = null; + foreach ($persistedMotives as $m) { + if (!$m instanceof Motive) { + continue; + } + $label = $m->getLabel(); + $fr = $label['fr'] ?? null; + if ('Child' === $fr) { + $child = $m; + } elseif ('Parent' === $fr) { + $parent = $m; + } + } + + $this->assertInstanceOf(Motive::class, $child, 'Child motive must be created'); + $this->assertInstanceOf(Motive::class, $parent, 'Parent motive must be created'); + $this->assertSame($parent, $child->getParent(), 'Child must reference the created parent'); + $this->assertTrue($parent->isParent(), 'Parent must have at least one child'); + $this->assertSame(7.0, $child->getOrdering(), 'Child ordering must be set from YAML'); + $this->assertSame(0.0, $parent->getOrdering(), 'Parent ordering must remain untouched (default)'); + + // 6) Cleanup + @unlink($tmpBase.DIRECTORY_SEPARATOR.'motives.yaml'); + @rmdir($tmpBase); + } + + public function testImportReusesParentAcrossItems(): void + { + // 1) Prépare un répertoire temporaire avec deux items partageant le même parent + $tmpBase = sys_get_temp_dir().DIRECTORY_SEPARATOR.'chill_ticket_import_'.bin2hex(random_bytes(6)); + $this->assertTrue(mkdir($tmpBase, 0777, true)); + + $yaml = <<<'YAML' +- label: + fr: "Parent > A" + ordering: 1 +- label: + fr: "Parent > B" + ordering: 2 +YAML; + file_put_contents($tmpBase.DIRECTORY_SEPARATOR.'motives.yaml', $yaml); + + // 2) Repository: parent/children absents initialement + $repoProphecy = $this->prophesize(MotiveRepository::class); + $repoProphecy->findByLabel('A', 'fr')->willReturn([])->shouldBeCalledTimes(1); + $repoProphecy->findByLabel('Parent', 'fr')->willReturn([])->shouldBeCalledTimes(1); + $repoProphecy->findByLabel('B', 'fr')->willReturn([])->shouldBeCalledTimes(1); + + // 3) Capture des persist() pour compter les entités créées + $persistedMotives = []; + $emProphecy = $this->prophesize(EntityManagerInterface::class); + $emProphecy->persist(Argument::type(Motive::class)) + ->will(function ($args) use (&$persistedMotives) { + $persistedMotives[] = $args[0]; + }) + ->shouldBeCalled(); + $emProphecy->flush()->shouldBeCalled(); + + // 4) Exécute l'import + $somMock = $this->createMock(StoredObjectManagerInterface::class); + $importer = new ImportMotivesFromDirectory( + $emProphecy->reveal(), + $somMock, + $repoProphecy->reveal(), + ); + $importer->import($tmpBase, 'fr'); + + // 5) Vérifications: un seul parent créé et partagé (déduplication par instance) + $unique = []; + foreach ($persistedMotives as $m) { + if (!$m instanceof Motive) { + continue; + } + $unique[spl_object_hash($m)] = $m; + } + + $parents = []; + $children = []; + foreach ($unique as $m) { + $label = $m->getLabel(); + $fr = $label['fr'] ?? null; + if ('Parent' === $fr) { + $parents[] = $m; + } + if ('A' === $fr || 'B' === $fr) { + $children[] = $m; + } + } + + $this->assertCount(1, $parents, 'Le parent doit être créé une seule fois'); + $this->assertCount(2, $children, 'Les deux enfants doivent être créés'); + $this->assertSame($parents[0], $children[0]->getParent()); + $this->assertSame($parents[0], $children[1]->getParent()); + $this->assertTrue($parents[0]->isParent()); + + // 6) Nettoyage + @unlink($tmpBase.DIRECTORY_SEPARATOR.'motives.yaml'); + @rmdir($tmpBase); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Service/Ticket/SuggestPersonForTicketTest.php b/src/Bundle/ChillTicketBundle/tests/Service/Ticket/SuggestPersonForTicketTest.php new file mode 100644 index 000000000..05a786385 --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Service/Ticket/SuggestPersonForTicketTest.php @@ -0,0 +1,70 @@ +<?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 Service\Ticket; + +use Chill\PersonBundle\Entity\Person; +use Chill\TicketBundle\Entity\CallerHistory; +use Chill\TicketBundle\Entity\Ticket; +use Chill\TicketBundle\Repository\PersonTicketACLAwareRepositoryInterface; +use Chill\TicketBundle\Service\Ticket\SuggestPersonForTicket; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; + +/** + * @internal + * + * @coversNothing + */ +class SuggestPersonForTicketTest extends TestCase +{ + use ProphecyTrait; + + private \Prophecy\Prophecy\ObjectProphecy $repository; + + protected function setUp(): void + { + $this->repository = $this->prophesize(PersonTicketACLAwareRepositoryInterface::class); + } + + private function buildSuggestPersonForTicket(): SuggestPersonForTicket + { + return new SuggestPersonForTicket($this->repository->reveal()); + } + + public function testSuggestPersonNoCaller() + { + $ticket = new Ticket(); + + assert(null === $ticket->getCaller()); + + $this->repository->findPersonPreviouslyAssociatedWithCaller(Argument::any())->shouldNotBeCalled(); + $suggester = $this->buildSuggestPersonForTicket(); + + self::assertEquals([], $suggester->suggestPerson($ticket)); + } + + public function testSuggestPersonCaller() + { + $ticket = new Ticket(); + new CallerHistory($person = new Person(), $ticket); + + assert($person === $ticket->getCaller()); + + $this->repository->findPersonPreviouslyAssociatedWithCaller($person, 0, 20) + ->willReturn($result = [new Person(), new Person()]) + ->shouldBeCalledOnce(); + $suggester = $this->buildSuggestPersonForTicket(); + + self::assertEquals($result, $suggester->suggestPerson($ticket, 0, 20)); + } +} diff --git a/src/Bundle/ChillTicketBundle/tests/Validation/Validator/SetPersonCommandConstraintValidatorTest.php b/src/Bundle/ChillTicketBundle/tests/Validation/Validator/SetPersonCommandConstraintValidatorTest.php new file mode 100644 index 000000000..fd164354a --- /dev/null +++ b/src/Bundle/ChillTicketBundle/tests/Validation/Validator/SetPersonCommandConstraintValidatorTest.php @@ -0,0 +1,90 @@ +<?php + +declare(strict_types=1); + +/* + * Chill is a software for social workers + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Validation\Validator; + +use Chill\PersonBundle\Entity\Person; +use Chill\TicketBundle\Action\Ticket\SetPersonsCommand; +use Chill\TicketBundle\Validation\Validator\SetPersonCommandConstraint; +use Chill\TicketBundle\Validation\Validator\SetPersonCommandConstraintValidator; +use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; +use Symfony\Component\Validator\Test\ConstraintValidatorTestCase; + +/** + * @internal + * + * @coversNothing + */ +class SetPersonCommandConstraintValidatorTest extends ConstraintValidatorTestCase +{ + private string $personPerTicket = 'many'; + + protected function createValidator() + { + return new SetPersonCommandConstraintValidator( + new ParameterBag(['chill_ticket' => ['ticket' => ['person_per_ticket' => $this->personPerTicket]]]) + ); + } + + /** + * Helper method to set the configuration and recreate the validator. + */ + private function setPersonPerTicket(string $personPerTicket): void + { + $this->personPerTicket = $personPerTicket; + $this->validator = $this->createValidator(); + $this->validator->initialize($this->context); + } + + public function testValidatorWithManyPersonsPerTicket(): void + { + $this->setPersonPerTicket('multi'); + + $command = new SetPersonsCommand([new Person(), new Person()]); + + $this->validator->validate($command, new SetPersonCommandConstraint()); + + $this->assertNoViolation(); + } + + public function testValidatorWithSinglePersonPerTicket(): void + { + $this->setPersonPerTicket('single'); + + $command = new SetPersonsCommand([new Person(), new Person()]); + + $this->validator->validate($command, $command = new SetPersonCommandConstraint()); + + $this->buildViolation($command->notMulti)->atPath('property.path.persons')->assertRaised(); + } + + public function testValidatorWithSinglePersonPerTicketAndEmptyPersons(): void + { + $this->setPersonPerTicket('single'); + + $command = new SetPersonsCommand([]); + + $this->validator->validate($command, new SetPersonCommandConstraint()); + + $this->assertNoViolation(); + } + + public function testValidatorWithSinglePersonPerTicketAndOnlyOnePerson(): void + { + $this->setPersonPerTicket('single'); + + $command = new SetPersonsCommand([new Person()]); + + $this->validator->validate($command, new SetPersonCommandConstraint()); + + $this->assertNoViolation(); + } +} diff --git a/src/Bundle/ChillWopiBundle/chill.webpack.config.js b/src/Bundle/ChillWopiBundle/chill.webpack.config.js index 05c231f68..8580597cc 100644 --- a/src/Bundle/ChillWopiBundle/chill.webpack.config.js +++ b/src/Bundle/ChillWopiBundle/chill.webpack.config.js @@ -2,7 +2,7 @@ module.exports = function (encore, entries) { encore.addEntry( "page_wopi_editor", - __dirname + "/src/Resources/public/page/editor/index.js", + __dirname + "/src/Resources/public/page/editor/index.ts", ); encore.addEntry( "mod_reload_page", diff --git a/src/Bundle/ChillWopiBundle/src/Resources/public/module/pending/index.ts b/src/Bundle/ChillWopiBundle/src/Resources/public/module/pending/index.ts index 584fac047..057ea6060 100644 --- a/src/Bundle/ChillWopiBundle/src/Resources/public/module/pending/index.ts +++ b/src/Bundle/ChillWopiBundle/src/Resources/public/module/pending/index.ts @@ -2,39 +2,39 @@ import { is_object_ready } from "ChillDocStoreAssets/vuejs/StoredObjectButton/he import { StoredObject } from "ChillDocStoreAssets/types"; declare global { - interface Window { - stored_object: string | undefined; - } + interface Window { + stored_object: string | undefined; + } } async function reload_if_needed( - stored_object: StoredObject, - i: number, + stored_object: StoredObject, + i: number, ): Promise<void> { - const current_status = await is_object_ready(stored_object); + const current_status = await is_object_ready(stored_object); - if (stored_object.status !== current_status.status) { - window.location.reload(); - } - wait_before_reload(stored_object, i + 1); - return Promise.resolve(); + if (stored_object.status !== current_status.status) { + window.location.reload(); + } + wait_before_reload(stored_object, i + 1); + return Promise.resolve(); } function wait_before_reload(stored_object: StoredObject, i: number): void { - /** - * value of the timeout. Set to 5 sec during the first 10 minutes, then every 1 minute - */ - const timeout = i < 1200 ? 5000 : 60000; + /** + * value of the timeout. Set to 5 sec during the first 10 minutes, then every 1 minute + */ + const timeout = i < 1200 ? 5000 : 60000; - setTimeout(reload_if_needed, timeout, stored_object, i); + setTimeout(reload_if_needed, timeout, stored_object, i); } window.addEventListener("DOMContentLoaded", async function () { - if (undefined === window.stored_object) { - console.error("window.stored_object is undefined"); - throw Error("window.stored_object is undefined"); - } + if (undefined === window.stored_object) { + console.error("window.stored_object is undefined"); + throw Error("window.stored_object is undefined"); + } - const stored_object = JSON.parse(window.stored_object) as StoredObject; - reload_if_needed(stored_object, 0); + const stored_object = JSON.parse(window.stored_object) as StoredObject; + reload_if_needed(stored_object, 0); }); diff --git a/src/Bundle/ChillWopiBundle/src/Resources/public/page/editor/index.js b/src/Bundle/ChillWopiBundle/src/Resources/public/page/editor/index.js deleted file mode 100644 index d946977df..000000000 --- a/src/Bundle/ChillWopiBundle/src/Resources/public/page/editor/index.js +++ /dev/null @@ -1,46 +0,0 @@ -require("./index.scss"); - -window.addEventListener("DOMContentLoaded", function () { - let frameholder = document.getElementById("frameholder"); - let office_frame = document.createElement("iframe"); - office_frame.name = "office_frame"; - office_frame.id = "office_frame"; - - // The title should be set for accessibility - office_frame.title = "Office Frame"; - - // This attribute allows true fullscreen mode in slideshow view - // when using PowerPoint's 'view' action. - office_frame.setAttribute("allowfullscreen", "true"); - - // The sandbox attribute is needed to allow automatic redirection to the O365 sign-in page in the business user flow - office_frame.setAttribute( - "sandbox", - "allow-downloads allow-scripts allow-same-origin allow-forms allow-modals allow-popups allow-top-navigation allow-popups-to-escape-sandbox", - ); - frameholder.appendChild(office_frame); - - document.getElementById("office_form").submit(); - - const url = new URL(editor_url); - const editor_domain = url.origin; - - window.addEventListener("message", function (message) { - if (message.origin !== editor_domain) { - return; - } - - let data = JSON.parse(message.data); - - if ("UI_Close" === data.MessageId) { - closeEditor(); - } - }); -}); - -function closeEditor() { - let params = new URLSearchParams(window.location.search), - returnPath = params.get("returnPath"); - - window.location.assign(returnPath); -} diff --git a/src/Bundle/ChillWopiBundle/src/Resources/public/page/editor/index.ts b/src/Bundle/ChillWopiBundle/src/Resources/public/page/editor/index.ts new file mode 100644 index 000000000..01ddad995 --- /dev/null +++ b/src/Bundle/ChillWopiBundle/src/Resources/public/page/editor/index.ts @@ -0,0 +1,68 @@ +import "./index.scss"; + +// Provided by the server-side template +declare const editor_url: string; + +window.addEventListener("DOMContentLoaded", function () { + const frameholder = document.getElementById("frameholder"); + const office_frame = document.createElement("iframe"); + office_frame.name = "office_frame"; + office_frame.id = "office_frame"; + + // The title should be set for accessibility + office_frame.title = "Office Frame"; + + // This attribute allows true fullscreen mode in slideshow view + // when using PowerPoint's 'view' action. + office_frame.setAttribute("allowfullscreen", "true"); + + // The sandbox attribute is needed to allow automatic redirection to the O365 sign-in page in the business user flow + office_frame.setAttribute( + "sandbox", + "allow-downloads allow-scripts allow-same-origin allow-forms allow-modals allow-popups allow-top-navigation allow-popups-to-escape-sandbox", + ); + + office_frame.setAttribute( + "allow", + "clipboard-read *; clipboard-write *; fullscreen *", + ); + if (frameholder) { + frameholder.appendChild(office_frame); + } + + const officeForm = document.getElementById( + "office_form", + ) as HTMLFormElement | null; + officeForm?.submit(); + + const url = new URL(editor_url); + const editor_domain = url.origin; + + window.addEventListener("message", function (message: MessageEvent) { + if (message.origin !== editor_domain) { + return; + } + + let data: { MessageId: "UI_Close" | null; data: string }; + try { + data = + typeof message.data === "string" + ? JSON.parse(message.data) + : message.data; + } catch (e: unknown) { + console.error("error while parsing data from message UI_CLOSE", e); + return; + } + + if ("UI_Close" === data.MessageId) { + closeEditor(); + } + }); +}); + +function closeEditor(): void { + const params = new URLSearchParams(window.location.search); + const returnPath = params.get("returnPath") ?? "/"; + + window.location.assign(returnPath); +} diff --git a/src/Bundle/ChillWopiBundle/src/translations/messages.nl.yml b/src/Bundle/ChillWopiBundle/src/translations/messages.nl.yml new file mode 100644 index 000000000..410e80dc5 --- /dev/null +++ b/src/Bundle/ChillWopiBundle/src/translations/messages.nl.yml @@ -0,0 +1,2 @@ +wopi_editor: + document unsupported for edition: Dit formaat van document is niet bewerkbaar. diff --git a/src/shims-custom.d.ts b/src/shims-custom.d.ts new file mode 100644 index 000000000..0e24561cc --- /dev/null +++ b/src/shims-custom.d.ts @@ -0,0 +1,3 @@ +declare module "vue-multiselect"; +declare module "ChillMainAssets/vuejs/_components/Entity/UserRenderBoxBadge.vue"; +declare module "ChillMainAssets/vuejs/OnTheFly/components/OnTheFly.vue"; diff --git a/src/shims-vue.d.ts b/src/shims-vue.d.ts index 8989854c7..e236dd9b8 100644 --- a/src/shims-vue.d.ts +++ b/src/shims-vue.d.ts @@ -1,7 +1,7 @@ declare module "*.vue" { - import { defineComponent } from "vue"; + import { defineComponent } from "vue"; - const Component: ReturnType<typeof defineComponent>; + const Component: ReturnType<typeof defineComponent>; - export default Component; + export default Component; } diff --git a/src/vue-multiselect.d.ts b/src/vue-multiselect.d.ts index efce40fdc..a463890b5 100644 --- a/src/vue-multiselect.d.ts +++ b/src/vue-multiselect.d.ts @@ -1,14 +1,14 @@ declare module "vue-multiselect" { - import { defineComponent } from "vue"; + import { defineComponent } from "vue"; - export interface VueMultiselectProps<T> { - options: T; - searchable: boolean; - trackBy: keyof T; - label: keyof T; - } + export interface VueMultiselectProps<T> { + options: T; + searchable: boolean; + trackBy: keyof T; + label: keyof T; + } - const Component: ReturnType<typeof defineComponent<VueMultiselectProps<T>>>; + const Component: ReturnType<typeof defineComponent<VueMultiselectProps<T>>>; - export default Component; + export default Component; } diff --git a/src/vuex.d.ts b/src/vuex.d.ts index 1edfe4fa9..ddb1d87c1 100644 --- a/src/vuex.d.ts +++ b/src/vuex.d.ts @@ -1,6 +1,6 @@ declare module "vuex" { - export * from "vuex/types/index.d.ts"; - export * from "vuex/types/helpers.d.ts"; - export * from "vuex/types/logger.d.ts"; - export * from "vuex/types/vue.d.ts"; + export * from "vuex/types/index.d.ts"; + export * from "vuex/types/helpers.d.ts"; + export * from "vuex/types/logger.d.ts"; + export * from "vuex/types/vue.d.ts"; } diff --git a/symfony.lock b/symfony.lock index b6a307005..55416b31e 100644 --- a/symfony.lock +++ b/symfony.lock @@ -249,6 +249,15 @@ "src/Kernel.php" ] }, + "symfony/loco-translation-provider": { + "version": "6.0", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "5.3", + "ref": "500b568fcdf2de12ac18e157e11820114db89986" + } + }, "symfony/mailer": { "version": "5.4", "recipe": { diff --git a/tests/app/config/routes/chill_ticket.yaml b/tests/app/config/routes/chill_ticket.yaml new file mode 100644 index 000000000..2ee15ef05 --- /dev/null +++ b/tests/app/config/routes/chill_ticket.yaml @@ -0,0 +1,3 @@ +chill_ticket_app: + resource: '@ChillTicketBundle/Controller/' + type: annotation \ No newline at end of file diff --git a/ts-config-base.json b/ts-config-base.json index 646ae0597..7260a798d 100644 --- a/ts-config-base.json +++ b/ts-config-base.json @@ -19,6 +19,7 @@ "sourceMap": true }, "includes": [ + "./assets/**/*.ts", "./src/**/*.ts", "./src/**/*.vue" ], diff --git a/tsconfig.json b/tsconfig.json index aaf111406..fc842b68e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,13 @@ "paths": { "translator": ["./assets/translator.ts"], "ChillMainAssets/*": ["./src/Bundle/ChillMainBundle/Resources/public/*"], - "ChillDocStoreAssets/*": ["./src/Bundle/ChillDocStoreBundle/Resources/public/*"] + "ChillDocStoreAssets/*": ["./src/Bundle/ChillDocStoreBundle/Resources/public/*"], + "ChillPersonAssets/*": ["./src/Bundle/ChillPersonBundle/Resources/public/*"] } }, + "include": [ + "src/**/*.ts", + "src/**/*.d.ts", + "src/**/*.vue" + ] } diff --git a/webpack.config.js b/webpack.config.js index d3a9b40b8..27a77ad45 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -104,7 +104,7 @@ module.exports = (async () => { await populateConfig(Encore, chillEntries); Encore.addAliases({ - translator: resolve(__dirname, './assets/translator'), + translator: resolve(__dirname, 'assets/translator.ts'), "@symfony/ux-translator": resolve(__dirname, './vendor/symfony/ux-translator/assets'), });