mirror of
				https://gitlab.com/Chill-Projet/chill-bundles.git
				synced 2025-10-31 09:18:24 +00:00 
			
		
		
		
	Compare commits
	
		
			43 Commits
		
	
	
		
			v4.2.1
			...
			#361-impro
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| caaed3e759 | |||
| 6380fdd9a4 | |||
| fcd5080e6f | |||
| 086d418aa3 | |||
| 4e61821e5b | |||
| e3aeab315f | |||
| 8ec1063ef8 | |||
| aad9c984b1 | |||
| 34b3e290e1 | |||
| 0987b575ab | |||
| d960578c5f | |||
| 176048bce6 | |||
| be210a6dd6 | |||
| 4323773595 | |||
| 6d432ca2cb | |||
| c2d9c73fd4 | |||
| 0d6d15fcf7 | |||
| f9ad96c78b | |||
| fcc9529a20 | |||
| 955cb817c4 | |||
| 823f9546b9 | |||
| be39fa16e7 | |||
| c8bb7575e7 | |||
|  | 80a3734171 | ||
| ab98f3a102 | |||
| 7516e68d77 | |||
| 7b60b7a8af | |||
| d984dec7db | |||
| 46a4dedab8 | |||
| db98519e65 | |||
| c39637180a | |||
| 15f9409bc8 | |||
| 5b90d23367 | |||
| c48625d1cd | |||
| 1195b54a68 | |||
| 2a280b814f | |||
| 230c758255 | |||
| eafda987ae | |||
| 7db8a371fc | |||
| 0d0649dd31 | |||
| ac12b8cdcf | |||
| 9c1611d052 | |||
| 90e3043c3d | 
							
								
								
									
										10
									
								
								.changes/v4.3.0.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								.changes/v4.3.0.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| ## v4.3.0 - 2025-09-08 | ||||
| ### Feature | ||||
| * ([#409](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/409)) Add 45 and 60 min calendar ranges    | ||||
| * Add a command to generate a list of permissions    | ||||
| * ([#412](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/412)) Add an absence end date    | ||||
|  | ||||
|   **Schema Change**: Add columns or tables | ||||
| ### Fixed | ||||
| * fix date formatting in calendar range display    | ||||
| * Change route URL to avoid clash with person duplicate controller method    | ||||
							
								
								
									
										8
									
								
								.changes/v4.4.0.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								.changes/v4.4.0.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| ## v4.4.0 - 2025-09-11 | ||||
| ### Feature | ||||
| * ([#359](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/359)) Allow the merge of two accompanying period works    | ||||
| * ([#369](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/369)) Duplication of a document to another accompanying period work evaluation    | ||||
| * ([#359](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/359)) Fusion of two accompanying period works    | ||||
| ### Fixed | ||||
| * Fix display of 'duplicate' and 'merge' buttons in CRUD templates    | ||||
| * Fix saving notification preferences in user's profile    | ||||
							
								
								
									
										3
									
								
								.changes/v4.4.1.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								.changes/v4.4.1.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| ## v4.4.1 - 2025-09-11 | ||||
| ### Fixed | ||||
| * fix translations in duplicate evaluation document modal and realign close modal button    | ||||
							
								
								
									
										3
									
								
								.changes/v4.4.2.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								.changes/v4.4.2.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| ## v4.4.2 - 2025-09-12 | ||||
| ### Fixed | ||||
| * Fix document generation and workflow generation do not work on accompanying period work documents    | ||||
							
								
								
									
										13
									
								
								.changes/v4.5.0.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								.changes/v4.5.0.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| ## v4.5.0 - 2025-10-03 | ||||
| ### Feature | ||||
| * Only allow delete of attachment on workflows that are not final    | ||||
| * Move up signature buttons on index workflow page for easier access    | ||||
| * Filter out document from attachment list if it is the same as the workflow document    | ||||
| * Block edition on attached document on workflow, if the workflow is finalized or sent external    | ||||
| * Convert workflow's attached document to pdf while sending them external    | ||||
| * After a signature is canceled or rejected, going to a waiting page until the post-process routines apply a workflow transition    | ||||
| ### Fixed | ||||
| * ([#426](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/426)) Increased the number of required characters when setting a new password in Chill from 9 to 14 - GDPR compliance    | ||||
| * Fix permissions on storedObject which are subject by a workflow    | ||||
| ### DX | ||||
| * Introduce a WaitingScreen component to display a waiting screen    | ||||
							
								
								
									
										4
									
								
								.changes/v4.5.1.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								.changes/v4.5.1.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| ## v4.5.1 - 2025-10-03 | ||||
| ### Fixed | ||||
| * Add missing javascript dependency    | ||||
| * Add exception handling for conversion of attachment on sending external, when documens are already in pdf    | ||||
| @@ -27,11 +27,11 @@ Chill is a comprehensive web application built as a set of Symfony bundles. It i | ||||
|  | ||||
| ## Project Structure | ||||
|  | ||||
| Note: This is a project which exists from a long time ago, and we found multiple structure inside each bundle. When having the choice, the developers should choose the new structure. | ||||
| Note: This is a project that's existed for a long time, and throughout the years we've used multiple structures inside each bundle. When having the choice, the developers should choose the new structure. | ||||
|  | ||||
| The project follows a standard Symfony bundle structure: | ||||
| - `/src/Bundle/`: Contains all the Chill bundles. The code is either at the root of the bundle directory, or within a `src/` directory (preferred). See psr4 mapping at the root's `composer.json`. | ||||
| - each bundle come with his own tests, either in the `Tests` directory (when the code is directly within the bundle directory (for instance `src/Bundle/ChillMainBundle/Tests`, `src/Bundle/ChillPersonBundle/Tests`)), or inside the `tests` directory, alongside to the `src/` sub-directory (example: `src/Bundle/ChillWopiBundle/tests`) (this is the preferred way). | ||||
| - each bundle comes with its own tests, either in the `Tests` directory (when the code is directly within the bundle directory (for instance `src/Bundle/ChillMainBundle/Tests`, `src/Bundle/ChillPersonBundle/Tests`)), or inside the `tests` directory, alongside the `src/` sub-directory (example: `src/Bundle/ChillWopiBundle/tests`) (this is the preferred way). | ||||
| - `/docs/`: Contains project documentation | ||||
|  | ||||
| Each bundle typically has the following structure: | ||||
| @@ -46,13 +46,13 @@ Each bundle typically has the following structure: | ||||
|  | ||||
| ### A special word about TicketBundle | ||||
|  | ||||
| The ticket bundle is developed using a kind of "Command" pattern. The controller fill a "Command", and a "CommandHandler" handle this command. They are savec in the `src/Bundle/ChillTicketBundle/src/Action` directory. | ||||
| The ticket bundle is developed using a kind of "Command" pattern. The controller fills a "Command," and a "CommandHandler" handles this command. They are saved in the `src/Bundle/ChillTicketBundle/src/Action` directory. | ||||
|  | ||||
| ## Development Guidelines | ||||
|  | ||||
| ### Building and Configuration Instructions | ||||
|  | ||||
| All the command should be run through the `symfony` command, which will configure the required variables. | ||||
| All the commands should be run through the `symfony` command, which will configure the required variables. | ||||
|  | ||||
| For assets, we must ensure that we use node at version `^20.0.0`. This is done using `nvm use 20`. | ||||
|  | ||||
| @@ -87,7 +87,7 @@ For assets, we must ensure that we use node at version `^20.0.0`. This is done u | ||||
|    docker compose up -d | ||||
|    ``` | ||||
|  | ||||
| 5. **Set Up the Database**: | ||||
| 6. **Set Up the Database**: | ||||
|    ```bash | ||||
|    # Create the database | ||||
|    symfony console doctrine:database:create | ||||
| @@ -99,20 +99,20 @@ For assets, we must ensure that we use node at version `^20.0.0`. This is done u | ||||
|    symfony console doctrine:fixtures:load | ||||
|    ``` | ||||
|  | ||||
| 6. **Build Assets**: | ||||
| 7. **Build Assets**: | ||||
|    ```bash | ||||
|    nvm use 20 | ||||
|    yarn run encore dev | ||||
|    ``` | ||||
|  | ||||
| 7. **Start the Development Server**: | ||||
| 8. **Start the Development Server**: | ||||
|    ```bash | ||||
|    symfony server:start -d | ||||
|    ``` | ||||
|  | ||||
| #### Docker Setup | ||||
|  | ||||
| The project includes Docker configuration for easier development: | ||||
| The project includes a Docker configuration for easier development: | ||||
|  | ||||
| 1. **Start Docker Services**: | ||||
|    ```bash | ||||
| @@ -153,9 +153,9 @@ Key configuration files: | ||||
|  | ||||
| Each time a doctrine entity is created, we generate migration to adapt the database. | ||||
|  | ||||
| The migration are created using the command `symfony console doctrine:migrations:diff --no-interaction --namespace <namespace>`, where the namespace is the relevant namespace for migration. As this is a bash script, do not forget to quote the `\` (`\` must become `\\` in your command). | ||||
| The migration is created using the command `symfony console doctrine:migrations:diff --no-interaction --namespace <namespace>`, where the namespace is the relevant namespace for migration. As this is a bash script, remember to quote the `\` (`\` must become `\\` in your command). | ||||
|  | ||||
| Each bundle has his own namespace for migration (always ask me to confirm that command, with a list of updated / created entities so that I can confirm you that it is ok): | ||||
| Each bundle has his own namespace for migration (always ask me to confirm that command with a list of updated / created entities so that I can confirm to you that it is ok): | ||||
|  | ||||
| - `Chill\Bundle\ActivityBundle` writes migrations to `Chill\Migrations\Activity`; | ||||
| - `Chill\Bundle\BudgetBundle` writes migrations to `Chill\Migrations\Budget`; | ||||
| @@ -183,7 +183,7 @@ Once created the, comment's classes should be removed and a description of the c | ||||
|  | ||||
| When we need to use a DateTime or DateTimeImmutable that need to express "now", we prefer the usage of | ||||
| `Symfony\Component\Clock\ClockInterface`, where possible. This is usually not possible in doctrine entities, | ||||
| where injection does not work when restoring an entity from database, but usually possible in services. | ||||
| where injection does not work when restoring an entity from a database, but usually possible in services. | ||||
|  | ||||
| In test, we use `\Symfony\Component\Clock\MockClock` which is an implementation of `Symfony\Component\Clock\ClockInterface` | ||||
| where we have full and easy control of the date. | ||||
| @@ -198,9 +198,9 @@ The project uses PHPUnit for testing. Each bundle has its own test suite, and th | ||||
|  | ||||
| For creating mock, we prefer using prophecy (library phpspec/prophecy). | ||||
|  | ||||
| ##### Useful helpers and tips that avoid create a mock | ||||
| ##### Useful helpers and tips that avoid creating a mock | ||||
|  | ||||
| Some notable implementations that are tests helper, and avoid to create a mock: | ||||
| Some notable implementations that are test helpers and avoid creating a mock: | ||||
|  | ||||
| - `\Psr\Log\NullLogger`, an implementation of `\Psr\Log\LoggerInterface`; | ||||
| - `\Symfony\Component\Clock\MockClock`, an implementation of `Symfony\Component\Clock\ClockInterface` (already mentioned above); | ||||
| @@ -297,7 +297,7 @@ class TicketTest extends TestCase | ||||
|  | ||||
| #### Test Database | ||||
|  | ||||
| For tests that require a database, the project uses postgresql database filled by fixtures (usage of doctrine-fixtures). You can configure a different database for testing in the `.env.test` file. | ||||
| For tests that require a database, the project uses a postgresql database filled with fixtures (usage of doctrine-fixtures). You can configure a different database for testing in the `.env.test` file. | ||||
|  | ||||
| ### Code Quality Tools | ||||
|  | ||||
|   | ||||
							
								
								
									
										47
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						
									
										47
									
								
								CHANGELOG.md
									
									
									
									
									
								
							| @@ -6,6 +6,53 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html), | ||||
| and is generated by [Changie](https://github.com/miniscruff/changie). | ||||
|  | ||||
|  | ||||
| ## v4.5.1 - 2025-10-03 | ||||
| ### Fixed | ||||
| * Add missing javascript dependency    | ||||
| * Add exception handling for conversion of attachment on sending external, when documens are already in pdf    | ||||
|  | ||||
| ## v4.5.0 - 2025-10-03 | ||||
| ### Feature | ||||
| * Only allow delete of attachment on workflows that are not final    | ||||
| * Move up signature buttons on index workflow page for easier access    | ||||
| * Filter out document from attachment list if it is the same as the workflow document    | ||||
| * Block edition on attached document on workflow, if the workflow is finalized or sent external    | ||||
| * Convert workflow's attached document to pdf while sending them external    | ||||
| * After a signature is canceled or rejected, going to a waiting page until the post-process routines apply a workflow transition    | ||||
| ### Fixed | ||||
| * ([#426](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/426)) Increased the number of required characters when setting a new password in Chill from 9 to 14 - GDPR compliance    | ||||
| * Fix permissions on storedObject which are subject by a workflow    | ||||
| ### DX | ||||
| * Introduce a WaitingScreen component to display a waiting screen    | ||||
|  | ||||
| ## v4.4.2 - 2025-09-12 | ||||
| ### Fixed | ||||
| * Fix document generation and workflow generation do not work on accompanying period work documents    | ||||
|  | ||||
| ## v4.4.1 - 2025-09-11 | ||||
| ### Fixed | ||||
| * fix translations in duplicate evaluation document modal and realign close modal button    | ||||
|  | ||||
| ## v4.4.0 - 2025-09-11 | ||||
| ### Feature | ||||
| * ([#359](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/359)) Allow the merge of two accompanying period works    | ||||
| * ([#369](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/369)) Duplication of a document to another accompanying period work evaluation    | ||||
| * ([#359](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/359)) Fusion of two accompanying period works    | ||||
| ### Fixed | ||||
| * Fix display of 'duplicate' and 'merge' buttons in CRUD templates    | ||||
| * Fix saving notification preferences in user's profile    | ||||
|  | ||||
| ## v4.3.0 - 2025-09-08 | ||||
| ### Feature | ||||
| * ([#409](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/409)) Add 45 and 60 min calendar ranges    | ||||
| * Add a command to generate a list of permissions    | ||||
| * ([#412](https://gitlab.com/Chill-Projet/chill-bundles/-/issues/412)) Add an absence end date    | ||||
|  | ||||
|   **Schema Change**: Add columns or tables | ||||
| ### Fixed | ||||
| * fix date formatting in calendar range display    | ||||
| * Change route URL to avoid clash with person duplicate controller method    | ||||
|  | ||||
| ## v4.2.1 - 2025-09-03 | ||||
| ### Fixed | ||||
| * Fix exports to work with DirectExportInterface    | ||||
|   | ||||
| @@ -17,3 +17,9 @@ when@dev: | ||||
|         defaults: | ||||
|             template: '@ChillMain/Dev/dev.assets.test2.html.twig' | ||||
|  | ||||
|  | ||||
|     sass_address_picker: | ||||
|         path: /_dev/address-picker | ||||
|         controller: Symfony\Bundle\FrameworkBundle\Controller\TemplateController | ||||
|         defaults: | ||||
|             template: '@ChillMain/Dev/dev.address-picker.html.twig' | ||||
|   | ||||
| @@ -45,6 +45,7 @@ | ||||
|     "webpack-cli": "^5.0.1" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "@fragaria/address-formatter": "^6.6.1", | ||||
|     "@fullcalendar/core": "^6.1.4", | ||||
|     "@fullcalendar/daygrid": "^6.1.4", | ||||
|     "@fullcalendar/interaction": "^6.1.4", | ||||
| @@ -55,6 +56,7 @@ | ||||
|     "@tsconfig/node20": "^20.1.4", | ||||
|     "@types/dompurify": "^3.0.5", | ||||
|     "@types/leaflet": "^1.9.3", | ||||
|     "@vueuse/core": "^13.9.0", | ||||
|     "bootstrap-icons": "^1.11.3", | ||||
|     "dropzone": "^5.7.6", | ||||
|     "es6-promise": "^4.2.8", | ||||
| @@ -65,10 +67,12 @@ | ||||
|     "mime": "^4.0.0", | ||||
|     "pdfjs-dist": "^4.3.136", | ||||
|     "vis-network": "^9.1.0", | ||||
|     "vue": "^3.5.6", | ||||
|     "vue": "^3.5.x", | ||||
|     "vue-i18n": "^9.1.6", | ||||
|     "vue-multiselect": "3.0.0-alpha.2", | ||||
|     "vue-toast-notification": "^3.1.2", | ||||
|     "vue-tsc": "^3.1.0", | ||||
|     "vue-use-leaflet": "^0.1.7", | ||||
|     "vuex": "^4.0.0" | ||||
|   }, | ||||
|   "browserslist": [ | ||||
|   | ||||
| @@ -55,5 +55,6 @@ | ||||
| 			</dl> | ||||
|  | ||||
| 		{% endblock %} | ||||
|         {% block content_view_actions_duplicate_link %}{% endblock %} | ||||
| 	{% endembed %} | ||||
| {% endblock %} | ||||
|   | ||||
| @@ -70,6 +70,8 @@ | ||||
|                         <option value="00:10:00">10 minutes</option> | ||||
|                         <option value="00:15:00">15 minutes</option> | ||||
|                         <option value="00:30:00">30 minutes</option> | ||||
|                         <option value="00:45:00">45 minutes</option> | ||||
|                         <option value="00:60:00">60 minutes</option> | ||||
|                     </select> | ||||
|                     <label class="input-group-text" for="slotMinTime">De</label> | ||||
|                     <select | ||||
|   | ||||
| @@ -32,6 +32,8 @@ | ||||
|                     <option value="00:10:00">10 minutes</option> | ||||
|                     <option value="00:15:00">15 minutes</option> | ||||
|                     <option value="00:30:00">30 minutes</option> | ||||
|                     <option value="00:45:00">45 minutes</option> | ||||
|                     <option value="00:60:00">60 minutes</option> | ||||
|                 </select> | ||||
|                 <label class="input-group-text" for="slotMinTime">De</label> | ||||
|                 <select | ||||
| @@ -102,7 +104,8 @@ | ||||
|                     event.title | ||||
|                 }}</b> | ||||
|                 <b v-else-if="event.extendedProps.is === 'range'" | ||||
|                     >{{ formatDate(event.startStr) }} - | ||||
|                     >{{ formatDate(event.startStr, "time") }} - | ||||
|                     {{ formatDate(event.endStr, "time") }}: | ||||
|                     {{ event.extendedProps.locationName }}</b | ||||
|                 > | ||||
|                 <b v-else-if="event.extendedProps.is === 'local'">{{ | ||||
| @@ -294,9 +297,26 @@ const nextWeeks = computed((): Weeks[] => | ||||
|     }), | ||||
| ); | ||||
|  | ||||
| const formatDate = (datetime: string) => { | ||||
|     console.log(typeof datetime); | ||||
|     return ISOToDate(datetime); | ||||
| const formatDate = (datetime: string, format: null | "time" = null) => { | ||||
|     const date = ISOToDate(datetime); | ||||
|     if (!date) return ""; | ||||
|  | ||||
|     if (format === "time") { | ||||
|         return date.toLocaleTimeString("fr-FR", { | ||||
|             hour: "2-digit", | ||||
|             minute: "2-digit", | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     // French date formatting | ||||
|     return date.toLocaleDateString("fr-FR", { | ||||
|         weekday: "short", | ||||
|         year: "numeric", | ||||
|         month: "short", | ||||
|         day: "numeric", | ||||
|         hour: "2-digit", | ||||
|         minute: "2-digit", | ||||
|     }); | ||||
| }; | ||||
|  | ||||
| const baseOptions = ref<CalendarOptions>({ | ||||
|   | ||||
| @@ -0,0 +1,20 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\DocStoreBundle\Exception; | ||||
|  | ||||
| class ConversionWithSameMimeTypeException extends \RuntimeException | ||||
| { | ||||
|     public function __construct(string $mimeType, ?\Throwable $previous = null) | ||||
|     { | ||||
|         parent::__construct("Conversion to same MIME type '{$mimeType}' is not allowed: already at the same MIME type", 0, $previous); | ||||
|     } | ||||
| } | ||||
| @@ -25,7 +25,7 @@ export interface GenericDoc { | ||||
|     type: "doc_store_generic_doc"; | ||||
|     uniqueKey: string; | ||||
|     key: string; | ||||
|     identifiers: object; | ||||
|     identifiers: { id: number }; | ||||
|     context: "person" | "accompanying-period"; | ||||
|     doc_date: DateTime; | ||||
|     metadata: GenericDocMetadata; | ||||
|   | ||||
| @@ -4,6 +4,7 @@ import { StoredObject, StoredObjectVersion } from "../../types"; | ||||
| import DropFileWidget from "ChillDocStoreAssets/vuejs/DropFileWidget/DropFileWidget.vue"; | ||||
| import { computed, reactive } from "vue"; | ||||
| import { useToast } from "vue-toast-notification"; | ||||
| import { DOCUMENT_ADD, trans } from "translator"; | ||||
|  | ||||
| interface DropFileConfig { | ||||
|     allowRemove: boolean; | ||||
| @@ -75,11 +76,9 @@ function closeModal(): void { | ||||
|         @click="openModal" | ||||
|         class="btn btn-create" | ||||
|     > | ||||
|         Ajouter un document | ||||
|     </button> | ||||
|     <button v-else @click="openModal" class="btn btn-edit"> | ||||
|         Remplacer le document | ||||
|         {{ trans(DOCUMENT_ADD) }} | ||||
|     </button> | ||||
|     <button v-else @click="openModal" class="btn btn-edit"></button> | ||||
|     <modal | ||||
|         v-if="state.showModal" | ||||
|         :modal-dialog-class="modalClasses" | ||||
|   | ||||
| @@ -46,6 +46,16 @@ abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface | ||||
|  | ||||
|     public function voteOnAttribute(StoredObjectRoleEnum $attribute, StoredObject $subject, TokenInterface $token): bool | ||||
|     { | ||||
|         // we first try to get the permission from the workflow, as attachement (this is the less intensive query) | ||||
|         $workflowPermissionAsAttachment = match ($attribute) { | ||||
|             StoredObjectRoleEnum::SEE => $this->workflowDocumentService->isAllowedByWorkflowForReadOperation($subject), | ||||
|             StoredObjectRoleEnum::EDIT => $this->workflowDocumentService->isAllowedByWorkflowForWriteOperation($subject), | ||||
|         }; | ||||
|  | ||||
|         if (WorkflowRelatedEntityPermissionHelper::FORCE_DENIED === $workflowPermissionAsAttachment) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         // Retrieve the related entity | ||||
|         $entity = $this->getRepository()->findAssociatedEntityToStoredObject($subject); | ||||
|  | ||||
| @@ -66,7 +76,7 @@ abstract class AbstractStoredObjectVoter implements StoredObjectVoterInterface | ||||
|         return match ($workflowPermission) { | ||||
|             WorkflowRelatedEntityPermissionHelper::FORCE_GRANT => true, | ||||
|             WorkflowRelatedEntityPermissionHelper::FORCE_DENIED => false, | ||||
|             WorkflowRelatedEntityPermissionHelper::ABSTAIN => $regularPermission, | ||||
|             WorkflowRelatedEntityPermissionHelper::ABSTAIN => WorkflowRelatedEntityPermissionHelper::FORCE_GRANT === $workflowPermissionAsAttachment || $regularPermission, | ||||
|         }; | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -14,6 +14,12 @@ namespace Chill\DocStoreBundle\Security\Authorization; | ||||
| use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; | ||||
|  | ||||
| /** | ||||
|  * Interface for voting on stored object permissions. | ||||
|  * | ||||
|  * Each time a stored object is attached to a document, the voter is responsible for determining | ||||
|  * whether the user has the necessary permissions to access or modify the stored object. | ||||
|  */ | ||||
| interface StoredObjectVoterInterface | ||||
| { | ||||
|     public function supports(StoredObjectRoleEnum $attribute, StoredObject $subject): bool; | ||||
|   | ||||
| @@ -15,6 +15,7 @@ use Chill\DocStoreBundle\Entity\StoredObject; | ||||
| use Chill\DocStoreBundle\Entity\StoredObjectPointInTime; | ||||
| use Chill\DocStoreBundle\Entity\StoredObjectPointInTimeReasonEnum; | ||||
| use Chill\DocStoreBundle\Entity\StoredObjectVersion; | ||||
| use Chill\DocStoreBundle\Exception\ConversionWithSameMimeTypeException; | ||||
| use Chill\DocStoreBundle\Exception\StoredObjectManagerException; | ||||
| use Chill\WopiBundle\Service\WopiConverter; | ||||
| use Symfony\Component\Mime\MimeTypesInterface; | ||||
| @@ -41,9 +42,10 @@ class StoredObjectToPdfConverter | ||||
|      * | ||||
|      * @return array{0: StoredObjectPointInTime, 1: StoredObjectVersion, 2?: string} contains the point in time before conversion and the new version of the stored object. The converted content is included in the response if $includeConvertedContent is true | ||||
|      * | ||||
|      * @throws \UnexpectedValueException    if the preferred mime type for the conversion is not found | ||||
|      * @throws \RuntimeException            if the conversion or storage of the new version fails | ||||
|      * @throws \UnexpectedValueException           if the preferred mime type for the conversion is not found | ||||
|      * @throws \RuntimeException                   if the conversion or storage of the new version fails | ||||
|      * @throws StoredObjectManagerException | ||||
|      * @throws ConversionWithSameMimeTypeException if the document has already the same mime type79* | ||||
|      */ | ||||
|     public function addConvertedVersion(StoredObject $storedObject, string $lang, $convertTo = 'pdf', bool $includeConvertedContent = false): array | ||||
|     { | ||||
| @@ -56,7 +58,7 @@ class StoredObjectToPdfConverter | ||||
|         $currentVersion = $storedObject->getCurrentVersion(); | ||||
|  | ||||
|         if ($currentVersion->getType() === $newMimeType) { | ||||
|             throw new \UnexpectedValueException('Already at the same mime type'); | ||||
|             throw new ConversionWithSameMimeTypeException($newMimeType); | ||||
|         } | ||||
|  | ||||
|         $content = $this->storedObjectManager->read($currentVersion); | ||||
|   | ||||
| @@ -86,9 +86,165 @@ class AbstractStoredObjectVoterTest extends TestCase | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @dataProvider dataProviderVoteOnAttribute | ||||
|      * @dataProvider dataProviderVoteOnAttributeWithStoredObjectPermission | ||||
|      */ | ||||
|     public function testVoteOnAttribute( | ||||
|     public function testVoteOnAttributeWithStoredObjectPermission( | ||||
|         StoredObjectRoleEnum $attribute, | ||||
|         bool $expected, | ||||
|         bool $isGrantedRegularPermission, | ||||
|         string $isGrantedWorkflowPermission, | ||||
|         string $isGrantedStoredObjectAttachment, | ||||
|     ): void { | ||||
|         $storedObject = new StoredObject(); | ||||
|         $repository = new DummyRepository($related = new \stdClass()); | ||||
|         $token = new UsernamePasswordToken(new User(), 'dummy'); | ||||
|  | ||||
|         $security = $this->prophesize(Security::class); | ||||
|         $security->isGranted('SOME_ROLE', $related)->willReturn($isGrantedRegularPermission); | ||||
|  | ||||
|         $workflowRelatedEntityPermissionHelper = $this->prophesize(WorkflowRelatedEntityPermissionHelper::class); | ||||
|  | ||||
|         $security = $this->prophesize(Security::class); | ||||
|         $security->isGranted('SOME_ROLE', $related)->willReturn($isGrantedRegularPermission); | ||||
|  | ||||
|         if (StoredObjectRoleEnum::SEE === $attribute) { | ||||
|             $workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($storedObject) | ||||
|                 ->shouldBeCalled() | ||||
|                 ->willReturn($isGrantedStoredObjectAttachment); | ||||
|             $workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($related) | ||||
|                 ->willReturn($isGrantedWorkflowPermission); | ||||
|         } elseif (StoredObjectRoleEnum::EDIT === $attribute) { | ||||
|             $workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForWriteOperation($storedObject) | ||||
|                 ->shouldBeCalled() | ||||
|                 ->willReturn($isGrantedStoredObjectAttachment); | ||||
|             $workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForWriteOperation($related) | ||||
|                 ->willReturn($isGrantedWorkflowPermission); | ||||
|         } else { | ||||
|             throw new \LogicException('Invalid attribute for StoredObjectVoter'); | ||||
|         } | ||||
|  | ||||
|         $storedObjectVoter = new class ($repository, $workflowRelatedEntityPermissionHelper->reveal(), $security->reveal()) extends AbstractStoredObjectVoter { | ||||
|             public function __construct(private $repository, $helper, $security) | ||||
|             { | ||||
|                 parent::__construct($security, $helper); | ||||
|             } | ||||
|  | ||||
|             protected function getRepository(): AssociatedEntityToStoredObjectInterface | ||||
|             { | ||||
|                 return $this->repository; | ||||
|             } | ||||
|  | ||||
|             protected function getClass(): string | ||||
|             { | ||||
|                 return \stdClass::class; | ||||
|             } | ||||
|  | ||||
|             protected function attributeToRole(StoredObjectRoleEnum $attribute): string | ||||
|             { | ||||
|                 return 'SOME_ROLE'; | ||||
|             } | ||||
|  | ||||
|             protected function canBeAssociatedWithWorkflow(): bool | ||||
|             { | ||||
|                 return true; | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         $actual = $storedObjectVoter->voteOnAttribute($attribute, $storedObject, $token); | ||||
|  | ||||
|         self::assertEquals($expected, $actual); | ||||
|     } | ||||
|  | ||||
|     public static function dataProviderVoteOnAttributeWithStoredObjectPermission(): iterable | ||||
|     { | ||||
|         foreach (['read' => StoredObjectRoleEnum::SEE, 'write' => StoredObjectRoleEnum::EDIT] as $action => $attribute) { | ||||
|             yield 'Not related to any workflow nor attachment ('.$action.')' => [ | ||||
|                 $attribute, | ||||
|                 true, | ||||
|                 true, | ||||
|                 WorkflowRelatedEntityPermissionHelper::ABSTAIN, | ||||
|                 WorkflowRelatedEntityPermissionHelper::ABSTAIN, | ||||
|             ]; | ||||
|  | ||||
|             yield 'Not related to any workflow nor attachment (refuse) ('.$action.')' => [ | ||||
|                 $attribute, | ||||
|                 false, | ||||
|                 false, | ||||
|                 WorkflowRelatedEntityPermissionHelper::ABSTAIN, | ||||
|                 WorkflowRelatedEntityPermissionHelper::ABSTAIN, | ||||
|             ]; | ||||
|  | ||||
|             yield 'Is granted by a workflow takes precedence (workflow) ('.$action.')' => [ | ||||
|                 $attribute, | ||||
|                 false, | ||||
|                 true, | ||||
|                 WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, | ||||
|                 WorkflowRelatedEntityPermissionHelper::ABSTAIN, | ||||
|             ]; | ||||
|  | ||||
|             yield 'Is granted by a workflow takes precedence (stored object) ('.$action.')' => [ | ||||
|                 $attribute, | ||||
|                 false, | ||||
|                 true, | ||||
|                 WorkflowRelatedEntityPermissionHelper::ABSTAIN, | ||||
|                 WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, | ||||
|             ]; | ||||
|  | ||||
|             yield 'Is granted by a workflow takes precedence (workflow) although grant ('.$action.')' => [ | ||||
|                 $attribute, | ||||
|                 false, | ||||
|                 true, | ||||
|                 WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, | ||||
|                 WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, | ||||
|             ]; | ||||
|  | ||||
|             yield 'Is granted by a workflow takes precedence (stored object) although grant ('.$action.')' => [ | ||||
|                 $attribute, | ||||
|                 false, | ||||
|                 true, | ||||
|                 WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, | ||||
|                 WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, | ||||
|             ]; | ||||
|  | ||||
|             yield 'Is granted by a workflow takes precedence (initially refused) (workflow) although grant ('.$action.')' => [ | ||||
|                 $attribute, | ||||
|                 false, | ||||
|                 false, | ||||
|                 WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, | ||||
|                 WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, | ||||
|             ]; | ||||
|  | ||||
|             yield 'Is granted by a workflow takes precedence (initially refused) (stored object) although grant ('.$action.')' => [ | ||||
|                 $attribute, | ||||
|                 false, | ||||
|                 false, | ||||
|                 WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, | ||||
|                 WorkflowRelatedEntityPermissionHelper::FORCE_DENIED, | ||||
|             ]; | ||||
|  | ||||
|             yield 'Force grant inverse the regular permission (workflow) ('.$action.')' => [ | ||||
|                 $attribute, | ||||
|                 true, | ||||
|                 false, | ||||
|                 WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, | ||||
|                 WorkflowRelatedEntityPermissionHelper::ABSTAIN, | ||||
|             ]; | ||||
|  | ||||
|             yield 'Force grant inverse the regular permission (so) ('.$action.')' => [ | ||||
|                 $attribute, | ||||
|                 true, | ||||
|                 false, | ||||
|                 WorkflowRelatedEntityPermissionHelper::ABSTAIN, | ||||
|                 WorkflowRelatedEntityPermissionHelper::FORCE_GRANT, | ||||
|             ]; | ||||
|  | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @dataProvider dataProviderVoteOnAttributeWithoutStoredObjectPermission | ||||
|      */ | ||||
|     public function testVoteOnAttributeWithoutStoredObjectPermission( | ||||
|         StoredObjectRoleEnum $attribute, | ||||
|         bool $expected, | ||||
|         bool $canBeAssociatedWithWorkflow, | ||||
| @@ -105,6 +261,10 @@ class AbstractStoredObjectVoterTest extends TestCase | ||||
|         $security->isGranted('SOME_ROLE', $related)->willReturn($isGrantedRegularPermission); | ||||
|  | ||||
|         $workflowRelatedEntityPermissionHelper = $this->prophesize(WorkflowRelatedEntityPermissionHelper::class); | ||||
|  | ||||
|         $workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($storedObject)->willReturn(WorkflowRelatedEntityPermissionHelper::ABSTAIN); | ||||
|         $workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForWriteOperation($storedObject)->willReturn(WorkflowRelatedEntityPermissionHelper::ABSTAIN); | ||||
|  | ||||
|         if (null !== $isGrantedWorkflowPermissionRead) { | ||||
|             $workflowRelatedEntityPermissionHelper->isAllowedByWorkflowForReadOperation($related) | ||||
|                 ->willReturn($isGrantedWorkflowPermissionRead)->shouldBeCalled(); | ||||
| @@ -123,7 +283,7 @@ class AbstractStoredObjectVoterTest extends TestCase | ||||
|         self::assertEquals($expected, $voter->voteOnAttribute($attribute, $storedObject, $token), $message); | ||||
|     } | ||||
|  | ||||
|     public static function dataProviderVoteOnAttribute(): iterable | ||||
|     public static function dataProviderVoteOnAttributeWithoutStoredObjectPermission(): iterable | ||||
|     { | ||||
|         // not associated on a workflow | ||||
|         yield [StoredObjectRoleEnum::SEE, true, false, true, null, null, 'not associated on a workflow, granted by regular access, must not rely on helper']; | ||||
|   | ||||
| @@ -23,6 +23,8 @@ See the document: Voir le document | ||||
|  | ||||
| document: | ||||
|     Any title: Aucun titre | ||||
|     replace: Remplacer | ||||
|     Add: Ajouter un document | ||||
|  | ||||
| generic_doc: | ||||
|     filter: | ||||
|   | ||||
| @@ -118,7 +118,7 @@ | ||||
|  | ||||
|         {{ entity.notes|chill_print_or_message("Aucune note", 'blockquote') }} | ||||
|     {% endblock crud_content_view_details %} | ||||
|  | ||||
|     {% block content_view_actions_duplicate_link %}{% endblock %} | ||||
|     {% block content_view_actions_back %} | ||||
|         <li class="cancel"> | ||||
|             <a class="btn btn-cancel" href="{{ chill_return_path_or('chill_job_report_index', { 'person': entity.person.id }) }}"> | ||||
|   | ||||
| @@ -46,6 +46,7 @@ | ||||
|             </dd> | ||||
|         </dl> | ||||
|     {% endblock crud_content_view_details %} | ||||
|     {% block content_view_actions_duplicate_link %}{% endblock %} | ||||
|  | ||||
|     {% block content_view_actions_back %} | ||||
|         <li class="cancel"> | ||||
|   | ||||
| @@ -206,6 +206,8 @@ | ||||
|             </a> | ||||
|         </li> | ||||
|     {% endblock %} | ||||
|     {% block content_view_actions_duplicate_link %}{% endblock %} | ||||
|  | ||||
|     {% block content_view_actions_after %} | ||||
|         <li> | ||||
|             <a class="btn btn-misc" href="{{ chill_return_path_or('chill_crud_immersion_bilan', { 'id': entity.id, 'person_id': entity.person.id }) }}"> | ||||
|   | ||||
| @@ -94,6 +94,7 @@ | ||||
|  | ||||
|  | ||||
|     {% endblock crud_content_view_details %} | ||||
|     {% block content_view_actions_duplicate_link %}{% endblock %} | ||||
|  | ||||
|     {% block content_view_actions_back %} | ||||
|         <li class="cancel"> | ||||
|   | ||||
| @@ -0,0 +1,64 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\MainBundle\Action\User\UpdateProfile; | ||||
|  | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Chill\MainBundle\Notification\NotificationFlagManager; | ||||
| use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint; | ||||
| use libphonenumber\PhoneNumber; | ||||
|  | ||||
| final class UpdateProfileCommand | ||||
| { | ||||
|     public array $notificationFlags = []; | ||||
|  | ||||
|     public function __construct( | ||||
|         #[PhonenumberConstraint] | ||||
|         public ?PhoneNumber $phonenumber, | ||||
|     ) {} | ||||
|  | ||||
|     public static function create(User $user, NotificationFlagManager $flagManager): self | ||||
|     { | ||||
|         $updateProfileCommand = new self($user->getPhonenumber()); | ||||
|  | ||||
|         foreach ($flagManager->getAllNotificationFlagProviders() as $provider) { | ||||
|             $updateProfileCommand->setNotificationFlag( | ||||
|                 $provider->getFlag(), | ||||
|                 User::NOTIF_FLAG_IMMEDIATE_EMAIL, | ||||
|                 $user->isNotificationSendImmediately($provider->getFlag()) | ||||
|             ); | ||||
|             $updateProfileCommand->setNotificationFlag( | ||||
|                 $provider->getFlag(), | ||||
|                 User::NOTIF_FLAG_DAILY_DIGEST, | ||||
|                 $user->isNotificationDailyDigest($provider->getFlag()) | ||||
|             ); | ||||
|         } | ||||
|  | ||||
|         return $updateProfileCommand; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @param User::NOTIF_FLAG_IMMEDIATE_EMAIL|User::NOTIF_FLAG_DAILY_DIGEST $kind | ||||
|      */ | ||||
|     private function setNotificationFlag(string $type, string $kind, bool $value): void | ||||
|     { | ||||
|         if (!array_key_exists($type, $this->notificationFlags)) { | ||||
|             $this->notificationFlags[$type] = ['immediate_email' => true, 'daily_digest' => false]; | ||||
|         } | ||||
|  | ||||
|         $k = match ($kind) { | ||||
|             User::NOTIF_FLAG_IMMEDIATE_EMAIL => 'immediate_email', | ||||
|             User::NOTIF_FLAG_DAILY_DIGEST => 'daily_digest', | ||||
|         }; | ||||
|  | ||||
|         $this->notificationFlags[$type][$k] = $value; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,27 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\MainBundle\Action\User\UpdateProfile; | ||||
|  | ||||
| use Chill\MainBundle\Entity\User; | ||||
|  | ||||
| final readonly class UpdateProfileCommandHandler | ||||
| { | ||||
|     public function updateProfile(User $user, UpdateProfileCommand $command): void | ||||
|     { | ||||
|         $user->setPhonenumber($command->phonenumber); | ||||
|  | ||||
|         foreach ($command->notificationFlags as $flag => $values) { | ||||
|             $user->setNotificationImmediately($flag, $values['immediate_email']); | ||||
|             $user->setNotificationDailyDigest($flag, $values['daily_digest']); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -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\MainBundle\Command; | ||||
|  | ||||
| use Chill\MainBundle\Security\RoleDumper; | ||||
| use Symfony\Component\Console\Attribute\AsCommand; | ||||
| use Symfony\Component\Console\Command\Command; | ||||
| use Symfony\Component\Console\Input\InputInterface; | ||||
| use Symfony\Component\Console\Output\OutputInterface; | ||||
|  | ||||
| #[AsCommand(name: 'chill:main:dump-list-permissions', description: 'Print a markdown reference of permissions (roles) grouped by title with dependencies).')] | ||||
| final class DumpListPermissionsCommand extends Command | ||||
| { | ||||
|     public function __construct(private readonly RoleDumper $roleDumper) | ||||
|     { | ||||
|         parent::__construct(); | ||||
|     } | ||||
|  | ||||
|     protected function execute(InputInterface $input, OutputInterface $output): int | ||||
|     { | ||||
|         $markdown = $this->roleDumper->dumpAsMarkdown(); | ||||
|         $output->writeln($markdown); | ||||
|  | ||||
|         return Command::SUCCESS; | ||||
|     } | ||||
| } | ||||
| @@ -48,6 +48,7 @@ class AbsenceController extends AbstractController | ||||
|         $user = $this->security->getUser(); | ||||
|  | ||||
|         $user->setAbsenceStart(null); | ||||
|         $user->setAbsenceEnd(null); | ||||
|         $em = $this->managerRegistry->getManager(); | ||||
|         $em->flush(); | ||||
|  | ||||
|   | ||||
| @@ -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\MainBundle\Controller; | ||||
|  | ||||
| use Chill\MainBundle\Repository\AddressReferenceRepositoryInterface; | ||||
| 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; | ||||
|  | ||||
| final readonly class AddressReferenceAggregatedApiController | ||||
| { | ||||
|     public function __construct( | ||||
|         private Security $security, | ||||
|         private AddressReferenceRepositoryInterface $addressReferenceRepository, | ||||
|     ) {} | ||||
|  | ||||
|     #[Route(path: '/api/1.0/main/address-reference/aggregated/search')] | ||||
|     public function search(Request $request): JsonResponse | ||||
|     { | ||||
|         if (!$this->security->isGranted('IS_AUTHENTICATED')) { | ||||
|             throw new AccessDeniedHttpException(); | ||||
|         } | ||||
|  | ||||
|         if (!$request->query->has('q')) { | ||||
|             throw new BadRequestHttpException('Parameter "q" is required.'); | ||||
|         } | ||||
|  | ||||
|         $q = trim($request->query->get('q')); | ||||
|  | ||||
|         if ('' === $q) { | ||||
|             throw new BadRequestHttpException('Parameter "q" is required and cannot be empty.'); | ||||
|         } | ||||
|  | ||||
|         $result = $this->addressReferenceRepository->findAggregatedBySearchString($q); | ||||
|  | ||||
|         return new JsonResponse(iterator_to_array($result)); | ||||
|     } | ||||
| } | ||||
| @@ -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\MainBundle\Controller; | ||||
|  | ||||
| use Chill\MainBundle\Repository\PostalCodeForAddressReferenceRepositoryInterface; | ||||
| 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; | ||||
|  | ||||
| final readonly class PostalCodeForAddressReferenceApiController | ||||
| { | ||||
|     public function __construct( | ||||
|         private PostalCodeForAddressReferenceRepositoryInterface $postalCodeForAddressReferenceRepository, | ||||
|         private Security $security, | ||||
|     ) {} | ||||
|  | ||||
|     #[Route('/api/1.0/main/address-reference/postal-code/search')] | ||||
|     public function findPostalCodeBySearch(Request $request): JsonResponse | ||||
|     { | ||||
|  | ||||
|         if (!$this->security->isGranted('IS_AUTHENTICATED')) { | ||||
|             throw new AccessDeniedHttpException(); | ||||
|         } | ||||
|  | ||||
|         $search = $request->query->get('q'); | ||||
|  | ||||
|         if (null === $search || '' === trim($search)) { | ||||
|             throw new BadRequestHttpException('No search query provided'); | ||||
|         } | ||||
|  | ||||
|         $postalCodes = iterator_to_array($this->postalCodeForAddressReferenceRepository->findPostalCode($search)); | ||||
|  | ||||
|         return new JsonResponse($postalCodes, json: false); | ||||
|     } | ||||
| } | ||||
| @@ -1,63 +0,0 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\MainBundle\Controller; | ||||
|  | ||||
| use Chill\MainBundle\Form\UserProfileType; | ||||
| use Chill\MainBundle\Security\ChillSecurity; | ||||
| use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; | ||||
| use Symfony\Component\HttpFoundation\Request; | ||||
| use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; | ||||
| use Symfony\Contracts\Translation\TranslatorInterface; | ||||
| use Symfony\Component\Routing\Annotation\Route; | ||||
|  | ||||
| final class UserProfileController extends AbstractController | ||||
| { | ||||
|     public function __construct( | ||||
|         private readonly TranslatorInterface $translator, | ||||
|         private readonly ChillSecurity $security, | ||||
|         private readonly \Doctrine\Persistence\ManagerRegistry $managerRegistry, | ||||
|     ) {} | ||||
|  | ||||
|     /** | ||||
|      * User profile that allows editing of phonenumber and visualization of certain data. | ||||
|      */ | ||||
|     #[Route(path: '/{_locale}/main/user/my-profile', name: 'chill_main_user_profile')] | ||||
|     public function __invoke(Request $request) | ||||
|     { | ||||
|         if (!$this->security->isGranted('ROLE_USER')) { | ||||
|             throw new AccessDeniedHttpException(); | ||||
|         } | ||||
|  | ||||
|         $user = $this->security->getUser(); | ||||
|         $editForm = $this->createForm(UserProfileType::class, $user); | ||||
|  | ||||
|         $editForm->get('notificationFlags')->setData($user->getNotificationFlags()); | ||||
|  | ||||
|         $editForm->handleRequest($request); | ||||
|  | ||||
|         if ($editForm->isSubmitted() && $editForm->isValid()) { | ||||
|             $notificationFlagsData = $editForm->get('notificationFlags')->getData(); | ||||
|             $user->setNotificationFlags($notificationFlagsData); | ||||
|  | ||||
|             $em = $this->managerRegistry->getManager(); | ||||
|             $em->flush(); | ||||
|             $this->addFlash('success', $this->translator->trans('user.profile.Profile successfully updated!')); | ||||
|  | ||||
|             return $this->redirectToRoute('chill_main_user_profile'); | ||||
|         } | ||||
|  | ||||
|         return $this->render('@ChillMain/User/profile.html.twig', [ | ||||
|             'user' => $user, | ||||
|             'form' => $editForm->createView(), | ||||
|         ]); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,75 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\MainBundle\Controller; | ||||
|  | ||||
| use Chill\MainBundle\Action\User\UpdateProfile\UpdateProfileCommand; | ||||
| use Chill\MainBundle\Action\User\UpdateProfile\UpdateProfileCommandHandler; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Chill\MainBundle\Form\UpdateProfileType; | ||||
| use Chill\MainBundle\Notification\NotificationFlagManager; | ||||
| use Chill\MainBundle\Security\ChillSecurity; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Symfony\Component\Form\FormFactoryInterface; | ||||
| use Symfony\Component\HttpFoundation\RedirectResponse; | ||||
| use Symfony\Component\HttpFoundation\Request; | ||||
| use Symfony\Component\HttpFoundation\Response; | ||||
| use Symfony\Component\HttpFoundation\Session\Session; | ||||
| use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; | ||||
| use Symfony\Component\Routing\Generator\UrlGeneratorInterface; | ||||
| use Symfony\Contracts\Translation\TranslatorInterface; | ||||
| use Symfony\Component\Routing\Annotation\Route; | ||||
| use Twig\Environment; | ||||
|  | ||||
| final readonly class UserUpdateProfileController | ||||
| { | ||||
|     public function __construct( | ||||
|         private TranslatorInterface $translator, | ||||
|         private ChillSecurity $security, | ||||
|         private EntityManagerInterface $entityManager, | ||||
|         private NotificationFlagManager $notificationFlagManager, | ||||
|         private FormFactoryInterface $formFactory, | ||||
|         private UrlGeneratorInterface $urlGenerator, | ||||
|         private Environment $twig, | ||||
|         private UpdateProfileCommandHandler $updateProfileCommandHandler, | ||||
|     ) {} | ||||
|  | ||||
|     /** | ||||
|      * User profile that allows editing of phonenumber and visualization of certain data. | ||||
|      */ | ||||
|     #[Route(path: '/{_locale}/main/user/my-profile', name: 'chill_main_user_profile')] | ||||
|     public function __invoke(Request $request, Session $session) | ||||
|     { | ||||
|         if (!$this->security->isGranted('ROLE_USER')) { | ||||
|             throw new AccessDeniedHttpException(); | ||||
|         } | ||||
|  | ||||
|         $user = $this->security->getUser(); | ||||
|  | ||||
|         $command = UpdateProfileCommand::create($user, $this->notificationFlagManager); | ||||
|         $editForm = $this->formFactory->create(UpdateProfileType::class, $command); | ||||
|  | ||||
|         $editForm->handleRequest($request); | ||||
|  | ||||
|         if ($editForm->isSubmitted() && $editForm->isValid()) { | ||||
|             $this->updateProfileCommandHandler->updateProfile($user, $command); | ||||
|             $this->entityManager->flush(); | ||||
|             $session->getFlashBag()->add('success', $this->translator->trans('user.profile.Profile successfully updated!')); | ||||
|  | ||||
|             return new RedirectResponse($this->urlGenerator->generate('chill_main_user_profile')); | ||||
|         } | ||||
|  | ||||
|         return new Response($this->twig->render('@ChillMain/User/profile.html.twig', [ | ||||
|             'user' => $user, | ||||
|             'form' => $editForm->createView(), | ||||
|         ])); | ||||
|     } | ||||
| } | ||||
| @@ -11,6 +11,7 @@ declare(strict_types=1); | ||||
|  | ||||
| namespace Chill\MainBundle\Controller; | ||||
|  | ||||
| use Chill\MainBundle\CRUD\Controller\ApiController; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflow; | ||||
| use Chill\MainBundle\Pagination\PaginatorFactory; | ||||
| @@ -27,7 +28,7 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
| use Symfony\Component\Serializer\SerializerInterface; | ||||
|  | ||||
| class WorkflowApiController | ||||
| class WorkflowApiController extends ApiController | ||||
| { | ||||
|     public function __construct(private readonly EntityManagerInterface $entityManager, private readonly EntityWorkflowRepository $entityWorkflowRepository, private readonly PaginatorFactory $paginatorFactory, private readonly Security $security, private readonly SerializerInterface $serializer) {} | ||||
|  | ||||
|   | ||||
| @@ -44,7 +44,7 @@ final readonly class WorkflowSignatureStateChangeController | ||||
|             $signature, | ||||
|             $request, | ||||
|             EntityWorkflowStepSignatureVoter::CANCEL, | ||||
|             function (EntityWorkflowStepSignature $signature) {$this->signatureStepStateChanger->markSignatureAsCanceled($signature); }, | ||||
|             fn (EntityWorkflowStepSignature $signature): string => $this->signatureStepStateChanger->markSignatureAsCanceled($signature), | ||||
|             '@ChillMain/WorkflowSignature/cancel.html.twig', | ||||
|         ); | ||||
|     } | ||||
| @@ -56,11 +56,18 @@ final readonly class WorkflowSignatureStateChangeController | ||||
|             $signature, | ||||
|             $request, | ||||
|             EntityWorkflowStepSignatureVoter::REJECT, | ||||
|             function (EntityWorkflowStepSignature $signature) {$this->signatureStepStateChanger->markSignatureAsRejected($signature); }, | ||||
|             fn (EntityWorkflowStepSignature $signature): string => $this->signatureStepStateChanger->markSignatureAsRejected($signature), | ||||
|             '@ChillMain/WorkflowSignature/reject.html.twig', | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @param callable(EntityWorkflowStepSignature): string $markSignature | ||||
|      * | ||||
|      * @throws \Twig\Error\LoaderError | ||||
|      * @throws \Twig\Error\RuntimeError | ||||
|      * @throws \Twig\Error\SyntaxError | ||||
|      */ | ||||
|     private function markSignatureAction( | ||||
|         EntityWorkflowStepSignature $signature, | ||||
|         Request $request, | ||||
| @@ -79,12 +86,13 @@ final readonly class WorkflowSignatureStateChangeController | ||||
|         $form->handleRequest($request); | ||||
|  | ||||
|         if ($form->isSubmitted() && $form->isValid()) { | ||||
|             $this->entityManager->wrapInTransaction(function () use ($signature, $markSignature) { | ||||
|                 $markSignature($signature); | ||||
|             }); | ||||
|             $expectedStep = $this->entityManager->wrapInTransaction(fn () => $markSignature($signature)); | ||||
|  | ||||
|             return new RedirectResponse( | ||||
|                 $this->chillUrlGenerator->returnPathOr('chill_main_workflow_show', ['id' => $signature->getStep()->getEntityWorkflow()->getId()]) | ||||
|                 $this->chillUrlGenerator->forwardReturnPath( | ||||
|                     'chill_main_workflow_wait', | ||||
|                     ['id' => $signature->getStep()->getEntityWorkflow()->getId(), 'expectedStep' => $expectedStep] | ||||
|                 ) | ||||
|             ); | ||||
|         } | ||||
|  | ||||
|   | ||||
| @@ -0,0 +1,41 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\MainBundle\Controller; | ||||
|  | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflow; | ||||
| use Chill\MainBundle\Routing\ChillUrlGeneratorInterface; | ||||
| use Symfony\Component\HttpFoundation\RedirectResponse; | ||||
| use Symfony\Component\HttpFoundation\Response; | ||||
| use Symfony\Component\Routing\Annotation\Route; | ||||
| use Twig\Environment; | ||||
|  | ||||
| final readonly class WorkflowWaitStepChangeController | ||||
| { | ||||
|     public function __construct( | ||||
|         private ChillUrlGeneratorInterface $chillUrlGenerator, | ||||
|         private Environment $twig, | ||||
|     ) {} | ||||
|  | ||||
|     #[Route('/{_locale}/main/workflow/{id}/wait/{expectedStep}', name: 'chill_main_workflow_wait', methods: ['GET'])] | ||||
|     public function waitForSignatureChange(EntityWorkflow $entityWorkflow, string $expectedStep): Response | ||||
|     { | ||||
|         if ($entityWorkflow->getStep() === $expectedStep) { | ||||
|             return new RedirectResponse( | ||||
|                 $this->chillUrlGenerator->returnPathOr('chill_main_workflow_show', ['id' => $entityWorkflow->getId()]) | ||||
|             ); | ||||
|         } | ||||
|  | ||||
|         return new Response( | ||||
|             $this->twig->render('@ChillMain/Workflow/waiting.html.twig', ['workflow' => $entityWorkflow, 'expectedStep' => $expectedStep]) | ||||
|         ); | ||||
|     } | ||||
| } | ||||
| @@ -30,6 +30,7 @@ use Chill\MainBundle\Controller\UserGroupAdminController; | ||||
| use Chill\MainBundle\Controller\UserGroupApiController; | ||||
| use Chill\MainBundle\Controller\UserJobApiController; | ||||
| use Chill\MainBundle\Controller\UserJobController; | ||||
| use Chill\MainBundle\Controller\WorkflowApiController; | ||||
| use Chill\MainBundle\DependencyInjection\Widget\Factory\WidgetFactoryInterface; | ||||
| use Chill\MainBundle\Doctrine\DQL\Age; | ||||
| use Chill\MainBundle\Doctrine\DQL\Extract; | ||||
| @@ -66,6 +67,7 @@ use Chill\MainBundle\Entity\Regroupment; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Chill\MainBundle\Entity\UserGroup; | ||||
| use Chill\MainBundle\Entity\UserJob; | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflow; | ||||
| use Chill\MainBundle\Form\CenterType; | ||||
| use Chill\MainBundle\Form\CivilityType; | ||||
| use Chill\MainBundle\Form\CountryType; | ||||
| @@ -79,6 +81,7 @@ use Chill\MainBundle\Form\UserGroupType; | ||||
| use Chill\MainBundle\Form\UserJobType; | ||||
| use Chill\MainBundle\Form\UserType; | ||||
| use Chill\MainBundle\Security\Authorization\ChillExportVoter; | ||||
| use Chill\MainBundle\Security\Authorization\EntityWorkflowVoter; | ||||
| use Misd\PhoneNumberBundle\Doctrine\DBAL\Types\PhoneNumberType; | ||||
| use Ramsey\Uuid\Doctrine\UuidType; | ||||
| use Symfony\Component\Config\FileLocator; | ||||
| @@ -940,6 +943,21 @@ class ChillMainExtension extends Extension implements | ||||
|                         ], | ||||
|                     ], | ||||
|                 ], | ||||
|                 [ | ||||
|                     'class' => EntityWorkflow::class, | ||||
|                     'name' => 'workflow', | ||||
|                     'base_path' => '/api/1.0/main/workflow', | ||||
|                     'base_role' => EntityWorkflowVoter::SEE, | ||||
|                     'controller' => WorkflowApiController::class, | ||||
|                     'actions' => [ | ||||
|                         '_entity' => [ | ||||
|                             'methods' => [ | ||||
|                                 Request::METHOD_GET => true, | ||||
|                                 Request::METHOD_HEAD => true, | ||||
|                             ], | ||||
|                         ], | ||||
|                     ], | ||||
|                 ], | ||||
|             ], | ||||
|         ]); | ||||
|     } | ||||
|   | ||||
| @@ -24,6 +24,7 @@ use Symfony\Component\Security\Core\User\UserInterface; | ||||
| use Symfony\Component\Serializer\Annotation as Serializer; | ||||
| use Symfony\Component\Validator\Context\ExecutionContextInterface; | ||||
| use Chill\MainBundle\Validation\Constraint\PhonenumberConstraint; | ||||
| use Symfony\Component\Validator\Constraints as Assert; | ||||
|  | ||||
| /** | ||||
|  * User. | ||||
| @@ -45,6 +46,8 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter | ||||
|     #[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true)] | ||||
|     private ?\DateTimeImmutable $absenceStart = null; | ||||
|  | ||||
|     #[ORM\Column(type: \Doctrine\DBAL\Types\Types::DATETIME_IMMUTABLE, nullable: true)] | ||||
|     private ?\DateTimeImmutable $absenceEnd = null; | ||||
|     /** | ||||
|      * Array where SAML attributes's data are stored. | ||||
|      */ | ||||
| @@ -157,6 +160,11 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter | ||||
|         return $this->absenceStart; | ||||
|     } | ||||
|  | ||||
|     public function getAbsenceEnd(): ?\DateTimeImmutable | ||||
|     { | ||||
|         return $this->absenceEnd; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * Get attributes. | ||||
|      * | ||||
| @@ -336,7 +344,13 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter | ||||
|  | ||||
|     public function isAbsent(): bool | ||||
|     { | ||||
|         return null !== $this->getAbsenceStart() && $this->getAbsenceStart() <= new \DateTimeImmutable('now'); | ||||
|         $now = new \DateTimeImmutable('now'); | ||||
|         $absenceStart = $this->getAbsenceStart(); | ||||
|         $absenceEnd = $this->getAbsenceEnd(); | ||||
|  | ||||
|         return null !== $absenceStart | ||||
|             && $absenceStart <= $now | ||||
|             && (null === $absenceEnd || $now <= $absenceEnd); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
| @@ -410,6 +424,11 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter | ||||
|         $this->absenceStart = $absenceStart; | ||||
|     } | ||||
|  | ||||
|     public function setAbsenceEnd(?\DateTimeImmutable $absenceEnd): void | ||||
|     { | ||||
|         $this->absenceEnd = $absenceEnd; | ||||
|     } | ||||
|  | ||||
|     public function setAttributeByDomain(string $domain, string $key, $value): self | ||||
|     { | ||||
|         $this->attributes[$domain][$key] = $value; | ||||
| @@ -633,46 +652,82 @@ class User implements UserInterface, \Stringable, PasswordAuthenticatedUserInter | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     public function getNotificationFlags(): array | ||||
|     private function getNotificationFlagData(string $flag): array | ||||
|     { | ||||
|         return $this->notificationFlags; | ||||
|     } | ||||
|  | ||||
|     public function setNotificationFlags(array $notificationFlags) | ||||
|     { | ||||
|         $this->notificationFlags = $notificationFlags; | ||||
|     } | ||||
|  | ||||
|     public function getNotificationFlagData(string $flag): array | ||||
|     { | ||||
|         return $this->notificationFlags[$flag] ?? []; | ||||
|     } | ||||
|  | ||||
|     public function setNotificationFlagData(string $flag, array $data): void | ||||
|     { | ||||
|         $this->notificationFlags[$flag] = $data; | ||||
|         return $this->notificationFlags[$flag] ?? [self::NOTIF_FLAG_IMMEDIATE_EMAIL]; | ||||
|     } | ||||
|  | ||||
|     public function isNotificationSendImmediately(string $type): bool | ||||
|     { | ||||
|         if ([] === $this->getNotificationFlagData($type) || in_array(User::NOTIF_FLAG_IMMEDIATE_EMAIL, $this->getNotificationFlagData($type), true)) { | ||||
|             return true; | ||||
|         return $this->isNotificationForElement($type, self::NOTIF_FLAG_IMMEDIATE_EMAIL); | ||||
|     } | ||||
|  | ||||
|     public function setNotificationImmediately(string $type, bool $active): void | ||||
|     { | ||||
|         $this->setNotificationFlagElement($type, $active, self::NOTIF_FLAG_IMMEDIATE_EMAIL); | ||||
|     } | ||||
|  | ||||
|     public function setNotificationDailyDigest(string $type, bool $active): void | ||||
|     { | ||||
|         $this->setNotificationFlagElement($type, $active, self::NOTIF_FLAG_DAILY_DIGEST); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @param self::NOTIF_FLAG_IMMEDIATE_EMAIL|self::NOTIF_FLAG_DAILY_DIGEST $kind | ||||
|      */ | ||||
|     private function setNotificationFlagElement(string $type, bool $active, string $kind): void | ||||
|     { | ||||
|         $notificationFlags = [...$this->notificationFlags]; | ||||
|         $changed = false; | ||||
|  | ||||
|         if (!isset($notificationFlags[$type])) { | ||||
|             $notificationFlags[$type] = [self::NOTIF_FLAG_IMMEDIATE_EMAIL]; | ||||
|             $changed = true; | ||||
|         } | ||||
|  | ||||
|         return false; | ||||
|         if ($active) { | ||||
|             if (!in_array($kind, $notificationFlags[$type], true)) { | ||||
|                 $notificationFlags[$type] = [...$notificationFlags[$type], $kind]; | ||||
|                 $changed = true; | ||||
|             } | ||||
|         } else { | ||||
|             if (in_array($kind, $notificationFlags[$type], true)) { | ||||
|                 $notificationFlags[$type] = array_values( | ||||
|                     array_filter($notificationFlags[$type], static fn ($k) => $k !== $kind) | ||||
|                 ); | ||||
|                 $changed = true; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if ($changed) { | ||||
|             $this->notificationFlags = [...$notificationFlags]; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private function isNotificationForElement(string $type, string $kind): bool | ||||
|     { | ||||
|         return in_array($kind, $this->getNotificationFlagData($type), true); | ||||
|     } | ||||
|  | ||||
|     public function isNotificationDailyDigest(string $type): bool | ||||
|     { | ||||
|         if (in_array(User::NOTIF_FLAG_DAILY_DIGEST, $this->getNotificationFlagData($type), true)) { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         return false; | ||||
|         return $this->isNotificationForElement($type, self::NOTIF_FLAG_DAILY_DIGEST); | ||||
|     } | ||||
|  | ||||
|     public function getLocale(): string | ||||
|     { | ||||
|         return 'fr'; | ||||
|     } | ||||
|  | ||||
|     #[Assert\Callback] | ||||
|     public function validateAbsenceDates(ExecutionContextInterface $context): void | ||||
|     { | ||||
|         if (null !== $this->getAbsenceEnd() && null === $this->getAbsenceStart()) { | ||||
|             $context->buildViolation( | ||||
|                 'user.absence_end_requires_start' | ||||
|             ) | ||||
|                 ->atPath('absenceEnd') | ||||
|                 ->addViolation(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -20,7 +20,7 @@ use Chill\MainBundle\Repository\CenterRepositoryInterface; | ||||
| use Chill\MainBundle\Repository\RegroupmentRepositoryInterface; | ||||
|  | ||||
| /** | ||||
|  * @phpstan-type NormalizedData array{centers: array{centers: list<int>, regroupments: list<int>}, export: array{form: array<string, mixed>, version: int}, filters: array<string, array{enabled: boolean, form: array<string, mixed>, version: int}>, aggregators: array<string, array{enabled: boolean, form: array<string, mixed>, version: int}>, pick_formatter: string, formatter: array{form: array<string, mixed>, version: int}} | ||||
|  * @phpstan-type NormalizedData array{centers: array{centers: list<int>, regroupments: list<int>}, export: array{form: array<string, mixed>, version: int}, filters: array<string, array{enabled: boolean, form: array<string, mixed>, version: int}>, aggregators: array<string, array{enabled: boolean, form: array<string, mixed>, version: int}>, pick_formatter?: string, formatter: array{form: array<string, mixed>, version: int}} | ||||
|  */ | ||||
| class ExportConfigNormalizer | ||||
| { | ||||
|   | ||||
| @@ -23,9 +23,14 @@ class AbsenceType extends AbstractType | ||||
|     { | ||||
|         $builder | ||||
|             ->add('absenceStart', ChillDateType::class, [ | ||||
|                 'required' => true, | ||||
|                 'required' => false, | ||||
|                 'input' => 'datetime_immutable', | ||||
|                 'label' => 'absence.Absence start', | ||||
|             ]) | ||||
|             ->add('absenceEnd', ChillDateType::class, [ | ||||
|                 'required' => false, | ||||
|                 'input' => 'datetime_immutable', | ||||
|                 'label' => 'absence.Absence end', | ||||
|             ]); | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -1,75 +0,0 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\MainBundle\Form\DataMapper; | ||||
|  | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Symfony\Component\Form\DataMapperInterface; | ||||
|  | ||||
| final readonly class NotificationFlagDataMapper implements DataMapperInterface | ||||
| { | ||||
|     public function __construct(private array $notificationFlagProviders) {} | ||||
|  | ||||
|     public function mapDataToForms($viewData, $forms): void | ||||
|     { | ||||
|         if (null === $viewData) { | ||||
|             $viewData = []; | ||||
|         } | ||||
|  | ||||
|         $formsArray = iterator_to_array($forms); | ||||
|  | ||||
|         foreach ($this->notificationFlagProviders as $flagProvider) { | ||||
|             $flag = $flagProvider->getFlag(); | ||||
|  | ||||
|             if (isset($formsArray[$flag])) { | ||||
|                 $flagForm = $formsArray[$flag]; | ||||
|  | ||||
|                 $immediateEmailChecked = in_array(User::NOTIF_FLAG_IMMEDIATE_EMAIL, $viewData[$flag] ?? [], true) | ||||
|                     || !array_key_exists($flag, $viewData); | ||||
|                 $dailyEmailChecked = in_array(User::NOTIF_FLAG_DAILY_DIGEST, $viewData[$flag] ?? [], true); | ||||
|  | ||||
|                 if ($flagForm->has('immediate_email')) { | ||||
|                     $flagForm->get('immediate_email')->setData($immediateEmailChecked); | ||||
|                 } | ||||
|                 if ($flagForm->has('daily_email')) { | ||||
|                     $flagForm->get('daily_email')->setData($dailyEmailChecked); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public function mapFormsToData($forms, &$viewData): void | ||||
|     { | ||||
|         $formsArray = iterator_to_array($forms); | ||||
|         $viewData = []; | ||||
|  | ||||
|         foreach ($this->notificationFlagProviders as $flagProvider) { | ||||
|             $flag = $flagProvider->getFlag(); | ||||
|  | ||||
|             if (isset($formsArray[$flag])) { | ||||
|                 $flagForm = $formsArray[$flag]; | ||||
|                 $viewData[$flag] = []; | ||||
|  | ||||
|                 if (true === $flagForm['immediate_email']->getData()) { | ||||
|                     $viewData[$flag][] = User::NOTIF_FLAG_IMMEDIATE_EMAIL; | ||||
|                 } | ||||
|  | ||||
|                 if (true === $flagForm['daily_email']->getData()) { | ||||
|                     $viewData[$flag][] = User::NOTIF_FLAG_DAILY_DIGEST; | ||||
|                 } | ||||
|  | ||||
|                 if ([] === $viewData[$flag]) { | ||||
|                     $viewData[$flag][] = User::NOTIF_FLAG_IMMEDIATE_EMAIL; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -11,11 +11,9 @@ declare(strict_types=1); | ||||
|  | ||||
| namespace Chill\MainBundle\Form\Type; | ||||
|  | ||||
| use Chill\MainBundle\Form\DataMapper\NotificationFlagDataMapper; | ||||
| use Chill\MainBundle\Notification\NotificationFlagManager; | ||||
| use Symfony\Component\Form\AbstractType; | ||||
| use Symfony\Component\Form\Extension\Core\Type\CheckboxType; | ||||
| use Symfony\Component\Form\Extension\Core\Type\FormType; | ||||
| use Symfony\Component\Form\FormBuilderInterface; | ||||
| use Symfony\Component\OptionsResolver\OptionsResolver; | ||||
|  | ||||
| @@ -30,27 +28,24 @@ class NotificationFlagsType extends AbstractType | ||||
|  | ||||
|     public function buildForm(FormBuilderInterface $builder, array $options): void | ||||
|     { | ||||
|         $builder->setDataMapper(new NotificationFlagDataMapper($this->notificationFlagProviders)); | ||||
|  | ||||
|         foreach ($this->notificationFlagProviders as $flagProvider) { | ||||
|             $flag = $flagProvider->getFlag(); | ||||
|             $builder->add($flag, FormType::class, [ | ||||
|             $flagBuilder = $builder->create($flag, options: [ | ||||
|                 'label' => $flagProvider->getLabel(), | ||||
|                 'required' => false, | ||||
|                 'compound' => true, | ||||
|             ]); | ||||
|  | ||||
|             $builder->get($flag) | ||||
|             $flagBuilder | ||||
|                 ->add('immediate_email', CheckboxType::class, [ | ||||
|                     'label' => false, | ||||
|                     'required' => false, | ||||
|                     'mapped' => false, | ||||
|                 ]) | ||||
|                 ->add('daily_email', CheckboxType::class, [ | ||||
|                 ->add('daily_digest', CheckboxType::class, [ | ||||
|                     'label' => false, | ||||
|                     'required' => false, | ||||
|                     'mapped' => false, | ||||
|                 ]) | ||||
|             ; | ||||
|             $builder->add($flagBuilder); | ||||
|         } | ||||
|     } | ||||
|  | ||||
| @@ -58,6 +53,7 @@ class NotificationFlagsType extends AbstractType | ||||
|     { | ||||
|         $resolver->setDefaults([ | ||||
|             'data_class' => null, | ||||
|             'compound' => true, | ||||
|         ]); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -11,31 +11,29 @@ declare(strict_types=1); | ||||
| 
 | ||||
| namespace Chill\MainBundle\Form; | ||||
| 
 | ||||
| use Chill\MainBundle\Action\User\UpdateProfile\UpdateProfileCommand; | ||||
| use Chill\MainBundle\Form\Type\ChillPhoneNumberType; | ||||
| use Chill\MainBundle\Form\Type\NotificationFlagsType; | ||||
| use Symfony\Component\Form\AbstractType; | ||||
| use Symfony\Component\Form\FormBuilderInterface; | ||||
| use Symfony\Component\OptionsResolver\OptionsResolver; | ||||
| 
 | ||||
| class UserProfileType extends AbstractType | ||||
| class UpdateProfileType extends AbstractType | ||||
| { | ||||
|     public function buildForm(FormBuilderInterface $builder, array $options) | ||||
|     public function buildForm(FormBuilderInterface $builder, array $options): void | ||||
|     { | ||||
|         $builder | ||||
|             ->add('phonenumber', ChillPhoneNumberType::class, [ | ||||
|                 'required' => false, | ||||
|             ]) | ||||
|             ->add('notificationFlags', NotificationFlagsType::class, [ | ||||
|                 'label' => false, | ||||
|                 'mapped' => false, | ||||
|             ]) | ||||
|             ->add('notificationFlags', NotificationFlagsType::class) | ||||
|         ; | ||||
|     } | ||||
| 
 | ||||
|     public function configureOptions(OptionsResolver $resolver) | ||||
|     public function configureOptions(OptionsResolver $resolver): void | ||||
|     { | ||||
|         $resolver->setDefaults([ | ||||
|             'data_class' => \Chill\MainBundle\Entity\User::class, | ||||
|             'data_class' => UpdateProfileCommand::class, | ||||
|         ]); | ||||
|     } | ||||
| } | ||||
| @@ -59,7 +59,7 @@ class UserPasswordType extends AbstractType | ||||
|                 'invalid_message' => 'The password fields must match', | ||||
|                 'constraints' => [ | ||||
|                     new Length([ | ||||
|                         'min' => 9, | ||||
|                         'min' => 14, | ||||
|                         'minMessage' => 'The password must be greater than {{ limit }} characters', | ||||
|                     ]), | ||||
|                     new NotBlank(), | ||||
|   | ||||
| @@ -105,6 +105,11 @@ class UserType extends AbstractType | ||||
|                 'required' => false, | ||||
|                 'input' => 'datetime_immutable', | ||||
|                 'label' => 'absence.Absence start', | ||||
|             ]) | ||||
|             ->add('absenceEnd', ChillDateType::class, [ | ||||
|                 'required' => false, | ||||
|                 'input' => 'datetime_immutable', | ||||
|                 'label' => 'absence.Absence end', | ||||
|             ]); | ||||
|  | ||||
|         // @phpstan-ignore-next-line | ||||
|   | ||||
| @@ -14,13 +14,14 @@ namespace Chill\MainBundle\Repository; | ||||
| use Chill\MainBundle\Entity\AddressReference; | ||||
| use Chill\MainBundle\Entity\PostalCode; | ||||
| use Chill\MainBundle\Search\SearchApiQuery; | ||||
| use Doctrine\DBAL\Types\Types; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Doctrine\ORM\EntityRepository; | ||||
| use Doctrine\ORM\NativeQuery; | ||||
| use Doctrine\ORM\Query\ResultSetMapping; | ||||
| use Doctrine\ORM\Query\ResultSetMappingBuilder; | ||||
| use Doctrine\Persistence\ObjectRepository; | ||||
|  | ||||
| final readonly class AddressReferenceRepository implements ObjectRepository | ||||
| final readonly class AddressReferenceRepository implements AddressReferenceRepositoryInterface | ||||
| { | ||||
|     private EntityManagerInterface $entityManager; | ||||
|  | ||||
| @@ -65,6 +66,121 @@ final readonly class AddressReferenceRepository implements ObjectRepository | ||||
|         return $this->repository->findAll(); | ||||
|     } | ||||
|  | ||||
|     public function findAggregatedBySearchString(string $search, PostalCode|int|null $postalCode = null, int $firstResult = 0, int $maxResults = 50): iterable | ||||
|     { | ||||
|         $terms = $this->buildTermsFromSearchString($search); | ||||
|         if ([] === $terms) { | ||||
|             return []; | ||||
|         } | ||||
|  | ||||
|         $connection = $this->entityManager->getConnection(); | ||||
|         $qb = $connection->createQueryBuilder(); | ||||
|  | ||||
|         $qb->select('row_number() OVER () AS row_number', 'var.street AS street', 'cmpc.id AS postcode_id', 'cmpc.code AS code', 'cmpc.label AS label', 'jsonb_object_agg(var.address_id, var.streetnumber ORDER BY var.row_number) AS positions') | ||||
|             ->from('view_chill_main_address_reference', 'var') | ||||
|             ->innerJoin('var', 'chill_main_postal_code', 'cmpc', 'cmpc.id = var.postcode_id') | ||||
|             ->groupBy('cmpc.id', 'var.street') | ||||
|             ->setFirstResult($firstResult) | ||||
|             ->setMaxResults($maxResults); | ||||
|  | ||||
|         $paramId = 0; | ||||
|  | ||||
|         foreach ($terms as $term) { | ||||
|             $qb->andWhere('var.address like UNACCENT(LOWER(?))'); | ||||
|             $qb->setParameter(++$paramId, "%{$term}%"); | ||||
|         } | ||||
|  | ||||
|         if (null !== $postalCode) { | ||||
|             $qb->andWhere('var.postcode_id = ?'); | ||||
|             $qb->setParameter(++$paramId, $postalCode instanceof PostalCode ? $postalCode->getId() : $postalCode); | ||||
|         } | ||||
|  | ||||
|         $result = $qb->executeQuery(); | ||||
|  | ||||
|         foreach ($result->iterateAssociative() as $row) { | ||||
|             yield [...$row, 'positions' => json_decode($row['positions'], true, 512, JSON_THROW_ON_ERROR)]; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @return iterable<AddressReference> | ||||
|      */ | ||||
|     public function findBySearchString(string $search, PostalCode|int|null $postalCode = null, int $firstResult = 0, int $maxResults = 50): iterable | ||||
|     { | ||||
|         $terms = $this->buildTermsFromSearchString($search); | ||||
|         if ([] === $terms) { | ||||
|             return []; | ||||
|         } | ||||
|  | ||||
|         $rsm = new ResultSetMappingBuilder($this->entityManager); | ||||
|         $rsm->addRootEntityFromClassMetadata(AddressReference::class, 'ar'); | ||||
|         $baseSql = 'SELECT '.$rsm->generateSelectClause(['ar' => 'ar']).' FROM chill_main_address_reference ar JOIN | ||||
|             view_chill_main_address_reference var ON var.address_id = ar.id'; | ||||
|         $nql = $this->buildQueryBySearchString($rsm, $baseSql, $terms, $postalCode); | ||||
|  | ||||
|         $orderBy = []; | ||||
|         $pertinence = []; | ||||
|         foreach ($terms as $k => $term) { | ||||
|             $pertinence[] = | ||||
|                 "(EXISTS (SELECT 1 FROM unnest(string_to_array(address, ' ')) AS t WHERE starts_with(t, UNACCENT(LOWER(:order{$k})))))::int"; | ||||
|             $pertinence[] = "(address LIKE UNACCENT(LOWER(:order{$k})))::int"; | ||||
|             $nql->setParameter('order'.$k, $term); | ||||
|         } | ||||
|         $orderBy[] = implode(' + ', $pertinence).' ASC'; | ||||
|         $orderBy[] = implode('row_number ASC', $orderBy); | ||||
|  | ||||
|         $nql->setSQL($nql->getSQL().' ORDER BY '.implode(', ', $orderBy)); | ||||
|  | ||||
|         return $nql->toIterable(); | ||||
|     } | ||||
|  | ||||
|     public function countBySearchString(string $search, PostalCode|int|null $postalCode = null): int | ||||
|     { | ||||
|         $terms = $this->buildTermsFromSearchString($search); | ||||
|         if ([] === $terms) { | ||||
|             return 0; | ||||
|         } | ||||
|  | ||||
|         $rsm = new ResultSetMappingBuilder($this->entityManager); | ||||
|         $rsm->addScalarResult('c', 'c', Types::INTEGER); | ||||
|         $nql = $this->buildQueryBySearchString($rsm, 'SELECT COUNT(var.*) AS c FROM view_chill_main_address_reference var', $terms, $postalCode); | ||||
|  | ||||
|         return $nql->getSingleScalarResult(); | ||||
|     } | ||||
|  | ||||
|     private function buildTermsFromSearchString(string $search): array | ||||
|     { | ||||
|         return array_filter( | ||||
|             array_map( | ||||
|                 static fn (string $term) => trim($term), | ||||
|                 explode(' ', $search) | ||||
|             ), | ||||
|             static fn (string $term) => '' !== $term | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     private function buildQueryBySearchString(ResultSetMapping $rsm, string $select, array $terms, PostalCode|int|null $postalCode = null): NativeQuery | ||||
|     { | ||||
|         $nql = $this->entityManager->createNativeQuery('', $rsm); | ||||
|  | ||||
|         $sql = $select.' WHERE '; | ||||
|  | ||||
|         $wheres = []; | ||||
|         foreach ($terms as $k => $term) { | ||||
|             $wheres[] = "var.address like :w{$k}"; | ||||
|             $nql->setParameter("w{$k}", '%'.$term.'%', Types::STRING); | ||||
|         } | ||||
|  | ||||
|         if (null !== $postalCode) { | ||||
|             $wheres[] = 'var.postcode_id = :postalCode'; | ||||
|             $nql->setParameter('postalCode', $postalCode instanceof PostalCode ? $postalCode->getId() : $postalCode); | ||||
|         } | ||||
|  | ||||
|         $nql->setSQL($sql.implode(' AND ', $wheres)); | ||||
|  | ||||
|         return $nql; | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @param mixed|null $limit | ||||
|      * @param mixed|null $offset | ||||
|   | ||||
| @@ -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\MainBundle\Repository; | ||||
|  | ||||
| use Chill\MainBundle\Entity\PostalCode; | ||||
| use Doctrine\Persistence\ObjectRepository; | ||||
|  | ||||
| interface AddressReferenceRepositoryInterface extends ObjectRepository | ||||
| { | ||||
|     public function findAggregatedBySearchString(string $search, PostalCode|int|null $postalCode = null, int $firstResult = 0, int $maxResults = 50): iterable; | ||||
| } | ||||
| @@ -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\MainBundle\Repository; | ||||
|  | ||||
| use Doctrine\DBAL\Connection; | ||||
|  | ||||
| final readonly class PostalCodeForAddressReferenceRepository implements PostalCodeForAddressReferenceRepositoryInterface | ||||
| { | ||||
|     public function __construct(private Connection $connection) {} | ||||
|  | ||||
|     public function findPostalCode(string $search, int $firstResult = 0, int $maxResults = 50): iterable | ||||
|     { | ||||
|         $terms = $this->buildTermsFromSearchString($search); | ||||
|         if ([] === $terms) { | ||||
|             return []; | ||||
|         } | ||||
|  | ||||
|         $qb = $this->connection->createQueryBuilder(); | ||||
|  | ||||
|         $qb->from('chill_main_postal_code', 'cmpc') | ||||
|             ->join('cmpc', 'view_chill_main_address_reference', 'vcmar', 'vcmar.postcode_id = cmpc.id') | ||||
|             ->join('vcmar', 'country', 'country', condition: 'cmpc.country_id = country.id') | ||||
|             ->setFirstResult($firstResult) | ||||
|             ->setMaxResults($maxResults) | ||||
|         ; | ||||
|  | ||||
|         $qb->select( | ||||
|             'DISTINCT ON (cmpc.code, cmpc.label) cmpc.id AS postcode_id', | ||||
|             'cmpc.code AS code', | ||||
|             'cmpc.label AS label', | ||||
|             'country.id AS country_id', | ||||
|             'country.countrycode AS country_code', | ||||
|             'country.name AS country_name' | ||||
|         ); | ||||
|  | ||||
|         $paramId = 0; | ||||
|  | ||||
|         foreach ($terms as $term) { | ||||
|             $qb->andWhere('vcmar.address like ?'); | ||||
|             $qb->setParameter(++$paramId, "%{$term}%"); | ||||
|         } | ||||
|  | ||||
|         $result = $qb->executeQuery(); | ||||
|  | ||||
|         foreach ($result->iterateAssociative() as $row) { | ||||
|             yield [...$row, 'country_name' => json_decode($row['country_name'], true, 512, JSON_THROW_ON_ERROR)]; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private function buildTermsFromSearchString(string $search): array | ||||
|     { | ||||
|         return array_filter( | ||||
|             array_map( | ||||
|                 static fn (string $term) => trim($term), | ||||
|                 explode(' ', $search) | ||||
|             ), | ||||
|             static fn (string $term) => '' !== $term | ||||
|         ); | ||||
|     } | ||||
| } | ||||
| @@ -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\MainBundle\Repository; | ||||
|  | ||||
| /** | ||||
|  * Search for postal code using optimized materialized view. | ||||
|  */ | ||||
| interface PostalCodeForAddressReferenceRepositoryInterface | ||||
| { | ||||
|     public function findPostalCode(string $search, int $firstResult = 0, int $maxResults = 50): iterable; | ||||
| } | ||||
| @@ -37,8 +37,13 @@ export const ISOToDate = (str: string | null): Date | null => { | ||||
|         return null; | ||||
|     } | ||||
|  | ||||
|     const [year, month, day] = str.split("-").map((p) => parseInt(p)); | ||||
|     // If the string already contains time info, use it directly | ||||
|     if (str.includes("T") || str.includes(" ")) { | ||||
|         return new Date(str); | ||||
|     } | ||||
|  | ||||
|     // Otherwise, parse date only | ||||
|     const [year, month, day] = str.split("-").map((p) => parseInt(p)); | ||||
|     return new Date(year, month - 1, day, 0, 0, 0, 0); | ||||
| }; | ||||
|  | ||||
| @@ -69,20 +74,19 @@ export const ISOToDatetime = (str: string | null): Date | null => { | ||||
|  * | ||||
|  */ | ||||
| export const datetimeToISO = (date: Date): string => { | ||||
|     let cal, time, offset; | ||||
|     cal = [ | ||||
|     const cal = [ | ||||
|         date.getFullYear(), | ||||
|         (date.getMonth() + 1).toString().padStart(2, "0"), | ||||
|         date.getDate().toString().padStart(2, "0"), | ||||
|     ].join("-"); | ||||
|  | ||||
|     time = [ | ||||
|     const time = [ | ||||
|         date.getHours().toString().padStart(2, "0"), | ||||
|         date.getMinutes().toString().padStart(2, "0"), | ||||
|         date.getSeconds().toString().padStart(2, "0"), | ||||
|     ].join(":"); | ||||
|  | ||||
|     offset = [ | ||||
|     const offset = [ | ||||
|         date.getTimezoneOffset() <= 0 ? "+" : "-", | ||||
|         Math.abs(Math.floor(date.getTimezoneOffset() / 60)) | ||||
|             .toString() | ||||
|   | ||||
| @@ -0,0 +1,13 @@ | ||||
| /** | ||||
|  * Extracts the "returnPath" parameter from the current URL's query string and returns it. | ||||
|  * If the parameter is not present, returns the provided fallback path. | ||||
|  * | ||||
|  * @param {string} fallbackPath - The fallback path to use if "returnPath" is not found in the query string. | ||||
|  * @return {string} The "returnPath" from the query string, or the fallback path if "returnPath" is not present. | ||||
|  */ | ||||
| export function returnPathOr(fallbackPath: string): string { | ||||
|     const urlParams = new URLSearchParams(window.location.search); | ||||
|     const returnPath = urlParams.get("returnPath"); | ||||
|  | ||||
|     return returnPath ?? fallbackPath; | ||||
| } | ||||
| @@ -0,0 +1,16 @@ | ||||
| import { EntityWorkflow } from "ChillMainAssets/types"; | ||||
| import { makeFetch } from "ChillMainAssets/lib/api/apiMethods"; | ||||
|  | ||||
| export const fetchWorkflow = async ( | ||||
|     workflowId: number, | ||||
| ): Promise<EntityWorkflow> => { | ||||
|     try { | ||||
|         return await makeFetch<null, EntityWorkflow>( | ||||
|             "GET", | ||||
|             `/api/1.0/main/workflow/${workflowId}.json`, | ||||
|         ); | ||||
|     } catch (error) { | ||||
|         console.error(`Failed to fetch workflow ${workflowId}:`, error); | ||||
|         throw error; | ||||
|     } | ||||
| }; | ||||
| @@ -1,5 +1,6 @@ | ||||
| import { GenericDoc } from "ChillDocStoreAssets/types/generic_doc"; | ||||
| import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types"; | ||||
| import { Person } from "../../../ChillPersonBundle/Resources/public/types"; | ||||
|  | ||||
| export interface DateTime { | ||||
|     datetime: string; | ||||
| @@ -74,6 +75,7 @@ export interface Postcode { | ||||
|     name: string; | ||||
|     code: string; | ||||
|     center: Point; | ||||
|     country: Country; | ||||
| } | ||||
|  | ||||
| export interface Point { | ||||
| @@ -89,6 +91,28 @@ export interface Country { | ||||
|  | ||||
| export type AddressRefStatus = "match" | "to_review" | "reviewed"; | ||||
|  | ||||
| /** | ||||
|  * An interface to create an address | ||||
|  */ | ||||
| export interface AddressCreation { | ||||
|     confidential: boolean; | ||||
|     isNoAddress: boolean; | ||||
|     street: string; | ||||
|     streetNumber: string; | ||||
|     postcode: Postcode; | ||||
|     point: Point; // [number, number]; // [longitude, latitude] | ||||
|     addressReference: AddressReference; | ||||
|     validFrom: DateTime|null; | ||||
|     floor: string; | ||||
|     corridor: string; | ||||
|     steps: string; | ||||
|     flat: string; | ||||
|     buildingName: string; | ||||
|     distribution: string; | ||||
|     extra: string; | ||||
| } | ||||
|  | ||||
|  | ||||
| export interface Address { | ||||
|     type: "address"; | ||||
|     address_id: number; | ||||
| @@ -107,7 +131,7 @@ export interface Address { | ||||
|     confidential: boolean; | ||||
|     lines: string[]; | ||||
|     addressReference: AddressReference | null; | ||||
|     validFrom: DateTime; | ||||
|     validFrom: DateTime  | null; // TODO there is no null for validFrom | ||||
|     validTo: DateTime | null; | ||||
|     point: Point | null; | ||||
|     refStatus: AddressRefStatus; | ||||
| @@ -202,6 +226,58 @@ export interface WorkflowAttachment { | ||||
|     genericDoc: null | GenericDoc; | ||||
| } | ||||
|  | ||||
| export interface Workflow { | ||||
|     name: string; | ||||
|     text: string; | ||||
| } | ||||
|  | ||||
| export interface EntityWorkflowStep { | ||||
|     type: "entity_workflow_step"; | ||||
|     id: number; | ||||
|     comment: string; | ||||
|     currentStep: StepDefinition; | ||||
|     isFinal: boolean; | ||||
|     isFreezed: boolean; | ||||
|     isFinalized: boolean; | ||||
|     transitionPrevious: Transition | null; | ||||
|     transitionAfter: Transition | null; | ||||
|     previousId: number | null; | ||||
|     nextId: number | null; | ||||
|     transitionPreviousBy: User | null; | ||||
|     transitionPreviousAt: DateTime | null; | ||||
| } | ||||
|  | ||||
| export interface Transition { | ||||
|     name: string; | ||||
|     text: string; | ||||
|     isForward: boolean; | ||||
| } | ||||
|  | ||||
| export interface StepDefinition { | ||||
|     name: string; | ||||
|     text: string; | ||||
| } | ||||
|  | ||||
| export interface EntityWorkflow { | ||||
|     type: "entity_workflow"; | ||||
|     id: number; | ||||
|     relatedEntityClass: string; | ||||
|     relatedEntityId: number; | ||||
|     workflow: Workflow; | ||||
|     currentStep: EntityWorkflowStep; | ||||
|     steps: EntityWorkflowStep[]; | ||||
|     datas: WorkflowData; | ||||
|     title: string; | ||||
|     isOnHoldAtCurrentStep: boolean; | ||||
|     _permissions: { | ||||
|         CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT: boolean; | ||||
|     }; | ||||
| } | ||||
|  | ||||
| export interface WorkflowData { | ||||
|     persons: Person[]; | ||||
| } | ||||
|  | ||||
| export interface ExportGeneration { | ||||
|     id: string; | ||||
|     type: "export_generation"; | ||||
| @@ -215,3 +291,8 @@ export interface ExportGeneration { | ||||
| export interface PrivateCommentEmbeddable { | ||||
|     comments: Record<number, string>; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Possible states for the WaitingScreen Component. | ||||
|  */ | ||||
| export type WaitingScreenState = "pending" | "failure" | "stopped" | "ready"; | ||||
|   | ||||
| @@ -0,0 +1,33 @@ | ||||
| <script setup lang="ts"> | ||||
|  | ||||
| import Modal from "ChillMainAssets/vuejs/_components/Modal.vue"; | ||||
| import AddressPicker from "ChillMainAssets/vuejs/AddressPicker/AddressPicker.vue"; | ||||
| import {Ref, ref} from "vue"; | ||||
|  | ||||
| const showModal: Ref<boolean> = ref(false); | ||||
|  | ||||
| const modalDialogClasses = {"modal-dialog": true, "modal-dialog-scrollable": true, "modal-xl": true}; | ||||
|  | ||||
| const clickButton = () => { | ||||
|     showModal.value = true; | ||||
| } | ||||
|  | ||||
| const closeModal = () => { | ||||
|     showModal.value = false; | ||||
| } | ||||
|  | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|     <modal v-if="showModal" :hide-footer="false" :modal-dialog-class="modalDialogClasses" @close="closeModal"> | ||||
|         <template v-slot:header>TODO</template> | ||||
|         <template v-slot:body> | ||||
|             <AddressPicker></AddressPicker> | ||||
|         </template> | ||||
|     </modal> | ||||
|     <button class="btn btn-submit" type="button" @click="clickButton">SEARCH ADDRESS</button> | ||||
| </template> | ||||
|  | ||||
| <style scoped lang="scss"> | ||||
|  | ||||
| </style> | ||||
| @@ -0,0 +1,170 @@ | ||||
| <script setup lang="ts"> | ||||
| import {Address, AddressReference} from "ChillMainAssets/types"; | ||||
| import SearchBar from "ChillMainAssets/vuejs/AddressPicker/Component/SearchBar.vue"; | ||||
| import { | ||||
|     AddressAggregated, | ||||
|     AssociatedPostalCode, fetchAddressReference, | ||||
|     getAddressesAggregated, | ||||
|     getPostalCodes, | ||||
| } from "ChillMainAssets/vuejs/AddressPicker/driver/local-search"; | ||||
| import {computed, Ref, ref} from "vue"; | ||||
| import AddressAggregatedList from "ChillMainAssets/vuejs/AddressPicker/Component/AddressAggregatedList.vue"; | ||||
| import AddressDetailsForm from "ChillMainAssets/vuejs/AddressPicker/Component/AddressDetailsForm.vue"; | ||||
| import AddressForm from "ChillMainAssets/vuejs/AddressPicker/Component/AddressForm.vue"; | ||||
| import {trans, SAVE} from "translator"; | ||||
|  | ||||
| interface AddressPickerProps { | ||||
|     suggestions?: Address[]; | ||||
| } | ||||
|  | ||||
| const props = withDefaults(defineProps<AddressPickerProps>(), { | ||||
|     suggestions: () => [], | ||||
| }); | ||||
|  | ||||
| const addresses: Ref<AddressAggregated[]> = ref([]); | ||||
| const postalCodes: Ref<AssociatedPostalCode[]> = ref([]); | ||||
| const searchTokens: Ref<string[]> = ref([]); | ||||
| const addressReference: Ref<AddressReference|null> = ref(null); | ||||
|  | ||||
| let abortControllerSearchAddress: null | AbortController = null; | ||||
| let abortControllerSearchPostalCode: null | AbortController = null; | ||||
|  | ||||
| const searchResultsClasses = computed(() => ({ | ||||
|     "mid-size": addressReference !== null, | ||||
| })); | ||||
|  | ||||
| const floor = ref<string>(""); | ||||
| const corridor = ref<string>(""); | ||||
| const steps = ref<string>(""); | ||||
| const flat = ref<string>(""); | ||||
| const buildingName = ref<string>(""); | ||||
| const extra = ref<string>(""); | ||||
| const distribution = ref<string>(""); | ||||
|  | ||||
|  | ||||
| const onSearch = async function (search: string): Promise<void> { | ||||
|     performSearchForAddress(search); | ||||
|     performSearchForPostalCode(search); | ||||
|     searchTokens.value = [search]; | ||||
| }; | ||||
|  | ||||
| const onPickPosition = async (id: string) => { | ||||
|     console.log('Pick Position', id); | ||||
|     addressReference.value = await fetchAddressReference(id); | ||||
| } | ||||
|  | ||||
| const performSearchForAddress = async (search: string): Promise<void> => { | ||||
|     if (null !== abortControllerSearchAddress) { | ||||
|         abortControllerSearchAddress.abort(); | ||||
|     } | ||||
|  | ||||
|     if ("" === search) { | ||||
|         addresses.value = []; | ||||
|         abortControllerSearchAddress = null; | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     abortControllerSearchAddress = new AbortController(); | ||||
|  | ||||
|     console.log("onSearch", search); | ||||
|     try { | ||||
|         addresses.value = await getAddressesAggregated( | ||||
|             search, | ||||
|             abortControllerSearchAddress, | ||||
|         ); | ||||
|         abortControllerSearchAddress = null; | ||||
|  | ||||
|         // check if there is only one result | ||||
|         if (addresses.value.length === 1 && Object.keys(addresses.value[0].positions).length === 1) { | ||||
|             onPickPosition(Object.keys(addresses.value[0].positions)[0]); | ||||
|         } | ||||
|     } catch (e: unknown) { | ||||
|         if (e instanceof DOMException && e.name === "AbortError") { | ||||
|             console.log("search aborted for:", search); | ||||
|  | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         throw e; | ||||
|     } | ||||
| }; | ||||
|  | ||||
| const performSearchForPostalCode = async (search: string): Promise<void> => { | ||||
|     if (null !== abortControllerSearchPostalCode) { | ||||
|         abortControllerSearchPostalCode.abort(); | ||||
|     } | ||||
|  | ||||
|     if ("" === search) { | ||||
|         addresses.value = []; | ||||
|         abortControllerSearchPostalCode = null; | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     abortControllerSearchPostalCode = new AbortController(); | ||||
|  | ||||
|     console.log("onSearch", search); | ||||
|     try { | ||||
|         postalCodes.value = await getPostalCodes( | ||||
|             search, | ||||
|             abortControllerSearchPostalCode, | ||||
|         ); | ||||
|         abortControllerSearchPostalCode = null; | ||||
|     } catch (e: unknown) { | ||||
|         if (e instanceof DOMException && e.name === "AbortError") { | ||||
|             console.log("search postal code aborted for:", search); | ||||
|  | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         throw e; | ||||
|     } | ||||
| }; | ||||
|  | ||||
| const save = async(): Promise<void> => { | ||||
|     console.log("save"); | ||||
|     console.log("content", floor, corridor, steps, flat, buildingName, extra, distribution); | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|     <search-bar @search="onSearch"></search-bar> | ||||
|     <div class="address-pick-content"> | ||||
|         <div class="search-results" :class="searchResultsClasses"> | ||||
|             <address-aggregated-list :addresses="addresses" :search-tokens="searchTokens" @pick-position="(id) => onPickPosition(id)"></address-aggregated-list> | ||||
|         </div> | ||||
|         <div v-if="addressReference !== null" class="address-details-form"> | ||||
|             <address-details-form :address="addressReference" | ||||
|                                   v-model:floor="floor" | ||||
|                                   v-model:corridor="corridor" | ||||
|                                   v-model:steps="steps" | ||||
|                                   v-model:flat="flat" | ||||
|                                   v-model:building-name="buildingName" | ||||
|                                   v-model:extra="extra" | ||||
|                                   v-model:distribution="distribution" | ||||
|             /> | ||||
|         </div> | ||||
|         <div> | ||||
|             <ul class="record_actions"> | ||||
|                 <li><button class="btn btn-save">{{ trans(SAVE) }}</button></li> | ||||
|             </ul> | ||||
|         </div> | ||||
|     </div> | ||||
|  | ||||
| </template> | ||||
|  | ||||
| <style scoped lang="scss"> | ||||
| .address-pick-content { | ||||
|     display: flex; | ||||
|     flex-direction: row; | ||||
|     gap: 1rem; | ||||
|  | ||||
|     .search-results { | ||||
|         &.mid-size { | ||||
|             width: 50%; | ||||
|         } | ||||
|     } | ||||
|     .address-details-form { | ||||
|         width: 50%; | ||||
|     } | ||||
| } | ||||
| </style> | ||||
| @@ -0,0 +1,25 @@ | ||||
| <script setup lang="ts"> | ||||
| import {AddressAggregated} from "ChillMainAssets/vuejs/AddressPicker/driver/local-search"; | ||||
| import AddressAggregatedListItem from "ChillMainAssets/vuejs/AddressPicker/Component/AddressAggregatedListItem.vue"; | ||||
|  | ||||
| interface AddressAggregatedListProps { | ||||
|     addresses: AddressAggregated[]; | ||||
|     searchTokens: string[]; | ||||
| } | ||||
|  | ||||
| const props = defineProps<AddressAggregatedListProps>(); | ||||
|  | ||||
| const emit = defineEmits<{ | ||||
|     pickPosition: [id: string] | ||||
| }>(); | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|     <template v-for="a in props.addresses" :key="a.row_number"> | ||||
|         <address-aggregated-list-item :address="a" :search-tokens="props.searchTokens" @pick-position="(id) => emit('pickPosition', id)"></address-aggregated-list-item> | ||||
|     </template> | ||||
| </template> | ||||
|  | ||||
| <style scoped lang="scss"> | ||||
|  | ||||
| </style> | ||||
| @@ -0,0 +1,82 @@ | ||||
| <script setup lang="ts"> | ||||
| import {AddressAggregated} from "ChillMainAssets/vuejs/AddressPicker/driver/local-search"; | ||||
| import {computed, ref} from "vue"; | ||||
|  | ||||
| interface AddressAggregatedListItemProps { | ||||
|     address: AddressAggregated; | ||||
|     searchTokens: string[]; | ||||
| } | ||||
|  | ||||
| const props = defineProps<AddressAggregatedListItemProps>(); | ||||
|  | ||||
| const emit = defineEmits<{ | ||||
|     pickPosition: [id: string] | ||||
| }>(); | ||||
|  | ||||
| const showAllPositions = ref<boolean>(false); | ||||
| const positionsToShow = computed((): Record<string, string> => { | ||||
|     const obj: Record<string, any> = {}; | ||||
|     let count = 0; | ||||
|     for (const [id, position] of Object.entries(props.address.positions)) { | ||||
|        obj[id] = position; | ||||
|        count++; | ||||
|        if (count >= 10 && !showAllPositions.value) { | ||||
|            break; | ||||
|        } | ||||
|     } | ||||
|  | ||||
|     return obj; | ||||
| }) | ||||
| const needToShowMorePosition = computed(() => { | ||||
|     return Object.keys(props.address.positions).length > 10; | ||||
| }) | ||||
|  | ||||
| const onClickButton = (id: string) => { | ||||
|     console.log('onClickButton', id); | ||||
|     emit('pickPosition', id); | ||||
| } | ||||
| const displayAllPositions = () => { | ||||
|     showAllPositions.value = true; | ||||
| } | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|     <div> | ||||
|         <div class="street"> | ||||
|             <span>{{ props.address.street }}</span> | ||||
|         </div> | ||||
|         <div class="postcode"> | ||||
|             <span>{{ props.address.code }}</span> <span>{{ address.label }}</span> | ||||
|         </div> | ||||
|         <div class="positions"> | ||||
|             <ul> | ||||
|                 <li v-for="(position, id) in positionsToShow" :key="id"  > | ||||
|                     <button type="button" @click="onClickButton(id)"  > | ||||
|                         {{ position }} | ||||
|                     </button> | ||||
|                 </li> | ||||
|                 <li v-if="needToShowMorePosition"> | ||||
|                     <button @click="displayAllPositions">show all</button> | ||||
|                 </li> | ||||
|             </ul> | ||||
|         </div> | ||||
|     </div> | ||||
| </template> | ||||
|  | ||||
| <style scoped lang="scss"> | ||||
| .street { | ||||
|     font-variant: small-caps; | ||||
|     font-weight: bold; | ||||
| } | ||||
| .postcode { | ||||
|     font-variant: small-caps; | ||||
| } | ||||
| .positions ul { | ||||
|     list-style-type: none; | ||||
|  | ||||
|     li { | ||||
|         display: inline-block; | ||||
|         margin-right: 2px; | ||||
|     } | ||||
| } | ||||
| </style> | ||||
| @@ -0,0 +1,44 @@ | ||||
| <script setup lang="ts"> | ||||
| import {AddressReference} from "ChillMainAssets/types"; | ||||
| import {computed, ref} from "vue"; | ||||
| import {addressReferenceToAddress} from "ChillMainAssets/vuejs/AddressPicker/helper"; | ||||
| import AddressDetailsContent from "ChillMainAssets/vuejs/_components/AddressDetails/AddressDetailsContent.vue"; | ||||
| import AddressForm from "ChillMainAssets/vuejs/AddressPicker/Component/AddressForm.vue"; | ||||
|  | ||||
| export interface AddressDetailsFormProps { | ||||
|     address: AddressReference; | ||||
| } | ||||
|  | ||||
| const props = defineProps<AddressDetailsFormProps>(); | ||||
|  | ||||
| const floor = ref<string>(""); | ||||
| const corridor = ref<string>(""); | ||||
| const steps = ref<string>(""); | ||||
| const flat = ref<string>(""); | ||||
| const buildingName = ref<string>(""); | ||||
| const extra = ref<string>(""); | ||||
| const distribution = ref<string>(""); | ||||
|  | ||||
| const address = computed(() => addressReferenceToAddress(props.address)); | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|     <div> | ||||
|         <address-form | ||||
|             @update:floor="val => (floor = val)" | ||||
|             @update:corridor="val => (corridor = val)" | ||||
|             @update:steps="val => (steps = val)" | ||||
|             @update:flat="val => (flat = val)" | ||||
|             @update:building-name="val => (buildingName = val)" | ||||
|             @update:extra="val => (extra = val)" | ||||
|             @update:distribution="val => (distribution = val)" | ||||
|         ></address-form> | ||||
|     </div> | ||||
|     <div> | ||||
|         <address-details-content :address="address"></address-details-content> | ||||
|     </div> | ||||
| </template> | ||||
|  | ||||
| <style scoped lang="scss"> | ||||
|  | ||||
| </style> | ||||
| @@ -0,0 +1,112 @@ | ||||
| <script setup lang="ts"> | ||||
| import { | ||||
|     ADDRESS_STREET, | ||||
|     ADDRESS_STREET_NUMBER, | ||||
|     ADDRESS_FLOOR, | ||||
|     ADDRESS_CORRIDOR, | ||||
|     ADDRESS_STEPS, | ||||
|     ADDRESS_FLAT, | ||||
|     ADDRESS_BUILDING_NAME, | ||||
|     ADDRESS_DISTRIBUTION, | ||||
|     ADDRESS_EXTRA, | ||||
|     ADDRESS_FILL_AN_ADDRESS, | ||||
|     trans, | ||||
| } from "translator"; | ||||
| import {ref} from "vue"; | ||||
|  | ||||
| const isNoAddress = ref(false); | ||||
|  | ||||
| const floor = defineModel("floor"); | ||||
| const corridor = defineModel("corridor"); | ||||
| const steps = defineModel("steps"); | ||||
| const flat = defineModel("flat"); | ||||
| const buildingName = defineModel("buildingName"); | ||||
| const extra = defineModel("extra"); | ||||
| const distribution = defineModel("distribution"); | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|     <div class="form-floating my-1"> | ||||
|         <input | ||||
|             class="form-control" | ||||
|             type="text" | ||||
|             name="floor" | ||||
|             :placeholder="trans(ADDRESS_FLOOR)" | ||||
|             v-model="floor" | ||||
|         /> | ||||
|         <label for="floor">{{ trans(ADDRESS_FLOOR) }}</label> | ||||
|     </div> | ||||
|     <div class="form-floating my-1"> | ||||
|         <input | ||||
|             class="form-control" | ||||
|             type="text" | ||||
|             name="corridor" | ||||
|             :placeholder="trans(ADDRESS_CORRIDOR)" | ||||
|             v-model="corridor" | ||||
|         /> | ||||
|         <label for="corridor">{{ trans(ADDRESS_CORRIDOR) }}</label> | ||||
|     </div> | ||||
|     <div class="form-floating my-1"> | ||||
|         <input | ||||
|             class="form-control" | ||||
|             type="text" | ||||
|             name="steps" | ||||
|             :placeholder="trans(ADDRESS_STEPS)" | ||||
|             v-model="steps" | ||||
|         /> | ||||
|         <label for="steps">{{ trans(ADDRESS_STEPS) }}</label> | ||||
|     </div> | ||||
|     <div class="form-floating my-1"> | ||||
|         <input | ||||
|             class="form-control" | ||||
|             type="text" | ||||
|             name="flat" | ||||
|             :placeholder="trans(ADDRESS_FLAT)" | ||||
|             v-model="flat" | ||||
|         /> | ||||
|         <label for="flat">{{ trans(ADDRESS_FLAT) }}</label> | ||||
|     </div> | ||||
|     <div :class="isNoAddress ? 'col-lg-12' : 'col-lg-6'"> | ||||
|         <div class="form-floating my-1" v-if="!isNoAddress"> | ||||
|             <input | ||||
|                 class="form-control" | ||||
|                 type="text" | ||||
|                 name="buildingName" | ||||
|                 maxlength="255" | ||||
|                 :placeholder="trans(ADDRESS_BUILDING_NAME)" | ||||
|                 v-model="buildingName" | ||||
|             /> | ||||
|             <label for="buildingName">{{ | ||||
|                     trans(ADDRESS_BUILDING_NAME) | ||||
|                 }}</label> | ||||
|         </div> | ||||
|         <div class="form-floating my-1"> | ||||
|             <input | ||||
|                 class="form-control" | ||||
|                 type="text" | ||||
|                 name="extra" | ||||
|                 maxlength="255" | ||||
|                 :placeholder="trans(ADDRESS_EXTRA)" | ||||
|                 v-model="extra" | ||||
|             /> | ||||
|             <label for="extra">{{ trans(ADDRESS_EXTRA) }}</label> | ||||
|         </div> | ||||
|         <div class="form-floating my-1" v-if="!isNoAddress"> | ||||
|             <input | ||||
|                 class="form-control" | ||||
|                 type="text" | ||||
|                 name="distribution" | ||||
|                 maxlength="255" | ||||
|                 :placeholder="trans(ADDRESS_DISTRIBUTION)" | ||||
|                 v-model="distribution" | ||||
|             /> | ||||
|             <label for="distribution">{{ | ||||
|                     trans(ADDRESS_DISTRIBUTION) | ||||
|                 }}</label> | ||||
|         </div> | ||||
|     </div> | ||||
| </template> | ||||
|  | ||||
| <style scoped lang="scss"> | ||||
|  | ||||
| </style> | ||||
| @@ -0,0 +1,39 @@ | ||||
| <script setup lang="ts"> | ||||
| import { ADDRESS_PICKER_SEARCH_FOR_ADDRESSES, trans } from 'translator'; | ||||
| const emits = defineEmits<{ | ||||
|     search: [search: string]; | ||||
| }>(); | ||||
|  | ||||
| let searchTimer = 0; | ||||
| let searchString: string; | ||||
|  | ||||
| const onInput = function (event: InputEvent) { | ||||
|     const target = event.target as HTMLInputElement; | ||||
|     const value = target.value; | ||||
|     searchString = value; | ||||
|  | ||||
|     if (0 === searchTimer) { | ||||
|         window.clearTimeout(searchTimer); | ||||
|         searchTimer = 0; | ||||
|     } | ||||
|  | ||||
|     searchTimer = window.setTimeout(() => { | ||||
|         if (value === searchString) { | ||||
|             emits("search", value); | ||||
|         } | ||||
|     }, 500); | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|     <div class="input-group mb-3"> | ||||
|         <span class="input-group-text"> | ||||
|             <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-search" viewBox="0 0 16 16"> | ||||
|               <path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001q.044.06.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1 1 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0"/> | ||||
|             </svg> | ||||
|         </span> | ||||
|         <input type="search" class="form-control" @input="onInput" :placeholder="trans(ADDRESS_PICKER_SEARCH_FOR_ADDRESSES)" /> | ||||
|     </div> | ||||
| </template> | ||||
|  | ||||
| <style scoped lang="scss"></style> | ||||
| @@ -0,0 +1,69 @@ | ||||
| import {AddressReference, TranslatableString} from "ChillMainAssets/types"; | ||||
|  | ||||
| export interface AddressAggregated { | ||||
|     row_number: number; | ||||
|     street: string; | ||||
|     postcode_id: number; | ||||
|     code: string; | ||||
|     label: string; | ||||
|     positions: Record<string, string>; | ||||
| } | ||||
|  | ||||
| export interface AssociatedPostalCode { | ||||
|     postcode_id: number; | ||||
|     code: string; | ||||
|     label: string; | ||||
|     country_id: number; | ||||
|     country_code: string; | ||||
|     country_name: TranslatableString; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * @throws {DOMException} when fetch is aborted, the property name is always equals to 'AbortError' | ||||
|  */ | ||||
| export const getAddressesAggregated = async ( | ||||
|     search: string, | ||||
|     abortController: AbortController, | ||||
| ): Promise<AddressAggregated[]> => { | ||||
|     const params = new URLSearchParams({ q: search.trim() }); | ||||
|     let response = null; | ||||
|  | ||||
|     response = await fetch( | ||||
|         `/api/1.0/main/address-reference/aggregated/search?${params}`, | ||||
|         { signal: abortController.signal }, | ||||
|     ); | ||||
|  | ||||
|     if (response.ok) { | ||||
|         return await response.json(); | ||||
|     } | ||||
|  | ||||
|     throw new Error(response.statusText); | ||||
| }; | ||||
|  | ||||
| export const getPostalCodes = async ( | ||||
|     search: string, | ||||
|     abortController: AbortController, | ||||
| ): Promise<AssociatedPostalCode[]> => { | ||||
|     const params = new URLSearchParams({ q: search.trim() }); | ||||
|     let response = null; | ||||
|  | ||||
|     response = await fetch( | ||||
|         `/api/1.0/main/address-reference/postal-code/search?${params}`, | ||||
|         { signal: abortController.signal }, | ||||
|     ); | ||||
|  | ||||
|     if (response.ok) { | ||||
|         return await response.json(); | ||||
|     } | ||||
|  | ||||
|     throw new Error(response.statusText); | ||||
| }; | ||||
|  | ||||
| export const fetchAddressReference = async (id: string): Promise<AddressReference> => { | ||||
|     const response = await fetch(`/api/1.0/main/address-reference/${id}.json`); | ||||
|     if (response.ok) { | ||||
|         return await response.json(); | ||||
|     } | ||||
|  | ||||
|     throw new Error(response.statusText); | ||||
| } | ||||
| @@ -0,0 +1,21 @@ | ||||
| import {Address, AddressCreation, AddressReference} from "ChillMainAssets/types"; | ||||
|  | ||||
| export const addressReferenceToAddress = (reference: AddressReference): AddressCreation => { | ||||
|     return { | ||||
|         street: reference.street, | ||||
|         streetNumber: reference.streetNumber, | ||||
|         postcode: reference.postcode, | ||||
|         floor: "", | ||||
|         corridor: "", | ||||
|         steps: "", | ||||
|         flat: "", | ||||
|         buildingName: "", | ||||
|         distribution: "", | ||||
|         extra: "", | ||||
|         confidential: false, | ||||
|         addressReference: reference, | ||||
|         point: reference.point, | ||||
|         isNoAddress: false, | ||||
|         validFrom: null, | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,12 @@ | ||||
| import { createApp } from "vue"; | ||||
| import AddressButton from "ChillMainAssets/vuejs/AddressPicker/AddressButton.vue"; | ||||
|  | ||||
| document.addEventListener("DOMContentLoaded", async () => { | ||||
|     document | ||||
|         .querySelectorAll<HTMLDivElement>("div[data-address-picker]") | ||||
|         .forEach((elem): void => { | ||||
|             const app = createApp(AddressButton); | ||||
|  | ||||
|             app.mount(elem); | ||||
|         }); | ||||
| }); | ||||
| @@ -10,7 +10,8 @@ import { computed, onMounted, ref } from "vue"; | ||||
| import { StoredObject, StoredObjectStatus } from "ChillDocStoreAssets/types"; | ||||
| import { fetchExportGenerationStatus } from "ChillMainAssets/lib/api/export"; | ||||
| import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue"; | ||||
| import { ExportGeneration } from "ChillMainAssets/types"; | ||||
| import WaitingScreen from "../_components/WaitingScreen.vue"; | ||||
| import { ExportGeneration, WaitingScreenState } from "ChillMainAssets/types"; | ||||
|  | ||||
| interface AppProps { | ||||
|     exportGenerationId: string; | ||||
| @@ -34,13 +35,16 @@ const storedObject = computed<null | StoredObject>(() => { | ||||
| }); | ||||
|  | ||||
| const isPending = computed<boolean>(() => status.value === "pending"); | ||||
| const isFetching = computed<boolean>( | ||||
|     () => tryiesForReady.value < maxTryiesForReady, | ||||
| ); | ||||
| const isReady = computed<boolean>(() => status.value === "ready"); | ||||
| const isFailure = computed<boolean>(() => status.value === "failure"); | ||||
| const filename = computed<string>(() => `${props.title}-${props.createdDate}`); | ||||
|  | ||||
| const state = computed<WaitingScreenState>((): WaitingScreenState => { | ||||
|     if (status.value === "empty") { | ||||
|         return "pending"; | ||||
|     } | ||||
|  | ||||
|     return status.value; | ||||
| }); | ||||
|  | ||||
| /** | ||||
|  * counter for the number of times that we check for a new status | ||||
|  */ | ||||
| @@ -85,57 +89,36 @@ onMounted(() => { | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|     <div id="waiting-screen"> | ||||
|         <div | ||||
|             v-if="isPending && isFetching" | ||||
|             class="alert alert-danger text-center" | ||||
|         > | ||||
|             <div> | ||||
|                 <p> | ||||
|                     {{ trans(EXPORT_GENERATION_EXPORT_GENERATION_IS_PENDING) }} | ||||
|                 </p> | ||||
|             </div> | ||||
|     <WaitingScreen :state="state"> | ||||
|         <template v-slot:pending> | ||||
|             <p> | ||||
|                 {{ trans(EXPORT_GENERATION_EXPORT_GENERATION_IS_PENDING) }} | ||||
|             </p> | ||||
|         </template> | ||||
|  | ||||
|             <div> | ||||
|                 <i class="fa fa-cog fa-spin fa-3x fa-fw"></i> | ||||
|                 <span class="sr-only">Loading...</span> | ||||
|             </div> | ||||
|         </div> | ||||
|         <div v-if="isPending && !isFetching" class="alert alert-info"> | ||||
|             <div> | ||||
|                 <p> | ||||
|                     {{ trans(EXPORT_GENERATION_TOO_MANY_RETRIES) }} | ||||
|                 </p> | ||||
|             </div> | ||||
|         </div> | ||||
|         <div v-if="isFailure" class="alert alert-danger text-center"> | ||||
|             <div> | ||||
|                 <p> | ||||
|                     {{ trans(EXPORT_GENERATION_ERROR_WHILE_GENERATING_EXPORT) }} | ||||
|                 </p> | ||||
|             </div> | ||||
|         </div> | ||||
|         <div v-if="isReady" class="alert alert-success text-center"> | ||||
|             <div> | ||||
|                 <p> | ||||
|                     {{ trans(EXPORT_GENERATION_EXPORT_READY) }} | ||||
|                 </p> | ||||
|         <template v-slot:stopped> | ||||
|             <p> | ||||
|                 {{ trans(EXPORT_GENERATION_TOO_MANY_RETRIES) }} | ||||
|             </p> | ||||
|         </template> | ||||
|  | ||||
|                 <p v-if="storedObject !== null"> | ||||
|                     <document-action-buttons-group | ||||
|                         :stored-object="storedObject" | ||||
|                         :filename="filename" | ||||
|                     ></document-action-buttons-group> | ||||
|                 </p> | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
|         <template v-slot:failure> | ||||
|             <p> | ||||
|                 {{ trans(EXPORT_GENERATION_ERROR_WHILE_GENERATING_EXPORT) }} | ||||
|             </p> | ||||
|         </template> | ||||
|  | ||||
|         <template v-slot:ready> | ||||
|             <p> | ||||
|                 {{ trans(EXPORT_GENERATION_EXPORT_READY) }} | ||||
|             </p> | ||||
|  | ||||
|             <p v-if="storedObject !== null"> | ||||
|                 <document-action-buttons-group | ||||
|                     :stored-object="storedObject" | ||||
|                     :filename="filename" | ||||
|                 ></document-action-buttons-group> | ||||
|             </p> | ||||
|         </template> | ||||
|     </WaitingScreen> | ||||
| </template> | ||||
|  | ||||
| <style scoped lang="scss"> | ||||
| #waiting-screen { | ||||
|     > .alert { | ||||
|         min-height: 350px; | ||||
|     } | ||||
| } | ||||
| </style> | ||||
|   | ||||
| @@ -0,0 +1,75 @@ | ||||
| <script setup lang="ts"> | ||||
| import { useIntervalFn } from "@vueuse/core"; | ||||
| import { fetchWorkflow } from "ChillMainAssets/lib/workflow/api"; | ||||
| import { returnPathOr } from "ChillMainAssets/lib/return_path/returnPathHelper"; | ||||
| import { ref } from "vue"; | ||||
| import WaitingScreen from "ChillMainAssets/vuejs/_components/WaitingScreen.vue"; | ||||
| import { WaitingScreenState } from "ChillMainAssets/types"; | ||||
| import { | ||||
|     trans, | ||||
|     WORKFLOW_WAIT_TITLE, | ||||
|     WORKFLOW_WAIT_ERROR_WHILE_WAITING, | ||||
|     WORKFLOW_WAIT_SUCCESS, | ||||
| } from "translator"; | ||||
|  | ||||
| interface WaitPostProcessWorkflowComponentProps { | ||||
|     workflowId: number; | ||||
|     expectedStep: string; | ||||
| } | ||||
|  | ||||
| const props = defineProps<WaitPostProcessWorkflowComponentProps>(); | ||||
| const counter = ref<number>(0); | ||||
| const MAX_TRYIES = 50; | ||||
|  | ||||
| const state = ref<WaitingScreenState>("pending"); | ||||
|  | ||||
| const { pause, resume } = useIntervalFn( | ||||
|     async () => { | ||||
|         try { | ||||
|             const workflow = await fetchWorkflow(props.workflowId); | ||||
|             counter.value++; | ||||
|             if (workflow.currentStep.currentStep.name === props.expectedStep) { | ||||
|                 window.location.assign( | ||||
|                     returnPathOr("/fr/main/workflow" + workflow.id + "/show"), | ||||
|                 ); | ||||
|                 resume(); | ||||
|                 state.value = "ready"; | ||||
|             } | ||||
|  | ||||
|             if (counter.value > MAX_TRYIES) { | ||||
|                 pause(); | ||||
|                 state.value = "failure"; | ||||
|             } | ||||
|         } catch (error) { | ||||
|             console.error(error); | ||||
|             pause(); | ||||
|         } | ||||
|     }, | ||||
|     2000, | ||||
|     { immediate: true }, | ||||
| ); | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|     <div class="container"> | ||||
|         <WaitingScreen :state="state"> | ||||
|             <template v-slot:pending> | ||||
|                 <p> | ||||
|                     {{ trans(WORKFLOW_WAIT_TITLE) }} | ||||
|                 </p> | ||||
|             </template> | ||||
|             <template v-slot:failure> | ||||
|                 <p> | ||||
|                     {{ trans(WORKFLOW_WAIT_ERROR_WHILE_WAITING) }} | ||||
|                 </p> | ||||
|             </template> | ||||
|             <template v-slot:ready> | ||||
|                 <p> | ||||
|                     {{ trans(WORKFLOW_WAIT_SUCCESS) }} | ||||
|                 </p> | ||||
|             </template> | ||||
|         </WaitingScreen> | ||||
|     </div> | ||||
| </template> | ||||
|  | ||||
| <style scoped lang="scss"></style> | ||||
| @@ -0,0 +1,51 @@ | ||||
| import { createApp } from "vue"; | ||||
| import App from "./App.vue"; | ||||
|  | ||||
| function mountApp(): void { | ||||
|     const el = document.querySelector<HTMLDivElement>(".screen-wait"); | ||||
|     if (!el) { | ||||
|         console.error( | ||||
|             "WaitPostProcessWorkflow: mount element .screen-wait not found", | ||||
|         ); | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     const workflowIdAttr = el.getAttribute("data-workflow-id"); | ||||
|     const expectedStep = el.getAttribute("data-expected-step") || ""; | ||||
|  | ||||
|     if (!workflowIdAttr) { | ||||
|         console.error( | ||||
|             "WaitPostProcessWorkflow: data-workflow-id attribute missing on mount element", | ||||
|         ); | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     if (!expectedStep) { | ||||
|         console.error( | ||||
|             "WaitPostProcessWorkflow: data-expected-step attribute missing on mount element", | ||||
|         ); | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     const workflowId = Number(workflowIdAttr); | ||||
|     if (Number.isNaN(workflowId)) { | ||||
|         console.error( | ||||
|             "WaitPostProcessWorkflow: data-workflow-id is not a valid number:", | ||||
|             workflowIdAttr, | ||||
|         ); | ||||
|         return; | ||||
|     } | ||||
|  | ||||
|     const app = createApp(App, { | ||||
|         workflowId, | ||||
|         expectedStep, | ||||
|     }); | ||||
|  | ||||
|     app.mount(el); | ||||
| } | ||||
|  | ||||
| if (document.readyState === "loading") { | ||||
|     document.addEventListener("DOMContentLoaded", mountApp); | ||||
| } else { | ||||
|     mountApp(); | ||||
| } | ||||
| @@ -1,10 +1,11 @@ | ||||
| <script setup lang="ts"> | ||||
| import { computed, useTemplateRef } from "vue"; | ||||
| import type { WorkflowAttachment } from "ChillMainAssets/types"; | ||||
| import { computed, onMounted, ref, useTemplateRef } from "vue"; | ||||
| import type { EntityWorkflow, WorkflowAttachment } from "ChillMainAssets/types"; | ||||
| import PickGenericDocModal from "ChillMainAssets/vuejs/WorkflowAttachment/Component/PickGenericDocModal.vue"; | ||||
| import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc"; | ||||
| import AttachmentList from "ChillMainAssets/vuejs/WorkflowAttachment/Component/AttachmentList.vue"; | ||||
| import { GenericDoc } from "ChillDocStoreAssets/types"; | ||||
| import { fetchWorkflow } from "ChillMainAssets/lib/workflow/api"; | ||||
|  | ||||
| interface AppConfig { | ||||
|     workflowId: number; | ||||
| @@ -34,6 +35,13 @@ const attachedGenericDoc = computed<GenericDocForAccompanyingPeriod[]>( | ||||
|             ) as GenericDocForAccompanyingPeriod[], | ||||
| ); | ||||
|  | ||||
| const workflow = ref<EntityWorkflow | null>(null); | ||||
|  | ||||
| onMounted(async () => { | ||||
|     workflow.value = await fetchWorkflow(Number(props.workflowId)); | ||||
|     console.log("workflow", workflow.value); | ||||
| }); | ||||
|  | ||||
| const openModal = function () { | ||||
|     pickDocModal.value?.openModal(); | ||||
| }; | ||||
| @@ -49,20 +57,30 @@ const onPickGenericDoc = ({ | ||||
| const onRemoveAttachment = (payload: { attachment: WorkflowAttachment }) => { | ||||
|     emit("removeAttachment", payload); | ||||
| }; | ||||
|  | ||||
| const canEditAttachement = computed<boolean>(() => { | ||||
|     if (null === workflow.value) { | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     return workflow.value._permissions.CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT; | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|     <pick-generic-doc-modal | ||||
|         :workflow="workflow" | ||||
|         :accompanying-period-id="props.accompanyingPeriodId" | ||||
|         :to-remove="attachedGenericDoc" | ||||
|         ref="pickDocModal" | ||||
|         @pickGenericDoc="onPickGenericDoc" | ||||
|     ></pick-generic-doc-modal> | ||||
|     <attachment-list | ||||
|         :workflow="workflow" | ||||
|         :attachments="props.attachments" | ||||
|         @removeAttachment="onRemoveAttachment" | ||||
|     ></attachment-list> | ||||
|     <ul class="record_actions"> | ||||
|     <ul v-if="canEditAttachement" class="record_actions"> | ||||
|         <li> | ||||
|             <button type="button" class="btn btn-create" @click="openModal"> | ||||
|                 Ajouter une pièce jointe | ||||
|   | ||||
| @@ -1,10 +1,11 @@ | ||||
| <script setup lang="ts"> | ||||
| import { WorkflowAttachment } from "ChillMainAssets/types"; | ||||
| import { EntityWorkflow, WorkflowAttachment } from "ChillMainAssets/types"; | ||||
| import GenericDocItemBox from "ChillMainAssets/vuejs/WorkflowAttachment/Component/GenericDocItemBox.vue"; | ||||
| import DocumentActionButtonsGroup from "ChillDocStoreAssets/vuejs/DocumentActionButtonsGroup.vue"; | ||||
|  | ||||
| interface AttachmentListProps { | ||||
|     attachments: WorkflowAttachment[]; | ||||
|     workflow: EntityWorkflow | null; | ||||
| } | ||||
|  | ||||
| const emit = defineEmits<{ | ||||
| @@ -36,7 +37,12 @@ const props = defineProps<AttachmentListProps>(); | ||||
|                             :stored-object="a.genericDoc.storedObject" | ||||
|                         ></document-action-buttons-group> | ||||
|                     </li> | ||||
|                     <li> | ||||
|                     <li | ||||
|                         v-if=" | ||||
|                             !workflow?._permissions | ||||
|                                 .CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT | ||||
|                         " | ||||
|                     > | ||||
|                         <button | ||||
|                             type="button" | ||||
|                             class="btn btn-delete" | ||||
|   | ||||
| @@ -6,8 +6,10 @@ import { | ||||
| import PickGenericDocItem from "ChillMainAssets/vuejs/WorkflowAttachment/Component/PickGenericDocItem.vue"; | ||||
| import { fetch_generic_docs_by_accompanying_period } from "ChillDocStoreAssets/js/generic-doc-api"; | ||||
| import { computed, onMounted, ref } from "vue"; | ||||
| import { EntityWorkflow } from "ChillMainAssets/types"; | ||||
|  | ||||
| interface PickGenericDocProps { | ||||
|     workflow: EntityWorkflow | null; | ||||
|     accompanyingPeriodId: number; | ||||
|     pickedList: GenericDocForAccompanyingPeriod[]; | ||||
|     toRemove: GenericDocForAccompanyingPeriod[]; | ||||
| @@ -36,9 +38,21 @@ const isPicked = (genericDoc: GenericDocForAccompanyingPeriod): boolean => | ||||
|     ) !== -1; | ||||
|  | ||||
| onMounted(async () => { | ||||
|     genericDocs.value = await fetch_generic_docs_by_accompanying_period( | ||||
|     const fetchedGenericDocs = await fetch_generic_docs_by_accompanying_period( | ||||
|         props.accompanyingPeriodId, | ||||
|     ); | ||||
|     const documentClasses = [ | ||||
|         "Chill\\DocStoreBundle\\Entity\\AccompanyingCourseDocument", | ||||
|         "Chill\\PersonBundle\\Entity\\AccompanyingPeriod\\AccompanyingPeriodWorkEvaluationDocument", | ||||
|         "Chill\\DocStoreBundle\\Entity\\PersonDocument", | ||||
|     ]; | ||||
|  | ||||
|     genericDocs.value = fetchedGenericDocs.filter( | ||||
|         (doc) => | ||||
|             !documentClasses.includes( | ||||
|                 props.workflow?.relatedEntityClass || "", | ||||
|             ) || props.workflow?.relatedEntityId !== doc.identifiers.id, | ||||
|     ); | ||||
|     loaded.value = true; | ||||
| }); | ||||
|  | ||||
|   | ||||
| @@ -3,8 +3,10 @@ import Modal from "ChillMainAssets/vuejs/_components/Modal.vue"; | ||||
| import { computed, ref, useTemplateRef } from "vue"; | ||||
| import PickGenericDoc from "ChillMainAssets/vuejs/WorkflowAttachment/Component/PickGenericDoc.vue"; | ||||
| import { GenericDocForAccompanyingPeriod } from "ChillDocStoreAssets/types/generic_doc"; | ||||
| import { EntityWorkflow } from "ChillMainAssets/types"; | ||||
|  | ||||
| interface PickGenericDocModalProps { | ||||
|     workflow: EntityWorkflow | null; | ||||
|     accompanyingPeriodId: number; | ||||
|     toRemove: GenericDocForAccompanyingPeriod[]; | ||||
| } | ||||
| @@ -80,6 +82,7 @@ defineExpose({ openModal, closeModal }); | ||||
|         </template> | ||||
|         <template v-slot:body> | ||||
|             <pick-generic-doc | ||||
|                 :workflow="props.workflow" | ||||
|                 :accompanying-period-id="props.accompanyingPeriodId" | ||||
|                 :to-remove="props.toRemove" | ||||
|                 :picked-list="pickeds" | ||||
|   | ||||
| @@ -4,24 +4,27 @@ | ||||
|         :show-button-details="false" | ||||
|     ></address-render-box> | ||||
|     <address-details-ref-matching | ||||
|         v-if="isAddress(props.address)" | ||||
|         :address="props.address" | ||||
|         @update-address="onUpdateAddress" | ||||
|     ></address-details-ref-matching> | ||||
|     <address-details-map :address="props.address"></address-details-map> | ||||
|     <address-details-geographical-layers | ||||
|         v-if="isAddress(props.address)" | ||||
|         :address="props.address" | ||||
|     ></address-details-geographical-layers> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { Address } from "../../../types"; | ||||
| import {Address, AddressCreation} from "../../../types"; | ||||
| import AddressDetailsMap from "./Parts/AddressDetailsMap.vue"; | ||||
| import AddressRenderBox from "../Entity/AddressRenderBox.vue"; | ||||
| import AddressDetailsGeographicalLayers from "./Parts/AddressDetailsGeographicalLayers.vue"; | ||||
| import AddressDetailsRefMatching from "./Parts/AddressDetailsRefMatching.vue"; | ||||
| import {isAddress} from "ChillMainAssets/vuejs/_components/AddressDetails/helper"; | ||||
|  | ||||
| interface AddressModalContentProps { | ||||
|     address: Address; | ||||
|     address: Address|AddressCreation; | ||||
| } | ||||
|  | ||||
| const props = defineProps<AddressModalContentProps>(); | ||||
|   | ||||
| @@ -12,90 +12,91 @@ | ||||
|         Voir sur | ||||
|         <a :href="makeUrlGoogleMap(props.address)" target="_blank" | ||||
|             >Google Maps</a | ||||
|         > | ||||
|         <a :href="makeUrlOsm(props.address)" target="_blank">OSM</a> | ||||
|         > <a | ||||
|           :href="makeUrlOsm(props.address)" target="_blank" | ||||
|         >OSM</a> | ||||
|     </p> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts" setup> | ||||
| import { onMounted, ref } from "vue"; | ||||
| import {computed, onMounted, ref} from "vue"; | ||||
| import "leaflet/dist/leaflet.css"; | ||||
| import markerIconPng from "leaflet/dist/images/marker-icon.png"; | ||||
| import L, { LatLngExpression, LatLngTuple } from "leaflet"; | ||||
| import { Address, Point } from "../../../../types"; | ||||
| import {Address, AddressCreation, Point} from "../../../../types"; | ||||
| import {buildAddressLines, getAddressPoint} from "ChillMainAssets/vuejs/_components/AddressDetails/helper"; | ||||
| import {useLeafletDisplayLayer, useLeafletMap, useLeafletMarker, useLeafletTileLayer} from "vue-use-leaflet"; | ||||
|  | ||||
| const lonLatForLeaflet = (point: Point): LatLngTuple => { | ||||
|     return [point.coordinates[1], point.coordinates[0]]; | ||||
| }; | ||||
|  | ||||
| export interface MapProps { | ||||
|     address: Address; | ||||
|     address: Address|AddressCreation; | ||||
| } | ||||
|  | ||||
| const props = defineProps<MapProps>(); | ||||
|  | ||||
| const map_div = ref<HTMLDivElement | null>(null); | ||||
| let map: L.Map | null = null; | ||||
| let marker: L.Marker | null = null; | ||||
|  | ||||
| onMounted(() => { | ||||
|     if (map_div.value === null) { | ||||
|         // there is no map div when the address does not have any Point | ||||
|         return; | ||||
| const markerIcon = L.icon({ | ||||
|     iconUrl: markerIconPng, | ||||
|     iconAnchor: [12, 41], | ||||
| }); | ||||
| const latLngMarker = computed((): LatLngExpression => { | ||||
|     if (props.address === null || props.address.point === null) { | ||||
|         return [0, 0, 0]; | ||||
|     } | ||||
|  | ||||
|     if (props.address.point !== null) { | ||||
|         map = L.map(map_div.value); | ||||
|         map.setView(lonLatForLeaflet(props.address.point), 18); | ||||
|  | ||||
|         L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { | ||||
|             attribution: | ||||
|                 '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors', | ||||
|         }).addTo(map); | ||||
|  | ||||
|         const markerIcon = L.icon({ | ||||
|             iconUrl: markerIconPng, | ||||
|             iconAnchor: [12, 41], | ||||
|         }); | ||||
|  | ||||
|         marker = L.marker(lonLatForLeaflet(props.address.point), { | ||||
|             icon: markerIcon, | ||||
|         }); | ||||
|         marker.addTo(map); | ||||
|     } | ||||
|     return [props.address.point.coordinates[1], props.address.point.coordinates[0], 0] | ||||
| }); | ||||
|  | ||||
| const makeUrlGoogleMap = (address: Address): string => { | ||||
| const map_div = ref<HTMLDivElement | null>(null); | ||||
| const map = useLeafletMap(map_div, {zoom: 18, center: latLngMarker}); | ||||
| const tileLayer = useLeafletTileLayer( | ||||
|     "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { | ||||
|         attribution: | ||||
|             '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors', | ||||
|     } | ||||
| ); | ||||
| useLeafletDisplayLayer(map, tileLayer); | ||||
|  | ||||
|  | ||||
| const marker = useLeafletMarker(latLngMarker, {icon: markerIcon}); | ||||
| useLeafletDisplayLayer(map, marker); | ||||
|  | ||||
|  | ||||
| const makeUrlGoogleMap = (address: Address|AddressCreation): string => { | ||||
|     const params = new URLSearchParams(); | ||||
|     params.append("api", "1"); | ||||
|     if (address.point !== null && address.addressReference !== null) { | ||||
|     const point = getAddressPoint(address); | ||||
|     if (point !== null && address.addressReference !== null) { | ||||
|         params.append( | ||||
|             "query", | ||||
|             `${address.point.coordinates[1]} ${address.point.coordinates[0]}`, | ||||
|             `${point.coordinates[1]} ${point.coordinates[0]}`, | ||||
|         ); | ||||
|     } else { | ||||
|         params.append("query", address.lines.join(", ")); | ||||
|         params.append("query", buildAddressLines(address).join(", ")); | ||||
|     } | ||||
|  | ||||
|     return `https://www.google.com/maps/search/?${params.toString()}`; | ||||
| }; | ||||
|  | ||||
| const makeUrlOsm = (address: Address): string => { | ||||
|     if (address.point !== null && address.addressReference !== null) { | ||||
| const makeUrlOsm = (address: Address|AddressCreation): string => { | ||||
|     const point = getAddressPoint(address); | ||||
|     if (point !== null && address.addressReference !== null) { | ||||
|         const params = new URLSearchParams(); | ||||
|         params.append("mlat", `${address.point.coordinates[1]}`); | ||||
|         params.append("mlon", `${address.point.coordinates[0]}`); | ||||
|         params.append("mlat", `${point.coordinates[1]}`); | ||||
|         params.append("mlon", `${point.coordinates[0]}`); | ||||
|         const hashParams = new URLSearchParams(); | ||||
|         hashParams.append( | ||||
|             "map", | ||||
|             `18/${address.point.coordinates[1]}/${address.point.coordinates[0]}`, | ||||
|             `18/${point.coordinates[1]}/${point.coordinates[0]}`, | ||||
|         ); | ||||
|  | ||||
|         return `https://www.openstreetmap.org/?${params.toString()}#${hashParams.toString()}`; | ||||
|     } | ||||
|  | ||||
|     const params = new URLSearchParams(); | ||||
|     params.append("query", address.lines.join(", ")); | ||||
|     params.append("query", buildAddressLines(address).join(", ")); | ||||
|  | ||||
|     return `https://www.openstreetmap.org/search?${params.toString()}`; | ||||
| }; | ||||
|   | ||||
| @@ -0,0 +1,46 @@ | ||||
| import {Address, AddressCreation, Point} from "ChillMainAssets/types"; | ||||
| import addressFormatter, {Input} from "@fragaria/address-formatter"; | ||||
|  | ||||
| /** | ||||
|  * Checks if the given object is of type Address by verifying the existence | ||||
|  * of the `lines` property and confirming that it is an array of strings. | ||||
|  * | ||||
|  * @param {AddressCreation | Address} obj - The object to check. | ||||
|  * @return {boolean} Returns true if the object is of type Address, otherwise false. | ||||
|  */ | ||||
| export function isAddress(obj: AddressCreation | Address): obj is Address { | ||||
|     return (obj as any).lines !== undefined && Array.isArray((obj as any).lines); | ||||
| } | ||||
|  | ||||
| function buildAddressFormatterObject(address: AddressCreation): Input { | ||||
|     return { | ||||
|         city: address.postcode.name, | ||||
|         postcode: address.postcode.code, | ||||
|         countryCode: address.postcode.country.code, | ||||
|         street: address.street, | ||||
|         houseNumber: address.streetNumber, | ||||
|     }; | ||||
| } | ||||
|  | ||||
|  | ||||
| export const buildAddressLines = (address: AddressCreation|Address): string[] => { | ||||
|     if (isAddress(address)) { | ||||
|         return address.lines; | ||||
|     } | ||||
|  | ||||
|     const lines = addressFormatter.format(buildAddressFormatterObject(address), {output: 'array', countryCode: address.addressReference.postcode.country.code }); | ||||
|     console.log('lines:', lines); | ||||
|     return lines; | ||||
| } | ||||
|  | ||||
| export const buildAddressText = (address: AddressCreation|Address): string => { | ||||
|     return buildAddressLines(address).join(' - '); | ||||
| } | ||||
|  | ||||
| export const getAddressPoint = (address: AddressCreation|Address): Point|null => { | ||||
|     if (isAddress(address)) { | ||||
|         return address.point; | ||||
|     } | ||||
|  | ||||
|     return address.addressReference?.point; | ||||
| } | ||||
| @@ -4,14 +4,14 @@ | ||||
|             <div v-if="isConfidential"> | ||||
|                 <confidential :position-btn-far="true"> | ||||
|                     <template #confidential-content> | ||||
|                         <div v-if="isMultiline === true"> | ||||
|                         <div v-if="isMultiline"> | ||||
|                             <p | ||||
|                                 v-for="(l, i) in address.lines" | ||||
|                                 v-for="(l, i) in buildAddressLines(address)" | ||||
|                                 :key="`line-${i}`" | ||||
|                             > | ||||
|                                 {{ l }} | ||||
|                             </p> | ||||
|                             <p v-if="showButtonDetails"> | ||||
|                             <p v-if="showButtonDetails && isAddress(address) "> | ||||
|                                 <address-details-button | ||||
|                                     :address_id="address.address_id" | ||||
|                                     :address_ref_status="address.refStatus" | ||||
| @@ -19,8 +19,8 @@ | ||||
|                             </p> | ||||
|                         </div> | ||||
|                         <div v-else> | ||||
|                             <p v-if="'' !== address.text" class="street"> | ||||
|                                 {{ address.text }} | ||||
|                             <p v-if="'' !== buildAddressText(address)" class="street"> | ||||
|                                 {{ buildAddressText(address) }} | ||||
|                             </p> | ||||
|                             <p | ||||
|                                 v-if="null !== address.postcode" | ||||
| @@ -29,8 +29,8 @@ | ||||
|                                 {{ address.postcode.code }} | ||||
|                                 {{ address.postcode.name }} | ||||
|                             </p> | ||||
|                             <p v-if="null !== address.country" class="country"> | ||||
|                                 {{ localizeString(address.country.name) }} | ||||
|                             <p v-if="null !== address.postcode" class="country"> | ||||
|                                 {{ localizeString(address.postcode.country.name) }} | ||||
|                             </p> | ||||
|                         </div> | ||||
|                     </template> | ||||
| @@ -38,11 +38,11 @@ | ||||
|             </div> | ||||
|  | ||||
|             <div v-if="!isConfidential"> | ||||
|                 <div v-if="isMultiline === true"> | ||||
|                     <p v-for="(l, i) in address.lines" :key="`line-${i}`"> | ||||
|                 <div v-if="isMultiline"> | ||||
|                     <p v-for="(l, i) in buildAddressLines(address)" :key="`line-${i}`"> | ||||
|                         {{ l }} | ||||
|                     </p> | ||||
|                     <p v-if="showButtonDetails"> | ||||
|                     <p v-if="showButtonDetails && isAddress(address) "> | ||||
|                         <address-details-button | ||||
|                             :address_id="address.address_id" | ||||
|                             :address_ref_status="address.refStatus" | ||||
| @@ -50,9 +50,9 @@ | ||||
|                     </p> | ||||
|                 </div> | ||||
|                 <div v-else> | ||||
|                     <p v-if="address.text" class="street"> | ||||
|                         {{ address.text }} | ||||
|                         <template v-if="showButtonDetails"> | ||||
|                     <p v-if="'' !== buildAddressText(address)" class="street"> | ||||
|                         {{ buildAddressText(address)}} | ||||
|                         <template v-if="showButtonDetails && isAddress(address) "> | ||||
|                             <address-details-button | ||||
|                                 :address_id="address.address_id" | ||||
|                                 :address_ref_status="address.refStatus" | ||||
| @@ -63,68 +63,49 @@ | ||||
|             </div> | ||||
|         </component> | ||||
|  | ||||
|         <div v-if="useDatePane === true" class="address-more"> | ||||
|         <div v-if="useDatePane" class="address-more"> | ||||
|             <div v-if="address.validFrom"> | ||||
|                 <span class="validFrom"> | ||||
|                     <b>{{ trans(ADDRESS_VALID_FROM) }}</b | ||||
|                     >: {{ $d(address.validFrom.date) }} | ||||
|                     >: {{ address.validFrom?.datetime8601 }} | ||||
|                 </span> | ||||
|             </div> | ||||
|             <div v-if="address.validTo"> | ||||
|             <div v-if="isAddress(address) && address.validTo !== null"> | ||||
|                 <span class="validTo"> | ||||
|                     <b>{{ trans(ADDRESS_VALID_TO) }}</b | ||||
|                     >: {{ $d(address.validTo.date) }} | ||||
|                     >: {{ address.validTo?.datetime8601 }} | ||||
|                 </span> | ||||
|             </div> | ||||
|         </div> | ||||
|     </component> | ||||
| </template> | ||||
|  | ||||
| <script> | ||||
| <script setup lang="ts"> | ||||
| import { computed } from "vue"; | ||||
| import Confidential from "ChillMainAssets/vuejs/_components/Confidential.vue"; | ||||
| import AddressDetailsButton from "ChillMainAssets/vuejs/_components/AddressDetails/AddressDetailsButton.vue"; | ||||
| import { trans, ADDRESS_VALID_FROM, ADDRESS_VALID_TO } from "translator"; | ||||
| import { localizeString } from "ChillMainAssets/lib/localizationHelper/localizationHelper"; | ||||
| import {Address, AddressCreation} from "ChillMainAssets/types"; | ||||
| import {isAddress, buildAddressLines, buildAddressText} from "ChillMainAssets/vuejs/_components/AddressDetails/helper"; | ||||
|  | ||||
| export default { | ||||
|     name: "AddressRenderBox", | ||||
|     methods: { localizeString }, | ||||
|     components: { | ||||
|         Confidential, | ||||
|         AddressDetailsButton, | ||||
|     }, | ||||
|     props: { | ||||
|         address: { | ||||
|             type: Object, | ||||
|         }, | ||||
|         isMultiline: { | ||||
|             default: true, | ||||
|             type: Boolean, | ||||
|         }, | ||||
|         useDatePane: { | ||||
|             default: false, | ||||
|             type: Boolean, | ||||
|         }, | ||||
|         showButtonDetails: { | ||||
|             default: true, | ||||
|             type: Boolean, | ||||
|         }, | ||||
|     }, | ||||
|     setup() { | ||||
|         return { trans, ADDRESS_VALID_FROM, ADDRESS_VALID_TO }; | ||||
|     }, | ||||
|     computed: { | ||||
|         component() { | ||||
|             return this.isMultiline === true ? "div" : "span"; | ||||
|         }, | ||||
|         multiline() { | ||||
|             return this.isMultiline === true ? "multiline" : ""; | ||||
|         }, | ||||
|         isConfidential() { | ||||
|             return this.address.confidential; | ||||
|         }, | ||||
|     }, | ||||
| }; | ||||
| const props = withDefaults( | ||||
|     defineProps<{ | ||||
|         address: Address|AddressCreation; | ||||
|         isMultiline?: boolean; | ||||
|         useDatePane?: boolean; | ||||
|         showButtonDetails?: boolean; | ||||
|     }>(), | ||||
|     { | ||||
|         isMultiline: true, | ||||
|         useDatePane: false, | ||||
|         showButtonDetails: true, | ||||
|     } | ||||
| ); | ||||
|  | ||||
| const component = computed(() => (props.isMultiline ? "div" : "span")); | ||||
| const multiline = computed(() => (props.isMultiline ? "multiline" : "")); | ||||
| const isConfidential = computed(() => Boolean(props.address?.confidential)); | ||||
| </script> | ||||
|  | ||||
| <style lang="scss" scoped> | ||||
|   | ||||
| @@ -84,6 +84,8 @@ const emits = defineEmits<{ | ||||
| } | ||||
| .modal-header .close { | ||||
|     border-top-right-radius: 0.3rem; | ||||
|     margin-right: 0; | ||||
|     margin-left: auto; | ||||
| } | ||||
| /* | ||||
|    * The following styles are auto-applied to elements with | ||||
|   | ||||
| @@ -0,0 +1,62 @@ | ||||
| <script setup lang="ts"> | ||||
| import { WaitingScreenState } from "ChillMainAssets/types"; | ||||
|  | ||||
| interface Props { | ||||
|     state: WaitingScreenState; | ||||
| } | ||||
|  | ||||
| const props = defineProps<Props>(); | ||||
| </script> | ||||
|  | ||||
| <template> | ||||
|     <div id="waiting-screen"> | ||||
|         <div | ||||
|             v-if="props.state === 'pending' && !!$slots.pending" | ||||
|             class="alert alert-danger text-center" | ||||
|         > | ||||
|             <div> | ||||
|                 <slot name="pending"></slot> | ||||
|             </div> | ||||
|  | ||||
|             <div> | ||||
|                 <i class="fa fa-cog fa-spin fa-3x fa-fw"></i> | ||||
|                 <span class="sr-only">Loading...</span> | ||||
|             </div> | ||||
|         </div> | ||||
|  | ||||
|         <div | ||||
|             v-if="props.state === 'stopped' && !!$slots.stopped" | ||||
|             class="alert alert-info" | ||||
|         > | ||||
|             <div> | ||||
|                 <slot name="stopped"></slot> | ||||
|             </div> | ||||
|         </div> | ||||
|  | ||||
|         <div | ||||
|             v-if="props.state === 'failure' && !!$slots.failure" | ||||
|             class="alert alert-danger text-center" | ||||
|         > | ||||
|             <div> | ||||
|                 <slot name="failure"></slot> | ||||
|             </div> | ||||
|         </div> | ||||
|  | ||||
|         <div | ||||
|             v-if="props.state === 'ready' && !!$slots.ready" | ||||
|             class="alert alert-success text-center" | ||||
|         > | ||||
|             <div> | ||||
|                 <slot name="ready"></slot> | ||||
|             </div> | ||||
|         </div> | ||||
|     </div> | ||||
| </template> | ||||
|  | ||||
| <style scoped lang="scss"> | ||||
| #waiting-screen { | ||||
|     > .alert { | ||||
|         min-height: 350px; | ||||
|     } | ||||
| } | ||||
| </style> | ||||
| @@ -44,17 +44,7 @@ | ||||
|                 {% endif %} | ||||
|                 {% endif %} | ||||
|             {% endblock content_view_actions_duplicate_link %} | ||||
|             {% block content_view_actions_merge %} | ||||
|                 <li> | ||||
|                     <a href="{{ chill_path_add_return_path('chill_thirdparty_find_duplicate', | ||||
|                         { 'thirdparty_id': entity.id }) }}" | ||||
|                        title="{{ 'Merge'|trans }}" | ||||
|                        class="btn btn-misc"> | ||||
|                         <i class="bi bi-chevron-contract"></i> | ||||
|                         {{ 'Merge'|trans }} | ||||
|                     </a> | ||||
|                 </li> | ||||
|             {% endblock %} | ||||
|             {% block content_view_actions_merge %}{% endblock %} | ||||
|             {% block content_view_actions_edit_link %} | ||||
|                 {% if chill_crud_action_exists(crud_name, 'edit') %} | ||||
|                 {% if is_granted(chill_crud_config('role', crud_name, 'edit'), entity) %} | ||||
|   | ||||
| @@ -0,0 +1,15 @@ | ||||
| {% extends '@ChillMain/layout.html.twig' %} | ||||
|  | ||||
| {% block css %} | ||||
|     {{ encore_entry_link_tags('address_picker') }} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block js %} | ||||
|     {{ encore_entry_script_tags('address_picker') }} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block content %} | ||||
|  | ||||
|     <div data-address-picker="data-address-picker"></div> | ||||
|  | ||||
| {% endblock %} | ||||
| @@ -280,11 +280,17 @@ | ||||
|     </div> | ||||
| {% endblock %} | ||||
|  | ||||
| {% block pick_linked_entities_widget %} | ||||
|     <input type="hidden" {{ block('widget_attributes') }} {% if value is not empty %}value="{{ value|escape('html_attr') }}" {% endif %} data-input-uniqid="{{ form.vars['uniqid'] }}" /> | ||||
|     <div data-input-uniqid="{{ form.vars['uniqid'] }}" data-module="pick-linked-entities" data-pick-entities-type="{{ form.vars['pick-entities-type'] }}" | ||||
|     ></div> | ||||
|  | ||||
| {% block pick_linked_entities_widget %} | ||||
|     <input type="hidden" {{ block('widget_attributes') }} | ||||
|         {% if value is not empty %}value="{{ value|escape('html_attr') }}" {% endif %} | ||||
|        data-input-uniqid="{{ form.vars['uniqid'] }}"/> | ||||
|     <div | ||||
|         data-input-uniqid="{{ form.vars['uniqid'] }}" | ||||
|         data-module="pick-linked-entities" | ||||
|         data-pick-entities-type="{{ form.vars['pick-entities-type'] }}" | ||||
|          data-suggested="{{ form.vars['suggested']|json_encode|escape('html_attr') }}" | ||||
|     ></div> | ||||
| {% endblock %} | ||||
|  | ||||
| {% block pick_postal_code_widget %} | ||||
|   | ||||
| @@ -8,36 +8,36 @@ | ||||
|  | ||||
|     <div class="col-md-10"> | ||||
|         <h2>{{ 'absence.My absence'|trans }}</h2> | ||||
|         <div> | ||||
|             {% if user.absenceStart is not null %} | ||||
|                 <div class="alert alert-success flash_message">{{ 'absence.You are listed as absent, as of {date, date, short}'|trans({ | ||||
|                         date: user.absenceStart | ||||
|                     }) }} | ||||
|                     {% if user.absenceEnd is not null %} | ||||
|                    {{ 'until %date%'|trans({'%date%': user.absenceEnd|format_date('short') }) }} | ||||
|                 {% endif %} | ||||
|                 </div> | ||||
|             {% else %} | ||||
|                 <div class="alert alert-warning flash_message">{{ 'absence.No absence listed'|trans }}</div> | ||||
|             {% endif %} | ||||
|         </div> | ||||
|         <div> | ||||
|             {{ form_start(form) }} | ||||
|             {{ form_row(form.absenceStart) }} | ||||
|             {{ form_row(form.absenceEnd) }} | ||||
|  | ||||
|         {% if user.absenceStart is not null %} | ||||
|             <div> | ||||
|                 <p>{{ 'absence.You are listed as absent, as of'|trans }} {{ user.absenceStart|format_date('long') }}</p> | ||||
|                 <ul class="record_actions sticky-form-buttons"> | ||||
|                     <li> | ||||
|                         <a href="{{ path('chill_main_user_absence_unset') }}" | ||||
|                            class="btn btn-delete">{{ 'absence.Unset absence'|trans }}</a> | ||||
|                     </li> | ||||
|                 </ul> | ||||
|             </div> | ||||
|         {% else %} | ||||
|             <div> | ||||
|                 <p class="chill-no-data-statement">{{ 'absence.No absence listed'|trans }}</p> | ||||
|             </div> | ||||
|             <div> | ||||
|                 {{ form_start(form) }} | ||||
|                 {{ form_row(form.absenceStart) }} | ||||
|  | ||||
|                 <ul class="record_actions sticky-form-buttons"> | ||||
|                     <li> | ||||
|                         <button class="btn btn-save" type="submit"> | ||||
|                             {{ 'Save'|trans }} | ||||
|                         </button> | ||||
|                     </li> | ||||
|                 </ul> | ||||
|  | ||||
|                 {{ form_end(form) }} | ||||
|             </div> | ||||
|         {% endif %} | ||||
|             <ul class="record_actions sticky-form-buttons"> | ||||
|                 <li> | ||||
|                     <a class="btn btn-delete" title="Modifier" href="{{ path('chill_main_user_absence_unset') }}">{{ 'absence.Unset absence'|trans }}</a> | ||||
|                 </li> | ||||
|                 <li> | ||||
|                     <button class="btn btn-save" type="submit"> | ||||
|                         {{ 'Save'|trans }} | ||||
|                     </button> | ||||
|                 </li> | ||||
|             </ul> | ||||
|             {{ form_end(form) }} | ||||
|         </div> | ||||
|     </div> | ||||
|  | ||||
| {% endblock %} | ||||
|   | ||||
| @@ -5,7 +5,7 @@ | ||||
|         role="button" | ||||
|         data-bs-toggle="dropdown" | ||||
|         aria-expanded="false"> | ||||
|         <i class="fa fa-flash"></i> | ||||
|         <i class="bi bi-lightning-fill"></i> | ||||
|     </a> | ||||
|     <div class="dropdown-menu"> | ||||
|         {% for menu in menus %} | ||||
|   | ||||
| @@ -64,7 +64,7 @@ | ||||
|                             {{ form_widget(flag.immediate_email, {'label_attr': { 'class': 'checkbox-inline checkbox-switch'}}) }} | ||||
|                         </td> | ||||
|                         <td class="text-center"> | ||||
|                             {{ form_widget(flag.daily_email, {'label_attr': { 'class': 'checkbox-inline checkbox-switch'}}) }} | ||||
|                             {{ form_widget(flag.daily_digest, {'label_attr': { 'class': 'checkbox-inline checkbox-switch'}}) }} | ||||
|                         </td> | ||||
|                     </tr> | ||||
|                 {% endfor %} | ||||
|   | ||||
| @@ -58,12 +58,14 @@ | ||||
|         {% endif %} | ||||
|     </section> | ||||
|  | ||||
|     {% if signatures|length > 0 %} | ||||
|     <section class="step my-4">{% include '@ChillMain/Workflow/_signature.html.twig' %}</section> | ||||
|     {% endif %} | ||||
|  | ||||
|     <section class="step my-4">{% include '@ChillMain/Workflow/_attachment.html.twig' %}</section> | ||||
|  | ||||
|     <section class="step my-4">{% include '@ChillMain/Workflow/_follow.html.twig' %}</section> | ||||
|     {% if signatures|length > 0 %} | ||||
|         <section class="step my-4">{% include '@ChillMain/Workflow/_signature.html.twig' %}</section> | ||||
|     {% elseif entity_workflow.currentStep.sends|length > 0 %} | ||||
|     {% if entity_workflow.currentStep.sends|length > 0 %} | ||||
|         <section class="step my-4"> | ||||
|             <h2>{{ 'workflow.external_views.title'|trans({'numberOfSends': entity_workflow.currentStep.sends|length }) }}</h2> | ||||
|             {% include '@ChillMain/Workflow/_send_views_list.html.twig' with {'sends': entity_workflow.currentStep.sends} %} | ||||
|   | ||||
| @@ -0,0 +1,18 @@ | ||||
| {% extends '@ChillMain/layout.html.twig' %} | ||||
|  | ||||
| {% block title %}{{ 'workflow.signature.waiting_for'|trans }}{% endblock %} | ||||
|  | ||||
| {% block css %} | ||||
|     {{ encore_entry_link_tags('page_workflow_waiting_post_process') }} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block js %} | ||||
|     {{ encore_entry_script_tags('page_workflow_waiting_post_process') }} | ||||
| {% endblock %} | ||||
|  | ||||
| {% block content %} | ||||
|     <h1>{{ block('title') }}</h1> | ||||
|  | ||||
|     <div class="screen-wait" data-workflow-id="{{ workflow.id|e('html_attr') }}" data-expected-step="{{ expectedStep|e('html_attr') }}"></div> | ||||
|  | ||||
| {% endblock %} | ||||
| @@ -79,7 +79,7 @@ | ||||
|                             <div class="d-flex flex-row mb-5 alert alert-warning" role="alert"> | ||||
|                                 <p class="m-2">{{'absence.You are marked as being absent'|trans }}</p> | ||||
|                                 <span class="ms-auto"> | ||||
|                                     <a class="btn btn-remove" title="Modifier" href="{{ path('chill_main_user_absence_index') }}">{{ 'absence.Unset absence'|trans }}</a> | ||||
|                                     <a class="btn btn-delete" title="Modifier" href="{{ path('chill_main_user_absence_unset') }}">{{ 'absence.Unset absence'|trans }}</a> | ||||
|                                 </span> | ||||
|                             </div> | ||||
|                         {% endif %} | ||||
|   | ||||
| @@ -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\MainBundle\Security\Authorization; | ||||
|  | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflow; | ||||
| use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; | ||||
| use Symfony\Component\Security\Core\Authorization\Voter\Voter; | ||||
| use Symfony\Component\Workflow\Registry; | ||||
|  | ||||
| final class EntityWorkflowAttachmentVoter extends Voter | ||||
| { | ||||
|     public function __construct( | ||||
|         private readonly Registry $registry, | ||||
|     ) {} | ||||
|     public const EDIT = 'CHILL_MAIN_WORKFLOW_ATTACHMENT_EDIT'; | ||||
|  | ||||
|     protected function supports(string $attribute, $subject): bool | ||||
|     { | ||||
|         return $subject instanceof EntityWorkflow && self::EDIT === $attribute; | ||||
|     } | ||||
|  | ||||
|     protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool | ||||
|     { | ||||
|         if (!$subject instanceof EntityWorkflow) { | ||||
|             throw new \UnexpectedValueException('Subject must be an instance of EntityWorkflow'); | ||||
|         } | ||||
|  | ||||
|         if ($subject->isFinal()) { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         $workflow = $this->registry->get($subject, $subject->getWorkflowName()); | ||||
|  | ||||
|         $marking = $workflow->getMarking($subject); | ||||
|         foreach ($marking->getPlaces() as $place => $int) { | ||||
|             $placeMetadata = $workflow->getMetadataStore()->getPlaceMetadata($place); | ||||
|             if ($placeMetadata['isSentExternal'] ?? false) { | ||||
|                 return false; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return true; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										86
									
								
								src/Bundle/ChillMainBundle/Security/RoleDumper.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								src/Bundle/ChillMainBundle/Security/RoleDumper.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,86 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\MainBundle\Security; | ||||
|  | ||||
| use Symfony\Component\Security\Core\Role\RoleHierarchyInterface; | ||||
| use Symfony\Contracts\Translation\TranslatorInterface; | ||||
|  | ||||
| final readonly class RoleDumper | ||||
| { | ||||
|     public function __construct( | ||||
|         private RoleProvider $roleProvider, | ||||
|         private RoleHierarchyInterface $roleHierarchy, | ||||
|         private TranslatorInterface $translator, | ||||
|     ) {} | ||||
|  | ||||
|     public function dumpAsMarkdown(): string | ||||
|     { | ||||
|         $roles = $this->roleProvider->getRoles(); | ||||
|         $rolesWithoutScopes = $this->roleProvider->getRolesWithoutScopes(); | ||||
|  | ||||
|         // Group roles by title | ||||
|         $groups = []; | ||||
|         foreach ($roles as $role) { | ||||
|             $title = $this->roleProvider->getRoleTitle($role); | ||||
|             $title ??= 'Other'; | ||||
|             $groups[$title][] = $role; | ||||
|         } | ||||
|  | ||||
|         // Sort groups by title | ||||
|         ksort($groups, SORT_NATURAL | SORT_FLAG_CASE); | ||||
|  | ||||
|         $lines = []; | ||||
|         foreach ($groups as $title => $roleList) { | ||||
|             // Sort roles by translated label for deterministic output | ||||
|             usort($roleList, function (string $a, string $b): int { | ||||
|                 $ta = $this->translator->trans($a); | ||||
|                 $tb = $this->translator->trans($b); | ||||
|  | ||||
|                 return strcasecmp($ta, $tb); | ||||
|             }); | ||||
|  | ||||
|             $translatedTitle = $this->translator->trans($title); | ||||
|             $lines[] = '## '.$translatedTitle; | ||||
|  | ||||
|             foreach ($roleList as $role) { | ||||
|                 // Translate primary role | ||||
|                 $translatedRole = $this->translator->trans($role); | ||||
|  | ||||
|                 // Scope marker: (S) if needs scope, (~~S~~) if no scope required | ||||
|                 $needsScope = !in_array($role, $rolesWithoutScopes, true); | ||||
|                 $scopeMarker = $needsScope ? '(S)' : '(~~S~~)'; | ||||
|  | ||||
|                 // Compute dependent roles from hierarchy (exclude itself) | ||||
|                 $reachable = $this->roleHierarchy->getReachableRoleNames([$role]); | ||||
|                 $dependents = array_values(array_filter($reachable, static fn (string $r): bool => $r !== $role)); | ||||
|  | ||||
|                 // Translate dependents and sort deterministically | ||||
|                 $translatedDependents = array_map(fn (string $r) => $this->translator->trans($r), $dependents); | ||||
|                 sort($translatedDependents, SORT_NATURAL | SORT_FLAG_CASE); | ||||
|  | ||||
|                 if (count($translatedDependents) > 0) { | ||||
|                     $lines[] = sprintf('- **%s** %s: %s', $translatedRole, $scopeMarker, implode(', ', $translatedDependents)); | ||||
|                 } else { | ||||
|                     $lines[] = sprintf('- **%s** %s', $translatedRole, $scopeMarker); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             // Add a blank line between groups | ||||
|             $lines[] = ''; | ||||
|         } | ||||
|  | ||||
|         // Trim possible trailing blank line | ||||
|         $markdown = rtrim(implode("\n", $lines)); | ||||
|  | ||||
|         return $markdown."\n"; // End with newline for POSIX friendliness | ||||
|     } | ||||
| } | ||||
| @@ -52,12 +52,8 @@ class RoleProvider | ||||
|  | ||||
|     /** | ||||
|      * Get the title for each role. | ||||
|      * | ||||
|      * @param string $role | ||||
|      * | ||||
|      * @return string the title of the role | ||||
|      */ | ||||
|     public function getRoleTitle($role) | ||||
|     public function getRoleTitle(string $role): ?string | ||||
|     { | ||||
|         $this->initializeRolesTitlesCache(); | ||||
|  | ||||
| @@ -73,7 +69,7 @@ class RoleProvider | ||||
|     /** | ||||
|      * initialize the array for caching role and titles. | ||||
|      */ | ||||
|     private function initializeRolesTitlesCache() | ||||
|     private function initializeRolesTitlesCache(): void | ||||
|     { | ||||
|         // break if already initialized | ||||
|         if (null !== $this->rolesTitlesCache) { | ||||
|   | ||||
| @@ -66,6 +66,7 @@ class AddressNormalizer implements ContextAwareNormalizerInterface, NormalizerAw | ||||
|                     'name' => $address->getPostCode()->getName(), | ||||
|                     'code' => $address->getPostCode()->getCode(), | ||||
|                     'center' => $address->getPostcode()->getCenter(), | ||||
|                     'country' => $this->normalizer->normalize($address->getPostCode()->getCountry(), $format, [AbstractNormalizer::GROUPS => ['read']]), | ||||
|                 ], | ||||
|                 'country' => [ | ||||
|                     'id' => $address->getPostCode()->getCountry()->getId(), | ||||
|   | ||||
| @@ -12,18 +12,25 @@ declare(strict_types=1); | ||||
| namespace Chill\MainBundle\Serializer\Normalizer; | ||||
|  | ||||
| use Chill\MainBundle\Entity\Workflow\EntityWorkflow; | ||||
| use Chill\MainBundle\Security\Authorization\EntityWorkflowAttachmentVoter; | ||||
| use Chill\MainBundle\Workflow\EntityWorkflowManager; | ||||
| use Chill\MainBundle\Workflow\Helper\MetadataExtractor; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
| use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; | ||||
| use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; | ||||
| use Symfony\Component\Serializer\Normalizer\NormalizerInterface; | ||||
| use Symfony\Component\Workflow\Registry; | ||||
|  | ||||
| class EntityWorkflowNormalizer implements NormalizerInterface, NormalizerAwareInterface | ||||
| final class EntityWorkflowNormalizer implements NormalizerInterface, NormalizerAwareInterface | ||||
| { | ||||
|     use NormalizerAwareTrait; | ||||
|  | ||||
|     public function __construct(private readonly EntityWorkflowManager $entityWorkflowManager, private readonly MetadataExtractor $metadataExtractor, private readonly Registry $registry) {} | ||||
|     public function __construct( | ||||
|         private readonly EntityWorkflowManager $entityWorkflowManager, | ||||
|         private readonly MetadataExtractor $metadataExtractor, | ||||
|         private readonly Registry $registry, | ||||
|         private readonly Security $security, | ||||
|     ) {} | ||||
|  | ||||
|     /** | ||||
|      * @param EntityWorkflow $object | ||||
| @@ -46,6 +53,9 @@ class EntityWorkflowNormalizer implements NormalizerInterface, NormalizerAwareIn | ||||
|             'datas' => $this->normalizer->normalize($handler->getEntityData($object), $format, $context), | ||||
|             'title' => $handler->getEntityTitle($object), | ||||
|             'isOnHoldAtCurrentStep' => $object->isOnHoldAtCurrentStep(), | ||||
|             '_permissions' => [ | ||||
|                 EntityWorkflowAttachmentVoter::EDIT => $this->security->isGranted(EntityWorkflowAttachmentVoter::EDIT, $object), | ||||
|             ], | ||||
|         ]; | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -39,6 +39,8 @@ class UserNormalizer implements ContextAwareNormalizerInterface, NormalizerAware | ||||
|         'label' => '', | ||||
|         'email' => '', | ||||
|         'isAbsent' => false, | ||||
|         'absenceStart' => null, | ||||
|         'absenceEnd' => null, | ||||
|     ]; | ||||
|  | ||||
|     public function __construct(private readonly UserRender $userRender, private readonly ClockInterface $clock) {} | ||||
| @@ -77,6 +79,11 @@ class UserNormalizer implements ContextAwareNormalizerInterface, NormalizerAware | ||||
|             ['docgen:expects' => PhoneNumber::class, 'groups' => 'docgen:read'] | ||||
|         ); | ||||
|  | ||||
|         $absenceDatesContext = array_merge( | ||||
|             $context, | ||||
|             ['docgen:expects' => \DateTimeImmutable::class, 'groups' => 'docgen:read'] | ||||
|         ); | ||||
|  | ||||
|         if (null === $object && 'docgen' === $format) { | ||||
|             return [...self::NULL_USER, 'phonenumber' => $this->normalizer->normalize(null, $format, $phonenumberContext), 'civility' => $this->normalizer->normalize(null, $format, $civilityContext), 'user_job' => $this->normalizer->normalize(null, $format, $userJobContext), 'main_center' => $this->normalizer->normalize(null, $format, $centerContext), 'main_scope' => $this->normalizer->normalize(null, $format, $scopeContext), 'current_location' => $this->normalizer->normalize(null, $format, $locationContext), 'main_location' => $this->normalizer->normalize(null, $format, $locationContext)]; | ||||
|         } | ||||
| @@ -99,6 +106,8 @@ class UserNormalizer implements ContextAwareNormalizerInterface, NormalizerAware | ||||
|             'main_center' => $this->normalizer->normalize($object->getMainCenter(), $format, $centerContext), | ||||
|             'main_scope' => $this->normalizer->normalize($object->getMainScope($at), $format, $scopeContext), | ||||
|             'isAbsent' => $object->isAbsent(), | ||||
|             'absenceStart' => $this->normalizer->normalize($object->getAbsenceStart(), $format, $absenceDatesContext), | ||||
|             'absenceEnd' => $this->normalizer->normalize($object->getAbsenceEnd(), $format, $absenceDatesContext), | ||||
|         ]; | ||||
|  | ||||
|         if ('docgen' === $format) { | ||||
|   | ||||
| @@ -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 Action\User\UpdateProfile; | ||||
|  | ||||
| use Chill\MainBundle\Action\User\UpdateProfile\UpdateProfileCommand; | ||||
| use Chill\MainBundle\Action\User\UpdateProfile\UpdateProfileCommandHandler; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use libphonenumber\PhoneNumber; | ||||
| use PHPUnit\Framework\TestCase; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
|  * | ||||
|  * @coversNothing | ||||
|  */ | ||||
| final class UpdateProfileCommandHandlerTest extends TestCase | ||||
| { | ||||
|     public function testUpdateProfileWithNullPhoneAndFlags(): void | ||||
|     { | ||||
|         $user = new User(); | ||||
|  | ||||
|         // Pre-set some flags to opposite values to check they are updated | ||||
|         $flag = 'tickets'; | ||||
|         $user->setNotificationImmediately($flag, true); | ||||
|         $user->setNotificationDailyDigest($flag, true); | ||||
|  | ||||
|         $command = new UpdateProfileCommand(null); | ||||
|         $command->notificationFlags = [ | ||||
|             $flag => [ | ||||
|                 'immediate_email' => false, | ||||
|                 'daily_digest' => false, | ||||
|             ], | ||||
|         ]; | ||||
|  | ||||
|         (new UpdateProfileCommandHandler())->updateProfile($user, $command); | ||||
|  | ||||
|         self::assertNull($user->getPhonenumber(), 'Phone should be set to null'); | ||||
|         self::assertFalse($user->isNotificationSendImmediately($flag)); | ||||
|         self::assertFalse($user->isNotificationDailyDigest($flag)); | ||||
|     } | ||||
|  | ||||
|     public function testUpdateProfileWithPhoneAndMultipleFlags(): void | ||||
|     { | ||||
|         $user = new User(); | ||||
|  | ||||
|         $phone = new PhoneNumber(); | ||||
|         $phone->setCountryCode(33); // France | ||||
|         $phone->setNationalNumber(612345678); | ||||
|  | ||||
|         $command = new UpdateProfileCommand($phone); | ||||
|         $command->notificationFlags = [ | ||||
|             'reports' => [ | ||||
|                 'immediate_email' => true, | ||||
|                 'daily_digest' => false, | ||||
|             ], | ||||
|             'activities' => [ | ||||
|                 'immediate_email' => false, | ||||
|                 'daily_digest' => true, | ||||
|             ], | ||||
|         ]; | ||||
|  | ||||
|         (new UpdateProfileCommandHandler())->updateProfile($user, $command); | ||||
|  | ||||
|         // Phone assigned | ||||
|         self::assertInstanceOf(PhoneNumber::class, $user->getPhonenumber()); | ||||
|         self::assertSame(33, $user->getPhonenumber()->getCountryCode()); | ||||
|         self::assertSame('612345678', (string) $user->getPhonenumber()->getNationalNumber()); | ||||
|  | ||||
|         // Flags applied | ||||
|         self::assertTrue($user->isNotificationSendImmediately('reports')); | ||||
|         self::assertFalse($user->isNotificationDailyDigest('reports')); | ||||
|  | ||||
|         self::assertFalse($user->isNotificationSendImmediately('activities')); | ||||
|         self::assertTrue($user->isNotificationDailyDigest('activities')); | ||||
|     } | ||||
| } | ||||
| @@ -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 Action\User\UpdateProfile; | ||||
|  | ||||
| use Chill\MainBundle\Action\User\UpdateProfile\UpdateProfileCommand; | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Chill\MainBundle\Notification\FlagProviders\NotificationFlagProviderInterface; | ||||
| use Chill\MainBundle\Notification\NotificationFlagManager; | ||||
| use libphonenumber\PhoneNumber; | ||||
| use PHPUnit\Framework\TestCase; | ||||
| use Symfony\Component\Translation\TranslatableMessage; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
|  * | ||||
|  * @coversNothing | ||||
|  */ | ||||
| final class UpdateProfileCommandTest extends TestCase | ||||
| { | ||||
|     public function testCreateTransfersPhonenumberAndNotificationFlags(): void | ||||
|     { | ||||
|         $user = new User(); | ||||
|  | ||||
|         // set a phone number | ||||
|         $phone = new PhoneNumber(); | ||||
|         $phone->setCountryCode(32); // Belgium | ||||
|         $phone->setNationalNumber(471234567); | ||||
|         $user->setPhonenumber($phone); | ||||
|  | ||||
|         // configure notification flags on the user via helpers | ||||
|         $flagA = 'foo'; | ||||
|         $flagB = 'bar'; | ||||
|  | ||||
|         // For tickets: immediate true, daily false | ||||
|         $user->setNotificationImmediately($flagA, true); | ||||
|         $user->setNotificationDailyDigest($flagA, false); | ||||
|  | ||||
|         // For reports: immediate false, daily true | ||||
|         $user->setNotificationImmediately($flagB, false); | ||||
|         $user->setNotificationDailyDigest($flagB, true); | ||||
|  | ||||
|         // a third flag not explicitly set to validate default behavior from User | ||||
|         $flagC = 'foobar'; // by default immediate-email is true, daily-digest is false per User::getNotificationFlagData | ||||
|  | ||||
|         $manager = $this->createNotificationFlagManager([$flagA, $flagB, $flagC]); | ||||
|  | ||||
|         $command = UpdateProfileCommand::create($user, $manager); | ||||
|  | ||||
|         // phone number transferred | ||||
|         self::assertInstanceOf(PhoneNumber::class, $command->phonenumber); | ||||
|         self::assertSame($phone->getCountryCode(), $command->phonenumber->getCountryCode()); | ||||
|         self::assertSame($phone->getNationalNumber(), $command->phonenumber->getNationalNumber()); | ||||
|  | ||||
|         // flags transferred consistently | ||||
|         self::assertArrayHasKey($flagA, $command->notificationFlags); | ||||
|         self::assertArrayHasKey($flagB, $command->notificationFlags); | ||||
|         self::assertArrayHasKey($flagC, $command->notificationFlags); | ||||
|  | ||||
|         self::assertSame([ | ||||
|             'immediate_email' => true, | ||||
|             'daily_digest' => false, | ||||
|         ], $command->notificationFlags[$flagA]); | ||||
|  | ||||
|         self::assertSame([ | ||||
|             'immediate_email' => false, | ||||
|             'daily_digest' => true, | ||||
|         ], $command->notificationFlags[$flagB]); | ||||
|  | ||||
|         // default from User::getNotificationFlagData -> immediate true, daily false | ||||
|         self::assertSame([ | ||||
|             'immediate_email' => true, | ||||
|             'daily_digest' => false, | ||||
|         ], $command->notificationFlags[$flagC]); | ||||
|     } | ||||
|  | ||||
|     private function createNotificationFlagManager(array $flags): NotificationFlagManager | ||||
|     { | ||||
|         $providers = array_map(fn (string $flag) => new class ($flag) implements NotificationFlagProviderInterface { | ||||
|             public function __construct(private readonly string $flag) {} | ||||
|  | ||||
|             public function getFlag(): string | ||||
|             { | ||||
|                 return $this->flag; | ||||
|             } | ||||
|  | ||||
|             public function getLabel(): TranslatableMessage | ||||
|             { | ||||
|                 return new TranslatableMessage($this->flag); | ||||
|             } | ||||
|         }, $flags); | ||||
|  | ||||
|         return new NotificationFlagManager($providers); | ||||
|     } | ||||
| } | ||||
| @@ -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\MainBundle\Tests\Controller; | ||||
|  | ||||
| use Chill\MainBundle\Controller\AddressReferenceAggregatedApiController; | ||||
| use Chill\MainBundle\Repository\AddressReferenceRepositoryInterface; | ||||
| use PHPUnit\Framework\TestCase; | ||||
| use Prophecy\PhpUnit\ProphecyTrait; | ||||
| use Symfony\Component\HttpFoundation\Request; | ||||
| use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; | ||||
| use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; | ||||
| use Symfony\Component\Security\Core\Security; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
|  * | ||||
|  * @covers \Chill\MainBundle\Controller\AddressReferenceAggregatedApiController | ||||
|  */ | ||||
| final class AddressReferenceAggregatedApiControllerTest extends TestCase | ||||
| { | ||||
|     use ProphecyTrait; | ||||
|  | ||||
|     public function testAccessDeniedWhenNotAuthenticated(): void | ||||
|     { | ||||
|         $security = $this->prophesize(Security::class); | ||||
|         $security->isGranted('IS_AUTHENTICATED')->willReturn(false); | ||||
|  | ||||
|         $repo = $this->prophesize(AddressReferenceRepositoryInterface::class); | ||||
|  | ||||
|         $controller = new AddressReferenceAggregatedApiController($security->reveal(), $repo->reveal()); | ||||
|  | ||||
|         $request = new Request(query: ['q' => 'anything']); | ||||
|  | ||||
|         $this->expectException(AccessDeniedHttpException::class); | ||||
|  | ||||
|         $controller->search($request); | ||||
|     } | ||||
|  | ||||
|     public function testBadRequestWhenQueryIsMissing(): void | ||||
|     { | ||||
|         $security = $this->prophesize(Security::class); | ||||
|         $security->isGranted('IS_AUTHENTICATED')->willReturn(true); | ||||
|  | ||||
|         $repo = $this->prophesize(AddressReferenceRepositoryInterface::class); | ||||
|  | ||||
|         $controller = new AddressReferenceAggregatedApiController($security->reveal(), $repo->reveal()); | ||||
|  | ||||
|         $request = new Request(); | ||||
|  | ||||
|         $this->expectException(BadRequestHttpException::class); | ||||
|         $this->expectExceptionMessage('Parameter "q" is required.'); | ||||
|  | ||||
|         $controller->search($request); | ||||
|     } | ||||
|  | ||||
|     public function testBadRequestWhenQueryIsEmpty(): void | ||||
|     { | ||||
|         $security = $this->prophesize(Security::class); | ||||
|         $security->isGranted('IS_AUTHENTICATED')->willReturn(true); | ||||
|  | ||||
|         $repo = $this->prophesize(AddressReferenceRepositoryInterface::class); | ||||
|  | ||||
|         $controller = new AddressReferenceAggregatedApiController($security->reveal(), $repo->reveal()); | ||||
|  | ||||
|         $request = new Request(query: ['q' => '   ']); | ||||
|  | ||||
|         $this->expectException(BadRequestHttpException::class); | ||||
|         $this->expectExceptionMessage('Parameter "q" is required and cannot be empty.'); | ||||
|  | ||||
|         $controller->search($request); | ||||
|     } | ||||
|  | ||||
|     public function testSuccessfulSearchReturnsJsonAndCallsRepositoryWithTrimmedQuery(): void | ||||
|     { | ||||
|         $security = $this->prophesize(Security::class); | ||||
|         $security->isGranted('IS_AUTHENTICATED')->willReturn(true); | ||||
|  | ||||
|         $expectedQuery = 'foo'; | ||||
|         $iterableResult = new \ArrayIterator([ | ||||
|             [ | ||||
|                 'street' => 'Main Street', | ||||
|                 'postcode_id' => 123, | ||||
|                 'code' => '1000', | ||||
|                 'label' => 'Brussels', | ||||
|                 'positions' => ['1' => '12', '2' => '14'], | ||||
|                 'row_number' => 1, | ||||
|             ], | ||||
|         ]); | ||||
|  | ||||
|         $repo = $this->prophesize(AddressReferenceRepositoryInterface::class); | ||||
|         $repo->findAggregatedBySearchString($expectedQuery)->willReturn($iterableResult)->shouldBeCalledOnce(); | ||||
|  | ||||
|         $controller = new AddressReferenceAggregatedApiController($security->reveal(), $repo->reveal()); | ||||
|  | ||||
|         // Provide spaces around to ensure trimming is applied | ||||
|         $request = new Request(query: ['q' => "  {$expectedQuery}  "]); | ||||
|  | ||||
|         $response = $controller->search($request); | ||||
|  | ||||
|         self::assertSame(200, $response->getStatusCode()); | ||||
|         $data = json_decode($response->getContent(), true, 512, JSON_THROW_ON_ERROR); | ||||
|         self::assertIsArray($data); | ||||
|         self::assertCount(1, $data); | ||||
|         self::assertSame('Main Street', $data[0]['street']); | ||||
|         self::assertSame(123, $data[0]['postcode_id']); | ||||
|         self::assertSame('1000', $data[0]['code']); | ||||
|         self::assertSame('Brussels', $data[0]['label']); | ||||
|     } | ||||
| } | ||||
| @@ -45,7 +45,7 @@ final class UserControllerTest extends WebTestCase | ||||
|         self::assertResponseIsSuccessful(); | ||||
|  | ||||
|         $username = 'Test_user'.uniqid(); | ||||
|         $password = 'Password1234!'; | ||||
|         $password = 'Password_1234!'; | ||||
|  | ||||
|         // Fill in the form and submit it | ||||
|  | ||||
| @@ -99,7 +99,7 @@ final class UserControllerTest extends WebTestCase | ||||
|     { | ||||
|         $client = $this->getClientAuthenticatedAsAdmin(); | ||||
|         $crawler = $client->request('GET', "/fr/admin/user/{$userId}/edit_password"); | ||||
|         $newPassword = '1234Password!'; | ||||
|         $newPassword = '1234_Password!'; | ||||
|  | ||||
|         $form = $crawler->selectButton('Changer le mot de passe')->form([ | ||||
|             'chill_mainbundle_user_password[new_password][first]' => $newPassword, | ||||
|   | ||||
| @@ -96,11 +96,13 @@ final class NotificationTest extends KernelTestCase | ||||
|         $this->assertTrue($user->isNotificationSendImmediately($notification->getType()), 'Should return true when no notification flags are set, by default immediate email'); | ||||
|  | ||||
|         // immediate-email preference | ||||
|         $user->setNotificationFlags(['test_notification_type' => [User::NOTIF_FLAG_IMMEDIATE_EMAIL, User::NOTIF_FLAG_DAILY_DIGEST]]); | ||||
|         $user->setNotificationImmediately('test_notification_type', true); | ||||
|         $user->setNotificationDailyDigest('test_notification_type', true); | ||||
|         $this->assertTrue($user->isNotificationSendImmediately($notification->getType()), 'Should return true when preferences contain immediate-email'); | ||||
|  | ||||
|         // daily-email preference | ||||
|         $user->setNotificationFlags(['test_notification_type' => [User::NOTIF_FLAG_DAILY_DIGEST]]); | ||||
|         $user->setNotificationDailyDigest('test_notification_type', true); | ||||
|         $user->setNotificationImmediately('test_notification_type', false); | ||||
|         $this->assertFalse($user->isNotificationSendImmediately($notification->getType()), 'Should return false when preference is daily-email only'); | ||||
|         $this->assertTrue($user->isNotificationDailyDigest($notification->getType()), 'Should return true when preference is daily-email'); | ||||
|  | ||||
|   | ||||
| @@ -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\MainBundle\Tests\Entity; | ||||
|  | ||||
| use Chill\MainBundle\Entity\User; | ||||
| use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
|  * | ||||
|  * @coversNothing | ||||
|  */ | ||||
| class UserNotificationFlagsPersistenceTest extends KernelTestCase | ||||
| { | ||||
|     public function testFlushPersistsNotificationFlagsChanges(): void | ||||
|     { | ||||
|         self::bootKernel(); | ||||
|         $em = self::getContainer()->get('doctrine')->getManager(); | ||||
|  | ||||
|         $user = new User(); | ||||
|         $user->setUsername('user_'.bin2hex(random_bytes(4))); | ||||
|         $user->setLabel('Test User'); | ||||
|         $user->setPassword('secret'); | ||||
|  | ||||
|         // Étape 1: créer et persister l’utilisateur | ||||
|         $em->persist($user); | ||||
|         $em->flush(); | ||||
|         $id = $user->getId(); | ||||
|         self::assertNotNull($id, 'User should have an ID after flush'); | ||||
|  | ||||
|         try { | ||||
|             // Sanity check: par défaut, pas de daily digest pour "alerts" | ||||
|             self::assertFalse($user->isNotificationDailyDigest('alerts')); | ||||
|  | ||||
|             // Étape 2: activer le daily digest -> setNotificationFlagElement réassigne la propriété | ||||
|             $user->setNotificationDailyDigest('alerts', true); | ||||
|             $em->flush(); // persist le changement | ||||
|             $em->clear(); // simule un nouveau cycle de requête | ||||
|  | ||||
|             // Étape 3: recharger depuis la base et vérifier la persistance | ||||
|             /** @var User $reloaded */ | ||||
|             $reloaded = $em->find(User::class, $id); | ||||
|             self::assertNotNull($reloaded); | ||||
|             self::assertTrue( | ||||
|                 $reloaded->isNotificationDailyDigest('alerts'), | ||||
|                 'Daily digest flag should be persisted' | ||||
|             ); | ||||
|  | ||||
|             // Étape 4: modifier via setNotificationFlagData (remplacement du tableau) | ||||
|             // Cette méthode doit réassigner la propriété (copie -> réassignation) | ||||
|             $reloaded->setNotificationImmediately('alerts', true); | ||||
|             $reloaded->setNotificationDailyDigest('alerts', false); | ||||
|             $em->flush(); | ||||
|             $em->clear(); | ||||
|  | ||||
|             /** @var User $reloaded2 */ | ||||
|             $reloaded2 = $em->find(User::class, $id); | ||||
|             self::assertNotNull($reloaded2); | ||||
|  | ||||
|             // Le daily digest n’est plus actif, seul immediate-email est présent | ||||
|             self::assertFalse($reloaded2->isNotificationDailyDigest('alerts')); | ||||
|             self::assertTrue($reloaded2->isNotificationSendImmediately('alerts')); | ||||
|         } finally { | ||||
|             // Nettoyage | ||||
|             $managed = $em->find(User::class, $id); | ||||
|             if (null !== $managed) { | ||||
|                 $em->remove($managed); | ||||
|                 $em->flush(); | ||||
|             } | ||||
|             $em->clear(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -67,4 +67,54 @@ class UserTest extends TestCase | ||||
|                 ->first()->getEndDate() | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     public function testIsAbsent() | ||||
|     { | ||||
|         $user = new User(); | ||||
|  | ||||
|         // Absent: today is within absence period | ||||
|         $absenceStart = new \DateTimeImmutable('-1 day'); | ||||
|         $absenceEnd = new \DateTimeImmutable('+1 day'); | ||||
|         $user->setAbsenceStart($absenceStart); | ||||
|         $user->setAbsenceEnd($absenceEnd); | ||||
|         self::assertTrue($user->isAbsent(), 'Should be absent when now is between start and end'); | ||||
|  | ||||
|         // Absent: end is null | ||||
|         $user->setAbsenceStart(new \DateTimeImmutable('-2 days')); | ||||
|         $user->setAbsenceEnd(null); | ||||
|         self::assertTrue($user->isAbsent(), 'Should be absent when started and no end'); | ||||
|  | ||||
|         // Not absent: absenceStart is in the future | ||||
|         $user->setAbsenceStart(new \DateTimeImmutable('+2 days')); | ||||
|         $user->setAbsenceEnd(null); | ||||
|         self::assertFalse($user->isAbsent(), 'Should not be absent if start is in the future'); | ||||
|  | ||||
|         // Not absent: absenceEnd is in the past | ||||
|         $user->setAbsenceStart(new \DateTimeImmutable('-5 days')); | ||||
|         $user->setAbsenceEnd(new \DateTimeImmutable('-1 day')); | ||||
|         self::assertFalse($user->isAbsent(), 'Should not be absent if end is in the past'); | ||||
|  | ||||
|         // Not absent: both are null | ||||
|         $user->setAbsenceStart(null); | ||||
|         $user->setAbsenceEnd(null); | ||||
|         self::assertFalse($user->isAbsent(), 'Should not be absent if start is null'); | ||||
|     } | ||||
|  | ||||
|     public function testSetNotification(): void | ||||
|     { | ||||
|         $user = new User(); | ||||
|  | ||||
|         self::assertTrue($user->isNotificationSendImmediately('dummy')); | ||||
|         self::assertFalse($user->isNotificationDailyDigest('dummy')); | ||||
|  | ||||
|         $user->setNotificationImmediately('dummy', false); | ||||
|         self::assertFalse($user->isNotificationSendImmediately('dummy')); | ||||
|  | ||||
|         $user->setNotificationDailyDigest('dummy', true); | ||||
|         self::assertTrue($user->isNotificationDailyDigest('dummy')); | ||||
|  | ||||
|         $user->setNotificationImmediately('dummy', true); | ||||
|         self::assertTrue($user->isNotificationSendImmediately('dummy')); | ||||
|         self::assertTrue($user->isNotificationDailyDigest('dummy')); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -144,7 +144,7 @@ class NotificationMailerTest extends TestCase | ||||
|         $idProperty->setValue($user, 456); | ||||
|  | ||||
|         // Set notification flags for the user | ||||
|         $user->setNotificationFlags(['test_notification_type' => [User::NOTIF_FLAG_IMMEDIATE_EMAIL]]); | ||||
|         $user->setNotificationImmediately('test_notification_type', true); | ||||
|  | ||||
|         $messageBus = $this->createMock(MessageBusInterface::class); | ||||
|         $messageBus->expects($this->once()) | ||||
|   | ||||
| @@ -0,0 +1,88 @@ | ||||
| <?php | ||||
|  | ||||
| declare(strict_types=1); | ||||
|  | ||||
| /* | ||||
|  * Chill is a software for social workers | ||||
|  * | ||||
|  * For the full copyright and license information, please view | ||||
|  * the LICENSE file that was distributed with this source code. | ||||
|  */ | ||||
|  | ||||
| namespace Chill\MainBundle\Tests\Repository; | ||||
|  | ||||
| use Chill\MainBundle\Entity\AddressReference; | ||||
| use Chill\MainBundle\Entity\PostalCode; | ||||
| use Chill\MainBundle\Repository\AddressReferenceRepository; | ||||
| use Doctrine\ORM\EntityManagerInterface; | ||||
| use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; | ||||
|  | ||||
| /** | ||||
|  * @internal | ||||
|  * | ||||
|  * @coversNothing | ||||
|  */ | ||||
| class AddressReferenceRepositoryTest extends KernelTestCase | ||||
| { | ||||
|     private static AddressReferenceRepository $repository; | ||||
|  | ||||
|     public static function setUpBeforeClass(): void | ||||
|     { | ||||
|         static::bootKernel(); | ||||
|         static::$repository = static::getContainer()->get(AddressReferenceRepository::class); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @dataProvider generateSearchString | ||||
|      */ | ||||
|     public function testFindBySearchString(string $search, int|PostalCode|null $postalCode, string $text, ?array $expected = null): void | ||||
|     { | ||||
|         $actual = static::$repository->findBySearchString($search, $postalCode); | ||||
|  | ||||
|         self::assertIsIterable($actual, $text); | ||||
|  | ||||
|         if (null !== $expected) { | ||||
|             self::assertEquals($expected, iterator_to_array($actual)); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @dataProvider generateSearchString | ||||
|      */ | ||||
|     public function testCountBySearchString(string $search, int|PostalCode|null $postalCode, string $text, ?array $expected = null): void | ||||
|     { | ||||
|         $actual = static::$repository->countBySearchString($search, $postalCode); | ||||
|  | ||||
|         self::assertIsInt($actual, $text); | ||||
|     } | ||||
|  | ||||
|     /** | ||||
|      * @dataProvider generateSearchString | ||||
|      */ | ||||
|     public function testFindAggreggateBySearchString(string $search, int|PostalCode|null $postalCode, string $text, ?array $expected = null): void | ||||
|     { | ||||
|         $actual = static::$repository->findAggregatedBySearchString($search, $postalCode); | ||||
|  | ||||
|         self::assertIsIterable($actual, $text); | ||||
|  | ||||
|         if (null !== $expected) { | ||||
|             self::assertEquals($expected, iterator_to_array($actual)); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public static function generateSearchString(): iterable | ||||
|     { | ||||
|         self::bootKernel(); | ||||
|         $em = static::getContainer()->get(EntityManagerInterface::class); | ||||
|         /** @var AddressReference $ar */ | ||||
|         $ar = $em->createQuery('SELECT ar FROM '.AddressReference::class.' ar') | ||||
|             ->setMaxResults(1) | ||||
|             ->getSingleResult(); | ||||
|  | ||||
|         yield ['', null, 'search with empty string', []]; | ||||
|         yield ['   ', null, 'search with spaces only', []]; | ||||
|         yield ['rue des    moulins', null, 'search contains an empty string']; | ||||
|         yield ['rue des moulins', $ar->getPostcode()->getId(), 'search with postal code as an id']; | ||||
|         yield ['rue des moulins', $ar->getPostcode(), 'search with postal code instance']; | ||||
|     } | ||||
| } | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user