mirror of
				https://gitlab.com/Chill-Projet/chill-bundles.git
				synced 2025-11-03 18:58: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