Compare commits

..

58 Commits

Author SHA1 Message Date
gitea-actions
96185e2334 chore: bump version to v0.1.35
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m17s
2026-03-13 11:11:40 +00:00
7d53000fc2 fix : validation autorisée pour les contrats 4h sans heures ou absence
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-03-13 12:11:31 +01:00
gitea-actions
c317a2a026 chore: bump version to v0.1.34
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m20s
2026-03-13 10:59:58 +00:00
8846e83df1 feat : modification de l'affichage des congés
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-03-13 11:57:02 +01:00
gitea-actions
ff824f233a chore: bump version to v0.1.33
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m15s
2026-03-13 10:03:51 +00:00
c4c9dfceab feat : amélioration des perfs de la page employée en séparant les responsabilités et le chargement par onglet
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-13 11:03:41 +01:00
gitea-actions
ca6597cd38 chore: bump version to v0.1.32
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 2m4s
2026-03-13 09:26:43 +00:00
4a2c3a8eed feat : Ajout du système de RTT sur la page employé avec le repport annuel des heures
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
2026-03-13 10:26:33 +01:00
gitea-actions
1858817649 chore: bump version to v0.1.31
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m12s
2026-03-12 15:58:05 +00:00
99f0f191f4 Merge remote-tracking branch 'origin/develop' into develop
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-03-12 16:57:55 +01:00
96617f04bc fix : style du drawer de suspension 2026-03-12 16:57:45 +01:00
gitea-actions
25d961c367 chore: bump version to v0.1.30
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m13s
2026-03-12 15:46:17 +00:00
38f09914cb feat : ajout des suspensions et des jours de présence
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-12 16:46:06 +01:00
gitea-actions
e6819bc68a chore: bump version to v0.1.29
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m11s
2026-03-12 11:30:22 +00:00
6153175ca0 feat : ajout des icons dans la sidebar et redirection après login sur le calendrier
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-12 12:30:13 +01:00
gitea-actions
49a1c07ed1 chore: bump version to v0.1.28
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m10s
2026-03-12 10:23:20 +00:00
9fe2397386 feat : ajout d'une date d'entrée pour les employés
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-12 11:23:09 +01:00
gitea-actions
bf3f7b35a5 chore: bump version to v0.1.27
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m15s
2026-03-12 09:37:13 +00:00
5c251800fa Merge remote-tracking branch 'origin/develop' into develop
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
2026-03-12 10:37:03 +01:00
e34e928264 fix : calcule des congés en cours d'acquisition au prorata (date début contrat) 2026-03-12 10:36:49 +01:00
gitea-actions
f7dc9b6988 chore: bump version to v0.1.26
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m11s
2026-03-11 16:34:29 +00:00
b0de877b27 Merge remote-tracking branch 'origin/develop' into develop
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-03-11 17:34:15 +01:00
59f05717bf fix : prise en compte des congés au provisionnel sauf pour les en cours d'acquisition 2026-03-11 17:34:07 +01:00
gitea-actions
f96fd64767 chore: bump version to v0.1.25
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m12s
2026-03-11 16:27:05 +00:00
523d4f296b fix : prise en compte des congés au provisionnel
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-03-11 17:26:48 +01:00
gitea-actions
3994be6556 chore: bump version to v0.1.24
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m8s
2026-03-11 12:53:39 +00:00
f46eeaa893 fix : prise en compte des jours de congés sur l'année N-1 même si on a pas d'historique de contrat sur l'année N-1
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-03-11 13:53:29 +01:00
gitea-actions
eb703272c7 chore: bump version to v0.1.23
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m11s
2026-03-11 10:41:38 +00:00
6629eb98cb fix : on ne prend plus en compte les jour de congé du samedi et dimanche pour les forfaits dans le calcule des RTT
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-03-11 11:41:26 +01:00
gitea-actions
029bc03a5a chore: bump version to v0.1.22
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m12s
2026-03-11 09:05:51 +00:00
82e575fff0 fix : plus de date de fin obligatoire sur les contrats interim
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-03-11 10:05:41 +01:00
gitea-actions
0213c0a97d chore: bump version to v0.1.21
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m11s
2026-03-11 07:50:38 +00:00
12def35dda Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-11 08:50:23 +01:00
2d1c1e6e22 fix : api jour férié qui a changé d'url 2026-03-11 08:42:57 +01:00
gitea-actions
f7568f2d09 chore: bump version to v0.1.20
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m30s
2026-03-10 16:07:18 +00:00
9c164fe78e fix : prise en compte de la balance des congés
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-10 17:07:09 +01:00
gitea-actions
180c108ded chore: bump version to v0.1.19
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m17s
2026-03-10 12:35:23 +00:00
f493ea237b Ajout des notification + page employé (#6)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #6
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-10 12:35:17 +00:00
gitea-actions
ae42c70d50 chore: bump version to v0.1.18
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m18s
2026-03-03 08:35:44 +00:00
812215f5f6 fix : validation bulk des heures. Moins de lag et de bug
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-03 09:33:53 +01:00
gitea-actions
36fe9ae54c chore: bump version to v0.1.17
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m11s
2026-03-02 09:50:14 +00:00
6395ffbe1c feat : modification des sélecteurs de date sur le calendrier
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-02 10:50:02 +01:00
gitea-actions
b5e7395760 chore: bump version to v0.1.16
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m26s
2026-03-02 09:34:21 +00:00
380c72c242 fix : règle de calcule des heures travaillées sur les contrats Forfait
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-02 10:33:42 +01:00
gitea-actions
107417a571 chore: bump version to v0.1.15
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m16s
2026-02-27 09:22:05 +00:00
5ff7e356be fix : validation RH qui invalidé les sites
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-02-27 10:21:54 +01:00
gitea-actions
635e24e9e1 chore: bump version to v0.1.14
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m11s
2026-02-26 16:15:25 +00:00
4d90f2cb42 feat : ajout du nouveau système de contrat et ajout de filtre d'impression
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-02-26 17:15:13 +01:00
gitea-actions
9261cb5b1a chore: bump version to v0.1.13
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m14s
2026-02-26 13:49:38 +00:00
b68fef61c4 fix : correction des Heures et ajout d'une validation pour les chefs de site
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-02-26 14:49:28 +01:00
gitea-actions
5cced46254 chore: bump version to v0.1.12
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m12s
2026-02-23 14:36:06 +00:00
07b84a2512 fix : modification du composant TimeSelect.vue pour pouvoir taper les heures au clavier
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-02-23 15:35:57 +01:00
gitea-actions
ca26b7f934 chore: bump version to v0.1.11
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m18s
2026-02-23 13:51:19 +00:00
9cf978f0f2 feat : ajout des titles + update README.md
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-02-23 14:51:06 +01:00
gitea-actions
ad9e8705ae chore: bump version to v0.1.10
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m10s
2026-02-20 16:17:28 +00:00
f8ca5e50a0 [#339] Ajout d'une page listant les règles de calcules (#5)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|        #339          |        Ajout d'une page listant les règles de calcules         |

## Description de la PR
[#339] Ajout d'une page listant les règles de calcules

## Modification du .env

## Check list

- [ ] Pas de régression
- [x] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #5
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-02-20 16:17:22 +00:00
gitea-actions
49fecfc27a chore: bump version to v0.1.9
All checks were successful
Auto Tag Develop / tag (push) Successful in 3s
Build Release Artefact / build (push) Successful in 1m13s
2026-02-20 11:23:59 +00:00
ee16779777 [#322] Page horaire (#4)
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|        #322          |        Page horaire         |

## Description de la PR
[#322] Page horaire

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #4
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-02-20 11:23:52 +00:00
226 changed files with 22767 additions and 529 deletions

View File

@@ -0,0 +1,27 @@
{
"permissions": {
"allow": [
"Bash(npx vue-tsc:*)",
"Bash(npx nuxi:*)",
"Bash(php:*)",
"Bash(docker compose:*)",
"Bash(make test:*)",
"Bash(grep:*)",
"Bash(docker exec:*)",
"Bash(php8.3 bin/phpunit tests/State/EmployeeWriteProcessorTest.php --filter=testSetsEntryDateOnNewEmployee 2>&1)",
"Read(//usr/bin/**)",
"Read(//usr/local/bin/**)",
"Bash(command -v php8.2)",
"Bash(command -v php8.1)",
"Bash(ls /usr/bin/php*)",
"Read(//opt/**)",
"Read(//home/m-tristan/.nix-profile/**)",
"Read(//home/m-tristan/.local/bin/**)",
"Bash(env)",
"Bash(ls /home/m-tristan/workspace/SIRH/docker* /home/m-tristan/workspace/SIRH/Makefile 2>/dev/null; cat /home/m-tristan/workspace/SIRH/Makefile 2>/dev/null | grep -E \"\\(phpunit|test|php\\)\" | head -20)",
"Bash(which python3:*)",
"Bash(sudo apt-get:*)",
"Bash(npx xlsx-cli:*)"
]
}
}

9
.idea/SIRH.iml generated
View File

@@ -145,6 +145,15 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/monolog/monolog" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bridge" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bundle" />
<excludeFolder url="file://$MODULE_DIR$/LOG" />
<excludeFolder url="file://$MODULE_DIR$/frontend/.nuxt" />
<excludeFolder url="file://$MODULE_DIR$/frontend/.output" />
<excludeFolder url="file://$MODULE_DIR$/frontend/dist" />
<excludeFolder url="file://$MODULE_DIR$/frontend/node_modules" />
<excludeFolder url="file://$MODULE_DIR$/public" />
<excludeFolder url="file://$MODULE_DIR$/var" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/data-fixtures" />
<excludeFolder url="file://$MODULE_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="db-tree-configuration">
<option name="data" value="----------------------------------------&#10;1:0:9cad43df-2147-4989-b7a4-443067034884&#10;2:0:ae622167-c834-4e7b-87a5-c1721036f5dc&#10;3:0:f407a514-c6b4-4b26-9555-445a85892502&#10;" />
<option name="data" value="----------------------------------------&#10;1:0:9cad43df-2147-4989-b7a4-443067034884&#10;2:0:ae622167-c834-4e7b-87a5-c1721036f5dc&#10;3:0:f407a514-c6b4-4b26-9555-445a85892502&#10;4:0:09e221b8-067a-488b-9c1d-4e155a333079&#10;" />
</component>
</project>

2
.idea/php.xml generated
View File

@@ -153,6 +153,8 @@
<path value="$PROJECT_DIR$/vendor/monolog/monolog" />
<path value="$PROJECT_DIR$/vendor/symfony/monolog-bridge" />
<path value="$PROJECT_DIR$/vendor/symfony/monolog-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/doctrine-fixtures-bundle" />
<path value="$PROJECT_DIR$/vendor/doctrine/data-fixtures" />
</include_path>
</component>
<component name="PhpProjectSharedConfiguration" php_language_level="8.4" />

138
AGENTS.md Normal file
View File

@@ -0,0 +1,138 @@
# AGENTS.md
État des lieux opérationnel du projet SIRH (backend + frontend), mis à jour après les évolutions sur heures/absences/validations.
## 1) Stack et structure
- Backend: Symfony + API Platform + Doctrine ORM
- Frontend: Nuxt 4 + Vue 3 + TypeScript + Tailwind
- Exécution locale: Docker via `makefile`
Arborescence clé:
- `src/`: domaine, API resources, state providers/processors, services
- `tests/`: TU backend (PHPUnit)
- `frontend/`: app Nuxt (pages, composants, composables, services)
- `migrations/`: migrations Doctrine
- `doc/`: documentation fonctionnelle et règles métier de référence
## 1.1) Référentiel Fonctionnel (obligatoire)
- Référence principale des règles métier: `doc/functional-rules.md`
- Toute intervention doit commencer par une vérification de cohérence avec cette documentation.
- Règle permanente: à chaque développement qui modifie le fonctionnel, la documentation dans `doc/` doit être mise à jour automatiquement dans la même intervention (pas de report).
## 2) Commandes utiles
- Démarrer stack: `make start`
- Tests backend: `make test`
- Build frontend: `cd frontend && npm run build`
- Dev frontend: `make dev-nuxt`
## 3) Domaine métier (résumé)
### Contrats
- Entité: `Contract`
- Champs principaux: `name`, `trackingMode`, `weeklyHours`, `isActive`, `type`
- `trackingMode`:
- `TIME`: suivi en heures
- `PRESENCE`: suivi en demi-journées/journées
- Enums backend:
- `App\Enum\TrackingMode`
- `App\Enum\ContractType` (`FORFAIT`, `THIRTY_FIVE_HOURS`, `THIRTY_NINE_HOURS`, `INTERIM`, `CUSTOM`)
- Historique de contrat par employé:
- table `employee_contract_periods`
- résolu par `App\Service\Contracts\EmployeeContractResolver`
### Heures / absences
- Les absences sont stockées en **lignes journalières** (découpage automatique dans `AbsenceWriteProcessor`).
- Les absences `countAsWorkedHours=true` créditent:
- minutes (contrats TIME)
- unités de présence (contrats PRESENCE)
- Les absences AM/PM effacent les plages horaires concernées.
## 4) Validations (important)
### Validation RH (admin)
- Champ: `work_hours.is_valid`
- Endpoint API Platform standard: `PATCH /api/work_hours/{id}`
- Gérée côté front par `updateWorkHourValidation`.
### Validation site (chef de site)
- Champ: `work_hours.is_site_valid`
- Endpoint dédié: `PATCH /api/work_hours/{id}/site-validation`
- Processor: `src/State/WorkHourSiteValidationProcessor.php`
- Autorisé uniquement aux utilisateurs "Sites" (ni `ROLE_ADMIN`, ni `ROLE_SELF`) dans leur scope site.
### Règles de verrouillage
- `is_valid=true`: ligne verrouillée pour tout le monde (admin inclus pour saisie heures/absence; peut toujours décocher validation RH).
- `is_site_valid=true`:
- non-admin: ligne verrouillée (heures + absences)
- admin: ligne éditable
- Toute modification de ligne (heures/présence/absence) remet:
- `is_site_valid=false`
- `is_valid=false`
## 5) Page Heures (front)
- Page: `frontend/pages/hours.vue`
- Composable principal: `frontend/composables/useHoursPage.ts`
- Composants:
- `frontend/components/hours/HoursToolbar.vue`
- `frontend/components/hours/HoursDayView.vue`
- `frontend/components/hours/HoursWeekView.vue`
### Comportement par profil (vue jour)
- Admin:
- colonne RH avec checkbox
- badge `Site validé` affiché près du site
- Chef de site:
- colonne `Validation site` avec checkbox
- colonne RH en lecture (`Validé`/`-`)
- Employé:
- colonne `Validation site` en lecture
- colonne RH en lecture
## 6) Résumé hebdo / calculs
- Provider: `src/State/WorkHourWeeklySummaryProvider.php`
- DTOs:
- `src/Dto/WorkHours/WeeklySummaryRow.php`
- `src/Dto/WorkHours/WeeklyDaySummary.php`
- Inclut: contrat résolu par jour, absences, crédits, jour/nuit/total, majorations, récup.
Règles majorations:
- Contrats <= 35h: +25% de 35h à 43h, +50% au-delà
- Contrats >= 39h: +25% de 39h à 43h, +50% au-delà
- `INTERIM`: pas de 25% / 50% / récup
## 7) Migrations sensibles
- `migrations/Version20260226183000.php`
- ajoute `work_hours.is_site_valid BOOLEAN NOT NULL DEFAULT FALSE`
- non destructive (pas de perte de données)
## 8) Points de vigilance prod
- Toujours exécuter migration avant déploiement code backend/front lié.
- Après déploiement backend, si route manquante côté runtime:
- `php bin/console cache:clear && php bin/console cache:warmup`
- Vérifier présence route:
- `/api/work_hours/{id}/site-validation` (PATCH)
## 9) Conventions techniques
- Favoriser DTO explicites plutôt que tableaux associatifs.
- Garder règles métier dans backend (providers/processors/services), front orienté affichage/interaction.
- Maintenir alignement backend DTO PHP / frontend DTO TS (`frontend/services/dto/*`).
- Mettre à jour TU si signature constructor/service change.
## 10) Fichiers à lire avant modification
- `src/State/WorkHourBulkUpsertProcessor.php`
- `src/State/AbsenceWriteProcessor.php`
- `src/State/WorkHourSiteValidationProcessor.php`
- `src/State/WorkHourWeeklySummaryProvider.php`
- `src/Service/WorkHours/WorkedHoursCreditPolicy.php`
- `frontend/composables/useHoursPage.ts`
- `frontend/components/hours/HoursDayView.vue`
- `frontend/components/hours/HoursWeekView.vue`

View File

@@ -1,2 +1,19 @@
# SIRH
Application de gestion des absences employée
## Importer un dump de prod en dev
Sur adminer fait un export bdd :
- Sortie : enregistrer
- Format : SQL
- Tables : DROP+CREATE, Incrément automatique, Déclencheurs
- Données : INSERT
Supprime la bdd et créer la bdd :
```shell
docker compose exec -T db psql -U root -d sirh -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
```
Remplie la base avec le dump :
```shell
docker compose exec -T db psql -U root -d sirh < sirh.sql
```

View File

@@ -87,6 +87,7 @@
}
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^4.3",
"friendsofphp/php-cs-fixer": "^3.93",
"phpunit/phpunit": "^12.5"
}

171
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "71d28cc0a29fa3f385b067186aa43678",
"content-hash": "b540b6cb25ef55c5eebccb57c76da584",
"packages": [
{
"name": "api-platform/doctrine-common",
@@ -8504,6 +8504,175 @@
],
"time": "2024-05-06T16:37:16+00:00"
},
{
"name": "doctrine/data-fixtures",
"version": "2.2.0",
"source": {
"type": "git",
"url": "https://github.com/doctrine/data-fixtures.git",
"reference": "7a615ba135e45d67674bb623d90f34f6c7b6bd97"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/data-fixtures/zipball/7a615ba135e45d67674bb623d90f34f6c7b6bd97",
"reference": "7a615ba135e45d67674bb623d90f34f6c7b6bd97",
"shasum": ""
},
"require": {
"doctrine/persistence": "^3.1 || ^4.0",
"php": "^8.1",
"psr/log": "^1.1 || ^2 || ^3"
},
"conflict": {
"doctrine/dbal": "<3.5 || >=5",
"doctrine/orm": "<2.14 || >=4",
"doctrine/phpcr-odm": "<1.3.0"
},
"require-dev": {
"doctrine/coding-standard": "^14",
"doctrine/dbal": "^3.5 || ^4",
"doctrine/mongodb-odm": "^1.3.0 || ^2.0.0",
"doctrine/orm": "^2.14 || ^3",
"ext-sqlite3": "*",
"fig/log-test": "^1",
"phpstan/phpstan": "2.1.31",
"phpunit/phpunit": "10.5.45 || 12.4.0",
"symfony/cache": "^6.4 || ^7",
"symfony/var-exporter": "^6.4 || ^7"
},
"suggest": {
"alcaeus/mongo-php-adapter": "For using MongoDB ODM 1.3 with PHP 7 (deprecated)",
"doctrine/mongodb-odm": "For loading MongoDB ODM fixtures",
"doctrine/orm": "For loading ORM fixtures",
"doctrine/phpcr-odm": "For loading PHPCR ODM fixtures"
},
"type": "library",
"autoload": {
"psr-4": {
"Doctrine\\Common\\DataFixtures\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jonathan Wage",
"email": "jonwage@gmail.com"
}
],
"description": "Data Fixtures for all Doctrine Object Managers",
"homepage": "https://www.doctrine-project.org",
"keywords": [
"database"
],
"support": {
"issues": "https://github.com/doctrine/data-fixtures/issues",
"source": "https://github.com/doctrine/data-fixtures/tree/2.2.0"
},
"funding": [
{
"url": "https://www.doctrine-project.org/sponsorship.html",
"type": "custom"
},
{
"url": "https://www.patreon.com/phpdoctrine",
"type": "patreon"
},
{
"url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdata-fixtures",
"type": "tidelift"
}
],
"time": "2025-10-17T20:06:20+00:00"
},
{
"name": "doctrine/doctrine-fixtures-bundle",
"version": "4.3.1",
"source": {
"type": "git",
"url": "https://github.com/doctrine/DoctrineFixturesBundle.git",
"reference": "9e013ed10d49bf7746b07204d336384a7d9b5a4d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/DoctrineFixturesBundle/zipball/9e013ed10d49bf7746b07204d336384a7d9b5a4d",
"reference": "9e013ed10d49bf7746b07204d336384a7d9b5a4d",
"shasum": ""
},
"require": {
"doctrine/data-fixtures": "^2.2",
"doctrine/doctrine-bundle": "^2.2 || ^3.0",
"doctrine/orm": "^2.14.0 || ^3.0",
"doctrine/persistence": "^2.4 || ^3.0 || ^4.0",
"php": "^8.1",
"psr/log": "^2 || ^3",
"symfony/config": "^6.4 || ^7.0 || ^8.0",
"symfony/console": "^6.4 || ^7.0 || ^8.0",
"symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0",
"symfony/deprecation-contracts": "^2.1 || ^3",
"symfony/doctrine-bridge": "^6.4.16 || ^7.1.9 || ^8.0",
"symfony/http-kernel": "^6.4 || ^7.0 || ^8.0"
},
"conflict": {
"doctrine/dbal": "< 3"
},
"require-dev": {
"doctrine/coding-standard": "14.0.0",
"phpstan/phpstan": "2.1.11",
"phpunit/phpunit": "^10.5.38 || 11.4.14"
},
"type": "symfony-bundle",
"autoload": {
"psr-4": {
"Doctrine\\Bundle\\FixturesBundle\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Doctrine Project",
"homepage": "https://www.doctrine-project.org"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony DoctrineFixturesBundle",
"homepage": "https://www.doctrine-project.org",
"keywords": [
"Fixture",
"persistence"
],
"support": {
"issues": "https://github.com/doctrine/DoctrineFixturesBundle/issues",
"source": "https://github.com/doctrine/DoctrineFixturesBundle/tree/4.3.1"
},
"funding": [
{
"url": "https://www.doctrine-project.org/sponsorship.html",
"type": "custom"
},
{
"url": "https://www.patreon.com/phpdoctrine",
"type": "patreon"
},
{
"url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdoctrine-fixtures-bundle",
"type": "tidelift"
}
],
"time": "2025-12-03T16:05:42+00:00"
},
{
"name": "evenement/evenement",
"version": "v3.0.2",

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
use ApiPlatform\Symfony\Bundle\ApiPlatformBundle;
use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
use Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle;
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle;
use Nelmio\CorsBundle\NelmioCorsBundle;
@@ -22,4 +23,5 @@ return [
ApiPlatformBundle::class => ['all' => true],
LexikJWTAuthenticationBundle::class => ['all' => true],
MonologBundle::class => ['all' => true],
DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
];

View File

@@ -1,14 +1,19 @@
monolog:
channels: [deprecation]
channels: [deprecation, cron]
when@dev:
monolog:
handlers:
cron:
type: stream
path: "%kernel.logs_dir%/cron.log"
level: info
channels: [cron]
main:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
channels: ["!event"]
channels: ["!event", "!cron"]
console:
type: console
process_psr_3_messages: false
@@ -17,11 +22,16 @@ when@dev:
when@prod:
monolog:
handlers:
cron:
type: stream
path: "%kernel.logs_dir%/cron.log"
level: info
channels: [cron]
main:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
channels: ["!deprecation"]
channels: ["!deprecation", "!cron"]
deprecation:
type: stream
channels: [deprecation]

View File

@@ -22,5 +22,15 @@ services:
App\:
resource: '../src/'
App\Service\PublicHolidayService:
arguments:
$holidayUrl: '%env(HOLIDAY_URL)%'
App\Repository\Contract\AbsenceReadRepositoryInterface: '@App\Repository\AbsenceRepository'
App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface: '@App\Repository\EmployeeContractPeriodRepository'
App\Repository\Contract\EmployeeScopedRepositoryInterface: '@App\Repository\EmployeeRepository'
App\Repository\Contract\WorkHourReadRepositoryInterface: '@App\Repository\WorkHourRepository'
App\Service\Contracts\EmployeeContractPeriodManagerInterface: '@App\Service\Contracts\EmployeeContractPeriodManager'
# add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.8'
app.version: '0.1.35'

260
doc/functional-rules.md Normal file
View File

@@ -0,0 +1,260 @@
# Règles Fonctionnelles SIRH
Ce document centralise les règles métier actuellement implémentées dans l'application.
Documents complementaires:
- `doc/leave-rollover.md` (rollover conges et checklist de lancement)
- `doc/rtt-rollover.md` (rollover RTT et checklist de lancement)
## 1) Utilisateurs et accès
- `ROLE_ADMIN`
- accès complet aux écrans d'administration
- vue semaine des heures
- validation RH des lignes d'heures
- `ROLE_SELF`
- accès limité à son périmètre personnel
- Accès "Sites" (via `user_site_roles` avec rôle `SITE_ACCESS`)
- accès au périmètre des sites autorisés
- validation site des lignes d'heures
## 2) Contrats
- Le profil de temps de travail est porté par `Contract`:
- `trackingMode`: `TIME` ou `PRESENCE`
- `weeklyHours` (ex: 35, 39, 4, etc.)
- La nature RH est portée par période employé:
- `CDI`, `CDD`, `INTERIM`
- Historique des contrats employé:
- table `employee_contract_periods`
- un employé peut avoir plusieurs périodes
### Règles de période
- `CDI`:
- à la création d'une période: `endDate` doit être vide
- en clôture d'un contrat en cours: `endDate` peut être renseignée
- `CDD` / `INTERIM`:
- `endDate` obligatoire
- `endDate` ne peut pas être antérieure à `startDate`
## 3) Heures (vue jour)
- Saisie par salarié et par date:
- matin / après-midi / soir
- pour `PRESENCE`: demi-journées matin/après-midi
- Sélecteur de temps:
- créneaux de 15 minutes uniquement (00:00, 00:15, ..., 23:45)
- saisie libre possible mais valeur vidée au blur si hors options
- Calculs affichés:
- `Jour`, `Nuit`, `Total`
- Heures de nuit:
- fenêtres `00:00-06:00` et `21:00-24:00`
- Date de modification (`updatedAt`):
- mise à jour uniquement quand un employé (`ROLE_SELF`) modifie ses propres heures
- non mise à jour lors de modifications admin ou chef de site
- affichée sous le nom de l'employé (visible admin uniquement)
## 4) Absences
- Les absences sont stockées par jour (découpage lors de l'écriture)
- Une absence peut être:
- journée complète
- demi-journée `AM` ou `PM`
- Colonne absence (vue jour):
- affiche le libellé
- fond coloré selon le type d'absence
- Calendrier congés: fond coloré selon la couleur du type d'absence (`AbsenceType.color`)
- demi-journée: dégradé diagonal
- journée complète: fond plein
### Effet absence sur les heures
- Absence `AM`:
- efface les heures du matin
- Absence `PM`:
- efface les heures d'après-midi et du soir
- Absence journée:
- efface toutes les plages horaires
### Absences "comptées comme travaillées"
- Si `countAsWorkedHours = true`:
- `TIME`: crédit de minutes selon contrat actif du jour
- `PRESENCE` (forfait): aucun crédit de présence (seules les checkboxes cochées comptent)
## 5) Validations des lignes d'heures
- Validation RH (`isValid`)
- action admin
- Validation site (`isSiteValid`)
- action chef de site
### Verrouillage
- Ligne validée RH:
- verrouillée pour modifications heures/absences
- Ligne validée site:
- verrouillée pour non-admin
- admin peut corriger
- Toute vraie modification d'une ligne:
- remet `isSiteValid = false`
- remet `isValid = false`
- Si aucun changement réel à l'enregistrement:
- les validations existantes ne sont pas altérées
## 6) Heures supplémentaires (vue semaine)
- Base de calcul:
- dépend du contrat actif par jour
- Tranche 25%:
- contrats <= 35h: de 35h à 43h
- contrats >= 39h: de 39h à 43h
- Tranche 50%:
- au-delà de 43h
- Nature `INTERIM`:
- pas de bonus 25%
- pas de bonus 50%
- pas de total récup
## 7) Fériés
- Les jours fériés sont identifiés et affichés
- Onglet congés: jours fériés affichés sur le calendrier avec fond `rgb(179, 229, 252)` et nom au survol
- Règle courante:
- absences bloquées sur jour férié
- saisie d'heures autorisée
## 8) Impression absences (PDF)
Filtres disponibles:
- période `from` / `to`
- sites
- nature de contrat (`CDI`, `CDD`, `INTERIM`)
- temps de travail (contrats de type Forfait, 35h, 39h, etc.)
Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
## 9) Employés
- Création employé:
- prénom, nom, site
- type de contrat (nature RH)
- temps de travail
- dates début/fin (selon règles nature)
- Modification employé:
- uniquement prénom, nom, site
- pas de modification de contrat depuis ce drawer
- Détail employé:
- onglet `Suivi contrat` avec affichage de l'historique des périodes de contrat
- chaque ligne expose: nature (`CDI`/`CDD`/`INTERIM`), contrat/temps de travail, date de début, date de fin (ou "En cours")
- action `Clôturer`:
- bouton actif uniquement s'il existe un contrat en cours non déjà clôturé à la date du jour
- ouvre un drawer en lecture seule (type/temps de travail/date de début)
- champs saisissables:
- `contractEndDate` (prérempli à aujourd'hui)
- `contractPaidLeaveSettled` (checkbox "Soldé dans le solde de tout compte")
- backend: en mode clôture, le flag `contractPaidLeaveSettled` est persisté sur la période clôturée
- action `Ajouter`:
- conserve le flux d'ajout d'un nouveau contrat via drawer dédié
- disponible uniquement s'il n'y a pas de contrat en cours, ou si le contrat en cours a déjà une date de fin
- onglet `Congé`:
- endpoint de synthèse: `GET /api/employees/{id}/leave-summary?year=YYYY`
- phase 1 métier (`CDI`/`CDD` non forfait + `FORFAIT`):
- exercice CP:
- `CDI`/`CDD` non forfait: du `1er juin (YYYY-1)` au `31 mai (YYYY)` (paramètre `year` = année de fin d'exercice)
- `FORFAIT`: du `1er janvier (YYYY)` au `31 décembre (YYYY)` (paramètre `year` = année civile)
- contrats `39h` / `35h` / `25h` (et plus largement CDI/CDD non forfait hors `4h`):
- acquis annuel CP: `25`
- acquis annuel samedi: `5`
- en cours d'acquisition jours: `25/12 = 2,08` jours/mois
- en cours d'acquisition samedis: `5/12 = 0,42` samedi/mois (non detaille en UI)
- en cas de début/fin en cours de mois, l'acquisition est proratisée au nombre de jours calendaires couverts dans le mois
- samedis acquis affiches: uniquement `opening_saturdays` (report N-1)
- contrat `4h`:
- acquis annuel CP: `10`
- acquis annuel samedi: `0`
- en cours d'acquisition: `0.83` jour/mois
- en cas de début/fin en cours de mois, l'acquisition est proratisée au nombre de jours calendaires couverts dans le mois
- contrat `FORFAIT`:
- base annuelle: `jours ouvrés de l'exercice (lundi-vendredi, hors jours fériés métropole) - 218`
- prorata: en cas de démarrage/fin de contrat en cours d'année civile, le calcul ne couvre que l'intervalle actif du contrat dans l'année
- reste à prendre: `acquis - absences` (toutes absences, demi-journées incluses)
- pas de samedi (`0`)
- pas de jours en cours d'acquisition (`0`)
- fractionné: saisie manuelle par la RH via `PATCH /employees/{id}/fractioned-days`, stocké dans `employee_leave_balances.fractioned_days`. Les jours fractionnés sont ajoutés aux acquis et au reste à prendre.
- pour `CDI`/`CDD` non forfait:
- pris CP: basé sur absences de type code `C` (CONGÉ), en tenant compte des demi-journées
- samedi pris: absences `C` posées le samedi (demi-journée incluse)
- restants = acquis - pris (borné à 0)
- pour `FORFAIT`:
- pris: basé sur toutes les absences (demi-journées incluses)
- restants = acquis - pris (borné à 0)
- report annuel:
- le reliquat (`restants`) de l'exercice précédent est reporté dans les acquis de l'exercice courant
- pour `CDI`/`CDD` non forfait: report séparé jours + samedis
- pour `FORFAIT`: report uniquement sur les jours
- si un solde d'ouverture existe en base (`employee_leave_balances`) pour l'exercice courant, ce solde devient la source prioritaire du report
- si une clôture de contrat est marquée `contractPaidLeaveSettled=true` sur l'exercice précédent, le report vers l'exercice suivant est remis à `0`
- si une clôture `contractPaidLeaveSettled=true` existe dans l'exercice courant, le calcul est réinitialisé à partir du lendemain de cette clôture (pas de continuité intra-exercice)
- lecture des compteurs:
- `acquis` = droits reportés de l'exercice N-1 (après application des règles de soldé)
- `en cours d'acquisition` = total droits générés sur l'exercice N (jours + samedis en cours), sans detail séparé en UI
- `en cours d'acquisition` est arrêté au dernier jour du mois précédent
- règle de consommation:
- les absences s'imputent d'abord sur `acquis`, puis sur `en cours d'acquisition`
- la prise sur `en cours d'acquisition` est autorisée (usage anticipé)
- `en cours d'acquisition` peut devenir négatif si la prise dépasse le généré (ex: `2.08 - 3 = -0.92`), puis se reconstitue avec les acquisitions suivantes
- date d'arret de calcul:
- `reste à prendre` est calculé en prévisionnel jusqu'à la fin de l'exercice
- les absences futures déjà posées sur l'exercice sont déduites du `reste à prendre`
- `en cours d'acquisition` reste calculé jusqu'au dernier jour du mois précédent
- exemple: au `11/03/2026`, l'exercice `2026` déduit les absences posées jusqu'au `31/05/2026`, mais l'acquisition reste arrêtée au `28/02/2026`
- hors périmètre phase 1: `INTERIM` (retour non supporté)
- onglet `RTT`:
- endpoint de synthèse: `GET /api/employees/{id}/rtt-summary?year=YYYY`
- exercice RTT: du `1er juin (YYYY-1)` au `31 mai (YYYY)` (paramètre `year` = année de fin d'exercice)
- affichage:
- détail hebdomadaire (semaine ISO) regroupé par mois
- total mensuel des minutes de récupération
- compteur global exercice = `report N-1 + acquis N`
- attribution mensuelle des semaines:
- une semaine ISO est affichée une seule fois, dans le mois qui contient le **samedi** de cette semaine
- si le weekend tombe en début de mois suivant, c'est le mois suivant qui porte la semaine
- logique de calcul:
- base identique aux calculs d'heures supplémentaires de la vue semaine Heures
- minutes de récupération hebdomadaires = `HS totales + bonus 25% + bonus 50%`
- contrats `INTERIM` et suivi `PRESENCE`: récupération à `0`
- compteur global:
- affiché en **jours** (1 jour = 7h = 420 minutes)
- report:
- le report N-1 correspond à la somme des minutes de récupération calculées sur l'exercice précédent
- si une ligne existe dans `employee_rtt_balances` pour `(employee, year)`, le champ `opening_minutes` est utilisé en priorité
- sinon, le calcul dynamique sur l'exercice N-1 est effectué
- rollover automatique:
- commande: `php bin/console app:rtt:rollover`
- s'exécute le `1er juin` (même cron que le rollover congés)
- calcule le total récup N-1 et le persiste en `opening_minutes` du nouvel exercice
- idempotent (ne recrée pas si la ligne existe)
- paiement RTT:
- saisie RH via `PATCH /employees/{id}/rtt-payments` (body: `month`, `minutes`, `rate`)
- stocké dans `employee_rtt_payments` (employee, year, month, minutes, rate)
- `rate`: taux de majoration, valeurs `25` ou `50`
- les heures payées sont soustraites du disponible RTT (`availableMinutes -= totalPaidMinutes`)
- affichage: 2 lignes par mois dans le tableau (25% et 50%)
- affichage:
- le compteur global RTT est affiché en **heures** (format `Xh00`)
## 10) Notifications
- Icône cloche en topbar:
- badge = nombre de notifications non lues
- ouverture panneau = liste des non lues
- fermeture panneau = marquage "lu" en masse
### Règle métier de déclenchement
- Les notifications de validation site ne sont pas envoyées ligne par ligne.
- Une notification est créée uniquement quand un chef de site termine la validation complète:
- condition: plus aucune ligne `work_hours` du site à la date concernée avec `isSiteValid = false`
- destinataires: utilisateurs `ROLE_ADMIN`

226
doc/leave-rollover.md Normal file
View File

@@ -0,0 +1,226 @@
# Rollover Conges - Regles et Mise en Production
Document de reference pour expliquer le fonctionnement metier du report N-1 et preparer le lancement en production.
## 1) Objectif
Eviter les recalculs "depuis le debut du contrat" et fiabiliser les soldes.
Principe:
- le solde est stocké par exercice
- au changement d'exercice, on ouvre la nouvelle période avec un "solde d'ouverture" (report N-1)
- un indicateur de cloture (`contractPaidLeaveSettled`) permet de couper la continuité entre 2 contrats
## 2) Exercices metier
- `CDI` / `CDD` non forfait:
- exercice: `1er juin` au `31 mai`
- `year` = annee de fin d'exercice (ex: `2026` = 01/06/2025 -> 31/05/2026)
- `FORFAIT`:
- exercice: `1er janvier` au `31 decembre`
- `year` = annee civile
- `INTERIM`:
- hors perimetre conges
## 3) Logique de compteurs
- `acquis`:
- correspond au report N-1 (solde d'ouverture)
- `en cours d'acquisition`:
- correspond aux droits generes sur l'exercice en cours
- `pris`:
- non forfait: absences type `C` (conge)
- forfait: toutes absences
- `restant`:
- `acquis + en_cours - pris` (borne a 0 dans l'affichage)
## 4) Effet du "solde de tout compte"
Le champ de cloture `contractPaidLeaveSettled` est saisi lors de la fermeture d'une periode contrat.
- `false`:
- continuite des droits entre contrats
- `true`:
- pas de reprise des droits precedents
- reset de continuite au lendemain de la date de cloture
## 5) Table cible
Table `employee_leave_balances` (une ligne par employe et exercice):
- `employee_id`
- `rule_code` (`CDI_CDD_NON_FORFAIT` ou `FORFAIT_218`)
- `year`
- `opening_days`
- `opening_saturdays`
- `accrued_days`
- `accrued_saturdays` (optionnel selon implementation)
- `taken_days`
- `taken_saturdays`
- `closing_days`
- `closing_saturdays`
- `is_locked`
- `created_at`, `updated_at`
Contrainte unique recommandee:
- `(employee_id, rule_code, year)`
Etat implementation:
- la table est creee
- le calcul de synthese conges lit en priorite `opening_days/opening_saturdays` de cette table quand une ligne existe pour `(employee, rule_code, year)`
- si aucune ligne n'existe, le calcul reste base sur le report dynamique N-1
- la commande `app:leave:rollover` calcule aussi le report dynamique N-1 si la ligne N-1 n'est pas encore persistée (pas de reset a 0 par defaut)
### Definition des colonnes
- `employee_id`:
- identifiant employe (FK vers `employees`)
- une ligne de solde par employe / regle / exercice
- `rule_code`:
- code de regle appliquee (`CDI_CDD_NON_FORFAIT`, `FORFAIT_218`)
- permet de savoir quelles regles de calcul sont utilisees
- `year`:
- annee d'exercice
- non forfait: annee de fin d'exercice (`2026` = 01/06/2025 -> 31/05/2026)
- forfait: annee civile (`2026` = 01/01/2026 -> 31/12/2026)
- `opening_days`:
- report N-1 en jours (solde d'ouverture)
- `opening_saturdays`:
- report N-1 "samedis" (0 pour forfait)
- `accrued_days`:
- droits generes sur l'exercice courant (N)
- `accrued_saturdays`:
- droits samedis generes sur N (0 pour forfait)
- `taken_days`:
- jours poses sur l'exercice
- `taken_saturdays`:
- samedis poses sur l'exercice (0 pour forfait)
- `closing_days`:
- solde de cloture jours (`opening_days + accrued_days - taken_days`)
- `closing_saturdays`:
- solde de cloture samedis (`opening_saturdays + accrued_saturdays - taken_saturdays`)
- `is_locked`:
- `false` sur exercice ouvert (recalcul possible)
- `true` apres validation RH (exercice fige)
- `created_at`, `updated_at`:
- trace technique creation / mise a jour
## 6) Rollover automatique
Commande quotidienne (cron) idempotente.
- commande Symfony: `php bin/console app:leave:rollover`
- comportement date metier:
- le `01/01`: traite uniquement `FORFAIT_218`
- le `01/06`: traite uniquement `CDI_CDD_NON_FORFAIT`
- les autres jours: sortie sans action
- option manuelle: `--force` pour executer hors date metier (reprise/correction)
Date d'effet:
- forfait: au `1er janvier`
- non forfait: au `1er juin`
Traitement par employe:
1. lire l'exercice precedent
2. determiner le report:
- si cloture `paidLeaveSettled=true` sur la periode precedente => report `0`
- sinon report = `closing` exercice precedent
3. creer la ligne du nouvel exercice avec ce report en `opening_*`
4. initialiser `accrued/taken/closing` pour le nouvel exercice
## 7) Donnees a fournir au go-live
La RH doit fournir un import d'ouverture:
Colonnes minimales:
- `employee_identifier` (id interne ou matricule)
- `rule_code`
- `year`
- `opening_days`
- `opening_saturdays` (0 pour forfait)
- `source_date` (date de reference du relevé RH)
- `comment` (optionnel)
Format recommande:
- CSV UTF-8
- separateur `;`
- decimales en point (`7.5`)
Exemple:
```csv
employee_id;rule_code;year;opening_days;opening_saturdays;source_date;comment
42;CDI_CDD_NON_FORFAIT;2026;12.5;2;2026-05-31;Reprise fichier RH
17;FORFAIT_218;2026;8;0;2025-12-31;Reprise fichier RH
```
## 8) Checklist mise en prod
1. Valider le mapping employe RH -> employe applicatif
2. Importer les soldes d'ouverture N-1
3. Verifier 5 cas metier:
- CDI simple sans changement de contrat
- CDD -> CDI avec `paidLeaveSettled=false`
- CDD -> CDI avec `paidLeaveSettled=true`
- Forfait sur annee complete
- Forfait avec debut en cours d'annee
4. Activer le cron de rollover
5. Geler (`is_locked`) les exercices historicises valides
Exemple cron (tous les jours a 02:10):
Dev
```cron
10 2 * * * cd /var/www/html && php bin/console app:leave:rollover --no-interaction 2>&1
```
Prod
```cron
10 2 * * * cd /var/www/sirh && php bin/console app:leave:rollover --no-interaction 2>&1
```
Explication de la ligne cron:
- `10 2 * * *`: planification
- `10` = minute
- `2` = heure
- `*` = tous les jours du mois
- `*` = tous les mois
- `*` = tous les jours de la semaine
- `cd /var/www/html`: se place dans le dossier de l application Symfony
- `php bin/console app:leave:rollover --no-interaction`: execute le rollover sans demander de confirmation
- hors `01/01` et `01/06`, la commande sort en no-op (normal)
- `>> var/log/leave-rollover.log`: ajoute la sortie standard dans le fichier de log (sans ecraser l historique)
- `2>&1`: redirige aussi les erreurs dans le meme fichier de log
Execution manuelle forcee:
```bash
php bin/console app:leave:rollover --force --no-interaction
```
Exemple de verification rapide:
```bash
tail -n 50 /var/www/html/var/log/leave-rollover.log
```
## 9) Points de vigilance
- Ne jamais recalculer les soldes historiques apres validation RH sans procedure explicite
- Garder une trace de toute correction manuelle (auteur, date, motif)
- Aligner strictement les regles UI et API sur les memes compteurs (pas de formule differente front/back)
## 10) Regle de consommation des droits
Regle metier:
- un employe peut poser des conges en cours d'acquisition
- la consommation se fait par ordre:
1. `acquis` (report N-1)
2. `en cours d'acquisition` (droits N)
Effet attendu:
- si `acquis = 0` et `en cours = 7.5`, puis prise de `7`, alors:
- `acquis` reste `0`
- `en cours` devient `0.5`
- si `acquis = 0` et `en cours = 2.5`, puis prise de `3`, alors:
- `acquis` reste `0`
- `en cours` devient `-0.5` (dette)
- le mois suivant, une acquisition de `2.5` ramené `en cours` a `2.0`
Formule de lecture recommandée:
- `restant_acquis = max(0, acquis - pris)`
- `reste_a_imputer_sur_en_cours = max(0, pris - acquis)`
- `restant_en_cours = en_cours - reste_a_imputer_sur_en_cours` (valeur negative autorisee)

163
doc/rtt-rollover.md Normal file
View File

@@ -0,0 +1,163 @@
# Rollover RTT - Regles et Mise en Production
Document de reference pour expliquer le fonctionnement metier du report RTT N-1 et preparer le lancement en production.
## 1) Objectif
Permettre le report des heures supplementaires (RTT) d'un exercice a l'autre et fiabiliser les soldes.
Principe:
- le solde d'ouverture est stocke par exercice
- au changement d'exercice, on ouvre la nouvelle periode avec un "solde d'ouverture" (report N-1)
- au go-live, les soldes d'ouverture sont importes manuellement (CSV ou insertion SQL)
## 2) Exercice metier
- exercice RTT: du `1er juin` au `31 mai`
- `year` = annee de fin d'exercice (ex: `2026` = 01/06/2025 -> 31/05/2026)
- employes eligibles: tous sauf `INTERIM` et suivi `PRESENCE`
## 3) Logique de compteurs
- `report N-1`:
- correspond au solde d'ouverture (`opening_minutes`)
- source prioritaire: table `employee_rtt_balances`
- fallback: calcul dynamique de la somme des minutes de recuperation de l'exercice precedent
- `acquis N`:
- somme des minutes de recuperation hebdomadaires de l'exercice en cours
- calcul: `HS totales + bonus 25% + bonus 50%` par semaine
- `disponible`:
- `report N-1 + acquis N`
- affichage du compteur global: en **jours** (1 jour = 7h = 420 minutes)
## 4) Attribution mensuelle des semaines
- une semaine ISO est affichee une seule fois, dans le mois qui contient le **samedi** de cette semaine
- si le weekend tombe en debut du mois suivant, c'est ce mois qui porte la semaine
- pas de prorata: la totalite des minutes de recuperation de la semaine est comptee dans un seul mois
## 5) Table cible
Table `employee_rtt_balances` (une ligne par employe et exercice):
- `employee_id`
- `year`
- `opening_minutes`
- `is_locked`
- `created_at`, `updated_at`
Contrainte unique:
- `(employee_id, year)`
Etat implementation:
- la table est creee
- le calcul de synthese RTT lit en priorite `opening_minutes` de cette table quand une ligne existe pour `(employee, year)`
- si aucune ligne n'existe, le calcul dynamique sur l'exercice N-1 est effectue
### Definition des colonnes
- `employee_id`:
- identifiant employe (FK vers `employees`)
- une ligne de solde par employe / exercice
- `year`:
- annee d'exercice (annee de fin)
- `2026` = 01/06/2025 -> 31/05/2026
- `opening_minutes`:
- report N-1 en minutes (solde d'ouverture)
- correspond a la somme des minutes de recuperation de l'exercice precedent
- `is_locked`:
- `false` sur exercice ouvert (recalcul possible)
- `true` apres validation RH (exercice fige)
- `created_at`, `updated_at`:
- trace technique creation / mise a jour
## 6) Rollover automatique
Commande quotidienne (cron) idempotente.
- commande Symfony: `php bin/console app:rtt:rollover`
- comportement date metier:
- le `01/06`: calcule et persiste le report pour chaque employe eligible
- les autres jours: sortie sans action
- option manuelle: `--force` pour executer hors date metier (reprise/correction)
Date d'effet:
- au `1er juin` (meme date que le rollover conges non forfait)
Traitement par employe:
1. verifier l'eligibilite (ni INTERIM, ni suivi PRESENCE)
2. verifier qu'aucune ligne n'existe deja pour `(employee, targetYear)` (idempotence)
3. calculer la somme des minutes de recuperation de l'exercice N-1
4. creer la ligne du nouvel exercice avec ce total en `opening_minutes`
## 7) Donnees a fournir au go-live
La RH doit fournir les soldes RTT a reporter.
Colonnes minimales:
- `employee_id` (id interne)
- `year`
- `opening_minutes` (total en minutes)
Format recommande:
- CSV UTF-8
- separateur `;`
Exemple:
```csv
employee_id;year;opening_minutes
42;2026;1260
17;2026;840
```
Equivalent en insertion SQL directe:
```sql
INSERT INTO employee_rtt_balances (employee_id, year, opening_minutes, is_locked, created_at, updated_at)
VALUES
(42, 2026, 1260, false, NOW(), NOW()),
(17, 2026, 840, false, NOW(), NOW());
```
Conversion rapide: `1260 minutes = 21h00 = 3.00 jours` (1 jour = 420 min = 7h)
## 8) Checklist mise en prod
1. Executer la migration (`employee_rtt_balances`)
2. Importer les soldes d'ouverture N-1 (CSV ou SQL)
3. Verifier 3 cas metier:
- CDI 39h avec heures supp sur l'exercice precedent
- CDI 35h sans heures supp (report = 0)
- INTERIM (doit etre ignore, pas de ligne creee)
4. Activer le cron de rollover
5. Geler (`is_locked`) les exercices historicises valides
Exemple cron (tous les jours a 02:15, juste apres le rollover conges):
Dev
```cron
15 2 * * * cd /var/www/html && php bin/console app:rtt:rollover --no-interaction 2>&1
```
Prod
```cron
10 2 * * * cd /var/www/sirh && php bin/console app:rtt:rollover --no-interaction 2>&1
```
Explication de la ligne cron:
- `15 2 * * *`: tous les jours a 02:15
- `php bin/console app:rtt:rollover --no-interaction`: execute le rollover sans confirmation
- hors `01/06`, la commande sort en no-op (normal)
- `>> var/log/rtt-rollover.log 2>&1`: log sortie standard et erreurs
Execution manuelle forcee:
```bash
php bin/console app:rtt:rollover --force --no-interaction
```
Exemple de verification rapide:
```bash
tail -n 50 /var/www/html/var/log/rtt-rollover.log
```
## 9) Points de vigilance
- Ne jamais modifier `opening_minutes` apres validation RH sans procedure explicite
- Garder une trace de toute correction manuelle (auteur, date, motif)
- Le calcul dynamique N-1 (fallback) parcourt toutes les heures de l'exercice precedent: preferer l'import explicite pour les exercices historiques
- La commande de rollover est idempotente: si une ligne existe deja, l'employe est ignore (pas d'ecrasement)

View File

@@ -0,0 +1,484 @@
# RTT : Affichage en heures + Paiement RTT
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Afficher les RTT en heures (plus en jours), permettre le paiement RTT via drawer avec majoration 25%/50%, stocker en BDD et afficher par mois.
**Architecture:** Nouvelle entity `EmployeeRttPayment` (employee, year, month, minutes, rate). Le provider RTT agrège les paiements par mois et les soustrait du disponible. Le frontend ajoute un drawer de saisie et deux lignes par mois (25% / 50%).
**Tech Stack:** Symfony + Doctrine + API Platform (backend), Nuxt 4 + Vue 3 + TypeScript + Tailwind (frontend)
---
## Contexte existant
- **Entity:** `EmployeeRttBalance` dans `src/Entity/EmployeeRttBalance.php` - stocke `openingMinutes` par exercice
- **Provider:** `src/State/EmployeeRttSummaryProvider.php` - calcule `availableMinutes = carry + currentYearRecovery`
- **Service:** `src/Service/Rtt/RttRecoveryComputationService.php` - calcul semaine par semaine
- **Frontend:** `frontend/components/employees/RttTab.vue` - affichage grille mois/semaines
- **DTO backend:** `src/ApiResource/EmployeeRttSummary.php` - champs `availableMinutes`, `weeks[]`
- **DTO frontend:** `frontend/services/dto/employee-rtt-summary.ts`
- Les minutes sont la base de calcul. 1 jour = 7h = 420 minutes.
- Exercice RTT : 1er juin N-1 -> 31 mai N (year = N)
---
### Task 1: Migration - Créer la table `employee_rtt_payments`
**Files:**
- Create: `migrations/Version20260309140000.php`
**Step 1: Créer la migration**
```php
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260309140000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create employee_rtt_payments table for RTT paid hours tracking.';
}
public function up(Schema $schema): void
{
$this->addSql(<<<'SQL'
CREATE TABLE employee_rtt_payments (
id SERIAL PRIMARY KEY,
employee_id INT NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
year INT NOT NULL,
month INT NOT NULL,
minutes INT NOT NULL,
rate VARCHAR(10) NOT NULL,
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL DEFAULT NOW()
)
SQL);
$this->addSql('CREATE INDEX idx_rtt_payment_employee_year ON employee_rtt_payments (employee_id, year)');
$this->addSql("COMMENT ON TABLE employee_rtt_payments IS 'Paiements RTT par employe, mois et taux de majoration.'");
$this->addSql("COMMENT ON COLUMN employee_rtt_payments.rate IS 'Taux de majoration: 25 ou 50.'");
$this->addSql("COMMENT ON COLUMN employee_rtt_payments.minutes IS 'Minutes RTT payees pour ce mois et ce taux.'");
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE employee_rtt_payments');
}
}
```
**Step 2: Exécuter la migration**
Run: `make migrate` ou `docker compose exec php php bin/console doctrine:migrations:migrate --no-interaction`
Expected: Migration exécutée sans erreur
**Step 3: Commit**
```bash
git add migrations/Version20260309140000.php
git commit -m "feat(rtt): add employee_rtt_payments table"
```
---
### Task 2: Entity + Repository `EmployeeRttPayment`
**Files:**
- Create: `src/Entity/EmployeeRttPayment.php`
- Create: `src/Repository/EmployeeRttPaymentRepository.php`
**Step 1: Créer l'entity**
```php
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Repository\EmployeeRttPaymentRepository;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: EmployeeRttPaymentRepository::class)]
#[ORM\Table(name: 'employee_rtt_payments')]
#[ORM\Index(columns: ['employee_id', 'year'], name: 'idx_rtt_payment_employee_year')]
class EmployeeRttPayment
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Employee::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?Employee $employee = null;
#[ORM\Column(type: 'integer')]
private int $year = 0;
#[ORM\Column(type: 'integer')]
private int $month = 0;
#[ORM\Column(type: 'integer', options: ['comment' => 'Minutes RTT payees pour ce mois et ce taux.'])]
private int $minutes = 0;
#[ORM\Column(length: 10, options: ['comment' => 'Taux de majoration: 25 ou 50.'])]
private string $rate = '25';
#[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $updatedAt;
public function __construct()
{
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
}
// Getters & setters (getId, getEmployee/setEmployee, getYear/setYear,
// getMonth/setMonth, getMinutes/setMinutes, getRate/setRate, touch)
// Suivre le même pattern que EmployeeLeaveBalance
}
```
**Step 2: Créer le repository**
```php
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Employee;
use App\Entity\EmployeeRttPayment;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<EmployeeRttPayment>
*/
final class EmployeeRttPaymentRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, EmployeeRttPayment::class);
}
/**
* @return list<EmployeeRttPayment>
*/
public function findByEmployeeAndYear(Employee $employee, int $year): array
{
return $this->createQueryBuilder('p')
->andWhere('p.employee = :employee')
->andWhere('p.year = :year')
->setParameter('employee', $employee)
->setParameter('year', $year)
->orderBy('p.month', 'ASC')
->getQuery()
->getResult();
}
}
```
**Step 3: Vérifier le lint**
Run: `docker compose exec php php -l src/Entity/EmployeeRttPayment.php && docker compose exec php php -l src/Repository/EmployeeRttPaymentRepository.php`
Expected: No syntax errors
**Step 4: Commit**
```bash
git add src/Entity/EmployeeRttPayment.php src/Repository/EmployeeRttPaymentRepository.php
git commit -m "feat(rtt): add EmployeeRttPayment entity and repository"
```
---
### Task 3: API Resource + Provider + Processor pour le paiement RTT
**Files:**
- Create: `src/ApiResource/EmployeeRttPaymentInput.php`
- Create: `src/State/EmployeeRttPaymentProvider.php`
- Create: `src/State/EmployeeRttPaymentProcessor.php`
**Step 1: Créer l'API Resource**
Endpoint: `PATCH /employees/{id}/rtt-payments` (ROLE_ADMIN)
Body: `{ "month": 3, "minutes": 120, "rate": "25" }`
```php
// src/ApiResource/EmployeeRttPaymentInput.php
#[ApiResource(
operations: [
new Patch(
uriTemplate: '/employees/{id}/rtt-payments',
security: "is_granted('ROLE_ADMIN')",
provider: EmployeeRttPaymentProvider::class,
processor: EmployeeRttPaymentProcessor::class
),
],
paginationEnabled: false
)]
final class EmployeeRttPaymentInput
{
public int $month = 0;
public int $minutes = 0;
public string $rate = '25';
public ?int $year = null;
}
```
**Step 2: Créer le Provider** (retourne un DTO vide, même pattern que `EmployeeFractionedDaysProvider`)
**Step 3: Créer le Processor**
Logique:
- Valider `rate` in `['25', '50']`, `month` in `[1..12]`, `minutes >= 0`
- Résoudre l'année (même logique exercice RTT que le provider existant)
- Persister un `EmployeeRttPayment`
**Step 4: Vérifier le lint**
**Step 5: Commit**
```bash
git add src/ApiResource/EmployeeRttPaymentInput.php src/State/EmployeeRttPaymentProvider.php src/State/EmployeeRttPaymentProcessor.php
git commit -m "feat(rtt): add PATCH endpoint for RTT payment"
```
---
### Task 4: Modifier le DTO + Provider RTT pour inclure les paiements par mois
**Files:**
- Modify: `src/ApiResource/EmployeeRttSummary.php`
- Modify: `src/State/EmployeeRttSummaryProvider.php`
- Create: `src/Dto/Rtt/RttMonthPayment.php`
- Modify: `frontend/services/dto/employee-rtt-summary.ts`
**Step 1: Créer le DTO mois-paiement**
```php
// src/Dto/Rtt/RttMonthPayment.php
final class RttMonthPayment
{
public function __construct(
public int $month,
public int $paidMinutes25 = 0,
public int $paidMinutes50 = 0,
) {}
}
```
**Step 2: Ajouter au summary backend**
Dans `EmployeeRttSummary.php`, ajouter:
```php
public int $totalPaidMinutes = 0;
/** @var list<RttMonthPayment> */
public array $monthPayments = [];
```
Et modifier `availableMinutes` pour soustraire les paiements:
```php
$summary->availableMinutes = $summary->carryFromPreviousYearMinutes
+ $summary->currentYearRecoveryMinutes
- $summary->totalPaidMinutes;
```
**Step 3: Modifier le provider** pour charger les paiements via le repository et les agréger par mois
Dans `EmployeeRttSummaryProvider::provide()`:
- Charger `$payments = $this->rttPaymentRepository->findByEmployeeAndYear($employee, $year)`
- Agréger par mois + rate pour construire les `RttMonthPayment`
- Calculer `totalPaidMinutes = sum(minutes)`
- Soustraire du `availableMinutes`
**Step 4: Mettre à jour le DTO frontend**
Dans `frontend/services/dto/employee-rtt-summary.ts`:
```typescript
export type RttMonthPayment = {
month: number
paidMinutes25: number
paidMinutes50: number
}
export type EmployeeRttSummary = {
// ... champs existants ...
totalPaidMinutes: number
monthPayments: RttMonthPayment[]
}
```
**Step 5: Commit**
```bash
git commit -m "feat(rtt): include paid hours in RTT summary by month"
```
---
### Task 5: Frontend - Afficher les RTT en heures + lignes payées
**Files:**
- Modify: `frontend/components/employees/RttTab.vue`
**Step 1: Changer l'affichage du header de jours en heures**
Ligne 4 actuelle:
```html
<p>...: {{ formatDays(summary?.availableMinutes ?? 0) }}</p>
```
Remplacer par:
```html
<p>...: {{ formatMinutes(summary?.availableMinutes ?? 0) }}</p>
```
**Step 2: Ajouter les 2 lignes de paiement par mois**
Après la ligne `Heure payée` existante (ligne 33-34), remplacer par 2 lignes distinctes:
```html
<div class="py-[6px] pl-3 border-r border-b border-primary-500">Heure payée 25%</div>
<div class="py-[6px] pl-3 border-b border-primary-500">{{ formatMinutes(getMonthPaid25(month.month)) }}</div>
<div class="py-[6px] pl-3 border-r border-primary-500">Heure payée 50%</div>
<div class="py-[6px] pl-3">{{ formatMinutes(getMonthPaid50(month.month)) }}</div>
```
**Step 3: Ajouter les helpers computed**
```typescript
const paymentsByMonth = computed(() => {
const map = new Map<number, { paid25: number; paid50: number }>()
for (const mp of props.summary?.monthPayments ?? []) {
map.set(mp.month, { paid25: mp.paidMinutes25, paid50: mp.paidMinutes50 })
}
return map
})
const getMonthPaid25 = (month: number) => paymentsByMonth.value.get(month)?.paid25 ?? 0
const getMonthPaid50 = (month: number) => paymentsByMonth.value.get(month)?.paid50 ?? 0
```
**Step 4: Commit**
```bash
git commit -m "feat(rtt): display hours instead of days + paid hours per month"
```
---
### Task 6: Frontend - Drawer de paiement RTT
**Files:**
- Modify: `frontend/components/employees/RttTab.vue`
- Modify: `frontend/services/employee-rtt-summary.ts`
- Modify: `frontend/composables/useEmployeeDetailPage.ts`
- Modify: `frontend/pages/employees/[id].vue`
**Step 1: Ajouter le service API**
Dans `frontend/services/employee-rtt-summary.ts`:
```typescript
export const createRttPayment = async (
employeeId: number,
month: number,
minutes: number,
rate: '25' | '50',
year?: number
) => {
const api = useApi()
const body: Record<string, unknown> = { month, minutes, rate }
if (year) body.year = year
return api.patch(`/employees/${employeeId}/rtt-payments`, body)
}
```
**Step 2: Ajouter au composable**
Dans `useEmployeeDetailPage.ts`:
- Import `createRttPayment`
- Ajouter `submitRttPayment(month, minutes, rate)` qui appelle l'API puis `loadEmployee()`
- Exposer dans le return
**Step 3: Passer l'event dans la page**
Dans `frontend/pages/employees/[id].vue`:
- Destructurer `submitRttPayment`
- Ajouter `@submit-rtt-payment="submitRttPayment"` sur `<EmployeesRttTab>`
**Step 4: Ajouter le drawer dans RttTab.vue**
Même pattern que le drawer fractionnés dans LeaveTab:
- Import `AppDrawer`
- State: `isPaymentDrawerOpen`, `paymentForm: { month, minutes, rate }`
- Bouton "Payer les RTT" ouvre le drawer
- Formulaire avec:
- Select mois (Janvier..Décembre)
- Input number "Nombre d'heures" (step 0.5, converti en minutes au submit)
- Select rate: "25%" / "50%"
- Boutons Annuler / Enregistrer
- Emit `submit-rtt-payment` au submit
**Step 5: Commit**
```bash
git commit -m "feat(rtt): add payment drawer with month/hours/rate fields"
```
---
### Task 7: Documentation fonctionnelle
**Files:**
- Modify: `doc/functional-rules.md`
**Step 1: Mettre à jour la section RTT**
Ajouter après les règles RTT existantes:
- Paiement RTT: saisie RH via `PATCH /employees/{id}/rtt-payments`
- Stocké dans `employee_rtt_payments` (employee, year, month, minutes, rate)
- Les heures payées sont soustraites du disponible RTT
- Affichage: 2 lignes par mois (25% et 50%)
- L'affichage global RTT est en heures (plus en jours)
**Step 2: Commit**
```bash
git commit -m "docs: update functional rules with RTT payment feature"
```
---
## Résumé des fichiers
| Action | Fichier |
|--------|---------|
| Create | `migrations/Version20260309140000.php` |
| Create | `src/Entity/EmployeeRttPayment.php` |
| Create | `src/Repository/EmployeeRttPaymentRepository.php` |
| Create | `src/ApiResource/EmployeeRttPaymentInput.php` |
| Create | `src/State/EmployeeRttPaymentProvider.php` |
| Create | `src/State/EmployeeRttPaymentProcessor.php` |
| Create | `src/Dto/Rtt/RttMonthPayment.php` |
| Modify | `src/ApiResource/EmployeeRttSummary.php` |
| Modify | `src/State/EmployeeRttSummaryProvider.php` |
| Modify | `frontend/services/dto/employee-rtt-summary.ts` |
| Modify | `frontend/services/employee-rtt-summary.ts` |
| Modify | `frontend/components/employees/RttTab.vue` |
| Modify | `frontend/composables/useEmployeeDetailPage.ts` |
| Modify | `frontend/pages/employees/[id].vue` |
| Modify | `doc/functional-rules.md` |

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,273 @@
# Employee Entry Date Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add an `entryDate` field to Employee, automatically populated from `contractStartDate` at creation.
**Architecture:** New nullable `DATE` column on `employees` table. The `EmployeeWriteProcessor` sets `entryDate` from the first contract period's start date during employee creation. Exposed read-only in the API. No fallback needed — existing employees will be updated manually in prod DB.
**Tech Stack:** Symfony/Doctrine (backend), Nuxt/Vue/TypeScript (frontend)
---
## File Structure
| File | Action | Responsibility |
|------|--------|----------------|
| `src/Entity/Employee.php` | Modify | Add `entryDate` column + getter/setter, expose in `employee:read` |
| `src/State/EmployeeWriteProcessor.php` | Modify | Set `entryDate` from `contractStartDate` on creation |
| `migrations/Version20260312120000.php` | Create | Add `entry_date` column to `employees` table |
| `tests/State/EmployeeWriteProcessorTest.php` | Modify | Assert `entryDate` is set on new employee |
| `frontend/services/dto/employee.ts` | Modify | Add `entryDate` field to `Employee` type |
| `frontend/pages/employees/index.vue` | Modify | Display entry date in employee list |
---
## Chunk 1: Backend
### Task 1: Migration
**Files:**
- Create: `migrations/Version20260312120000.php`
- [ ] **Step 1: Create the migration file**
```php
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260312120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add entry_date column to employees table';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE employees ADD entry_date DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\'');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE employees DROP entry_date');
}
}
```
- [ ] **Step 2: Run migration**
Run: `php bin/console doctrine:migrations:migrate --no-interaction`
Expected: Migration applied successfully
- [ ] **Step 3: Commit**
```bash
git add migrations/Version20260312120000.php
git commit -m "feat : ajout colonne entry_date sur employees"
```
---
### Task 2: Entity — add `entryDate` property
**Files:**
- Modify: `src/Entity/Employee.php:56-61` (insert after `displayOrder`)
- [ ] **Step 1: Add the column, getter, and setter to Employee entity**
Add after the `displayOrder` property (line 58):
```php
#[ORM\Column(type: 'date_immutable', nullable: true)]
#[Groups(['employee:read'])]
private ?\DateTimeImmutable $entryDate = null;
```
Add getter and setter after `setDisplayOrder()` (after line 167):
```php
public function getEntryDate(): ?\DateTimeImmutable
{
return $this->entryDate;
}
public function setEntryDate(?\DateTimeImmutable $entryDate): self
{
$this->entryDate = $entryDate;
return $this;
}
```
- [ ] **Step 2: Verify schema is in sync**
Run: `php bin/console doctrine:schema:validate`
Expected: OK (or only existing unrelated warnings)
- [ ] **Step 3: Commit**
```bash
git add src/Entity/Employee.php
git commit -m "feat : ajout propriete entryDate sur Employee"
```
---
### Task 3: Set `entryDate` on employee creation
**Files:**
- Modify: `src/State/EmployeeWriteProcessor.php:60-71`
- Modify: `tests/State/EmployeeWriteProcessorTest.php`
- [ ] **Step 1: Write the failing test**
Add `use ApiPlatform\Metadata\Post;` to the imports at the top of the test file (alongside the existing `Delete` and `Patch` imports).
Then add this test method to `EmployeeWriteProcessorTest`:
```php
public function testSetsEntryDateOnNewEmployee(): void
{
$employee = new Employee();
$employee->setFirstName('Jane');
$employee->setLastName('Doe');
$employee->setContractStartDate('2026-04-01');
$employee->setContractNature('CDI');
$contract = new Contract()
->setName('35h')
->setTrackingMode(Contract::TRACKING_TIME)
->setWeeklyHours(35);
$employee->setContract($contract);
$persistProcessor = $this->createMock(ProcessorInterface::class);
$removeProcessor = $this->createStub(ProcessorInterface::class);
$entityManager = $this->createStub(EntityManagerInterface::class);
$periodRepository = $this->createStub(EmployeeContractPeriodReadRepositoryInterface::class);
$changeRequestFactory = new EmployeeContractChangeRequestFactory();
$periodManager = $this->createMock(EmployeeContractPeriodManagerInterface::class);
$persistProcessor
->expects(self::once())
->method('process')
->willReturn($employee);
$periodManager
->expects(self::once())
->method('ensureContractPeriodExists');
$processor = new EmployeeWriteProcessor(
$persistProcessor,
$removeProcessor,
$entityManager,
$periodRepository,
$changeRequestFactory,
$periodManager
);
$processor->process($employee, new Post());
self::assertNotNull($employee->getEntryDate());
self::assertSame('2026-04-01', $employee->getEntryDate()->format('Y-m-d'));
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `php bin/phpunit tests/State/EmployeeWriteProcessorTest.php --filter=testSetsEntryDateOnNewEmployee`
Expected: FAIL — `entryDate` is null
- [ ] **Step 3: Implement — set entryDate in EmployeeWriteProcessor**
In `src/State/EmployeeWriteProcessor.php`, inside the `if ($isNew)` block (line 60-71), add **before** `return $result;` (line 71):
```php
$data->setEntryDate($startDate);
```
The full block becomes:
```php
if ($isNew) {
$startDate = $changeRequest->contractStartDate ?? new DateTimeImmutable('1970-01-01');
$nature = $changeRequest->contractNature ?? ContractNature::CDI;
$this->periodManager->ensureContractPeriodExists(
employee: $data,
contract: $currentContract,
startDate: $startDate,
endDate: $changeRequest->contractEndDate,
nature: $nature
);
$data->setEntryDate($startDate);
return $result;
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `php bin/phpunit tests/State/EmployeeWriteProcessorTest.php --filter=testSetsEntryDateOnNewEmployee`
Expected: PASS
- [ ] **Step 5: Run all EmployeeWriteProcessor tests**
Run: `php bin/phpunit tests/State/EmployeeWriteProcessorTest.php`
Expected: All tests pass (no regression)
- [ ] **Step 6: Commit**
```bash
git add src/State/EmployeeWriteProcessor.php tests/State/EmployeeWriteProcessorTest.php
git commit -m "feat : remplissage automatique entryDate a la creation employe"
```
---
## Chunk 2: Frontend
### Task 4: Update frontend DTO and display entry date
**Files:**
- Modify: `frontend/services/dto/employee.ts:14-25`
- Modify: `frontend/pages/employees/index.vue`
- [ ] **Step 1: Add `entryDate` to the Employee DTO**
In `frontend/services/dto/employee.ts:24`, add `entryDate` after `displayOrder`:
```typescript
displayOrder?: number
entryDate?: string | null
```
- [ ] **Step 2: Display entry date in the employee card hover overlay**
In `frontend/pages/employees/index.vue`, inside the hover overlay `<div>` (line 49-54), add a new line after the "Site" line (after line 53):
```vue
<p><strong>Entree :</strong> {{ employee.entryDate ? employee.entryDate.split('-').reverse().join('/') : '-' }}</p>
```
This uses string splitting instead of `new Date()` to avoid timezone parsing issues with date-only strings.
- [ ] **Step 3: Verify in browser**
1. Check the API response for an employee: `GET /api/employees` should include `entryDate` field (confirms backend `employee:read` group works)
2. Open the employee list page, hover over a card — entry date should appear in the overlay
3. Create a new employee, verify the entry date shows the contract start date
4. Existing employees without entry date should show "-"
- [ ] **Step 4: Commit**
```bash
git add frontend/services/dto/employee.ts frontend/pages/employees/index.vue
git commit -m "feat : affichage date d'entree dans la liste employes"
```

View File

@@ -0,0 +1,563 @@
# Refonte onglet RTT — Plan d'implémentation
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Remplacer la vue annuelle RTT par une vue mensuelle avec tableau détaillé par semaine (base/25%/50%) et un système de paiement à 4 champs.
**Architecture:** Enrichir `RttRecoveryComputationService` pour retourner le détail base/bonus par palier. Modifier l'entité `EmployeeRttPayment` pour stocker 4 valeurs. Réécrire le composant `RttTab.vue` avec navigation mensuelle et tableau 7 colonnes.
**Tech Stack:** Symfony + API Platform + Doctrine (backend), Nuxt 4 + Vue 3 + TypeScript + Tailwind (frontend), PostgreSQL.
**Spec:** `docs/superpowers/specs/2026-03-13-rtt-tab-redesign.md`
---
## Task 1: Enrichir le retour de `RttRecoveryComputationService::computeRecoveryByWeek`
**Files:**
- Create: `src/Dto/Rtt/WeekRecoveryDetail.php`
- Modify: `src/Service/Rtt/RttRecoveryComputationService.php:97-206`
Actuellement `computeRecoveryByWeek` retourne `array<string, int>` (weekKey => totalMinutes). Il faut retourner `array<string, WeekRecoveryDetail>` avec le détail ventilé.
- [ ] **Step 1: Créer le DTO `WeekRecoveryDetail`**
```php
// src/Dto/Rtt/WeekRecoveryDetail.php
<?php
declare(strict_types=1);
namespace App\Dto\Rtt;
final class WeekRecoveryDetail
{
public function __construct(
public int $overtimeMinutes = 0,
public int $base25Minutes = 0,
public int $bonus25Minutes = 0,
public int $base50Minutes = 0,
public int $bonus50Minutes = 0,
public int $totalMinutes = 0,
) {}
}
```
- [ ] **Step 2: Modifier `computeRecoveryByWeek` pour retourner `array<string, WeekRecoveryDetail>`**
Changer le retour de la méthode. Les variables internes existent déjà (`weeklyOvertimeTotalMinutes`, `weeklyOvertime25Minutes`, `weeklyOvertime50Minutes`). Il faut calculer en plus les bases séparées.
La logique de ventilation des heures de base entre palier 25% et palier 50% :
- `base25Minutes` = heures sup dans la tranche 25% = `min(overtimeMinutes, max(0, overtime25StartMinutes - overtimeReferenceMinutes))`... En fait, c'est plus simple :
- `base25Minutes` = `min(weeklyOvertimeTotalMinutes, max(0, 43*60 - overtime25StartMinutes))` quand overtimeTotal > 0
- Plus simplement : `base25Minutes` = heures entre le seuil 25% et 43h, `base50Minutes` = heures au-dessus de 43h
Reprenons la logique existante (lignes 189-202) :
- `overtimeReferenceMinutes` = seuil à partir duquel on compte les heures sup (max(35, weeklyHours) * 60 réparti sur les jours)
- `overtime25StartMinutes` = seuil à partir duquel les heures sup sont à 25% (39h si contrat >= 39h, sinon 35h)
- `weeklyOvertimeTotalMinutes` = max(0, worked - overtimeReference) — total heures sup brutes
- `weeklyOvertime25Minutes` = bonus 25% = round(min(worked, 43*60) - overtime25Start) * 0.25
- `weeklyOvertime50Minutes` = bonus 50% = round(max(0, worked - 43*60)) * 0.5
Pour la ventilation :
- `base25Minutes` = min(weeklyOvertimeTotalMinutes, max(0, 43*60 - overtime25StartMinutes)) — Non, c'est la tranche 25% en termes d'heures travaillées, pas en termes d'heures sup.
En fait :
- Les heures sup brutes = `weeklyOvertimeTotalMinutes` = `worked - overtimeReference`
- Les heures dans le palier 25% = heures entre `overtime25Start` et `min(worked, 43*60)` = c'est `max(0, min(worked, 43*60) - overtime25Start)`. C'est la base sur laquelle le 25% est calculé.
- Les heures dans le palier 50% = heures au-dessus de 43h = `max(0, worked - 43*60)`. C'est la base sur laquelle le 50% est calculé.
Modifier les lignes 191-202 :
```php
$weeklyOvertimeTotalMinutes = $isWeekPresenceTracking
? 0
: max(0, $weeklyTotalMinutes - $overtimeReferenceMinutes);
$base25 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : max(0, min($weeklyTotalMinutes, 43 * 60) - $overtime25StartMinutes);
$bonus25 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : (int) round($base25 * 0.25);
$base50 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : max(0, $weeklyTotalMinutes - 43 * 60);
$bonus50 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : (int) round($base50 * 0.5);
$results[$weekKey] = new WeekRecoveryDetail(
overtimeMinutes: $weeklyOvertimeTotalMinutes,
base25Minutes: $base25,
bonus25Minutes: $bonus25,
base50Minutes: $base50,
bonus50Minutes: $bonus50,
totalMinutes: ($isWeekPresenceTracking || $disableOvertimeBonuses)
? 0
: $weeklyOvertimeTotalMinutes + $bonus25 + $bonus50,
);
```
Les cas "zéro" (weekStart vide, limitDate dépassée, etc.) retournent `new WeekRecoveryDetail()` (tout à 0).
- [ ] **Step 3: Adapter `computeTotalRecoveryForExercise` pour retourner un `WeekRecoveryDetail` agrégé**
Cette méthode retournait `int`. Elle doit maintenant retourner un `WeekRecoveryDetail` qui agrège toutes les semaines (somme par champ). Le rollover et le provider en ont besoin pour la ventilation du carry-over.
```php
public function computeTotalRecoveryForExercise(Employee $employee, int $exerciseYear): WeekRecoveryDetail
{
[$from, $to] = $this->resolveExerciseBounds($exerciseYear);
$weeks = $this->buildWeeksForExercise($from, $to);
$weekRanges = array_map(
static fn (array $week): array => [
'month' => (int) $week['month'],
'weekNumber' => (int) $week['weekNumber'],
'start' => $week['start'],
'end' => $week['end'],
],
$weeks
);
$byWeek = $this->computeRecoveryByWeek($employee, $weekRanges, $from, $to, null);
$total = new WeekRecoveryDetail();
foreach ($byWeek as $detail) {
$total = new WeekRecoveryDetail(
overtimeMinutes: $total->overtimeMinutes + $detail->overtimeMinutes,
base25Minutes: $total->base25Minutes + $detail->base25Minutes,
bonus25Minutes: $total->bonus25Minutes + $detail->bonus25Minutes,
base50Minutes: $total->base50Minutes + $detail->base50Minutes,
bonus50Minutes: $total->bonus50Minutes + $detail->bonus50Minutes,
totalMinutes: $total->totalMinutes + $detail->totalMinutes,
);
}
return $total;
}
```
- [ ] **Step 4: Vérifier que le code compile**
Run: `docker exec php-sirh-fpm php bin/console cache:clear`
---
## Task 2: Modifier l'entité `EmployeeRttBalance` (carry-over ventilé) + rollover
**Files:**
- Modify: `src/Entity/EmployeeRttBalance.php`
- Modify: `src/Repository/EmployeeRttBalanceRepository.php`
- Modify: `src/Command/RttRolloverCommand.php`
Le carry-over doit être ventilé sur les mêmes 4 colonnes que le tableau (base25, bonus25, base50, bonus50) pour pouvoir afficher une ligne "Report" dans le mois de juin.
- [ ] **Step 1: Remplacer `openingMinutes` par 4 champs dans `EmployeeRttBalance`**
Remplacer la propriété `$openingMinutes` par :
```php
#[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 base 25% en minutes.', 'default' => 0])]
private int $openingBase25Minutes = 0;
#[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 bonus 25% en minutes.', 'default' => 0])]
private int $openingBonus25Minutes = 0;
#[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 base 50% en minutes.', 'default' => 0])]
private int $openingBase50Minutes = 0;
#[ORM\Column(type: 'integer', options: ['comment' => 'Report N-1 bonus 50% en minutes.', 'default' => 0])]
private int $openingBonus50Minutes = 0;
```
Ajouter les getters/setters. Supprimer `getOpeningMinutes`/`setOpeningMinutes`. Ajouter un helper `getTotalOpeningMinutes()` qui retourne la somme des 4 champs.
- [ ] **Step 2: Adapter `RttRolloverCommand`**
`computeTotalRecoveryForExercise` retourne maintenant un `WeekRecoveryDetail`. Utiliser les 4 champs :
```php
$carry = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $previousYear);
$balance = new EmployeeRttBalance()
->setEmployee($employee)
->setYear($targetYear)
->setOpeningBase25Minutes($carry->base25Minutes)
->setOpeningBonus25Minutes($carry->bonus25Minutes)
->setOpeningBase50Minutes($carry->base50Minutes)
->setOpeningBonus50Minutes($carry->bonus50Minutes)
->setIsLocked(false)
;
```
- [ ] **Step 3: Adapter `EmployeeRttSummaryProvider::resolveCarryMinutes`**
Cette méthode retournait `int`. La renommer en `resolveCarry` et retourner un `WeekRecoveryDetail` :
```php
private function resolveCarry(Employee $employee, int $year): WeekRecoveryDetail
{
$balance = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $year);
if (null !== $balance) {
return new WeekRecoveryDetail(
base25Minutes: $balance->getOpeningBase25Minutes(),
bonus25Minutes: $balance->getOpeningBonus25Minutes(),
base50Minutes: $balance->getOpeningBase50Minutes(),
bonus50Minutes: $balance->getOpeningBonus50Minutes(),
totalMinutes: $balance->getTotalOpeningMinutes(),
);
}
return $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $year - 1);
}
```
Adapter le provider pour utiliser le carry ventilé dans le summary :
- `carryFromPreviousYearMinutes` = carry->totalMinutes
- Ajouter les 4 champs de carry dans `EmployeeRttSummary` pour le frontend
- [ ] **Step 4: Ajouter les champs carry dans `EmployeeRttSummary`**
```php
public int $carryBase25Minutes = 0;
public int $carryBonus25Minutes = 0;
public int $carryBase50Minutes = 0;
public int $carryBonus50Minutes = 0;
```
- [ ] **Step 5: Générer et exécuter la migration**
Run: `docker exec php-sirh-fpm php bin/console doctrine:migrations:diff`
Run: `docker exec php-sirh-fpm php bin/console doctrine:migrations:migrate --no-interaction`
Note : faire la migration après la Task 3 (EmployeeRttPayment) pour regrouper les changements dans une seule migration.
---
## Task 3: Modifier l'entité `EmployeeRttPayment` et la migration
**Files:**
- Modify: `src/Entity/EmployeeRttPayment.php`
- Modify: `src/Repository/EmployeeRttPaymentRepository.php`
- [ ] **Step 1: Remplacer `minutes` + `rate` par 4 champs dans l'entité**
Remplacer les propriétés `$minutes` et `$rate` par :
```php
#[ORM\Column(type: 'integer', options: ['comment' => 'Base heures palier 25% en minutes.', 'default' => 0])]
private int $base25Minutes = 0;
#[ORM\Column(type: 'integer', options: ['comment' => 'Bonus 25% en minutes.', 'default' => 0])]
private int $bonus25Minutes = 0;
#[ORM\Column(type: 'integer', options: ['comment' => 'Base heures palier 50% en minutes.', 'default' => 0])]
private int $base50Minutes = 0;
#[ORM\Column(type: 'integer', options: ['comment' => 'Bonus 50% en minutes.', 'default' => 0])]
private int $bonus50Minutes = 0;
```
Ajouter les getters/setters correspondants. Supprimer `getMinutes`/`setMinutes`/`getRate`/`setRate`.
- [ ] **Step 2: Adapter le repository**
Remplacer `findOneByEmployeeYearMonthRate` par `findOneByEmployeeYearMonth` (plus besoin du rate) :
```php
public function findOneByEmployeeYearMonth(Employee $employee, int $year, int $month): ?EmployeeRttPayment
{
return $this->findOneBy([
'employee' => $employee,
'year' => $year,
'month' => $month,
]);
}
```
- [ ] **Step 3: Générer et vérifier la migration**
Run: `docker exec php-sirh-fpm php bin/console doctrine:migrations:diff`
Vérifier que la migration :
- Ajoute `base25_minutes`, `bonus25_minutes`, `base50_minutes`, `bonus50_minutes`
- Supprime `minutes` et `rate`
Run: `docker exec php-sirh-fpm php bin/console doctrine:migrations:migrate --no-interaction`
---
## Task 3: Adapter le DTO `RttMonthPayment` et `EmployeeRttWeekSummary`
**Files:**
- Modify: `src/Dto/Rtt/RttMonthPayment.php`
- Modify: `src/Dto/Rtt/EmployeeRttWeekSummary.php`
- [ ] **Step 1: Modifier `RttMonthPayment`**
Remplacer `paidMinutes25` et `paidMinutes50` par les 4 champs :
```php
final class RttMonthPayment
{
public function __construct(
public int $month,
public int $paidBase25Minutes = 0,
public int $paidBonus25Minutes = 0,
public int $paidBase50Minutes = 0,
public int $paidBonus50Minutes = 0,
) {}
}
```
- [ ] **Step 2: Enrichir `EmployeeRttWeekSummary`**
Ajouter les champs de détail :
```php
final class EmployeeRttWeekSummary
{
public function __construct(
public int $month,
public int $weekNumber,
public string $weekStart,
public string $weekEnd,
public int $overtimeMinutes = 0,
public int $base25Minutes = 0,
public int $bonus25Minutes = 0,
public int $base50Minutes = 0,
public int $bonus50Minutes = 0,
public int $totalMinutes = 0,
) {}
}
```
Supprimer l'ancien champ `recoveryMinutes`.
---
## Task 4: Adapter le provider et le processor backend
**Files:**
- Modify: `src/State/EmployeeRttSummaryProvider.php`
- Modify: `src/ApiResource/EmployeeRttSummary.php`
- Modify: `src/ApiResource/EmployeeRttPaymentInput.php`
- Modify: `src/State/EmployeeRttPaymentProcessor.php`
- [ ] **Step 1: Adapter `EmployeeRttSummaryProvider::provide`**
Le mapping des semaines (ligne 87-96) doit utiliser les nouveaux champs du `WeekRecoveryDetail` :
```php
$summary->weeks = array_map(
static function (array $week) use ($currentByWeekStart) {
$detail = $currentByWeekStart[$week['start']->format('Y-m-d')] ?? new WeekRecoveryDetail();
return new EmployeeRttWeekSummary(
month: (int) $week['month'],
weekNumber: (int) $week['weekNumber'],
weekStart: $week['start']->format('Y-m-d'),
weekEnd: $week['end']->format('Y-m-d'),
overtimeMinutes: $detail->overtimeMinutes,
base25Minutes: $detail->base25Minutes,
bonus25Minutes: $detail->bonus25Minutes,
base50Minutes: $detail->base50Minutes,
bonus50Minutes: $detail->bonus50Minutes,
totalMinutes: $detail->totalMinutes,
);
},
$weekRanges
);
```
Le `currentYearRecoveryMinutes` doit sommer les `totalMinutes` :
```php
$summary->currentYearRecoveryMinutes = array_sum(
array_map(static fn (WeekRecoveryDetail $d) => $d->totalMinutes, $currentByWeekStart)
);
```
Adapter l'agrégation des paiements (lignes 98-121) pour les 4 champs :
```php
foreach ($payments as $payment) {
$m = $payment->getMonth();
if (!isset($monthBuckets[$m])) {
$monthBuckets[$m] = ['base25' => 0, 'bonus25' => 0, 'base50' => 0, 'bonus50' => 0];
}
$monthBuckets[$m]['base25'] += $payment->getBase25Minutes();
$monthBuckets[$m]['bonus25'] += $payment->getBonus25Minutes();
$monthBuckets[$m]['base50'] += $payment->getBase50Minutes();
$monthBuckets[$m]['bonus50'] += $payment->getBonus50Minutes();
}
foreach ($monthBuckets as $m => $bucket) {
$monthPayments[] = new RttMonthPayment($m, $bucket['base25'], $bucket['bonus25'], $bucket['base50'], $bucket['bonus50']);
$totalPaidMinutes += $bucket['base25'] + $bucket['bonus25'] + $bucket['base50'] + $bucket['bonus50'];
}
```
- [ ] **Step 2: Adapter `EmployeeRttPaymentInput`**
```php
final class EmployeeRttPaymentInput
{
public int $month = 0;
public int $base25Minutes = 0;
public int $bonus25Minutes = 0;
public int $base50Minutes = 0;
public int $bonus50Minutes = 0;
public ?int $year = null;
}
```
- [ ] **Step 3: Adapter `EmployeeRttPaymentProcessor`**
Supprimer la validation du `rate`. Adapter le upsert :
```php
$payment = $this->rttPaymentRepository->findOneByEmployeeYearMonth($employee, $year, $data->month);
if (null === $payment) {
$payment = new EmployeeRttPayment();
$payment->setEmployee($employee);
$payment->setYear($year);
$payment->setMonth($data->month);
$this->entityManager->persist($payment);
}
$payment->setBase25Minutes($data->base25Minutes);
$payment->setBonus25Minutes($data->bonus25Minutes);
$payment->setBase50Minutes($data->base50Minutes);
$payment->setBonus50Minutes($data->bonus50Minutes);
$payment->touch();
$this->entityManager->flush();
```
- [ ] **Step 4: Vérifier**
Run: `docker exec php-sirh-fpm php bin/console cache:clear`
---
## Task 5: Adapter le frontend — DTOs et service
**Files:**
- Modify: `frontend/services/dto/employee-rtt-summary.ts`
- Modify: `frontend/services/employee-rtt-summary.ts`
- [ ] **Step 1: Mettre à jour les types TS**
```typescript
export type EmployeeRttWeekSummary = {
month: number
weekNumber: number
weekStart: string
weekEnd: string
overtimeMinutes: number
base25Minutes: number
bonus25Minutes: number
base50Minutes: number
bonus50Minutes: number
totalMinutes: number
}
export type RttMonthPayment = {
month: number
paidBase25Minutes: number
paidBonus25Minutes: number
paidBase50Minutes: number
paidBonus50Minutes: number
}
export type EmployeeRttSummary = {
year: number
carryFromPreviousYearMinutes: number
carryBase25Minutes: number
carryBonus25Minutes: number
carryBase50Minutes: number
carryBonus50Minutes: number
currentYearRecoveryMinutes: number
totalPaidMinutes: number
availableMinutes: number
weeks: EmployeeRttWeekSummary[]
monthPayments: RttMonthPayment[]
}
```
- [ ] **Step 2: Adapter le service `createRttPayment`**
```typescript
export const createRttPayment = async (
employeeId: number,
month: number,
base25Minutes: number,
bonus25Minutes: number,
base50Minutes: number,
bonus50Minutes: number,
year?: number
) => {
const api = useApi()
const body: Record<string, unknown> = { month, base25Minutes, bonus25Minutes, base50Minutes, bonus50Minutes }
if (year) body.year = year
return api.patch(`/employees/${employeeId}/rtt-payments`, body)
}
```
---
## Task 6: Réécrire `RttTab.vue`
**Files:**
- Modify: `frontend/components/employees/RttTab.vue`
- [ ] **Step 1: Réécrire le composant complet**
Structure du template :
1. En-tête avec navigation mensuelle (flèches `<` `>`) et "RTT À LA DATE DU JOUR : X heure"
2. Tableau 7 colonnes : Semaine | Heure | Base | 25% | Base | 50% | Total
3. Si mois de juin (premier mois de l'exercice) et carry > 0 : ligne "Report" avec les 4 valeurs carry (colonne Heure = "-")
4. 5 lignes semaines (padding si < 5)
5. Ligne Total (somme par colonne, incluant le report si présent)
6. Ligne Payé (valeurs négatives, "-" pour colonne Heure)
7. Ligne Reste (Total - |Payé|, "-" pour colonne Heure)
8. Bouton "+ Payer les RRT"
9. Drawer de paiement avec 5 champs
Script setup :
- `currentMonthIndex` : ref (0-11) pour la navigation dans `orderedMonthIndexes` (toujours [5,6,7,8,9,10,11,0,1,2,3,4] = juin à mai)
- Initialiser `currentMonthIndex` au mois courant dans l'exercice
- `currentMonth` : computed qui retourne le numéro de mois (1-12) basé sur l'index
- `weeksForMonth` : computed filtrant les semaines du summary pour le mois courant, paddé à 5
- `monthPayment` : computed trouvant le paiement du mois dans `summary.monthPayments`
- Totaux par colonne : computed sommant les semaines
- `formatMinutes` : existant, réutiliser (format `Xh` ou `Xh Ym`)
- Navigation : `prevMonth` / `nextMonth` modifiant `currentMonthIndex` avec bornes [0, 11]
Drawer de paiement :
- Champs : Mois (select), Base 25% (number en heures), Heures 25% (number en heures), Base 50% (number en heures), Heures 50% (number en heures)
- Si paiement existant pour le mois sélectionné : pré-remplir en convertissant minutes → heures
- Emit : `submit-rtt-payment` avec les 4 valeurs converties en minutes + le mois
- [ ] **Step 2: Adapter le composant parent**
Chercher où `RttTab` est utilisé et adapter l'event handler `submit-rtt-payment` pour passer les 4 champs au lieu de `(month, minutes, rate)`.
Run: `grep -rn "submit-rtt-payment" frontend/` pour trouver le parent.
---
## Task 7: Test de bout en bout
- [ ] **Step 1: Vérifier le cache et la migration**
```bash
docker exec php-sirh-fpm php bin/console cache:clear
docker exec php-sirh-fpm php bin/console doctrine:migrations:migrate --no-interaction
```
- [ ] **Step 2: Tester l'API**
Vérifier que `GET /api/employees/{id}/rtt-summary` retourne les nouveaux champs par semaine.
Vérifier que `PATCH /api/employees/{id}/rtt-payments` accepte les 4 champs.
- [ ] **Step 3: Tester le frontend**
- Navigation mensuelle (flèches, mois courant par défaut)
- Tableau : vérifier les valeurs par semaine
- Paiement : créer, modifier, vérifier pré-remplissage
- "RTT À LA DATE DU JOUR" : vérifier le cumul

View File

@@ -0,0 +1,187 @@
# Suspension de contrat — Design Spec
## Objectif
Permettre de suspendre un contrat employé. Une suspension empêche l'acquisition de congés durant la période concernée (prorata). S'applique aux CDI/CDD non forfait et aux forfaits 218.
## Contraintes
- Plusieurs suspensions possibles par période de contrat
- Pas de suppression de suspension (hors scope)
- Règle de calcul : on exclut les jours de suspension jusqu'au dernier mois complet terminé (cohérent avec la règle existante)
- Suspension sans date de fin = suspension en cours indéfiniment (exclut les mois jusqu'au dernier mois terminé)
- Les suspensions ne doivent pas se chevaucher sur une même période de contrat
## Modèle de données
### Nouvelle entité `ContractSuspension`
Nouvelle table `contract_suspensions` :
| Colonne | Type | Nullable | Description |
|---------|------|----------|-------------|
| `id` | SERIAL | non | PK |
| `contract_period_id` | INT | non | FK vers `employee_contract_periods`, CASCADE delete |
| `start_date` | DATE | non | Début de suspension |
| `end_date` | DATE | oui | Fin de suspension (null = en cours) |
| `comment` | TEXT | oui | Commentaire libre |
| `created_at` | TIMESTAMP | non | Date de création technique |
Index : `(contract_period_id, start_date)`.
Relation : `EmployeeContractPeriod` ← OneToMany → `ContractSuspension`.
## Backend — API
### Endpoint dédié
Les suspensions sont gérées via un endpoint dédié plutôt que via les champs transients Employee. Cela évite de complexifier le `EmployeeWriteProcessor` et permet de gérer N suspensions proprement.
**Nouvel ApiResource `ContractSuspension` :**
- `POST /api/contract_suspensions` — créer une suspension (body : `contractPeriod` IRI, `startDate`, `endDate`, `comment`)
- `PATCH /api/contract_suspensions/{id}` — modifier une suspension existante
- Security : `ROLE_ADMIN`
- Pagination désactivée
**Processor custom `ContractSuspensionWriteProcessor` :**
- Résout la période de contrat depuis l'IRI
- Validation :
- `startDate` requis
- `endDate >= startDate` si renseigné
- `startDate >= period.startDate`
- Pour les CDD/contrats avec date de fin : `startDate` et `endDate` dans les bornes de la période
- Pas de chevauchement avec les autres suspensions de la même période
- Rejet si la période de contrat est déjà clôturée (date de fin dans le passé)
### Lecture
Exposer les suspensions dans la sérialisation de l'Employee :
- `Employee::getCurrentSuspensions(): array` — retourne les suspensions de la période de contrat courante, groupe `employee:read`
- Ajouter les suspensions au `ContractHistoryItem` via `EmployeeContractPeriod::getSuspensions()`
## Backend — Calcul des congés
### Deux points d'impact
Le calcul d'acquisition existe à **deux endroits** qui doivent tous les deux prendre en compte les suspensions :
1. **`EmployeeLeaveSummaryProvider::computeAccruedDaysFromStart()`** — affichage live des congés en cours d'acquisition
2. **`LeaveBalanceComputationService::computeAccruedDays()`** — utilisé par le rollover (`LeaveRolloverCommand`) pour calculer le solde de report
Les deux méthodes ont la même structure (boucle mois par mois) et doivent être modifiées de la même manière.
### Modification des méthodes de calcul
Pour les deux méthodes, ajouter un paramètre optionnel : `array $suspensions = []` (tableau de `{start: DateTimeImmutable, end: ?DateTimeImmutable}`).
Dans la boucle mois par mois, pour chaque mois :
1. Calculer les jours couverts par la période de contrat (existant)
2. Pour chaque suspension, calculer le nombre de jours suspendus qui tombent dans ce mois
3. Soustraire le total des jours suspendus
4. Le ratio du mois = max(0, jours couverts - jours suspendus) / jours dans le mois
Cela gère automatiquement les suspensions qui commencent/finissent en milieu de mois (prorata).
Une suspension sans date de fin utilise la date de fin de calcul comme borne (dernier jour du mois précédent, cohérent avec la règle existante).
**Note :** chaque méthode est appelée deux fois — une pour les jours, une pour les samedis. La soustraction de suspension s'applique aux deux appels.
### Impact sur les forfaits 218
Pour les forfaits, les jours acquis en début d'exercice (ex: 34 jours pour 2026) sont réduits au prorata des jours de suspension.
Calcul : `jours acquis = base × (jours ouvrés effectifs / jours ouvrés totaux de l'exercice)`
`jours ouvrés effectifs = jours ouvrés totaux - jours ouvrés suspendus`.
Cela impacte `EmployeeLeaveSummaryProvider` dans la branche forfait et `LeaveBalanceComputationService` dans le calcul forfait de `computeDynamicClosingForYear()`.
### Passage des données de suspension aux méthodes
- **`EmployeeLeaveSummaryProvider`** : le provider a accès aux périodes de contrat via l'Employee. Il doit résoudre les suspensions de la période couvrant l'exercice et les passer aux méthodes de calcul.
- **`LeaveBalanceComputationService`** : le service utilise `$employee->getContractHistory()`. Il doit trouver les suspensions de la période couvrant l'exercice. L'accès au repository `EmployeeContractPeriodRepository` est déjà injecté — ajouter l'accès au repository `ContractSuspensionRepository` ou passer par la relation Doctrine.
### Impact sur la bascule d'exercice (rollover au 01/06)
Le rollover (`LeaveRolloverCommand`) appelle `LeaveBalanceComputationService::computeDynamicClosingForYear()` qui appelle `computeAccruedDays()`. En modifiant `computeAccruedDays()` pour accepter et traiter les suspensions, le rollover prendra automatiquement en compte les suspensions. Les jours acquis au rollover reflèteront la déduction.
**Exemple CDI :** exercice 2027 (juin 2026 - mai 2027), 2 suspensions totalisant 3 mois → au lieu de 25j acquis, l'employé bascule avec ~18.75j (9 mois effectifs × 2.083j/mois).
### Règles non impactées
- INTERIM : pas de congés gérés
## Frontend — UI
### Bouton et drawer
Le bouton "Clôturer" devient **"Modifier"**. Il ouvre le drawer existant avec le titre **"Modifier le contrat"**. Le bouton **"+ Ajouter"** (création de nouveau contrat) reste inchangé.
Le drawer contient 2 onglets :
**Onglet "Clôturer"** — contenu identique à l'actuel (type contrat, temps de travail, début contrat en readonly, date fin, commentaire, checkbox solde de tout compte).
**Onglet "Suspendre"** — formulaires empilés :
- Pour chaque suspension existante : un formulaire pré-rempli avec les 3 champs (date début, date fin, commentaire) et un bouton **"Modifier"**
- En bas : un bouton **"+ Ajouter"** qui ajoute un nouveau formulaire vide avec les 3 champs et un bouton **"Ajouter"**
- Chaque formulaire est indépendant (soumission individuelle)
### Champs par formulaire de suspension
- Date de début (required, input date)
- Date de fin (optionnel, input date)
- Commentaire (optionnel, textarea)
### Données nécessaires côté frontend
Nouveau type `ContractSuspension` (DTO) :
```typescript
type ContractSuspension = {
id: number
startDate: string
endDate?: string | null
comment?: string | null
}
```
Ajouter au type `Employee` (DTO) :
- `currentSuspensions?: ContractSuspension[]`
Ajouter au type `ContractHistoryItem` :
- `suspensions?: ContractSuspension[]`
Nouveau service `frontend/services/contractSuspensions.ts` :
- `createSuspension(payload)` — POST
- `updateSuspension(id, payload)` — PATCH
## Exemples de calcul
### CDI/CDD non forfait
Contrat CDI démarré le 01/06/2026, exercice 2027 (juin 2026 - mai 2027).
Accrual : 25j / 12 mois = 2.083j/mois.
Sans suspension au 12/03/2027 (9 mois complets : juin-février) :
- En cours d'acquisition = 9 × 2.083 = 18.75j
Avec 2 suspensions (01/01 au 31/01 + 01/03 au 31/03 = 2 mois) au 12/04/2027 (10 mois complets - 2 suspendus = 8 mois effectifs) :
- En cours d'acquisition = 8 × 2.083 = 16.67j
Samedis (5/12 par mois) :
- Sans suspension : 9 × 0.417 = 3.75j
- Avec 2 suspensions : 8 × 0.417 = 3.33j
### Forfait 218
Exercice 2026 (année civile), 34 jours acquis, 252 jours ouvrés dans l'année.
Suspension de 2 mois (44 jours ouvrés).
- Jours ouvrés effectifs = 252 - 44 = 208
- Jours acquis = 34 × (208 / 252) = 28.06j
## Hors scope
- Suppression d'une suspension
- Affichage de la suspension dans l'historique des contrats (les données sont sérialisées mais pas de rendu spécifique dans le tableau historique)
- Auto-fermeture des suspensions lors de la clôture du contrat

View File

@@ -0,0 +1,117 @@
# Refonte onglet RTT employé
## Contexte
L'onglet RTT actuel affiche une grille annuelle de 12 mois avec les minutes de récupération par semaine. Il doit être remplacé par une vue mensuelle détaillée avec navigation, un tableau ventilé par palier de majoration (25% / 50%), et un système de paiement à 4 champs.
## Maquette de référence
Fichier : `RTT.png` à la racine du projet.
## Structure de la vue
### En-tête
- Navigation mensuelle : `< MOIS ANNÉE >` (flèches gauche/droite)
- Navigation limitée aux mois de l'exercice (juin N-1 à mai N)
- Mois courant affiché par défaut à l'ouverture
- En haut à droite : `RTT À LA DATE DU JOUR : X heure` (cumul annuel toutes semaines confondues)
### Tableau
7 colonnes :
| Semaine | Heure | Base | 25% | Base | 50% | Total |
|---------|-------|------|-----|------|-----|-------|
- **Semaine** : label "Semaine 1" à "Semaine 5" (toujours 5 lignes, vide si le mois n'a que 4 semaines)
- **Heure** : heures supplémentaires brutes de la semaine
- **Base** (1er) : heures de base dans le palier 25% (heures entre 35h et 39h pour un contrat 39h)
- **25%** : bonus = base 25% × 0.25
- **Base** (2e) : heures de base dans le palier 50% (heures au-delà de 43h)
- **50%** : bonus = base 50% × 0.50
- **Total** : somme de toutes les bases + tous les bonus
### Lignes de synthèse
- **Total** : somme des 5 semaines par colonne
- **Payé** : montants payés pour ce mois (affichés en négatif). Colonne "Heure" = "-"
- **Reste** : Total - |Payé| par colonne. Colonne "Heure" = "-"
### Bouton
`+ Payer les RRT` en bas, centré. Ouvre un drawer.
## Drawer de paiement
Champs :
1. **Mois** (select) : liste des mois de l'exercice
2. **Base 25%** (number, en heures)
3. **Heures 25%** (number, en heures)
4. **Base 50%** (number, en heures)
5. **Heures 50%** (number, en heures)
Si des paiements existent pour le mois sélectionné, le formulaire est pré-rempli pour modification.
Boutons : Annuler / Enregistrer.
## Rattachement semaine → mois
Règle existante conservée : une semaine est rattachée au mois de son **samedi** (voir `RttRecoveryComputationService::buildWeeksForExercise`).
## Backend
### Modification de `EmployeeRttSummary`
Le provider retourne les données pour un mois donné (paramètre query `?month=X`) en plus du cumul annuel.
Nouvelles données par semaine :
- `overtimeMinutes` : heures sup brutes
- `base25Minutes` : base palier 25%
- `bonus25Minutes` : bonus 25%
- `base50Minutes` : base palier 50%
- `bonus50Minutes` : bonus 50%
- `totalMinutes` : somme base + bonus
### Modification de `EmployeeRttPayment`
Remplacer les champs `minutes` (int) + `rate` (int 25/50) par :
- `base25Minutes` (int)
- `bonus25Minutes` (int)
- `base50Minutes` (int)
- `bonus50Minutes` (int)
Migration Doctrine nécessaire.
### Modification de `EmployeeRttPaymentInput`
Adapter les champs pour correspondre aux 4 nouvelles valeurs.
### Modification de `RttRecoveryComputationService`
`computeRecoveryByWeek` retourne déjà les minutes totales. Il faut enrichir le retour pour ventiler base/bonus par palier. La logique de calcul des paliers existe déjà en interne, il suffit de l'exposer.
## Frontend
### Stockage vs affichage
- Backend : stockage en **minutes** (inchangé)
- Frontend : conversion minutes ↔ heures à l'affichage et à la saisie
### Réécriture de `RttTab.vue`
- Supprimer la grille annuelle de 12 mois
- Navigation mensuelle avec état réactif (mois courant)
- Tableau HTML avec les 7 colonnes décrites
- 5 lignes semaines + Total + Payé + Reste
- Formatage en "Xh" ou "Xh Ym" (ex: "6h 30m")
### Modification du DTO TypeScript
Adapter `EmployeeRttSummary` et `EmployeeRttWeekSummary` pour les nouveaux champs.
## Unités de conversion
- Affichage : heures et minutes (ex: "6h 30m", "30 m")
- Saisie paiement : en heures décimales (number input)
- Stockage : minutes entières (int)

View File

@@ -9,6 +9,7 @@
id="employee"
v-model="absenceForm.employeeId"
:class="employeeFieldClass"
:disabled="props.lockEmployee"
>
<option value="" disabled>Choisir un employé</option>
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
@@ -48,6 +49,7 @@
v-model="absenceForm.startDate"
type="date"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
:disabled="props.lockDates"
/>
<select
v-model="absenceForm.startHalf"
@@ -67,6 +69,7 @@
v-model="absenceForm.endDate"
type="date"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
:disabled="props.lockDates"
/>
<select
v-model="absenceForm.endHalf"
@@ -80,7 +83,7 @@
</div>
</div>
<div>
<div v-if="props.showComment !== false">
<label class="text-md font-semibold text-neutral-700" for="comment">Commentaire</label>
<textarea
id="comment"
@@ -142,6 +145,9 @@ const props = defineProps<{
}
editingAbsence: Absence | null
isSubmitting: boolean
lockEmployee?: boolean
lockDates?: boolean
showComment?: boolean
}>()
const emit = defineEmits<{

View File

@@ -54,6 +54,48 @@
</p>
</div>
<div class="space-y-2">
<p class="text-md font-semibold text-neutral-700">
Type de contrat <span class="text-red-600">*</span>
</p>
<div class="flex flex-wrap gap-4 rounded-md border border-neutral-300 px-3 py-2">
<div v-for="nature in contractNatures" :key="nature.value" class="flex items-center gap-2">
<label class="text-md" :for="`print-contract-nature-${nature.value}`">{{ nature.label }}</label>
<input
:id="`print-contract-nature-${nature.value}`"
v-model="printForm.contractNatures"
:value="nature.value"
type="checkbox"
class="h-4 w-4"
/>
</div>
</div>
<p v-if="showContractNaturesError" class="text-sm text-red-600">
Sélectionne au moins un type de contrat.
</p>
</div>
<div class="space-y-2">
<p class="text-md font-semibold text-neutral-700">
Temps de travail <span class="text-red-600">*</span>
</p>
<div class="flex flex-wrap gap-4 rounded-md border border-neutral-300 px-3 py-2">
<div v-for="workContract in workContracts" :key="workContract.id" class="flex items-center gap-2">
<label class="text-md" :for="`print-work-contract-${workContract.id}`">{{ workContract.name }}</label>
<input
:id="`print-work-contract-${workContract.id}`"
v-model="printForm.workContractIds"
:value="workContract.id"
type="checkbox"
class="h-4 w-4"
/>
</div>
</div>
<p v-if="showWorkContractsError" class="text-sm text-red-600">
Sélectionne au moins un temps de travail.
</p>
</div>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
@@ -84,13 +126,27 @@ type SiteOption = {
color: string
}
type ContractNatureOption = {
value: 'CDI' | 'CDD' | 'INTERIM'
label: string
}
type WorkContractOption = {
id: number
name: string
}
const props = defineProps<{
modelValue: boolean
sites: SiteOption[]
contractNatures: ContractNatureOption[]
workContracts: WorkContractOption[]
printForm: {
from: string
to: string
siteIds: number[]
contractNatures: Array<'CDI' | 'CDD' | 'INTERIM'>
workContractIds: number[]
}
}>()
@@ -110,19 +166,36 @@ const printForm = toRef(props, 'printForm')
const validationTouched = reactive({
from: false,
to: false,
sites: false
sites: false,
contractNatures: false,
workContracts: false
})
const isFromValid = computed(() => printForm.value.from.trim() !== '')
const isToValid = computed(() => printForm.value.to.trim() !== '')
const isSitesValid = computed(() => printForm.value.siteIds.length > 0)
const isContractNaturesValid = computed(() => {
if (props.contractNatures.length === 0) return true
return printForm.value.contractNatures.length > 0
})
const isWorkContractsValid = computed(() => {
if (props.workContracts.length === 0) return true
return printForm.value.workContractIds.length > 0
})
const isFormValid = computed(
() => isFromValid.value && isToValid.value && isSitesValid.value
() =>
isFromValid.value &&
isToValid.value &&
isSitesValid.value &&
isContractNaturesValid.value &&
isWorkContractsValid.value
)
const showFromError = computed(() => validationTouched.from && !isFromValid.value)
const showToError = computed(() => validationTouched.to && !isToValid.value)
const showSitesError = computed(() => validationTouched.sites && !isSitesValid.value)
const showContractNaturesError = computed(() => validationTouched.contractNatures && !isContractNaturesValid.value)
const showWorkContractsError = computed(() => validationTouched.workContracts && !isWorkContractsValid.value)
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-md text-neutral-900'
@@ -150,6 +223,8 @@ const handleSubmit = () => {
validationTouched.from = true
validationTouched.to = true
validationTouched.sites = true
validationTouched.contractNatures = true
validationTouched.workContracts = true
if (!isFormValid.value) return
emit('submit')
}
@@ -166,6 +241,8 @@ watch(
validationTouched.from = false
validationTouched.to = false
validationTouched.sites = false
validationTouched.contractNatures = false
validationTouched.workContracts = false
}
}
)

View File

@@ -17,7 +17,7 @@
</button>
</div>
<div class="p-6">
<div class="overflow-y-auto p-6" style="max-height: calc(100% - 65px)">
<slot />
</div>
</div>

View File

@@ -0,0 +1,233 @@
<template>
<header ref="headerRef" class="border-b border-neutral-200 bg-primary-500 p-5 text-white">
<div class="flex h-full items-center justify-end">
<div class="flex gap-6 text-xl text-white">
<div v-if="isAdmin" ref="bellRoot" class="relative">
<button type="button" class="relative self-center cursor-pointer flex items-center" @click="toggleNotifications">
<Icon name="mdi:bell-plus" size="36"/>
<span
v-if="unreadCount > 0"
class="absolute -right-1 -top-1 inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1 text-xs font-bold text-white"
>
{{ unreadCount }}
</span>
</button>
<div
v-if="isNotificationsOpen"
class="fixed right-[20px] z-30 w-[400px] rounded-md border border-neutral-200 bg-white text-neutral-800 shadow-lg"
:style="{ top: `${navbarBottom + 20}px` }"
>
<div class="px-3 pt-3 pb-6 text-xl font-semibold">
Notifications
</div>
<div class="flex gap-6 px-3 pb-2 border-b border-black">
<button
type="button"
class="border-b-2 cursor-pointer text-[18px]"
:class="activeNotifTab === 'unread' ? 'border-black font-semibold text-black' : 'border-transparent text-black hover:text-primary-500'"
@click="switchNotifTab('unread')"
>
Non lues
</button>
<button
type="button"
class="border-b-2 cursor-pointer text-[18px]"
:class="activeNotifTab === 'history' ? 'border-black font-semibold text-black' : 'border-transparent text-black hover:text-primary-500'"
@click="switchNotifTab('history')"
>
Historique
</button>
</div>
<div v-if="isLoadingNotifications" class="px-3 py-3 text-sm text-neutral-500">
Chargement...
</div>
<div v-else-if="displayedNotifications.length === 0" class="px-3 py-3 text-sm text-neutral-500">
Aucune notification.
</div>
<div v-else class="max-h-80 overflow-auto">
<NuxtLink
:to="notification.target"
v-for="notification in displayedNotifications"
:key="notification.id"
class="flex gap-5 items-center border-b border-black px-3 py-4 last:border-b-0 relative hover:bg-tertiary-500"
:class="notification.isRead ? '' : 'bg-tertiary-500'"
>
<div class="rounded-full h-[46px] w-[46px] min-w-[46px] bg-primary-500"></div>
<div class="flex flex-col min-w-0 text-[16px]">
<p class="text-black"><span class="font-semibold capitalize">{{ notification.actorName }}</span> {{ notification.message }}</p>
<p class="text-black">{{ formatTimeAgo(notification.createdAt) }} - {{ notification.category }}</p>
</div>
<span v-if="!notification.isRead" class="absolute right-4 bg-primary-500 h-4 w-4 rounded-full"></span>
</NuxtLink>
</div>
</div>
</div>
<div ref="userMenuRoot" class="relative flex gap-4">
<button type="button" class="flex items-center gap-4 cursor-pointer" @click="toggleUserMenu">
<Icon name="mdi:account-circle-outline" class="self-center" size="36"/>
<p class="self-center">{{ user?.username }}</p>
</button>
<div
v-if="isUserMenuOpen"
class="fixed right-[20px] z-30 w-60 rounded-md border border-neutral-200 bg-white text-[16px] text-black font-semibold shadow-lg"
:style="{ top: `${navbarBottom + 20}px` }"
>
<button
type="button"
class="block w-full px-3 py-4 text-left hover:bg-tertiary-500 border-b border-black"
>
Mon profil
</button>
<button
type="button"
class="block w-full px-3 py-4 text-left hover:bg-tertiary-500 flex justify-between items-center"
@click="handleLogout"
>
<p>Déconnexion</p>
<Icon name="mdi:logout-variant" size="20"/>
</button>
</div>
</div>
</div>
</div>
</header>
</template>
<script setup lang="ts">
import type {User} from '~/services/dto/user'
import type {NotificationItem} from '~/services/dto/notification'
import {listUnreadNotifications, listTodayNotifications, listHistoryNotifications, markAllNotificationsRead} from '~/services/notifications'
defineProps<{
user?: User
}>()
const formatTimeAgo = (dateString: string): string => {
const date = new Date(dateString)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMinutes = Math.floor(diffMs / 60000)
if (diffMinutes < 1) return "À l'instant"
if (diffMinutes < 60) return `${diffMinutes} minute${diffMinutes > 1 ? 's' : ''}`
const diffHours = Math.floor(diffMinutes / 60)
if (diffHours < 24) return `${diffHours} heure${diffHours > 1 ? 's' : ''}`
const diffDays = Math.floor(diffHours / 24)
return `${diffDays} jour${diffDays > 1 ? 's' : ''}`
}
const auth = useAuthStore()
const route = useRoute()
const headerRef = ref<HTMLElement | null>(null)
const bellRoot = ref<HTMLElement | null>(null)
const userMenuRoot = ref<HTMLElement | null>(null)
const isUserMenuOpen = ref(false)
const navbarBottom = ref(0)
const updateNavbarBottom = () => {
if (headerRef.value) {
navbarBottom.value = headerRef.value.getBoundingClientRect().bottom
}
}
const todayNotifications = ref<NotificationItem[]>([])
const historyNotifications = ref<NotificationItem[]>([])
const isNotificationsOpen = ref(false)
const isLoadingNotifications = ref(false)
const activeNotifTab = ref<'unread' | 'history'>('unread')
const unreadCount = computed(() => todayNotifications.value.length)
const displayedNotifications = computed(() => activeNotifTab.value === 'unread' ? todayNotifications.value : historyNotifications.value)
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
const toggleUserMenu = () => {
updateNavbarBottom()
isUserMenuOpen.value = !isUserMenuOpen.value
}
const handleLogout = async () => {
await auth.logout()
await navigateTo('/login')
}
const loadTodayNotifications = async () => {
todayNotifications.value = await listTodayNotifications()
}
const loadHistoryNotifications = async () => {
historyNotifications.value = await listHistoryNotifications()
}
const loadNotifications = async () => {
isLoadingNotifications.value = true
try {
await loadTodayNotifications()
} finally {
isLoadingNotifications.value = false
}
}
const switchNotifTab = async (tab: 'unread' | 'history') => {
activeNotifTab.value = tab
isLoadingNotifications.value = true
try {
if (tab === 'history') {
await loadHistoryNotifications()
} else {
await loadTodayNotifications()
}
} finally {
isLoadingNotifications.value = false
}
}
const closeNotifications = async () => {
if (!isNotificationsOpen.value) return
isNotificationsOpen.value = false
if (todayNotifications.value.length > 0) {
await markAllNotificationsRead()
todayNotifications.value = []
}
}
const toggleNotifications = async () => {
if (isNotificationsOpen.value) {
await closeNotifications()
return
}
updateNavbarBottom()
activeNotifTab.value = 'unread'
isNotificationsOpen.value = true
await loadNotifications()
}
const handleClickOutside = async (event: MouseEvent) => {
const target = event.target as Node | null
if (!target) return
if (bellRoot.value && !bellRoot.value.contains(target)) {
await closeNotifications()
}
if (userMenuRoot.value && !userMenuRoot.value.contains(target)) {
isUserMenuOpen.value = false
}
}
onMounted(async () => {
updateNavbarBottom()
if (isAdmin.value) {
await loadNotifications()
}
document.addEventListener('click', handleClickOutside)
})
watch(
() => route.fullPath,
async () => {
if (!isAdmin.value) return
await loadNotifications()
}
)
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>

View File

@@ -1,7 +1,7 @@
<template>
<div class="h-full min-h-0 overflow-auto rounded-lg border border-neutral-200 bg-white">
<div class="min-w-[900px]">
<div class="grid" :style="gridStyle">
<div class="grid" :style="gridStyle" @mouseleave="clearHoveredCell">
<div
class="sticky left-0 top-0 z-30 border-b border-neutral-200 bg-tertiary-500 px-4 py-3 text-md font-semibold text-neutral-700"
>
@@ -10,16 +10,23 @@
<div
v-for="day in daysInMonth"
:key="day.date"
class="sticky top-0 z-20 border-b border-neutral-200 bg-tertiary-500 px-2 py-3 text-center text-xs font-semibold text-neutral-700"
class="sticky top-0 z-20 border-b border-neutral-200 px-2 py-3 text-center text-xs font-semibold transition-colors"
:class="isHoveredColumn(day.date) || day.date === today ? 'bg-primary-500 text-white' : 'bg-tertiary-500 text-neutral-700'"
>
<div>{{ day.label }}</div>
<div class="text-[10px] text-neutral-500">{{ day.weekday }}</div>
<div
class="text-[10px]"
:class="isHoveredColumn(day.date) || day.date === today ? 'text-white/90' : 'text-neutral-500'"
>
{{ day.weekday }}
</div>
</div>
<template v-for="employee in visibleEmployees" :key="employee.id">
<div
class="sticky left-0 z-10 border-b border-neutral-100 px-4 py-3 text-md font-semibold text-black cursor-pointer"
:style="{ backgroundColor: employee.site?.color ?? '#304998' }"
class="sticky left-0 z-10 border-b border-neutral-100 px-4 py-3 text-md font-semibold text-black cursor-pointer transition-shadow"
:class="isHoveredRow(employee.id) ? 'bg-primary-500 text-white ring-2 ring-inset ring-primary-500/40' : ''"
:style="rowHeaderStyle(employee)"
draggable="true"
@dragstart="handleDragStart($event, employee)"
@dragover="handleDragOver"
@@ -30,12 +37,14 @@
<div
v-for="day in daysInMonth"
:key="employee.id + '-' + day.date"
class="border-b border-neutral-300 px-2 py-2 text-center text-xs text-neutral-800 hover:bg-neutral-500"
class="border-b border-neutral-300 px-2 py-2 text-center text-xs text-neutral-800 transition-colors"
:class="cellContainerClass(employee.id, day.date)"
@mouseenter="setHoveredCell(employee.id, day.date)"
>
<template v-if="getCellInfo(employee.id, day.date)">
<button
type="button"
class="relative flex h-8 w-full items-center justify-center overflow-hidden rounded-md border border-neutral-200 text-[11px] font-semibold text-neutral-800 hover:border-white"
class="relative flex h-8 w-full items-center justify-center overflow-hidden rounded-md border border-neutral-200 text-[11px] font-semibold text-neutral-800"
:class="isHolidayDate(day.date) ? 'cursor-not-allowed opacity-80' : ''"
:style="getCellStyle(employee.id, day.date)"
:disabled="isHolidayDate(day.date)"
@@ -63,7 +72,7 @@
<template v-else>
<button
type="button"
class="relative flex h-8 w-full items-center justify-center rounded-md border border-neutral-200 text-[11px] font-semibold text-neutral-800 bg-white hover:border-white"
class="relative flex h-8 w-full items-center justify-center rounded-md border border-neutral-200 text-[11px] font-semibold text-neutral-800 bg-white"
:class="isHolidayDate(day.date) ? 'cursor-not-allowed opacity-80' : ''"
:style="getCellStyle(employee.id, day.date)"
:disabled="isHolidayDate(day.date)"
@@ -82,6 +91,10 @@
<script setup lang="ts">
import type { Employee } from '~/services/dto/employee'
import type { HalfDay } from '~/services/dto/half-day'
import { toYmd } from '~/utils/date'
const now = new Date()
const today = toYmd(now.getFullYear(), now.getMonth(), now.getDate())
type DayInfo = {
date: string
@@ -89,7 +102,7 @@ type DayInfo = {
weekday: string
}
defineProps<{
const props = defineProps<{
daysInMonth: DayInfo[]
visibleEmployees: Employee[]
gridStyle: Record<string, string>
@@ -124,4 +137,56 @@ const handleDrop = (event: DragEvent, employee: Employee) => {
if (!dragId || dragId === employee.id) return
emit('reorder', { dragId, dropId: employee.id })
}
// Etat de la cellule actuellement survolee.
const hoveredEmployeeId = ref<number | null>(null)
const hoveredDate = ref<string | null>(null)
const setHoveredCell = (employeeId: number, date: string) => {
hoveredEmployeeId.value = employeeId
hoveredDate.value = date
}
const clearHoveredCell = () => {
hoveredEmployeeId.value = null
hoveredDate.value = null
}
const isHoveredRow = (employeeId: number) => hoveredEmployeeId.value === employeeId
const isHoveredColumn = (date: string) => hoveredDate.value === date
// On garde la couleur du site tant que la ligne n'est pas survolee.
const rowHeaderStyle = (employee: Employee) => {
if (isHoveredRow(employee.id)) return undefined
return { backgroundColor: employee.site?.color ?? '#304998' }
}
// Index de ligne par employe pour savoir si une case est "au-dessus" de la case survolee.
const employeeIndexById = computed(() => {
const indexMap = new Map<number, number>()
props.visibleEmployees.forEach((employee, index) => {
indexMap.set(employee.id, index)
})
return indexMap
})
const cellContainerClass = (employeeId: number, date: string) => {
if (!hoveredEmployeeId.value || !hoveredDate.value) return 'hover:bg-primary-500'
const hoveredRowIndex = employeeIndexById.value.get(hoveredEmployeeId.value)
const currentRowIndex = employeeIndexById.value.get(employeeId)
// Forme en L:
// - ligne: toutes les cases a gauche (et la case cible)
// - colonne: toutes les cases au-dessus (et la case cible)
const isOnLeftSegment = isHoveredRow(employeeId) && date <= hoveredDate.value
const isOnTopSegment = isHoveredColumn(date)
&& typeof hoveredRowIndex === 'number'
&& typeof currentRowIndex === 'number'
&& currentRowIndex <= hoveredRowIndex
if (isOnLeftSegment || isOnTopSegment) return 'bg-primary-500'
return 'hover:bg-primary-500'
}
</script>

View File

@@ -0,0 +1,26 @@
<template>
<div class="relative w-full max-w-[340px]">
<input
id="employee-search"
v-model="model"
type="text"
:placeholder="placeholder"
class="h-10 w-full rounded-md border border-neutral-300 bg-white pl-3 pr-10 text-md text-neutral-900"
/>
<Icon
name="mdi:magnify"
size="18"
class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500"
/>
</div>
</template>
<script setup lang="ts">
const model = defineModel<string>({required: true})
withDefaults(defineProps<{
placeholder?: string
}>(), {
placeholder: "Recherche d'un employé"
})
</script>

View File

@@ -0,0 +1,77 @@
<template>
<div class="relative inline-flex h-10 items-center overflow-hidden rounded-md border border-primary-500 bg-white" :class="widthClass">
<input
ref="nativeInput"
:value="pickerValue"
:type="pickerType"
class="pointer-events-none absolute inset-0 h-full w-full opacity-0"
tabindex="-1"
aria-hidden="true"
@input="onPickerInput"
@change="onPickerInput"
/>
<button
type="button"
class="h-10 px-3 text-lg font-semibold text-primary-500 hover:bg-tertiary-500 active:bg-tertiary-500 active:scale-[0.96]"
:aria-label="prevAriaLabel"
@click="emit('prev')"
>
</button>
<button
type="button"
class="h-10 flex-1 border-x border-primary-500 px-4 text-sm font-semibold text-primary-500 text-center hover:bg-tertiary-500 active:bg-tertiary-500"
@click="openPicker"
>
{{ label }}
</button>
<button
type="button"
class="h-10 px-3 text-lg font-semibold text-primary-500 hover:bg-tertiary-500 active:bg-tertiary-500 active:scale-[0.96]"
:aria-label="nextAriaLabel"
@click="emit('next')"
>
</button>
</div>
</template>
<script setup lang="ts">
const props = withDefaults(defineProps<{
label: string
pickerType: 'date' | 'week' | 'month'
pickerValue: string
widthClass?: string
prevAriaLabel?: string
nextAriaLabel?: string
}>(), {
widthClass: 'w-[320px]',
prevAriaLabel: 'Précédent',
nextAriaLabel: 'Suivant'
})
const emit = defineEmits<{
(e: 'prev'): void
(e: 'next'): void
(e: 'pick', value: string): void
}>()
const nativeInput = ref<HTMLInputElement | null>(null)
const openPicker = () => {
const input = nativeInput.value
if (!input) return
if (typeof input.showPicker === 'function') {
input.showPicker()
return
}
input.focus()
input.click()
}
const onPickerInput = (event: Event) => {
const value = (event.target as HTMLInputElement).value
if (!value) return
emit('pick', value)
}
</script>

View File

@@ -0,0 +1,69 @@
<template>
<div ref="root" class="relative inline-block w-fit max-w-full">
<button
type="button"
class="inline-flex w-[320px] min-h-10 items-center justify-between gap-2 rounded-md border border-primary-500 bg-white px-3 py-2 text-md font-semibold text-primary-500 hover:bg-tertiary-500"
@click="isOpen = !isOpen"
>
<span>Sites</span>
<span class="inline-flex items-center gap-2">
<span class="text-sm font-medium text-neutral-600">{{ selectedCount }}/{{ sites.length }}</span>
<Icon :name="isOpen ? 'mdi:chevron-up' : 'mdi:chevron-down'" size="18" />
</span>
</button>
<div
v-if="isOpen"
class="absolute left-0 top-full z-20 mt-2 max-h-80 w-full overflow-auto rounded-md border border-neutral-200 bg-white p-3 shadow-lg"
>
<div class="flex flex-col gap-2">
<label
v-for="site in sites"
:key="site.id"
:for="`site-${site.id}`"
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-tertiary-500"
>
<input
:id="`site-${site.id}`"
v-model="selectedSiteIds"
:value="site.id"
type="checkbox"
class="h-4 w-4"
/>
<span class="text-md text-neutral-800">{{ site.name }}</span>
</label>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import type { Site } from '~/services/dto/site'
const selectedSiteIds = defineModel<number[]>({ required: true })
const isOpen = ref(false)
const root = ref<HTMLElement | null>(null)
defineProps<{
sites: Site[]
}>()
const selectedCount = computed(() => selectedSiteIds.value.length)
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node | null
if (!root.value || !target) return
if (!root.value.contains(target)) {
isOpen.value = false
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>

View File

@@ -0,0 +1,346 @@
<template>
<section class="mt-8">
<div class="overflow-hidden rounded-lg border border-neutral-200 bg-white">
<div class="grid grid-cols-4 border-b border-neutral-200 bg-neutral-50 px-6 py-3 text-md font-semibold text-neutral-700">
<p>Contrat</p>
<p>Heures</p>
<p>Date de début</p>
<p>Date de fin</p>
</div>
<div v-if="contractHistory.length === 0" class="px-6 py-4 text-md text-neutral-600">
Aucun historique de contrat.
</div>
<div v-else>
<div
v-for="item in contractHistory"
:key="`${item.startDate}-${item.endDate ?? 'open'}-${item.contractId ?? item.contractName}`"
class="grid grid-cols-4 border-b border-neutral-100 px-6 py-3 text-md text-primary-500 last:border-b-0"
>
<p>{{ contractNatureLabel(item.contractNature) }}</p>
<p>{{ contractHistoryLabel(item) }}</p>
<p>{{ formatDate(item.startDate) }}</p>
<p>{{ formatDate(item.endDate) }}</p>
</div>
</div>
</div>
<div class="mt-8 flex justify-center gap-12">
<button
type="button"
class="w-[200px] rounded-md bg-blue-500 py-2 text-white disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isContractSubmitting || !canCloseCurrentContract"
@click="onOpenCloseContractDrawer"
>
Modifier
</button>
<button
type="button"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md text-white disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isCreateContractSubmitting || contracts.length === 0 || !canCreateContract"
@click="onOpenCreateContractDrawer"
>
+ Ajouter
</button>
</div>
<AppDrawer :model-value="isContractDrawerOpen" title="Modifier le contrat" @update:model-value="onUpdateContractDrawerOpen">
<div class="mb-4 flex border-b border-neutral-200">
<button
type="button"
class="pb-2 px-4 border-b-2 font-semibold"
:class="drawerTab === 'close'
? 'border-primary-500 text-primary-500'
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
@click="drawerTab = 'close'"
>
Clôturer
</button>
<button
type="button"
class="pb-2 px-4 border-b-2 font-semibold"
:class="drawerTab === 'suspend'
? 'border-primary-500 text-primary-500'
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
@click="drawerTab = 'suspend'"
>
Suspendre
</button>
</div>
<div v-if="drawerTab === 'close'">
<form class="space-y-4" @submit.prevent="onSubmitCloseContract">
<div>
<label class="text-md font-semibold text-neutral-700" for="contract-nature">
Type de contrat
</label>
<input id="contract-nature" :value="contractNatureLabel(contractForm.contractNature)" type="text" :class="readonlyFieldClass" readonly />
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="contract">
Temps de travail
</label>
<input id="contract" :value="closeContractWorkedHoursLabel" type="text" :class="readonlyFieldClass" readonly />
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="contract-start-date">
Début contrat
</label>
<input
id="contract-start-date"
:value="contractForm.startDate"
type="date"
:class="readonlyFieldClass"
readonly
/>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="contract-end-date">
Fin contrat <span class="text-red-600">*</span>
</label>
<input
id="contract-end-date"
v-model="contractForm.endDate"
type="date"
:class="contractEndDateFieldClass"
/>
<p v-if="showContractEndDateError" class="mt-1 text-sm text-red-600">La date de fin est obligatoire.</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="contract-comment">
Commentaire
</label>
<textarea
id="contract-comment"
v-model="contractForm.comment"
rows="3"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
placeholder="Motif de la clôture..."
/>
</div>
<div class="rounded-md border border-neutral-200 bg-neutral-50 p-3">
<label class="inline-flex items-center gap-2 text-md font-semibold text-neutral-700" for="contract-paid-leave-settled">
<input
id="contract-paid-leave-settled"
v-model="contractForm.paidLeaveSettled"
type="checkbox"
class="h-4 w-4 rounded border-neutral-300 text-primary-500 focus:ring-primary-500"
/>
Soldé dans le solde de tout compte
</label>
</div>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
:disabled="isContractSubmitting"
@click="onUpdateContractDrawerOpen(false)"
>
Annuler
</button>
<button
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isContractSubmitting || !isContractEndDateValid"
>
Enregistrer
</button>
</div>
</form>
</div>
<div v-if="drawerTab === 'suspend'" class="space-y-6">
<div
v-for="(form, index) in suspensionForms"
:key="form.id ?? `new-${index}`"
class="space-y-4 rounded-lg border border-neutral-200 p-4"
>
<div>
<label class="text-md font-semibold text-neutral-700">
Date de début <span class="text-red-600">*</span>
</label>
<input
v-model="form.startDate"
type="date"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<div>
<label class="text-md font-semibold text-neutral-700">
Date de fin
</label>
<input
v-model="form.endDate"
type="date"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<div>
<label class="text-md font-semibold text-neutral-700">
Commentaire
</label>
<textarea
v-model="form.comment"
rows="3"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<button
type="button"
class="w-full rounded-md bg-primary-500 px-4 py-2 text-base font-semibold text-white transition hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-60"
:disabled="!form.startDate || isSuspensionSubmitting"
@click="onSubmitSuspension(index)"
>
{{ form.id ? 'Modifier' : 'Ajouter' }}
</button>
</div>
<button
type="button"
class="w-full rounded-md border-2 border-dashed border-primary-500/50 px-4 py-3 text-base font-semibold text-primary-500/50 transition hover:border-primary-500 hover:text-primary-500"
@click="onAddSuspensionForm"
>
+ Ajouter une suspension
</button>
</div>
</AppDrawer>
<AppDrawer :model-value="isCreateContractDrawerOpen" title="Ajouter un contrat" @update:model-value="onUpdateCreateContractDrawerOpen">
<form class="space-y-4" @submit.prevent="onSubmitCreateContract">
<div>
<label class="text-md font-semibold text-neutral-700" for="create-contract-nature">
Type de contrat <span class="text-red-600">*</span>
</label>
<select id="create-contract-nature" v-model="createContractForm.contractNature" :class="createContractNatureFieldClass">
<option value="CDI">CDI</option>
<option value="CDD">CDD</option>
<option value="INTERIM">Intérim</option>
</select>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="create-contract-id">
Temps de travail <span class="text-red-600">*</span>
</label>
<select id="create-contract-id" v-model="createContractForm.contractId" :class="createContractFieldClass">
<option value="">Sélectionner un contrat</option>
<option v-for="contract in contracts" :key="contract.id" :value="contract.id">
{{ contract.name }}
</option>
</select>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="create-contract-start-date">
Début contrat <span class="text-red-600">*</span>
</label>
<input id="create-contract-start-date" v-model="createContractForm.startDate" type="date" :class="createContractStartDateFieldClass" />
</div>
<div v-if="showsCreateContractEndDate">
<label class="text-md font-semibold text-neutral-700" for="create-contract-end-date">
Fin contrat <span v-if="requiresCreateContractEndDate" class="text-red-600">*</span>
</label>
<input id="create-contract-end-date" v-model="createContractForm.endDate" type="date" :class="createContractEndDateFieldClass" />
</div>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
:disabled="isCreateContractSubmitting"
@click="onUpdateCreateContractDrawerOpen(false)"
>
Annuler
</button>
<button
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isCreateContractSubmitting || !isCreateContractFormValid"
>
Enregistrer
</button>
</div>
</form>
</AppDrawer>
</section>
</template>
<script setup lang="ts">
import type { Contract } from '~/services/dto/contract'
import type { ContractHistoryItem } from '~/services/dto/employee'
type SuspensionForm = {
id: number | null
startDate: string
endDate: string
comment: string
}
type ContractForm = {
contractId: number | ''
contractName: string
weeklyHours: number | null
contractNature: 'CDI' | 'CDD' | 'INTERIM'
startDate: string
endDate: string
paidLeaveSettled: boolean
comment: string
}
type CreateContractForm = {
contractId: number | ''
contractNature: 'CDI' | 'CDD' | 'INTERIM'
startDate: string
endDate: string
}
const props = defineProps<{
contractHistory: ContractHistoryItem[]
contractNatureLabel: (value?: 'CDI' | 'CDD' | 'INTERIM') => string
contractHistoryLabel: (item: ContractHistoryItem) => string
formatDate: (value?: string | null) => string
isContractSubmitting: boolean
canCloseCurrentContract: boolean
isCreateContractSubmitting: boolean
contracts: Contract[]
canCreateContract: boolean
isContractDrawerOpen: boolean
contractForm: ContractForm
readonlyFieldClass: string
closeContractWorkedHoursLabel: string
contractEndDateFieldClass: string
showContractEndDateError: boolean
isContractEndDateValid: boolean
isCreateContractDrawerOpen: boolean
createContractForm: CreateContractForm
createContractNatureFieldClass: string
createContractFieldClass: string
createContractStartDateFieldClass: string
showsCreateContractEndDate: boolean
requiresCreateContractEndDate: boolean
createContractEndDateFieldClass: string
isCreateContractFormValid: boolean
onOpenCloseContractDrawer: () => void
onOpenCreateContractDrawer: () => void
onUpdateContractDrawerOpen: (open: boolean) => void
onUpdateCreateContractDrawerOpen: (open: boolean) => void
onSubmitCloseContract: () => void
onSubmitCreateContract: () => void
suspensionForms: SuspensionForm[]
isSuspensionSubmitting: boolean
onSubmitSuspension: (index: number) => void
onAddSuspensionForm: () => void
currentContractPeriodId?: number | null
}>()
const drawerTab = ref<'close' | 'suspend'>('close')
watch(() => props.isContractDrawerOpen, (open) => {
if (open) drawerTab.value = 'close'
})
</script>

View File

@@ -0,0 +1,339 @@
<template>
<section class="flex h-full min-h-0 flex-col overflow-hidden pt-8">
<div class="grid grid-cols-4 rounded-md bg-tertiary-500 text-primary-500 text-[18px] border border-primary-500">
<p class="col-start-1 p-[10px] border-b border-b-black"><strong class="uppercase font-semibold">Année acquis :</strong> {{
formatCount(summary?.acquiredDays)
}} Jours
</p>
<p class="col-start-2 p-[10px] border-b border-b-black"><strong class="uppercase font-semibold">Pris :</strong>
{{ formatCount(isForfaitRule ? currentYearTakenDays : summary?.takenDays) }} Jours
</p>
<p class="col-start-3 p-[10px] border-b border-b-black"><strong class="uppercase font-semibold">Reste à prendre :</strong>
{{ formatCount(summary?.remainingDays) }} Jours
</p>
<p class="col-start-4 p-[10px] border-b border-b-black"><strong class="uppercase font-semibold">En cours d'acquisition :</strong>
{{ formatCount(summary?.accruingDays) }} Jours
</p>
<p v-if="!isForfaitRule" class="col-start-1 p-[10px]"><span class="uppercase font-semibold">Samedi acquis :</span>
{{ formatCount(summary?.acquiredSaturdays) }} Jours
</p>
<p v-else class="col-start-1 p-[10px]"><span class="uppercase font-semibold">Année N-1 acquis :</span>
{{ formatCount(summary?.previousYearAcquiredDays) }} Jours
</p>
<p v-if="!isForfaitRule" class="col-start-2 p-[10px]"><span class="uppercase font-semibold">Pris :</span>
{{ formatCount(summary?.takenSaturdays) }} Jours
</p>
<p v-if="!isForfaitRule" class="col-start-3 p-[10px]"><span class="uppercase font-semibold">Reste à prendre :</span>
{{ formatCount(summary?.remainingSaturdays) }} Jours
</p>
<p v-else class="col-start-2 p-[10px]"><span class="uppercase font-semibold">Pris :</span>
{{ formatCount(summary?.previousYearTakenDays) }} Jours
</p>
<p v-if="isForfaitRule" class="col-start-3 p-[10px]"><span class="uppercase font-semibold">Reste à prendre :</span>
{{ formatCount(summary?.previousYearRemainingDays) }} Jours
</p>
<div v-if="!isForfaitRule" class="col-start-4 p-[10px] flex gap-7 items-center">
<div>
<span class="uppercase font-semibold">Fractionné acquis : </span>
<span>{{ formatCount(summary?.fractionedDays) }} Jours</span>
</div>
<button
class="flex items-center"
@click="openFractionedDrawer"
>
<Icon name="mdi:edit-box" size="24"/>
</button>
</div>
</div>
<div class="mt-8 min-h-0 flex-1 overflow-y-auto pr-2">
<div class="grid grid-cols-4 gap-10">
<div v-for="month in months" :key="month.label"
class="rounded-md bg-tertiary-500 text-primary-500 flex flex-col justify-between">
<div class="flex justify-center rounded-t-md bg-primary-500 py-1 font-bold uppercase text-white">
{{ month.label }}
</div>
<div class="grid grid-cols-7 gap-1 px-2 py-2 text-center text-md font-bold">
<p v-for="weekday in weekDayLabels" :key="weekday">{{ weekday }}</p>
</div>
<div class="grid grid-cols-7 gap-4 px-2 pb-2 text-center text-md">
<template v-for="(day, index) in month.cells" :key="`${month.label}-${index}`">
<div v-if="!day" class="h-6"/>
<div
v-else
class="flex items-center justify-center"
>
<div
class="h-6 w-6"
:class="getDayClass(day)"
:style="getDayStyle(day)"
:title="getDayTitle(day)"
>
{{ getDayText(day) }}
</div>
</div>
</template>
</div>
<div class="px-2 py-2 text-center border-t border-primary-500">Jours de présence :
{{ summary?.presenceDaysByMonth?.[month.monthKey] ?? 0 }}
</div>
</div>
</div>
</div>
<AppDrawer v-model="isFractionedDrawerOpen" title="Jours fractionnés">
<form class="space-y-4" @submit.prevent="handleSubmitFractioned">
<div>
<label class="text-md font-semibold text-neutral-700" for="fractioned-days">
Nombre de jours <span class="text-red-600">*</span>
</label>
<input
id="fractioned-days"
v-model="fractionedForm.days"
type="number"
step="0.5"
min="0"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
@click="isFractionedDrawerOpen = false"
>
Annuler
</button>
<button
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
>
Enregistrer
</button>
</div>
</form>
</AppDrawer>
</section>
</template>
<script setup lang="ts">
import type {Absence} from '~/services/dto/absence'
import type {EmployeeLeaveSummary} from '~/services/dto/employee-leave-summary'
import {normalizeDate, toYmd} from '~/utils/date'
import AppDrawer from '~/components/AppDrawer.vue'
type DayLeaveState = {
am: boolean
pm: boolean
labels: string[]
colors: string[]
}
const props = defineProps<{
absences: Absence[]
summary: EmployeeLeaveSummary | null
publicHolidays: Record<string, string>
}>()
const emit = defineEmits<{
(event: 'update-fractioned-days', days: number): void
}>()
const isFractionedDrawerOpen = ref(false)
const fractionedForm = reactive({days: 0})
const openFractionedDrawer = () => {
fractionedForm.days = props.summary?.fractionedDays ?? 0
isFractionedDrawerOpen.value = true
}
const handleSubmitFractioned = () => {
const value = Number(fractionedForm.days)
if (Number.isNaN(value) || value < 0) return
emit('update-fractioned-days', value)
isFractionedDrawerOpen.value = false
}
const monthLabels = [
'Janvier',
'Fevrier',
'Mars',
'Avril',
'Mai',
'Juin',
'Juillet',
'Aout',
'Septembre',
'Octobre',
'Novembre',
'Decembre'
] as const
const weekDayLabels = ['L', 'M', 'M', 'J', 'V', 'S', 'D'] as const
const isForfaitRule = computed(() => props.summary?.ruleCode === 'FORFAIT_218')
const currentYearTakenDays = computed(() => {
if (!props.summary) return null
return props.summary.takenDays - (props.summary.previousYearTakenDays ?? 0)
})
const displayedYear = computed(() => {
if (props.summary?.year) return props.summary.year
const today = new Date()
const year = today.getFullYear()
const month = today.getMonth() + 1
return month >= 6 ? year + 1 : year
})
const orderedMonthIndexes = computed(() => {
if (isForfaitRule.value) return [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
return [5, 6, 7, 8, 9, 10, 11, 0, 1, 2, 3, 4]
})
const buildDateFromYmd = (value: string) => new Date(`${value}T00:00:00`)
const dayLeaveMap = computed(() => {
const map = new Map<string, DayLeaveState>()
for (const absence of props.absences) {
const startYmd = normalizeDate(absence.startDate)
const endYmd = normalizeDate(absence.endDate)
const start = buildDateFromYmd(startYmd)
const end = buildDateFromYmd(endYmd)
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) continue
for (const cursor = new Date(start); cursor <= end; cursor.setDate(cursor.getDate() + 1)) {
const ymd = toYmd(cursor.getFullYear(), cursor.getMonth(), cursor.getDate())
const existing = map.get(ymd) ?? {
am: false,
pm: false,
labels: [] as string[],
colors: [] as string[]
}
const isStart = ymd === startYmd
const isEnd = ymd === endYmd
const isSingleDay = startYmd === endYmd
let am = false
let pm = false
if (isSingleDay) {
am = absence.startHalf === 'AM'
pm = absence.endHalf === 'PM'
} else if (isStart) {
am = absence.startHalf === 'AM'
pm = true
} else if (isEnd) {
am = true
pm = absence.endHalf === 'PM'
} else {
am = true
pm = true
}
const typeLabel = absence.type?.label ?? absence.type?.code ?? 'Absence'
const typeColor = absence.type?.color ?? '#222783'
const halfSuffix = am && !pm ? ' (Matin)' : (!am && pm ? ' (Apres-midi)' : '')
const hoverLabel = `${typeLabel}${halfSuffix}`
const colors = existing.colors.includes(typeColor)
? existing.colors
: [...existing.colors, typeColor]
map.set(ymd, {
am: existing.am || am,
pm: existing.pm || pm,
labels: existing.labels.includes(hoverLabel)
? existing.labels
: [...existing.labels, hoverLabel],
colors
})
}
}
return map
})
const months = computed(() => {
return orderedMonthIndexes.value.map((monthIndex) => {
const label = monthLabels[monthIndex]
const monthYear = isForfaitRule.value
? displayedYear.value
: (monthIndex >= 5 ? displayedYear.value - 1 : displayedYear.value)
const first = new Date(monthYear, monthIndex, 1)
const daysInMonth = new Date(monthYear, monthIndex + 1, 0).getDate()
const mondayBasedFirstDay = (first.getDay() + 6) % 7
const cells: Array<{ ymd: string; label: string; leave: DayLeaveState | null; isHoliday: boolean } | null> = []
for (let i = 0; i < mondayBasedFirstDay; i += 1) {
cells.push(null)
}
for (let day = 1; day <= daysInMonth; day += 1) {
const ymd = toYmd(monthYear, monthIndex, day)
cells.push({
ymd,
label: String(day),
leave: dayLeaveMap.value.get(ymd) ?? null,
isHoliday: ymd in props.publicHolidays
})
}
while (cells.length % 7 !== 0) {
cells.push(null)
}
const monthKey = `${monthYear}-${String(monthIndex + 1).padStart(2, '0')}`
return {
label,
cells,
monthKey
}
})
})
const getDayClass = (day: { leave: DayLeaveState | null; isHoliday: boolean }) => {
if (day.leave) {
return 'rounded font-semibold text-white'
}
if (day.isHoliday) return 'text-primary-500 rounded font-semibold'
return 'text-primary-500'
}
const getDayStyle = (day: { leave: DayLeaveState | null; isHoliday: boolean }) => {
if (day.leave) {
const color = day.leave.colors[0] ?? '#222783'
if (day.leave.am && day.leave.pm) {
return {backgroundColor: color}
}
const colorFaded = `${color}60`
const backgroundImage = day.leave.am
? `linear-gradient(180deg, ${color} 0 50%, ${colorFaded} 50% 100%)`
: `linear-gradient(180deg, ${colorFaded} 0 50%, ${color} 50% 100%)`
return {backgroundImage, backgroundColor: 'transparent'}
}
if (day.isHoliday) return {backgroundColor: 'rgb(179, 229, 252)'}
return undefined
}
const getDayText = (day: { label: string; leave: DayLeaveState | null }) => {
return day.label
}
const getDayTitle = (day: { leave: DayLeaveState | null; isHoliday: boolean; ymd: string }) => {
if (day.leave && day.leave.labels.length > 0) return day.leave.labels.join(' / ')
if (day.isHoliday) return props.publicHolidays[day.ymd] ?? 'Jour férié'
return ''
}
const formatCount = (value: number | null | undefined) => {
if (value === null || value === undefined) return '-'
const rounded = Math.round(value * 100) / 100
if (Number.isInteger(rounded)) return String(rounded)
return rounded.toFixed(2).replace('.', ',')
}
</script>

View File

@@ -0,0 +1,407 @@
<template>
<section class="flex h-full min-h-0 flex-col overflow-hidden pt-8">
<!-- Header bar -->
<div class="flex items-center justify-between rounded-t-md bg-tertiary-500 px-5 py-4 text-black border border-primary-500">
<div class="flex items-center">
<button
class="rounded px-2 py-1 font-bold hover:bg-primary-600 disabled:opacity-40 disabled:cursor-not-allowed flex items-center"
:disabled="currentMonthIndex === 0"
@click="currentMonthIndex--"
>
<Icon name="mdi:chevron-left" size="24"/>
</button>
<span class="text-lg font-bold tracking-wide min-w-[170px] text-center">
{{ currentMonthLabel }} {{ currentYear }}
</span>
<button
class="rounded px-2 py-1 font-bold hover:bg-primary-600 disabled:opacity-40 disabled:cursor-not-allowed flex items-center"
:disabled="currentMonthIndex === 11"
@click="currentMonthIndex++"
>
<Icon name="mdi:chevron-right" size="24"/>
</button>
</div>
<p class="text-[16px]">
<span class="font-semibold">RTT À LA DATE DU JOUR :</span>
{{ formatMinutes(summary?.availableMinutes ?? 0) }}
</p>
<div class="flex justify-center">
<button
class="rounded-md bg-primary-500 px-8 py-2 font-bold text-white hover:bg-primary-600"
@click="openPaymentDrawer"
>
+ Payer les RRT
</button>
</div>
</div>
<!-- Table -->
<div class="min-h-0 flex-1 overflow-y-auto">
<table class="w-full table-fixed border-collapse text-[18px]">
<colgroup>
<col />
<col class="w-[14%]" />
<col class="w-[14%]" />
<col class="w-[14%]" />
<col class="w-[14%]" />
<col class="w-[14%]" />
<col class="w-[14%]" />
</colgroup>
<thead>
<tr>
<th class="px-5 py-[10px] text-left font-bold text-primary-500 border border-primary-500">Semaine</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">Heure</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">Base</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">25%</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">Base</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">50%</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">Total</th>
</tr>
</thead>
<tbody>
<!-- Report row (only on June when carry > 0) -->
<tr v-if="showReportRow">
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">Report</td>
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-r-2">-</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBase25Minutes) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(summary!.carryBonus25Minutes) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBase50Minutes) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(summary!.carryBonus50Minutes) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryFromPreviousYearMinutes) }}</td>
</tr>
<!-- Week rows (always 5) -->
<tr
v-for="(week, idx) in paddedWeeks"
:key="week ? week.weekStart : `empty-${idx}`"
>
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">
<span v-if="week">Semaine {{ week.weekNumber }}</span>
<span v-else>&nbsp;</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">
<span v-if="week">{{ formatMinutes(week.overtimeMinutes) }}</span>
<span v-else>0 h</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
<span v-if="week">{{ formatMinutes(week.base25Minutes) }}</span>
<span v-else>0 h</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">
<span v-if="week">{{ formatMinutes(week.bonus25Minutes) }}</span>
<span v-else>0 h</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
<span v-if="week">{{ formatMinutes(week.base50Minutes) }}</span>
<span v-else>0 h</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">
<span v-if="week">{{ formatMinutes(week.bonus50Minutes) }}</span>
<span v-else>0 h</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
<span v-if="week">{{ formatMinutes(week.totalMinutes) }}</span>
<span v-else>0 h</span>
</td>
</tr>
<!-- Total row -->
<tr>
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500 border-t-2">Total</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2 border-t-2">{{ formatMinutes(totals.overtime) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-t-2">{{ formatMinutes(totals.base25) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2 border-t-2">{{ formatMinutes(totals.bonus25) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-t-2">{{ formatMinutes(totals.base50) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2 border-t-2">{{ formatMinutes(totals.bonus50) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-t-2">{{ formatMinutes(totals.total) }}</td>
</tr>
<!-- Payé row -->
<tr>
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">Payé</td>
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-r-2">-</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBase25Minutes) : '0 h' }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ currentPayment ? formatMinutes(-currentPayment.paidBonus25Minutes) : '0 h' }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBase50Minutes) : '0 h' }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ currentPayment ? formatMinutes(-currentPayment.paidBonus50Minutes) : '0 h' }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(paidTotal) }}</td>
</tr>
<!-- Reste row -->
<tr>
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">Reste</td>
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-r-2">-</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(totals.base25 - (currentPayment?.paidBase25Minutes ?? 0)) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(totals.bonus25 - (currentPayment?.paidBonus25Minutes ?? 0)) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(totals.base50 - (currentPayment?.paidBase50Minutes ?? 0)) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(totals.bonus50 - (currentPayment?.paidBonus50Minutes ?? 0)) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(resteTotal) }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Payment Drawer -->
<AppDrawer v-model="isPaymentDrawerOpen" title="Payer des RTT">
<form @submit.prevent="onSubmitPayment">
<div class="mb-4">
<label class="block text-sm font-medium text-neutral-700">Mois</label>
<select
v-model.number="paymentForm.month"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
>
<option v-for="m in orderedMonthOptions" :key="m.value" :value="m.value">{{ m.label }}</option>
</select>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-neutral-700">Base 25% (heures)</label>
<input
v-model.number="paymentForm.base25Hours"
type="number"
step="0.5"
min="0"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-neutral-700">Heures 25% (heures)</label>
<input
v-model.number="paymentForm.bonus25Hours"
type="number"
step="0.5"
min="0"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-neutral-700">Base 50% (heures)</label>
<input
v-model.number="paymentForm.base50Hours"
type="number"
step="0.5"
min="0"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<div class="mb-6">
<label class="block text-sm font-medium text-neutral-700">Heures 50% (heures)</label>
<input
v-model.number="paymentForm.bonus50Hours"
type="number"
step="0.5"
min="0"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<div class="flex justify-end gap-3">
<button
type="button"
class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
@click="isPaymentDrawerOpen = false"
>
Annuler
</button>
<button
type="submit"
class="rounded-md bg-primary-500 px-4 py-2 text-sm font-medium text-white hover:bg-primary-600"
>
Enregistrer
</button>
</div>
</form>
</AppDrawer>
</section>
</template>
<script setup lang="ts">
import type { EmployeeRttSummary, EmployeeRttWeekSummary } from '~/services/dto/employee-rtt-summary'
import AppDrawer from '~/components/AppDrawer.vue'
const props = defineProps<{
summary: EmployeeRttSummary | null
}>()
const emit = defineEmits<{
(event: 'submit-rtt-payment', month: number, base25Minutes: number, bonus25Minutes: number, base50Minutes: number, bonus50Minutes: number): void
}>()
// --- Month navigation ---
const orderedMonths = [6, 7, 8, 9, 10, 11, 12, 1, 2, 3, 4, 5] as const
const monthLabels: Record<number, string> = {
1: 'JANVIER',
2: 'FEVRIER',
3: 'MARS',
4: 'AVRIL',
5: 'MAI',
6: 'JUIN',
7: 'JUILLET',
8: 'AOUT',
9: 'SEPTEMBRE',
10: 'OCTOBRE',
11: 'NOVEMBRE',
12: 'DECEMBRE',
}
const orderedMonthOptions = [
{ value: 6, label: 'Juin' },
{ value: 7, label: 'Juillet' },
{ value: 8, label: 'Aout' },
{ value: 9, label: 'Septembre' },
{ value: 10, label: 'Octobre' },
{ value: 11, label: 'Novembre' },
{ value: 12, label: 'Decembre' },
{ value: 1, label: 'Janvier' },
{ value: 2, label: 'Fevrier' },
{ value: 3, label: 'Mars' },
{ value: 4, label: 'Avril' },
{ value: 5, label: 'Mai' },
]
// Initialize to current month's position in the exercise
const today = new Date()
const todayMonth = today.getMonth() + 1
const initialIndex = orderedMonths.indexOf(todayMonth as (typeof orderedMonths)[number])
const currentMonthIndex = ref(initialIndex >= 0 ? initialIndex : 0)
const currentMonth = computed(() => orderedMonths[currentMonthIndex.value])
const currentMonthLabel = computed(() => monthLabels[currentMonth.value])
const currentYear = computed(() => {
if (!props.summary) return ''
return currentMonth.value >= 6 ? props.summary.year - 1 : props.summary.year
})
// --- Weeks for current month ---
const weeksForCurrentMonth = computed((): EmployeeRttWeekSummary[] => {
if (!props.summary) return []
return props.summary.weeks.filter((w) => w.month === currentMonth.value)
})
const paddedWeeks = computed((): (EmployeeRttWeekSummary | null)[] => {
const weeks = weeksForCurrentMonth.value
const padded: (EmployeeRttWeekSummary | null)[] = [...weeks]
while (padded.length < 5) {
padded.push(null)
}
return padded
})
// --- Report row ---
const reportMonth = computed(() => {
if (!props.summary) return 6
const carryMonth = props.summary.carryMonth
// Report appears in the month AFTER carryMonth (wrapping 12 -> 1)
return carryMonth >= 12 ? 1 : carryMonth + 1
})
const showReportRow = computed(() => {
return (
currentMonth.value === reportMonth.value &&
(props.summary?.carryFromPreviousYearMinutes ?? 0) > 0
)
})
// --- Totals ---
const totals = computed(() => {
const weeks = weeksForCurrentMonth.value
const base = {
overtime: weeks.reduce((s, w) => s + w.overtimeMinutes, 0),
base25: weeks.reduce((s, w) => s + w.base25Minutes, 0),
bonus25: weeks.reduce((s, w) => s + w.bonus25Minutes, 0),
base50: weeks.reduce((s, w) => s + w.base50Minutes, 0),
bonus50: weeks.reduce((s, w) => s + w.bonus50Minutes, 0),
total: weeks.reduce((s, w) => s + w.totalMinutes, 0),
}
if (showReportRow.value && props.summary) {
base.base25 += props.summary.carryBase25Minutes
base.bonus25 += props.summary.carryBonus25Minutes
base.base50 += props.summary.carryBase50Minutes
base.bonus50 += props.summary.carryBonus50Minutes
base.total += props.summary.carryFromPreviousYearMinutes
}
return base
})
const currentPayment = computed(() => {
if (!props.summary) return null
return props.summary.monthPayments.find((p) => p.month === currentMonth.value) ?? null
})
const paidTotal = computed(() => {
if (!currentPayment.value) return 0
const p = currentPayment.value
return -(p.paidBase25Minutes + p.paidBonus25Minutes + p.paidBase50Minutes + p.paidBonus50Minutes)
})
const resteTotal = computed(() => {
return totals.value.total + paidTotal.value
})
// --- Format ---
const formatMinutes = (minutes: number): string => {
if (minutes === 0) return '0 h'
const sign = minutes < 0 ? '- ' : ''
const abs = Math.abs(minutes)
const hours = Math.floor(abs / 60)
const rest = abs % 60
if (rest === 0) return `${sign}${hours} h`
return `${sign}${hours} h ${rest} m`
}
// --- Payment drawer ---
const isPaymentDrawerOpen = ref(false)
const paymentForm = reactive({
month: 6,
base25Hours: 0,
bonus25Hours: 0,
base50Hours: 0,
bonus50Hours: 0,
})
const prefillFromExistingPayment = (month: number) => {
const existing = props.summary?.monthPayments.find((p) => p.month === month) ?? null
if (existing) {
paymentForm.base25Hours = existing.paidBase25Minutes / 60
paymentForm.bonus25Hours = existing.paidBonus25Minutes / 60
paymentForm.base50Hours = existing.paidBase50Minutes / 60
paymentForm.bonus50Hours = existing.paidBonus50Minutes / 60
} else {
paymentForm.base25Hours = 0
paymentForm.bonus25Hours = 0
paymentForm.base50Hours = 0
paymentForm.bonus50Hours = 0
}
}
watch(() => paymentForm.month, (newMonth) => {
prefillFromExistingPayment(newMonth)
})
const openPaymentDrawer = () => {
paymentForm.month = currentMonth.value
prefillFromExistingPayment(currentMonth.value)
isPaymentDrawerOpen.value = true
}
const onSubmitPayment = () => {
emit(
'submit-rtt-payment',
paymentForm.month,
Math.round(paymentForm.base25Hours * 60),
Math.round(paymentForm.bonus25Hours * 60),
Math.round(paymentForm.base50Hours * 60),
Math.round(paymentForm.bonus50Hours * 60),
)
isPaymentDrawerOpen.value = false
}
</script>

View File

@@ -0,0 +1,267 @@
<template>
<div class="rounded-lg border border-neutral-200 bg-white overflow-hidden flex min-h-0 flex-col">
<div class="overflow-y-auto min-h-0">
<div
class="grid w-full min-w-0 gap-1 border-b border-neutral-200 bg-tertiary-500 px-4 py-3 text-sm font-semibold text-neutral-700 sticky top-0 z-10"
:style="{ gridTemplateColumns: dayGridCols }"
>
<span>Nom</span>
<span class="pl-2">Absence</span>
<span class="pl-4">Début matin</span>
<span class="pr-2">Fin matin</span>
<span class="pl-2">Début après-midi</span>
<span class="pr-2">Fin après-midi</span>
<span class="pl-2">Début soir</span>
<span class="pr-2">Fin soir</span>
<span class="pl-2">Jour</span>
<span>Nuit</span>
<span>Total</span>
<span v-if="isAdmin" class="flex justify-between items-center">
<span>Valider</span>
<input
ref="bulkValidationInput"
:checked="isBulkValidationChecked"
type="checkbox"
class="h-4 w-4 cursor-pointer"
@change="onBulkValidationChange"
/>
</span>
<span v-else-if="isSiteManager" class="inline-flex items-center gap-2">
<span>Site</span>
<input
ref="bulkSiteValidationInput"
:checked="isBulkSiteValidationChecked"
type="checkbox"
class="h-4 w-4"
:class="canBulkToggleSiteValidation ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'"
:disabled="!canBulkToggleSiteValidation"
@change="onBulkSiteValidationChange"
/>
</span>
<span v-else>Site <Icon name="mdi:check-bold" class="ml-1"/></span>
<span v-if="!isAdmin">RH <Icon name="mdi:check-bold" class="ml-1"/></span>
</div>
<div
v-for="employee in employees"
:key="employee.id"
class="grid w-full min-w-0 items-center gap-1 border-b border-neutral-100 px-4 py-2 text-sm last:border-b-0"
:style="{ gridTemplateColumns: dayGridCols }"
>
<div class="text-neutral-900 min-w-0">
<p class="font-semibold truncate">
{{ employee.firstName }} {{ employee.lastName }}
<span class="font-normal text-neutral-600">({{ contractLabel(employee) }})</span>
</p>
<p class="text-neutral-500 truncate inline-flex items-center gap-2">
<span>{{ employee.site?.name ?? 'Sans site' }}</span>
<span
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
class="rounded-full bg-green-500 flex justify-center item-center text-white p-0.5"
title="Validation site"
>
<Icon name="mdi:check"/>
</span>
</p>
<p v-if="isAdmin && getRowUpdatedAt(employee.id)" class="text-neutral-400 text-xs truncate">
Modifié le {{ getRowUpdatedAt(employee.id) }}
</p>
</div>
<div class="pl-2 min-w-0 self-stretch flex flex-col gap-1 justify-between py-0.5">
<p
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate"
:class="getRowAbsenceLabel(employee.id) ? 'text-white' : 'invisible'"
:title="getRowAbsenceLabel(employee.id) || ''"
:style="getRowAbsenceStyle(employee.id)"
>
{{ getRowAbsenceLabel(employee.id) || '—' }}
</p>
<button
type="button"
class="self-start text-left text-xs font-semibold underline"
:class="isHoliday || isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
:disabled="isHoliday || isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
@click="onAbsenceClick(employee.id)"
>
Modifier
</button>
</div>
<div class="pl-4">
<TimeSelect
v-if="isTimeTracking(employee)"
v-model="rows[employee.id].morningFrom"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
/>
<input
v-else-if="isPresenceTracking(employee)"
v-model="rows[employee.id].isPresentMorning"
type="checkbox"
class="cursor-pointer h-4 w-4"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
/>
</div>
<div class="pr-2">
<TimeSelect
v-if="isTimeTracking(employee)"
v-model="rows[employee.id].morningTo"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
/>
</div>
<div class="pl-2">
<TimeSelect
v-if="isTimeTracking(employee)"
v-model="rows[employee.id].afternoonFrom"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
/>
<input
v-else-if="isPresenceTracking(employee)"
v-model="rows[employee.id].isPresentAfternoon"
type="checkbox"
class="cursor-pointer h-4 w-4"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
/>
</div>
<div class="pr-2">
<TimeSelect
v-if="isTimeTracking(employee)"
v-model="rows[employee.id].afternoonTo"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
/>
</div>
<div class="pl-2">
<TimeSelect
v-if="isTimeTracking(employee)"
v-model="rows[employee.id].eveningFrom"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
/>
</div>
<div class="pr-2">
<TimeSelect
v-if="isTimeTracking(employee)"
v-model="rows[employee.id].eveningTo"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
/>
</div>
<div class="pl-2 text-sm font-semibold text-neutral-700">
<div v-if="isTimeTracking(employee)">{{
formatMinutes(getRowMetrics(employee.id).dayMinutes)
}}
</div>
</div>
<div class="text-sm font-semibold text-neutral-700">
<div v-if="isTimeTracking(employee)">{{
formatMinutes(getRowMetrics(employee.id).nightMinutes)
}}
</div>
</div>
<div class="text-sm font-semibold text-neutral-700">
<div v-if="isTimeTracking(employee)">{{
formatMinutes(getRowMetrics(employee.id).totalMinutes)
}}
</div>
<div v-else>{{ getPresenceDayValue(employee.id) }}</div>
</div>
<div v-if="isAdmin" class="text-right">
<input
:checked="rows[employee.id]?.isValid ?? false"
type="checkbox"
class="h-4 w-4 cursor-pointer"
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
/>
</div>
<div v-else class="text-right p-5">
<input
v-if="isSiteManager"
:checked="rows[employee.id]?.isSiteValid ?? false"
type="checkbox"
class="h-4 w-4 cursor-pointer"
:disabled="(!canToggleSiteValidation(employee.id) && !canCreateSiteValidationRowFromAbsence(employee.id)) || isSiteValidationPending(employee.id)"
@change="onToggleSiteValidation(employee.id, ($event.target as HTMLInputElement).checked)"
/>
<span v-else-if="rows[employee.id]?.isSiteValid" class="text-xs font-semibold text-neutral-700">Validé</span>
<span v-else class="text-xs text-neutral-500">-</span>
</div>
<div v-if="!isAdmin">
<span v-if="rows[employee.id]?.isValid" class="text-xs font-semibold text-neutral-700">Validé</span>
<span v-else class="text-xs text-neutral-500">-</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type {Employee} from '~/services/dto/employee'
import TimeSelect from '~/components/ui/TimeSelect.vue'
import type {HourRow} from './types'
const rows = defineModel<Record<number, HourRow>>('rows', {required: true})
const bulkValidationInput = ref<HTMLInputElement | null>(null)
const bulkSiteValidationInput = ref<HTMLInputElement | null>(null)
const props = defineProps<{
employees: Employee[]
isAdmin: boolean
isSiteManager: boolean
dayGridCols: string
isHoliday: boolean
contractLabel: (employee: Employee) => string
isTimeTracking: (employee: Employee) => boolean
isPresenceTracking: (employee: Employee) => boolean
isRowLocked: (employeeId: number) => boolean
isHalfLockedByAbsence: (employeeId: number, half: 'AM' | 'PM') => boolean
isEveningLockedByAbsence: (employeeId: number) => boolean
hasContractAtSelectedDate: (employeeId: number) => boolean
isValidationPending: (employeeId: number) => boolean
isSiteValidationPending: (employeeId: number) => boolean
canToggleValidation: (employeeId: number) => boolean
canToggleSiteValidation: (employeeId: number) => boolean
canCreateSiteValidationRowFromAbsence: (employeeId: number) => boolean
isBulkValidationChecked: boolean
isBulkValidationIndeterminate: boolean
isBulkSiteValidationChecked: boolean
isBulkSiteValidationIndeterminate: boolean
canBulkToggleSiteValidation: boolean
onToggleValidation: (employeeId: number, checked: boolean) => void
onToggleSiteValidation: (employeeId: number, checked: boolean) => void
onToggleValidationBulk: (checked: boolean) => Promise<void> | void
onToggleSiteValidationBulk: (checked: boolean) => Promise<void> | void
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number }
getRowAbsenceLabel: (employeeId: number) => string
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
getRowUpdatedAt: (employeeId: number) => string
getPresenceDayValue: (employeeId: number) => string
onAbsenceClick: (employeeId: number) => void
formatMinutes: (minutes: number) => string
}>()
const onBulkValidationChange = (event: Event) => {
props.onToggleValidationBulk((event.target as HTMLInputElement).checked)
}
const onBulkSiteValidationChange = (event: Event) => {
props.onToggleSiteValidationBulk((event.target as HTMLInputElement).checked)
}
const onToggleSiteValidation = (employeeId: number, checked: boolean) => {
props.onToggleSiteValidation(employeeId, checked)
}
watch(
() => props.isBulkValidationIndeterminate,
(isIndeterminate) => {
if (!bulkValidationInput.value) return
bulkValidationInput.value.indeterminate = isIndeterminate
},
{immediate: true}
)
watch(
() => props.isBulkSiteValidationIndeterminate,
(isIndeterminate) => {
if (!bulkSiteValidationInput.value) return
bulkSiteValidationInput.value.indeterminate = isIndeterminate
},
{immediate: true}
)
</script>

View File

@@ -0,0 +1,178 @@
<template>
<div class="py-6 flex flex-col gap-3">
<div class="flex gap-4">
<SiteFilterSelector v-if="sites.length > 0 && isAdmin" v-model="selectedSiteIds" :sites="sites" />
<div v-if="isAdmin" class="w-80 max-w-full">
<EmployeeNameFilterInput v-model="employeeFilter" />
</div>
</div>
<div class="flex justify-between items-center gap-4">
<div class="flex gap-4 flex-wrap">
<div
v-if="viewMode === 'day'"
class="inline-flex h-10 w-[320px] overflow-hidden rounded-md border border-primary-500 bg-white"
>
<button
type="button"
class="flex-1 px-4 py-2 text-sm font-semibold active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
:class="shortcutButtonClass('yesterday')"
@click="emit('set-yesterday')"
>
Hier
</button>
<button
type="button"
class="flex-1 border-x border-primary-500 px-4 py-2 text-sm font-semibold active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
:class="shortcutButtonClass('today')"
@click="emit('set-today')"
>
Aujourd'hui
</button>
<button
type="button"
class="flex-1 px-4 py-2 text-sm font-semibold active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
:class="shortcutButtonClass('tomorrow')"
@click="emit('set-tomorrow')"
>
Demain
</button>
</div>
<div
v-else
class="inline-flex h-10 w-[320px] overflow-hidden rounded-md border border-primary-500 bg-white"
>
<button
type="button"
class="flex-1 px-4 py-2 text-sm font-semibold active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
:class="weekShortcutButtonClass('previousWeek')"
@click="emit('set-previous-week')"
>
{{ getWeekShortcutLabel('previousWeek') }}
</button>
<button
type="button"
class="flex-1 border-x border-primary-500 px-4 py-2 text-sm font-semibold active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
:class="weekShortcutButtonClass('thisWeek')"
@click="emit('set-this-week')"
>
{{ getWeekShortcutLabel('thisWeek') }}
</button>
<button
type="button"
class="flex-1 px-4 py-2 text-sm font-semibold active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
:class="weekShortcutButtonClass('nextWeek')"
@click="emit('set-next-week')"
>
{{ getWeekShortcutLabel('nextWeek') }}
</button>
</div>
<PeriodStepperPicker
width-class="w-[320px]"
:label="formattedSelectedDate"
:picker-type="viewMode === 'week' ? 'week' : 'date'"
:picker-value="pickerValue"
prev-aria-label="Période précédente"
next-aria-label="Période suivante"
@prev="emit('shift-date', -1)"
@next="emit('shift-date', 1)"
@pick="onPickerValue"
/>
</div>
<div v-if="isAdmin" class="inline-flex h-10 overflow-hidden rounded-md border border-primary-500 bg-white">
<button
type="button"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-semibold active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
:class="viewModeButtonClass('day')"
@click="viewMode = 'day'"
>
<Icon name="mdi:calendar-clock" />
Jour
</button>
<button
type="button"
class="inline-flex items-center gap-2 border-l border-primary-500 px-4 py-2 text-sm font-semibold active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
:class="viewModeButtonClass('week')"
@click="viewMode = 'week'"
>
<Icon name="mdi:calendar-week" />
Semaine
</button>
</div>
</div>
<div
v-if="isAdmin && viewMode === 'week' && absenceTypes.length > 0"
class="flex flex-wrap items-center gap-6"
>
<p class="font-bold">Légende :</p>
<div v-for="type in absenceTypes" :key="type.id" class="flex items-center gap-2">
<div :style="{ backgroundColor: type.color }" class="h-4 w-4 rounded"></div>
<p>{{ type.label }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Site } from '~/services/dto/site'
import type { AbsenceType } from '~/services/dto/absence-type'
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
import PeriodStepperPicker from '~/components/PeriodStepperPicker.vue'
import { weekInputValueToYmd, ymdToWeekInputValue } from '~/utils/date'
const selectedDate = defineModel<string>('selectedDate', { required: true })
const viewMode = defineModel<'day' | 'week'>('viewMode', { required: true })
const selectedSiteIds = defineModel<number[]>('selectedSiteIds', { required: true })
const employeeFilter = defineModel<string>('employeeFilter', { required: true })
defineProps<{
isAdmin: boolean
sites: Site[]
absenceTypes: AbsenceType[]
formattedSelectedDate: string
shortcutButtonClass: (target: 'yesterday' | 'today' | 'tomorrow') => string
weekShortcutButtonClass: (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => string
getWeekShortcutLabel: (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => string
}>()
const emit = defineEmits<{
(e: 'set-yesterday'): void
(e: 'set-today'): void
(e: 'set-tomorrow'): void
(e: 'set-previous-week'): void
(e: 'set-this-week'): void
(e: 'set-next-week'): void
(e: 'shift-date', value: number): void
}>()
const pickerValue = computed(() => {
if (viewMode.value === 'week') return ymdToWeekInputValue(selectedDate.value)
return selectedDate.value
})
const viewModeButtonClass = (mode: 'day' | 'week') => {
if (viewMode.value === mode) {
return 'bg-primary-500 text-white'
}
return 'bg-white text-primary-500 hover:bg-tertiary-500'
}
const onPickerValue = (value: string) => {
if (!value) return
if (viewMode.value === 'week') {
const ymd = weekInputValueToYmd(value)
if (!ymd) return
selectedDate.value = ymd
return
}
selectedDate.value = value
}
</script>

View File

@@ -0,0 +1,98 @@
<template>
<div class="rounded-lg border border-neutral-200 bg-white overflow-hidden flex min-h-0 flex-col">
<div v-if="isWeekLoading" class="p-6 text-md text-neutral-600">Chargement de la semaine...</div>
<div v-else class="overflow-y-auto min-h-0">
<div
class="grid w-full min-w-0 gap-1 border-b border-neutral-200 bg-tertiary-500 px-4 py-3 text-sm font-semibold text-neutral-700 sticky top-0 z-10"
:style="{ gridTemplateColumns: weekGridCols }"
>
<span>Nom</span>
<span v-for="day in weekDayHeaders" :key="day.date" class="text-left">{{ day.label }}</span>
<span>Jour/Nuit <br>sem.</span>
<span>Total <br>sem.</span>
<span>Total <br>h. supp.</span>
<span>+25%</span>
<span>+50%</span>
<span>Total <br>récup.</span>
</div>
<div
v-for="row in weeklySummary?.rows ?? []"
:key="row.employeeId"
class="grid w-full min-w-0 items-center gap-1 border-b border-neutral-100 px-4 py-2 text-sm last:border-b-0 hover:bg-tertiary-500"
:style="{ gridTemplateColumns: weekGridCols }"
>
<div class="text-neutral-900 min-w-0">
<p class="font-semibold truncate">
{{ row.firstName }} {{ row.lastName }}
<span class="font-normal text-neutral-600">({{ row.contractName ?? '-' }})</span>
</p>
<p class="text-[11px] text-neutral-500 truncate">{{ row.siteName ?? 'Sans site' }}</p>
</div>
<div
v-for="daily in row.daily"
:key="daily.date"
class="text-left leading-4 rounded-md px-2 py-1"
:class="daily.hasAbsence ? 'text-white' : ''"
:style="getDailyCellStyle(daily)"
:title="daily.absenceLabel ?? ''"
>
<template v-if="row.trackingMode === 'PRESENCE'">{{ daily.present ?? 0 }}</template>
<template v-else>
<div>J {{ formatMinutes(daily.dayMinutes) }}</div>
<div>N {{ formatMinutes(daily.nightMinutes) }}</div>
</template>
</div>
<div class="font-semibold leading-4">
<template v-if="row.trackingMode === 'PRESENCE'">-</template>
<template v-else>
<div>J {{ formatMinutes(row.weeklyDayMinutes) }}</div>
<div>N {{ formatMinutes(row.weeklyNightMinutes) }}</div>
</template>
</div>
<div class="font-semibold">
{{ row.trackingMode === 'PRESENCE' ? (row.weeklyPresenceCount ?? 0) : formatMinutes(row.weeklyTotalMinutes) }}
</div>
<div class="font-semibold">
{{ row.trackingMode === 'PRESENCE' ? '-' : formatMinutes(row.weeklyOvertimeTotalMinutes ?? 0) }}
</div>
<div class="font-semibold">
{{ row.trackingMode === 'PRESENCE' || isInterimContract(row.contractType) ? '-' : formatMinutes(row.weeklyOvertime25Minutes ?? 0) }}
</div>
<div class="font-semibold">
{{ row.trackingMode === 'PRESENCE' || isInterimContract(row.contractType) ? '-' : formatMinutes(row.weeklyOvertime50Minutes ?? 0) }}
</div>
<div class="font-semibold">
{{ row.trackingMode === 'PRESENCE' || isInterimContract(row.contractType) ? '-' : formatMinutes(row.weeklyRecoveryMinutes ?? 0) }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { WeeklyWorkHourSummary } from '~/services/dto/work-hour'
import { CONTRACT_TYPES, type ContractType } from '~/services/dto/contract'
const isInterimContract = (contractType?: ContractType | null) => {
return contractType === CONTRACT_TYPES.INTERIM
}
const getDailyCellStyle = (daily: {
hasAbsence?: boolean
absenceColor?: string | null
}) => {
if (!daily.hasAbsence) return undefined
return { backgroundColor: daily.absenceColor || '#dc2626' }
}
defineProps<{
isWeekLoading: boolean
weekGridCols: string
weeklySummary: WeeklyWorkHourSummary | null
weekDayHeaders: Array<{ date: string; label: string }>
formatMinutes: (minutes: number) => string
}>()
</script>

View File

@@ -0,0 +1,14 @@
export type HourRow = {
workHourId: number | null
morningFrom: string
morningTo: string
afternoonFrom: string
afternoonTo: string
eveningFrom: string
eveningTo: string
isPresentMorning: boolean
isPresentAfternoon: boolean
isSiteValid: boolean
isValid: boolean
updatedAt: string | null
}

View File

@@ -0,0 +1,253 @@
<template>
<div ref="root" class="relative w-full">
<div
ref="trigger"
class="w-full flex items-center rounded-md border border-neutral-300 px-2 text-sm text-neutral-900 focus-within:border-primary-500"
:class="props.disabled ? 'cursor-not-allowed border-neutral-300 bg-neutral-200 text-neutral-500' : 'bg-white'"
>
<input
ref="inputRef"
v-model="inputValue"
type="text"
inputmode="numeric"
:placeholder="placeholder"
:disabled="props.disabled"
class="h-9 w-full bg-transparent px-1 outline-none disabled:cursor-not-allowed disabled:bg-neutral-200 disabled:text-neutral-500"
@focus="openMenu"
@keydown.down.prevent="openMenuAndFocusFirst"
@keydown.enter.prevent="commitInput"
@keydown.esc.prevent="closeMenu"
@input="onInput($event)"
@blur="onInputBlur"
/>
<button
type="button"
tabindex="-1"
class="inline-flex h-8 w-8 items-center justify-center rounded text-neutral-600 hover:bg-tertiary-500 disabled:cursor-not-allowed disabled:bg-neutral-200 disabled:text-neutral-500"
:disabled="props.disabled"
@mousedown.prevent
@click="toggleOpen"
>
<Icon name="mdi:chevron-down" />
</button>
</div>
</div>
<Teleport to="body">
<div
v-if="isOpen"
ref="menu"
class="fixed z-[120] overflow-y-auto rounded-md border border-neutral-300 bg-white shadow-sm"
:style="menuStyle"
>
<button type="button" class="block w-full px-2 py-2 text-left text-sm hover:bg-tertiary-500" @click="selectValue('')">
{{ placeholder }}
</button>
<button
v-for="slot in filteredTimeSlots"
:key="slot"
type="button"
class="block w-full px-2 py-2 text-left text-sm hover:bg-tertiary-500"
@click="selectValue(slot)"
>
{{ slot }}
</button>
<p v-if="filteredTimeSlots.length === 0" class="px-2 py-2 text-sm text-neutral-500">
Aucun résultat
</p>
</div>
</Teleport>
</template>
<script setup lang="ts">
const props = withDefaults(defineProps<{
modelValue: string
placeholder?: string
disabled?: boolean
}>(), {
placeholder: '--',
disabled: false
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
const root = ref<HTMLElement | null>(null)
const trigger = ref<HTMLElement | null>(null)
const menu = ref<HTMLElement | null>(null)
const inputRef = ref<HTMLInputElement | null>(null)
const isOpen = ref(false)
const inputValue = ref('')
const menuStyle = ref<Record<string, string>>({
top: '0px',
left: '0px',
width: '0px',
maxHeight: '224px'
})
const timeSlots = computed(() => {
const slots: string[] = []
for (let hour = 0; hour < 24; hour++) {
for (let minute = 0; minute < 60; minute += 15) {
slots.push(`${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`)
}
}
return slots
})
const filteredTimeSlots = computed(() => {
const query = inputValue.value.trim()
if (!query) return timeSlots.value
return timeSlots.value.filter((slot) => slot.includes(query))
})
const applyTimeMask = (value: string): string => {
const digits = value.replace(/\D/g, '').slice(0, 4)
if (digits.length <= 2) return digits
return `${digits.slice(0, 2)}:${digits.slice(2)}`
}
const normalizeTypedTime = (value: string): string | null => {
const trimmed = value.trim()
if (trimmed === '') return ''
// Accepte HH:MM ou H:MM puis normalise en HH:MM.
const match = trimmed.match(/^(\d{1,2}):(\d{2})$/)
if (!match) return null
const hours = Number(match[1])
const minutes = Number(match[2])
if (!Number.isInteger(hours) || !Number.isInteger(minutes)) return null
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
}
const updateMenuPosition = () => {
const triggerEl = trigger.value
if (!triggerEl) return
const rect = triggerEl.getBoundingClientRect()
const menuHeight = 224
const belowTop = rect.bottom + 4
const aboveTop = Math.max(8, rect.top - menuHeight - 4)
const canOpenBelow = belowTop + menuHeight <= window.innerHeight - 8
const top = canOpenBelow ? belowTop : aboveTop
menuStyle.value = {
top: `${top}px`,
left: `${rect.left}px`,
width: `${rect.width}px`,
maxHeight: `${menuHeight}px`
}
}
const toggleOpen = () => {
if (props.disabled) return
const next = !isOpen.value
isOpen.value = next
if (next) {
nextTick(updateMenuPosition)
}
}
const openMenu = () => {
if (props.disabled) return
if (!isOpen.value) {
isOpen.value = true
nextTick(updateMenuPosition)
}
}
const openMenuAndFocusFirst = () => {
openMenu()
}
const closeMenu = () => {
isOpen.value = false
}
const commitInput = () => {
const normalized = normalizeTypedTime(inputValue.value)
if (normalized === null || (normalized !== '' && !timeSlots.value.includes(normalized))) {
emit('update:modelValue', '')
inputValue.value = ''
closeMenu()
return
}
emit('update:modelValue', normalized)
inputValue.value = normalized
closeMenu()
}
const onInput = (event: Event) => {
const target = event.target as HTMLInputElement
const masked = applyTimeMask(target.value)
if (masked !== inputValue.value) {
inputValue.value = masked
}
openMenu()
}
const onInputBlur = () => {
// Laisse le temps au click menu de passer avant fermeture.
setTimeout(() => {
if (menu.value?.contains(document.activeElement)) return
commitInput()
}, 50)
}
const selectValue = (value: string) => {
if (props.disabled) return
emit('update:modelValue', value)
inputValue.value = value
isOpen.value = false
nextTick(() => inputRef.value?.focus())
}
const onDocumentClick = (event: MouseEvent) => {
const target = event.target as Node | null
if (!target) return
if (root.value?.contains(target) || menu.value?.contains(target)) return
isOpen.value = false
}
const onWindowChange = () => {
if (!isOpen.value) return
updateMenuPosition()
}
watch(isOpen, (open) => {
if (open) {
window.addEventListener('resize', onWindowChange)
window.addEventListener('scroll', onWindowChange, true)
nextTick(updateMenuPosition)
} else {
window.removeEventListener('resize', onWindowChange)
window.removeEventListener('scroll', onWindowChange, true)
}
})
watch(() => props.disabled, (disabled) => {
if (disabled) {
isOpen.value = false
}
})
watch(
() => props.modelValue,
(value) => {
inputValue.value = value
},
{ immediate: true }
)
onMounted(() => {
document.addEventListener('click', onDocumentClick)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onDocumentClick)
window.removeEventListener('resize', onWindowChange)
window.removeEventListener('scroll', onWindowChange, true)
})
</script>

View File

@@ -21,6 +21,7 @@ export type ApiClient = {
export type ApiFetchOptions<ResponseType extends 'json' | 'blob'> =
FetchOptions<ResponseType> & {
toast?: boolean
toastOn401?: boolean
toastTitle?: string
toastErrorMessage?: string
toastSuccessMessage?: string
@@ -102,9 +103,31 @@ export const useApi = (): ApiClient => {
}
},
async onResponseError({ response, error, options }) {
const apiOptions = options as ApiFetchOptions<'json'>
if (response?.status === 401) {
const requestUrl = typeof options?.url === 'string' ? options.url : ''
if (!requestUrl.includes('/login_check') && !requestUrl.includes('/logout')) {
const isLoginCheck = requestUrl.includes('/login_check')
const isLogout = requestUrl.includes('/logout')
const shouldToast401 = apiOptions?.toastOn401 === true && apiOptions?.toast !== false
if (shouldToast401) {
const errorKey = apiOptions?.toastErrorKey
const errorMessage =
errorKey ? (te(errorKey) ? t(errorKey) : errorKey) : ''
const extractedMessage = extractErrorMessage(error, response?._data)
const message =
apiOptions?.toastErrorMessage ||
errorMessage ||
extractedMessage ||
'Une erreur est survenue.'
toast.error({
title: apiOptions?.toastTitle ?? 'Erreur',
message
})
}
if (!isLoginCheck && !isLogout) {
if (!isHandlingUnauthorized) {
isHandlingUnauthorized = true
auth.clearSession()
@@ -115,10 +138,10 @@ export const useApi = (): ApiClient => {
isHandlingUnauthorized = false
}
}
return
}
const apiOptions = options as ApiFetchOptions<'json'>
if (apiOptions?.toast === false) {
return
}

View File

@@ -0,0 +1,344 @@
import type { Ref } from 'vue'
import type { Contract } from '~/services/dto/contract'
import type { ContractHistoryItem, Employee } from '~/services/dto/employee'
import { listContracts } from '~/services/contracts'
import { updateEmployee } from '~/services/employees'
import { createSuspension, updateSuspension } from '~/services/contractSuspensions'
import { formatNullableYmdToFr, getTodayYmd, shiftYmd } from '~/utils/date'
import { contractNatureLabel, isContractNature, requiresContractEndDate, showsContractEndDate } from '~/utils/contract'
type SuspensionForm = {
id: number | null
startDate: string
endDate: string
comment: string
}
export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
const toast = useToast()
const contracts = ref<Contract[]>([])
const isContractDrawerOpen = ref(false)
const isContractSubmitting = ref(false)
const isCreateContractDrawerOpen = ref(false)
const isCreateContractSubmitting = ref(false)
const suspensionForms = ref<SuspensionForm[]>([])
const isSuspensionSubmitting = ref(false)
const contractForm = reactive({
contractId: '' as number | '',
contractName: '',
weeklyHours: null as number | null,
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
startDate: '',
endDate: '',
paidLeaveSettled: false,
comment: ''
})
const validationTouched = reactive({
endDate: false
})
const createContractForm = reactive({
contractId: '' as number | '',
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
startDate: '',
endDate: ''
})
const createValidationTouched = reactive({
contractId: false,
contractNature: false,
startDate: false,
endDate: false
})
const contractHistory = computed(() => employee.value?.contractHistory ?? [])
const formatDate = (value?: string | null) => formatNullableYmdToFr(value)
const contractHistoryLabel = (item: ContractHistoryItem) => {
if (item.weeklyHours !== null && item.weeklyHours !== undefined) {
return `${item.weeklyHours} heures`
}
return item.contractName ?? '-'
}
const currentActiveContractPeriod = computed(() => {
const today = getTodayYmd()
const history = employee.value?.contractHistory ?? []
return history.find((item) => item.startDate <= today && (!item.endDate || item.endDate >= today)) ?? null
})
const currentActiveContractPeriodId = computed<number | null>(() => {
const period = currentActiveContractPeriod.value
return period?.periodId ?? null
})
const canCloseCurrentContract = computed(() => {
const active = currentActiveContractPeriod.value
if (!active) return false
if (!active.endDate) return true
return active.endDate > getTodayYmd()
})
const canCreateContract = computed(() => {
const active = currentActiveContractPeriod.value
if (!active) return true
return !!active.endDate
})
const isContractEndDateValid = computed(() => contractForm.endDate !== '')
const showContractEndDateError = computed(() => validationTouched.endDate && !isContractEndDateValid.value)
const showsCreateContractEndDate = computed(() => showsContractEndDate(createContractForm.contractNature))
const requiresCreateContractEndDate = computed(() => requiresContractEndDate(createContractForm.contractNature))
const isCreateContractValid = computed(() => createContractForm.contractId !== '')
const isCreateContractNatureValid = computed(() => isContractNature(createContractForm.contractNature))
const isCreateContractStartDateValid = computed(() => createContractForm.startDate !== '')
const isCreateContractEndDateValid = computed(() => !requiresCreateContractEndDate.value || createContractForm.endDate !== '')
const isCreateContractFormValid = computed(() =>
isCreateContractValid.value &&
isCreateContractNatureValid.value &&
isCreateContractStartDateValid.value &&
isCreateContractEndDateValid.value
)
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
const readonlyFieldClass = `${baseInputClass} border-neutral-300 bg-neutral-100 text-neutral-700`
const contractEndDateFieldClass = computed(() => showContractEndDateError.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
const baseSelectClass = 'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
const createContractFieldClass = computed(() => createValidationTouched.contractId && !isCreateContractValid.value ? `${baseSelectClass} border-red-500` : `${baseSelectClass} border-neutral-300`)
const createContractNatureFieldClass = computed(() => createValidationTouched.contractNature && !isCreateContractNatureValid.value ? `${baseSelectClass} border-red-500` : `${baseSelectClass} border-neutral-300`)
const createContractStartDateFieldClass = computed(() => createValidationTouched.startDate && !isCreateContractStartDateValid.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
const createContractEndDateFieldClass = computed(() => createValidationTouched.endDate && !isCreateContractEndDateValid.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`)
const closeContractWorkedHoursLabel = computed(() => {
if (contractForm.weeklyHours !== null && contractForm.weeklyHours !== undefined) return `${contractForm.weeklyHours} heures`
return contractForm.contractName || '-'
})
const resetContractValidation = () => {
validationTouched.endDate = false
}
const hydrateSuspensionForms = () => {
const current = employee.value?.currentSuspensions ?? []
suspensionForms.value = current.map(s => ({
id: s.id,
startDate: s.startDate,
endDate: s.endDate ?? '',
comment: s.comment ?? ''
}))
}
const hydrateContractFormFromCurrent = () => {
const current = employee.value
const active = currentActiveContractPeriod.value
if (!current || !active) return
contractForm.contractId = active.contractId ?? current.contract?.id ?? ''
contractForm.contractName = active.contractName ?? current.contract?.name ?? ''
contractForm.weeklyHours = active.weeklyHours ?? current.contract?.weeklyHours ?? null
contractForm.contractNature = active.contractNature
contractForm.startDate = active.startDate
contractForm.endDate = getTodayYmd()
contractForm.paidLeaveSettled = false
contractForm.comment = ''
}
const openCloseContractDrawer = () => {
if (!employee.value || !canCloseCurrentContract.value) return
hydrateContractFormFromCurrent()
resetContractValidation()
hydrateSuspensionForms()
isContractDrawerOpen.value = true
}
const setContractDrawerOpen = (open: boolean) => {
isContractDrawerOpen.value = open
}
const resetCreateValidation = () => {
createValidationTouched.contractId = false
createValidationTouched.contractNature = false
createValidationTouched.startDate = false
createValidationTouched.endDate = false
}
const openCreateContractDrawer = () => {
if (!employee.value || !canCreateContract.value) return
createContractForm.contractId = ''
createContractForm.contractNature = 'CDI'
createContractForm.endDate = ''
createContractForm.startDate = currentActiveContractPeriod.value?.endDate
? (shiftYmd(currentActiveContractPeriod.value.endDate, 1) ?? currentActiveContractPeriod.value.endDate)
: getTodayYmd()
resetCreateValidation()
isCreateContractDrawerOpen.value = true
}
const setCreateContractDrawerOpen = (open: boolean) => {
isCreateContractDrawerOpen.value = open
}
const submitContractUpdate = async () => {
if (!employee.value || isContractSubmitting.value || !currentActiveContractPeriod.value) return
validationTouched.endDate = true
if (!isContractEndDateValid.value) return
if (contractForm.endDate < currentActiveContractPeriod.value.startDate) {
toast.error({
title: 'Erreur',
message: `La date de fin doit être postérieure au ${formatDate(currentActiveContractPeriod.value.startDate)}.`
})
return
}
isContractSubmitting.value = true
try {
await updateEmployee(employee.value.id, {
firstName: employee.value.firstName,
lastName: employee.value.lastName,
siteId: employee.value.site?.id ?? null,
contractId: Number(contractForm.contractId),
contractEndDate: contractForm.endDate || null,
contractPaidLeaveSettled: contractForm.paidLeaveSettled,
contractComment: contractForm.comment || null
})
isContractDrawerOpen.value = false
await reloadEmployee()
} finally {
isContractSubmitting.value = false
}
}
const submitCreateContract = async () => {
if (!employee.value || isCreateContractSubmitting.value) return
createValidationTouched.contractId = true
createValidationTouched.contractNature = true
createValidationTouched.startDate = true
createValidationTouched.endDate = true
if (!isCreateContractFormValid.value) return
if (currentActiveContractPeriod.value?.endDate) {
const minStartDate = shiftYmd(currentActiveContractPeriod.value.endDate, 1) ?? currentActiveContractPeriod.value.endDate
if (createContractForm.startDate < minStartDate) {
toast.error({
title: 'Erreur',
message: `La date de début doit être au moins le ${formatDate(minStartDate)}.`
})
return
}
}
isCreateContractSubmitting.value = true
try {
await updateEmployee(employee.value.id, {
firstName: employee.value.firstName,
lastName: employee.value.lastName,
siteId: employee.value.site?.id ?? null,
contractId: Number(createContractForm.contractId),
contractNature: createContractForm.contractNature,
contractStartDate: createContractForm.startDate,
contractEndDate: createContractForm.endDate || null
})
isCreateContractDrawerOpen.value = false
await reloadEmployee()
} finally {
isCreateContractSubmitting.value = false
}
}
const submitSuspension = async (index: number) => {
const form = suspensionForms.value[index]
if (!form || !form.startDate) return
const periodId = currentActiveContractPeriodId.value
if (!periodId) return
isSuspensionSubmitting.value = true
try {
if (form.id) {
await updateSuspension(form.id, {
startDate: form.startDate,
endDate: form.endDate || null,
comment: form.comment || null
})
} else {
await createSuspension({
contractPeriodId: periodId,
startDate: form.startDate,
endDate: form.endDate || null,
comment: form.comment || null
})
}
await reloadEmployee()
hydrateSuspensionForms()
} finally {
isSuspensionSubmitting.value = false
}
}
const addSuspensionForm = () => {
suspensionForms.value.push({
id: null,
startDate: '',
endDate: '',
comment: ''
})
}
const loadContracts = async () => {
contracts.value = await listContracts()
}
watch(showsCreateContractEndDate, (shows) => {
if (!shows) {
createContractForm.endDate = ''
}
})
return {
contracts,
contractHistory,
contractForm,
createContractForm,
isContractDrawerOpen,
isContractSubmitting,
isCreateContractDrawerOpen,
isCreateContractSubmitting,
canCloseCurrentContract,
canCreateContract,
readonlyFieldClass,
closeContractWorkedHoursLabel,
contractEndDateFieldClass,
showContractEndDateError,
isContractEndDateValid,
createContractNatureFieldClass,
createContractFieldClass,
createContractStartDateFieldClass,
showsCreateContractEndDate,
requiresCreateContractEndDate,
createContractEndDateFieldClass,
isCreateContractFormValid,
contractNatureLabel,
contractHistoryLabel,
formatDate,
openCloseContractDrawer,
openCreateContractDrawer,
setContractDrawerOpen,
setCreateContractDrawerOpen,
submitContractUpdate,
submitCreateContract,
suspensionForms,
isSuspensionSubmitting,
submitSuspension,
addSuspensionForm,
currentActiveContractPeriodId,
loadContracts
}
}

View File

@@ -0,0 +1,80 @@
import type { Employee } from '~/services/dto/employee'
import { CONTRACT_TYPES } from '~/services/dto/contract'
import { getEmployee } from '~/services/employees'
export const useEmployeeDetailPage = () => {
const route = useRoute()
const employee = ref<Employee | null>(null)
const isLoading = ref(false)
const activeTab = ref<'contract' | 'leave' | 'rtt'>('contract')
const showLeaveTab = computed(() => employee.value?.currentContractNature !== 'INTERIM')
const showRttTab = computed(() => employee.value?.contract?.type !== CONTRACT_TYPES.FORFAIT)
const employeeContractWorkLabel = computed(() => {
const contract = employee.value?.contract
if (!contract) return '-'
if (contract.type === CONTRACT_TYPES.FORFAIT) return 'Forfait'
if (contract.weeklyHours !== null && contract.weeklyHours !== undefined) return `${contract.weeklyHours} heures`
return contract.name || '-'
})
const loadEmployee = async () => {
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
const employeeId = Number(idParam)
if (!Number.isInteger(employeeId) || employeeId <= 0) {
return
}
isLoading.value = true
try {
employee.value = await getEmployee(employeeId)
if (!showLeaveTab.value && activeTab.value === 'leave') {
activeTab.value = 'contract'
}
if (!showRttTab.value && activeTab.value === 'rtt') {
activeTab.value = 'contract'
}
leave.resetLoaded()
rtt.resetLoaded()
if (activeTab.value === 'leave' && showLeaveTab.value) {
await leave.loadLeaveData()
} else if (activeTab.value === 'rtt' && showRttTab.value) {
await rtt.loadRttData()
}
} finally {
isLoading.value = false
}
}
const contract = useEmployeeContract(employee, loadEmployee)
const leave = useEmployeeLeave(employee, loadEmployee)
const rtt = useEmployeeRtt(employee, loadEmployee)
watch(activeTab, (tab) => {
if (tab === 'leave' && !leave.leaveDataLoaded.value && showLeaveTab.value) {
leave.loadLeaveData()
} else if (tab === 'rtt' && !rtt.rttDataLoaded.value && showRttTab.value) {
rtt.loadRttData()
}
})
onMounted(async () => {
await contract.loadContracts()
await loadEmployee()
})
return {
employee,
isLoading,
activeTab,
showLeaveTab,
showRttTab,
employeeContractWorkLabel,
...contract,
...leave,
...rtt
}
}

View File

@@ -0,0 +1,70 @@
import type { Ref } from 'vue'
import type { Absence } from '~/services/dto/absence'
import type { EmployeeLeaveSummary } from '~/services/dto/employee-leave-summary'
import type { Employee } from '~/services/dto/employee'
import { CONTRACT_TYPES } from '~/services/dto/contract'
import { listAbsences } from '~/services/absences'
import { getEmployeeLeaveSummary, updateFractionedDays } from '~/services/employee-leave-summary'
import { listPublicHolidays } from '~/services/public-holidays'
export const useEmployeeLeave = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
const employeeAbsences = ref<Absence[]>([])
const leaveSummary = ref<EmployeeLeaveSummary | null>(null)
const publicHolidays = ref<Record<string, string>>({})
const isLeaveLoading = ref(false)
const leaveDataLoaded = ref(false)
const getLeaveYear = () => {
const now = new Date()
const isForfait = employee.value?.contract?.type === CONTRACT_TYPES.FORFAIT
return isForfait
? now.getFullYear()
: (now.getMonth() >= 5 ? now.getFullYear() + 1 : now.getFullYear())
}
const loadLeaveData = async () => {
if (!employee.value || isLeaveLoading.value) return
isLeaveLoading.value = true
try {
const isForfait = employee.value.contract?.type === CONTRACT_TYPES.FORFAIT
const leaveYear = getLeaveYear()
const from = isForfait ? `${leaveYear}-01-01` : `${leaveYear - 1}-06-01`
const to = isForfait ? `${leaveYear}-12-31` : `${leaveYear}-05-31`
const holidayYears = isForfait ? [leaveYear] : [leaveYear - 1, leaveYear]
const [absences, summary, ...holidayResults] = await Promise.all([
listAbsences({ from, to, employeeId: employee.value.id }),
getEmployeeLeaveSummary(employee.value.id, leaveYear),
...holidayYears.map((y) => listPublicHolidays('metropole', y))
])
employeeAbsences.value = absences
leaveSummary.value = summary
publicHolidays.value = Object.assign({}, ...holidayResults)
leaveDataLoaded.value = true
} finally {
isLeaveLoading.value = false
}
}
const resetLoaded = () => {
leaveDataLoaded.value = false
}
const submitFractionedDays = async (days: number) => {
if (!employee.value) return
const year = leaveSummary.value?.year ?? undefined
await updateFractionedDays(employee.value.id, days, year)
await reloadEmployee()
}
return {
employeeAbsences,
leaveSummary,
publicHolidays,
isLeaveLoading,
leaveDataLoaded,
loadLeaveData,
resetLoaded,
submitFractionedDays
}
}

View File

@@ -0,0 +1,42 @@
import type { Ref } from 'vue'
import type { EmployeeRttSummary } from '~/services/dto/employee-rtt-summary'
import type { Employee } from '~/services/dto/employee'
import { getEmployeeRttSummary, createRttPayment } from '~/services/employee-rtt-summary'
export const useEmployeeRtt = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
const rttSummary = ref<EmployeeRttSummary | null>(null)
const isRttLoading = ref(false)
const rttDataLoaded = ref(false)
const loadRttData = async () => {
if (!employee.value || isRttLoading.value) return
isRttLoading.value = true
try {
const rttYear = new Date().getMonth() >= 5 ? new Date().getFullYear() + 1 : new Date().getFullYear()
rttSummary.value = await getEmployeeRttSummary(employee.value.id, rttYear)
rttDataLoaded.value = true
} finally {
isRttLoading.value = false
}
}
const resetLoaded = () => {
rttDataLoaded.value = false
}
const submitRttPayment = async (month: number, base25Minutes: number, bonus25Minutes: number, base50Minutes: number, bonus50Minutes: number) => {
if (!employee.value) return
const year = rttSummary.value?.year ?? undefined
await createRttPayment(employee.value.id, month, base25Minutes, bonus25Minutes, base50Minutes, bonus50Minutes, year)
await reloadEmployee()
}
return {
rttSummary,
isRttLoading,
rttDataLoaded,
loadRttData,
resetLoaded,
submitRttPayment
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
<template>
<div class="min-h-screen bg-tertiary-500 from-tertiary-50 via-white to-neutral-100 text-neutral-900">
<div class="min-h-screen bg-tertiary-500 from-tertiary-500 via-white to-neutral-100 text-neutral-900">
<main class="mx-auto flex min-h-screen w-full max-w-[720px] items-center px-6 py-12">
<slot />
</main>

View File

@@ -2,71 +2,87 @@
<div class="h-screen overflow-hidden">
<div class="flex h-full">
<aside class="flex h-full w-64 flex-shrink-0 flex-col border-r border-neutral-200 bg-tertiary-500">
<div>
<div class="h-[75px]">
<img src="/malio.png" alt="Logo" class="w-auto"/>
</div>
<nav class="flex-1 px-4 pb-6">
<template v-if="isAdmin">
<NuxtLink
to="/"
class="flex items-center gap-3 px-4 pb-3 pt-6 text-md font-semibold text-neutral-700 hover:bg-primary-50 hover:text-primary-600 border-t border-secondary-500"
active-class="bg-primary-50 text-primary-600"
>
Tableau de bord
</NuxtLink>
<NuxtLink
to="/calendar"
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-primary-50 hover:text-primary-600"
active-class="bg-primary-50 text-primary-600"
class="flex items-center gap-2 pb-2 pt-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500 border-t border-secondary-500"
:class="route.path.startsWith('/calendar')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
>
Calendrier
<Icon name="mdi:calendar-blank" size="24"/>
<p>Calendrier</p>
</NuxtLink>
</template>
<NuxtLink
to="/hours"
class="flex items-center gap-2 py-2 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
:class="route.path.startsWith('/hours')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
>
<Icon name="mdi:clock-time-four-outline" size="24"/>
<p>Heures</p>
</NuxtLink>
<template v-if="isAdmin">
<NuxtLink
to="/employees"
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-primary-50 hover:text-primary-600"
active-class="bg-primary-50 text-primary-600"
class="flex items-center gap-2 py-2 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
:class="route.path.startsWith('/employees')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
>
Employés
<Icon name="mdi:account-group-outline" size="24"/>
<p>Employés</p>
</NuxtLink>
<NuxtLink
to="/sites"
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-primary-50 hover:text-primary-600"
active-class="bg-primary-50 text-primary-600"
class="flex items-center gap-2 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
:class="route.path.startsWith('/sites')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
>
Sites
<Icon name="mdi:business" size="24"/>
<p>Sites</p>
</NuxtLink>
<NuxtLink
to="/absence-types"
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-primary-50 hover:text-primary-600"
active-class="bg-primary-50 text-primary-600"
class="flex items-center gap-2 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
:class="route.path.startsWith('/absence-types')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
>
Types d'absence
<Icon name="mdi:umbrella-beach-outline" size="24"/>
<p>Types d'absence</p>
</NuxtLink>
<NuxtLink
to="/users"
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-primary-50 hover:text-primary-600"
active-class="bg-primary-50 text-primary-600"
class="flex items-center gap-3 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
:class="route.path.startsWith('/users')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
>
Utilisateurs
<Icon name="mdi:account-outline" size="24"/>
<p>Utilisateurs</p>
</NuxtLink>
</template>
</nav>
<div class="flex flex-col gap-2 items-center p-4">
<button
type="button"
class="w-full rounded-lg px-4 py-2 text-md font-semibold text-white bg-primary-500"
@click="handleLogout"
>
Déconnexion
</button>
<p class="font-bold">v{{ version }}</p>
</div>
</aside>
<main class="h-full flex-1 overflow-y-auto px-8 py-8">
<slot/>
</main>
<div class="h-full flex-1 overflow-hidden flex flex-col">
<AppTopNav :user="auth.user" />
<main class="flex-1 overflow-y-auto px-8 py-12">
<slot/>
</main>
</div>
</div>
</div>
</template>
@@ -75,9 +91,5 @@
const auth = useAuthStore()
const {version} = useAppVersion()
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
const handleLogout = async () => {
await auth.logout()
await navigateTo('/login')
}
const route = useRoute()
</script>

View File

@@ -3,13 +3,16 @@ export default defineNuxtConfig({
devtools: {enabled: false},
ssr: false,
app: {
baseURL: process.env.NUXT_PUBLIC_APP_BASE || '/'
baseURL: process.env.NODE_ENV === 'production'
? (process.env.NUXT_PUBLIC_APP_BASE || '/')
: '/'
},
modules: [
'@nuxtjs/tailwindcss',
'@pinia/nuxt',
'nuxt-toast',
'@nuxtjs/i18n'
'@nuxtjs/tailwindcss',
'@pinia/nuxt',
'nuxt-toast',
'@nuxtjs/i18n',
'@nuxt/icon'
],
runtimeConfig: {
public: {
@@ -19,7 +22,7 @@ export default defineNuxtConfig({
devServer: {port: 3001},
toast: {
settings: {
timeout: 10000,
timeout: 2000,
closeOnClick: true,
progressBar: false
}

View File

@@ -7,6 +7,7 @@
"name": "frontend",
"hasInstallScript": true,
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.1",
"@pinia/nuxt": "^0.11.3",
"nuxt": "^4.3.0",
@@ -32,6 +33,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@antfu/install-pkg": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz",
"integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==",
"license": "MIT",
"dependencies": {
"package-manager-detector": "^1.3.0",
"tinyexec": "^1.0.1"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -1220,6 +1234,47 @@
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@iconify/collections": {
"version": "1.0.651",
"resolved": "https://registry.npmjs.org/@iconify/collections/-/collections-1.0.651.tgz",
"integrity": "sha512-ALGlYxNVOIylxNHjFaylqPTzgNaMHeoFA8ao/piPHjYGD526xEp847F7KePy9jvOLChy2bzQVwAV9Em3HiicjQ==",
"license": "MIT",
"dependencies": {
"@iconify/types": "*"
}
},
"node_modules/@iconify/types": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
"license": "MIT"
},
"node_modules/@iconify/utils": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.0.tgz",
"integrity": "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==",
"license": "MIT",
"dependencies": {
"@antfu/install-pkg": "^1.1.0",
"@iconify/types": "^2.0.0",
"mlly": "^1.8.0"
}
},
"node_modules/@iconify/vue": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@iconify/vue/-/vue-5.0.0.tgz",
"integrity": "sha512-C+KuEWIF5nSBrobFJhT//JS87OZ++QDORB6f2q2Wm6fl2mueSTpFBeBsveK0KW9hWiZ4mNiPjsh6Zs4jjdROSg==",
"license": "MIT",
"dependencies": {
"@iconify/types": "^2.0.0"
},
"funding": {
"url": "https://github.com/sponsors/cyberalien"
},
"peerDependencies": {
"vue": ">=3"
}
},
"node_modules/@intlify/bundle-utils": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@intlify/bundle-utils/-/bundle-utils-11.0.3.tgz",
@@ -2372,6 +2427,28 @@
"devtools-wizard": "cli.mjs"
}
},
"node_modules/@nuxt/icon": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/@nuxt/icon/-/icon-2.2.1.tgz",
"integrity": "sha512-GI840yYGuvHI0BGDQ63d6rAxGzG96jQcWrnaWIQKlyQo/7sx9PjXkSHckXUXyX1MCr9zY6U25Td6OatfY6Hklw==",
"license": "MIT",
"dependencies": {
"@iconify/collections": "^1.0.641",
"@iconify/types": "^2.0.0",
"@iconify/utils": "^3.1.0",
"@iconify/vue": "^5.0.0",
"@nuxt/devtools-kit": "^3.1.1",
"@nuxt/kit": "^4.2.2",
"consola": "^3.4.2",
"local-pkg": "^1.1.2",
"mlly": "^1.8.0",
"ohash": "^2.0.11",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^3.10.0",
"tinyglobby": "^0.2.15"
}
},
"node_modules/@nuxt/kit": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-4.3.0.tgz",

View File

@@ -11,6 +11,7 @@
"build:dist": "nuxt generate && rm -rf dist && cp -R .output/public dist"
},
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.1",
"@pinia/nuxt": "^0.11.3",
"nuxt": "^4.3.0",

View File

@@ -19,10 +19,11 @@
</div>
<div v-else class="overflow-hidden rounded-lg border border-neutral-200 bg-white">
<div class="grid grid-cols-[120px_120px_1fr_200px] gap-4 border-b border-neutral-200 bg-tertiary-500 px-6 py-3 text-md font-semibold text-neutral-700">
<div class="grid grid-cols-[120px_160px_1fr_220px_200px] gap-4 border-b border-neutral-200 bg-tertiary-500 px-6 py-3 text-md font-semibold text-neutral-700">
<span class="text-left">Code</span>
<span class="text-left">Libellé</span>
<span class="text-left">Couleur</span>
<span class="text-left">Compte en heures</span>
<span class="text-right">Actions</span>
</div>
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
@@ -32,7 +33,7 @@
<div
v-for="type in absenceTypes"
:key="type.id"
class="grid grid-cols-[120px_120px_1fr_200px] items-center gap-4 border-b border-neutral-100 px-6 py-3 text-md text-neutral-800 last:border-b-0"
class="grid grid-cols-[120px_160px_1fr_220px_200px] items-center gap-4 border-b border-neutral-100 px-6 py-3 text-md text-neutral-800 last:border-b-0"
>
<span class="font-semibold text-left">{{ type.code }}</span>
<span class="text-left">{{ type.label }}</span>
@@ -43,6 +44,14 @@
/>
<span class="text-md uppercase text-neutral-500">{{ type.color }}</span>
</div>
<div class="text-left">
<span
class="inline-flex rounded-md px-2 py-1 text-sm font-semibold"
:class="type.countAsWorkedHours ? 'bg-emerald-100 text-emerald-700' : 'bg-neutral-100 text-neutral-700'"
>
{{ type.countAsWorkedHours ? 'Oui' : 'Non' }}
</span>
</div>
<div class="flex items-center justify-end gap-2">
<button
type="button"
@@ -94,6 +103,31 @@
Le libellé est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700">
Compté comme travaillé
</label>
<div class="mt-2 flex items-center gap-6">
<label class="inline-flex items-center gap-2 text-md text-neutral-800">
<input
v-model="form.countAsWorkedHours"
type="radio"
class="h-4 w-4"
:value="true"
/>
Oui
</label>
<label class="inline-flex items-center gap-2 text-md text-neutral-800">
<input
v-model="form.countAsWorkedHours"
type="radio"
class="h-4 w-4"
:value="false"
/>
Non
</label>
</div>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="color">
Couleur <span class="text-red-600">*</span>
@@ -136,6 +170,10 @@
import type { AbsenceType } from '~/services/dto/absence-type'
import { createAbsenceType, deleteAbsenceType, listAbsenceTypes, updateAbsenceType } from '~/services/absence-types'
useHead({
title: 'Types d\'absences'
})
const isDrawerOpen = ref(false)
const isSubmitting = ref(false)
const isLoading = ref(false)
@@ -150,7 +188,8 @@ const drawerTitle = computed(() =>
const form = reactive({
code: '',
label: '',
color: '#222783'
color: '#222783',
countAsWorkedHours: true
})
const validationTouched = reactive({
@@ -171,7 +210,7 @@ const showLabelError = computed(() => validationTouched.label && !isLabelValid.v
const showColorError = computed(() => validationTouched.color && !isColorValid.value)
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200'
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
const codeFieldClass = computed(() => {
if (showCodeError.value) {
return `${baseInputClass} border-red-500`
@@ -214,6 +253,7 @@ const resetForm = () => {
form.code = ''
form.label = ''
form.color = '#222783'
form.countAsWorkedHours = true
}
const openCreate = () => {
@@ -227,6 +267,7 @@ const openEdit = (type: AbsenceType) => {
form.code = type.code
form.label = type.label
form.color = type.color
form.countAsWorkedHours = type.countAsWorkedHours
isDrawerOpen.value = true
}
@@ -249,13 +290,15 @@ const handleSubmit = async () => {
await updateAbsenceType(editingType.value.id, {
code: form.code,
label: form.label,
color: form.color
color: form.color,
countAsWorkedHours: form.countAsWorkedHours
})
} else {
await createAbsenceType({
code: form.code,
label: form.label,
color: form.color
color: form.color,
countAsWorkedHours: form.countAsWorkedHours
})
}

View File

@@ -3,38 +3,10 @@
<div class="flex flex-wrap items-center justify-between gap-4">
<h1 class="text-4xl font-bold text-primary-500">Calendrier des absences</h1>
</div>
<div class="py-6">
<div class="flex flex-col gap-3 py-6">
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-4">
<div class="flex flex-wrap items-center gap-4 rounded-md border border-neutral-300 px-3 py-2">
<div v-for="site in sites" :key="site.id" class="flex items-center gap-2">
<div :style="{ backgroundColor: site.color }" class="h-4 w-4 rounded"></div>
<label class="text-md" :for="`site-${site.id}`">{{ site.name }}</label>
<input
:id="`site-${site.id}`"
v-model="selectedSiteIds"
:value="site.id"
type="checkbox"
class="h-4 w-4"
/>
</div>
</div>
<select
v-model="selectedMonth"
class="h-10 rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
>
<option v-for="month in months" :key="month.value" :value="month.value">
{{ month.label }}
</option>
</select>
<select
v-model="selectedYear"
class="h-10 rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
>
<option v-for="year in years" :key="year" :value="year">
{{ year }}
</option>
</select>
<SiteFilterSelector v-model="selectedSiteIds" :sites="sites"/>
</div>
<div class="flex gap-4">
<button
@@ -53,24 +25,31 @@
</button>
</div>
</div>
<div class="flex justify-between mt-3">
<div class="flex items-center gap-4 w-80">
<input
v-model="employeeFilter"
type="text"
placeholder="Chercher un employé (nom ou prénom)"
class="h-10 w-full max-w-md rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
<div class="flex justify-between">
<div class="flex items-center gap-4">
<div class="w-80">
<EmployeeNameFilterInput v-model="employeeFilter"/>
</div>
<PeriodStepperPicker
width-class="w-[260px]"
:label="selectedMonthLabel"
picker-type="month"
:picker-value="monthPickerValue"
prev-aria-label="Mois précédent"
next-aria-label="Mois suivant"
@prev="shiftMonth(-1)"
@next="shiftMonth(1)"
@pick="onMonthPickerValue"
/>
</div>
<div class="flex flex-wrap items-center gap-4 rounded-md border border-neutral-300 px-3 py-2">
<p class="font-bold">Légende :</p>
<div v-for="type in absenceTypes" :key="type.id" class="flex items-center gap-2">
<div :style="{ backgroundColor: type.color }" class="h-4 w-4 rounded"></div>
<p>{{ type.label }}</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-6 py-2">
<p class="font-bold">Légende :</p>
<div v-for="type in absenceTypes" :key="type.id" class="flex items-center gap-2">
<div :style="{ backgroundColor: type.color }" class="h-4 w-4 rounded"></div>
<p>{{ type.label }}</p>
</div>
</div>
</div>
<div class="flex-1 min-h-0">
@@ -102,6 +81,8 @@
<AbsencePrintDrawer
v-model="isPrintOpen"
:sites="sites"
:contract-natures="contractNatureOptions"
:work-contracts="workContractOptions"
:print-form="printForm"
@submit="handlePrint"
@cancel="closePrint"
@@ -119,10 +100,18 @@ import {listEmployees, updateEmployeeOrder} from '~/services/employees'
import {listAbsenceTypes} from '~/services/absence-types'
import {createAbsence, deleteAbsence, listAbsences, updateAbsence} from '~/services/absences'
import {listPublicHolidays} from '~/services/public-holidays'
import {getDaysInMonth, normalizeDate, toYmd} from '~/utils/date'
import {getDaysInMonth, normalizeDate, parseYmd, toYmd} from '~/utils/date'
import {compareEmployeesInSite, sortEmployeesBySiteAndOrder} from '~/utils/employee'
import CalendarGrid from '~/components/CalendarGrid.vue'
import AbsenceFormDrawer from '~/components/AbsenceFormDrawer.vue'
import AbsencePrintDrawer from '~/components/AbsencePrintDrawer.vue'
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
import PeriodStepperPicker from '~/components/PeriodStepperPicker.vue'
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
useHead({
title: 'Calendrier'
})
// Données principales affichées dans la grille.
const employees = ref<Employee[]>([])
@@ -149,27 +138,11 @@ watch(sites, (next) => {
if (sitesInitialized.value || next.length === 0) return
selectedSiteIds.value = next.map((site) => site.id)
sitesInitialized.value = true
}, { immediate: true })
}, {immediate: true})
// Tri stable: site -> nom -> prénom.
const sortedEmployees = computed(() => {
return [...employees.value].sort((employeeA, employeeB) => {
const siteOrderA = employeeA.site?.displayOrder ?? 0
const siteOrderB = employeeB.site?.displayOrder ?? 0
if (siteOrderA !== siteOrderB) return siteOrderA - siteOrderB
const siteNameA = employeeA.site?.name ?? ''
const siteNameB = employeeB.site?.name ?? ''
if (siteNameA !== siteNameB) return siteNameA.localeCompare(siteNameB, 'fr')
const orderA = employeeA.displayOrder ?? 0
const orderB = employeeB.displayOrder ?? 0
if (orderA !== orderB) return orderA - orderB
const lastNameA = employeeA.lastName ?? ''
const lastNameB = employeeB.lastName ?? ''
if (lastNameA !== lastNameB) return lastNameA.localeCompare(lastNameB, 'fr')
const firstNameA = employeeA.firstName ?? ''
const firstNameB = employeeB.firstName ?? ''
return firstNameA.localeCompare(firstNameB, 'fr')
})
return sortEmployeesBySiteAndOrder(employees.value)
})
// Employés visibles selon le filtre de sites.
@@ -218,8 +191,8 @@ const months = [
{value: 11, label: 'Décembre'}
]
const years = Array.from({length: 5}, (unusedValue, index) => now.getFullYear() - 2 + index)
const selectedMonthLabel = computed(() => `${months[selectedMonth.value]?.label ?? ''}`)
const monthPickerValue = computed(() => `${selectedYear.value}-${String(selectedMonth.value + 1).padStart(2, '0')}`)
// Infos de calendrier calculées.
const daysInMonth = computed(() => getDaysInMonth(selectedYear.value, selectedMonth.value))
@@ -246,7 +219,25 @@ const form = reactive({
const printForm = reactive({
from: '',
to: '',
siteIds: [] as number[]
siteIds: [] as number[],
contractNatures: [] as Array<'CDI' | 'CDD' | 'INTERIM'>,
workContractIds: [] as number[]
})
const contractNatureOptions = [
{ value: 'CDI' as const, label: 'CDI' },
{ value: 'CDD' as const, label: 'CDD' },
{ value: 'INTERIM' as const, label: 'Intérim' }
]
const workContractOptions = computed(() => {
const byId = new Map<number, { id: number; name: string }>()
for (const employee of employees.value) {
const contract = employee.contract
if (!contract?.id) continue
byId.set(contract.id, { id: contract.id, name: contract.name })
}
return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name, 'fr'))
})
// Remet le formulaire à zéro.
@@ -274,6 +265,8 @@ const openPrint = () => {
printForm.from = monthStart
printForm.to = monthEnd
printForm.siteIds = [...selectedSiteIds.value]
printForm.contractNatures = contractNatureOptions.map((item) => item.value)
printForm.workContractIds = workContractOptions.value.map((item) => item.id)
isPrintOpen.value = true
}
@@ -281,13 +274,6 @@ const closePrint = () => {
isPrintOpen.value = false
}
// Parse "YYYY-MM-DD" en Date (ou null).
const parseYmd = (value: string) => {
const [year, month, day] = value.split('-').map(Number)
if (!year || !month || !day) return null
return new Date(year, month - 1, day)
}
// Détermine si la journée est une demi-journée (AM/PM) ou complète.
const getHalfForDate = (
startDate: string,
@@ -326,6 +312,22 @@ const addMonths = (date: Date, months: number) => {
return next
}
const shiftMonth = (delta: number) => {
const next = new Date(selectedYear.value, selectedMonth.value + delta, 1)
selectedYear.value = next.getFullYear()
selectedMonth.value = next.getMonth()
}
const onMonthPickerValue = (value: string) => {
if (!value) return
const [yearStr, monthStr] = value.split('-')
const year = Number(yearStr)
const month = Number(monthStr)
if (!Number.isInteger(year) || !Number.isInteger(month) || month < 1 || month > 12) return
selectedYear.value = year
selectedMonth.value = month - 1
}
// Limite l'intervalle d'impression à 2 mois max.
const enforcePrintRange = () => {
if (!printForm.from) return
@@ -576,10 +578,6 @@ const handleSubmit = async () => {
window.alert("La demi-journee de fin ne peut pas etre avant la demi-journee de debut.")
return
}
if (hasHolidayInRange(start, end)) {
window.alert("Impossible de creer une absence sur un jour ferie.")
return
}
const overlaps = absences.value.filter((absence) => {
if (absence.employee?.id !== Number(form.employeeId)) return false
if (editingAbsence.value && absence.id === editingAbsence.value.id) return false
@@ -681,7 +679,7 @@ const formatEmployeeName = (employee: Employee) => {
}
// Impression PDF de l'intervalle sélectionné.
const { printPdf } = usePdfPrinter()
const {printPdf} = usePdfPrinter()
const handlePrint = async () => {
const params = new URLSearchParams()
params.set('from', printForm.from)
@@ -689,6 +687,12 @@ const handlePrint = async () => {
if (printForm.siteIds.length > 0) {
params.set('sites', printForm.siteIds.join(','))
}
if (printForm.contractNatures.length > 0) {
params.set('contractNatures', printForm.contractNatures.join(','))
}
if (printForm.workContractIds.length > 0) {
params.set('workContracts', printForm.workContractIds.join(','))
}
await printPdf(`/absences/print?${params.toString()}`)
isPrintOpen.value = false
}
@@ -703,17 +707,7 @@ const handleReorder = async (payload: { dragId: number; dropId: number }) => {
const siteEmployees = [...employees.value]
.filter((employee) => employee.site?.id === dragSiteId)
.sort((employeeA, employeeB) => {
const orderA = employeeA.displayOrder ?? 0
const orderB = employeeB.displayOrder ?? 0
if (orderA !== orderB) return orderA - orderB
const lastNameA = employeeA.lastName ?? ''
const lastNameB = employeeB.lastName ?? ''
if (lastNameA !== lastNameB) return lastNameA.localeCompare(lastNameB, 'fr')
const firstNameA = employeeA.firstName ?? ''
const firstNameB = employeeB.firstName ?? ''
return firstNameA.localeCompare(firstNameB, 'fr')
})
.sort(compareEmployeesInSite)
const fromIndex = siteEmployees.findIndex((employee) => employee.id === dragEmployee.id)
const toIndex = siteEmployees.findIndex((employee) => employee.id === dropEmployee.id)
@@ -726,7 +720,7 @@ const handleReorder = async (payload: { dragId: number; dropId: number }) => {
siteEmployees.forEach((employee, index) => {
const nextOrder = index + 1
if ((employee.displayOrder ?? 0) !== nextOrder) {
updates.push({ id: employee.id, displayOrder: nextOrder })
updates.push({id: employee.id, displayOrder: nextOrder})
}
employee.displayOrder = nextOrder
})

View File

@@ -1,279 +0,0 @@
<template>
<div>
<div class="flex items-center justify-between pb-12">
<h1 class="text-4xl font-bold text-primary-500">Employés</h1>
<button
type="button"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
@click="isDrawerOpen = true"
>
Ajouter un employé
</button>
</div>
<div
v-if="!isLoading && employees.length === 0"
class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600"
>
Aucun employé pour le moment.
</div>
<div v-else class="max-h-[80vh] overflow-auto rounded-lg border border-neutral-200 bg-white">
<div class="grid grid-cols-[120px_1fr_1fr_200px] gap-4 border-b border-neutral-200 bg-tertiary-500 px-6 py-3 text-md font-semibold text-neutral-700">
<span class="text-left">Prénom</span>
<span class="text-left">Nom</span>
<span class="text-left">Site</span>
<span class="text-right">Actions</span>
</div>
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
Chargement...
</div>
<div v-else>
<div
v-for="employee in employees"
:key="employee.id"
class="grid grid-cols-[120px_1fr_1fr_200px] items-center gap-4 border-b border-neutral-100 px-6 py-3 text-md text-neutral-800 last:border-b-0"
>
<span>{{ employee.firstName }}</span>
<span>{{ employee.lastName }}</span>
<span>{{ employee.site?.name ?? '-' }}</span>
<div class="flex items-center justify-end gap-2">
<button
type="button"
class="rounded-md border border-neutral-200 px-2 py-1 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
@click="openEdit(employee)"
>
Modifier
</button>
<button
type="button"
class="rounded-md border border-red-200 px-2 py-1 text-md font-semibold text-red-600 hover:bg-red-50"
@click="confirmDelete(employee)"
>
Supprimer
</button>
</div>
</div>
</div>
</div>
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle">
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="first-name">
Prénom <span class="text-red-600">*</span>
</label>
<input
id="first-name"
v-model="form.firstName"
type="text"
:class="firstNameFieldClass"
/>
<p v-if="showFirstNameError" class="mt-1 text-sm text-red-600">
Le prénom est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="last-name">
Nom <span class="text-red-600">*</span>
</label>
<input
id="last-name"
v-model="form.lastName"
type="text"
:class="lastNameFieldClass"
/>
<p v-if="showLastNameError" class="mt-1 text-sm text-red-600">
Le nom est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="site">
Site <span class="text-red-600">*</span>
</label>
<select
id="site"
v-model="form.siteId"
:class="siteFieldClass"
>
<option value="">Aucun site</option>
<option v-for="site in sites" :key="site.id" :value="site.id">
{{ site.name }}
</option>
</select>
<p v-if="showSiteError" class="mt-1 text-sm text-red-600">
Le site est obligatoire.
</p>
</div>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
@click="isDrawerOpen = false"
>
Annuler
</button>
<button
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:class="submitButtonClass"
>
Enregistrer
</button>
</div>
</form>
</AppDrawer>
</div>
</template>
<script setup lang="ts">
import type { Employee } from '~/services/dto/employee'
import type { Site } from '~/services/dto/site'
import { createEmployee, deleteEmployee, listEmployees, updateEmployee } from '~/services/employees'
import { listSites } from '~/services/sites'
const isDrawerOpen = ref(false)
const isSubmitting = ref(false)
const isLoading = ref(false)
const editingEmployee = ref<Employee | null>(null)
const drawerTitle = computed(() =>
editingEmployee.value ? 'Modifier un employé' : 'Ajouter un employé'
)
const employees = ref<Employee[]>([])
const sites = ref<Site[]>([])
const form = reactive({
firstName: '',
lastName: '',
siteId: '' as number | ''
})
const validationTouched = reactive({
firstName: false,
lastName: false,
siteId: false
})
const isFirstNameValid = computed(() => form.firstName.trim() !== '')
const isLastNameValid = computed(() => form.lastName.trim() !== '')
const isSiteValid = computed(() => form.siteId !== '')
const isFormValid = computed(
() => isFirstNameValid.value && isLastNameValid.value && isSiteValid.value
)
const showFirstNameError = computed(
() => validationTouched.firstName && !isFirstNameValid.value
)
const showLastNameError = computed(
() => validationTouched.lastName && !isLastNameValid.value
)
const showSiteError = computed(
() => validationTouched.siteId && !isSiteValid.value
)
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200'
const firstNameFieldClass = computed(() => {
if (showFirstNameError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const lastNameFieldClass = computed(() => {
if (showLastNameError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const siteFieldClass = computed(() => {
const baseSelectClass =
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
if (showSiteError.value) {
return `${baseSelectClass} border-red-500`
}
return `${baseSelectClass} border-neutral-300`
})
const submitButtonClass = computed(() => {
if (isSubmitting.value || !isFormValid.value) {
return 'opacity-50 cursor-not-allowed'
}
return ''
})
const loadEmployees = async () => {
isLoading.value = true
try {
employees.value = await listEmployees()
} finally {
isLoading.value = false
}
}
const loadSites = async () => {
sites.value = await listSites()
}
onMounted(async () => {
await Promise.all([loadEmployees(), loadSites()])
})
const handleSubmit = async () => {
if (isSubmitting.value) return
validationTouched.firstName = true
validationTouched.lastName = true
validationTouched.siteId = true
if (!isFormValid.value) return
isSubmitting.value = true
try {
if (editingEmployee.value) {
await updateEmployee(editingEmployee.value.id, {
firstName: form.firstName,
lastName: form.lastName,
siteId: form.siteId === '' ? null : Number(form.siteId)
})
} else {
await createEmployee({
firstName: form.firstName,
lastName: form.lastName,
siteId: form.siteId === '' ? null : Number(form.siteId)
})
}
form.firstName = ''
form.lastName = ''
form.siteId = ''
editingEmployee.value = null
isDrawerOpen.value = false
await loadEmployees()
} finally {
isSubmitting.value = false
}
}
watch(isDrawerOpen, (isOpen) => {
if (!isOpen) {
validationTouched.firstName = false
validationTouched.lastName = false
validationTouched.siteId = false
}
})
const openEdit = (employee: Employee) => {
editingEmployee.value = employee
form.firstName = employee.firstName
form.lastName = employee.lastName
form.siteId = employee.site?.id ?? ''
isDrawerOpen.value = true
}
const confirmDelete = async (employee: Employee) => {
const ok = window.confirm(`Supprimer ${employee.firstName} ${employee.lastName} ?`)
if (!ok) return
await deleteEmployee(employee.id)
await loadEmployees()
}
</script>

View File

@@ -0,0 +1,184 @@
<template>
<div class="h-full overflow-hidden flex flex-col">
<div v-if="isLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Chargement...
</div>
<div v-else-if="!employee"
class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Employé introuvable.
</div>
<div v-else class="flex min-h-0 flex-1 flex-col">
<div class="flex items-center justify-between">
<div>
<h1 class="text-[32px] font-bold">{{ employee.firstName }} {{ employee.lastName }}</h1>
<p>Date d'entrée : {{ employee.entryDate ? employee.entryDate.split('-').reverse().join('/') : '-' }}</p>
</div>
<div class="text-right">
<p class="font-bold text-[22px]">{{ contractNatureLabel(employee.currentContractNature) }} {{ employeeContractWorkLabel }}</p>
<p class="text-[18px]">{{ employee.site?.name ?? '-' }}</p>
</div>
</div>
<div class="mt-[44px] border-b border-primary-500">
<div class="flex justify-center gap-16 text-2xl font-bold">
<button
class="pb-2 border-b-2 flex items-center gap-3"
:class="activeTab === 'contract'
? 'border-primary-500 text-primary-500'
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
@click="activeTab = 'contract'"
>
<Icon name="mdi:file-check-outline" size="24" class="align-self"/>
Suivi contrat
</button>
<button
v-if="showLeaveTab"
class="pb-2 border-b-2 flex items-center gap-3"
:class="activeTab === 'leave'
? 'border-primary-500 text-primary-500'
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
@click="activeTab = 'leave'"
>
<Icon name="mdi:event-blank-outline" size="24" class="align-self"/>
Congé
</button>
<button
v-if="showRttTab"
class="pb-2 border-b-2 flex items-center gap-3"
:class="activeTab === 'rtt'
? 'border-primary-500 text-primary-500'
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
@click="activeTab = 'rtt'"
>
<Icon name="mdi:schedule" size="24" class="align-self"/>
RTT
</button>
</div>
</div>
<div class="min-h-0 flex-1">
<EmployeesContractTab
v-if="activeTab === 'contract'"
class="h-full overflow-y-auto pr-1"
:contract-history="contractHistory"
:contract-nature-label="contractNatureLabel"
:contract-history-label="contractHistoryLabel"
:format-date="formatDate"
:is-contract-submitting="isContractSubmitting"
:can-close-current-contract="canCloseCurrentContract"
:is-create-contract-submitting="isCreateContractSubmitting"
:contracts="contracts"
:can-create-contract="canCreateContract"
:is-contract-drawer-open="isContractDrawerOpen"
:contract-form="contractForm"
:readonly-field-class="readonlyFieldClass"
:close-contract-worked-hours-label="closeContractWorkedHoursLabel"
:contract-end-date-field-class="contractEndDateFieldClass"
:show-contract-end-date-error="showContractEndDateError"
:is-contract-end-date-valid="isContractEndDateValid"
:is-create-contract-drawer-open="isCreateContractDrawerOpen"
:create-contract-form="createContractForm"
:create-contract-nature-field-class="createContractNatureFieldClass"
:create-contract-field-class="createContractFieldClass"
:create-contract-start-date-field-class="createContractStartDateFieldClass"
:shows-create-contract-end-date="showsCreateContractEndDate"
:requires-create-contract-end-date="requiresCreateContractEndDate"
:create-contract-end-date-field-class="createContractEndDateFieldClass"
:is-create-contract-form-valid="isCreateContractFormValid"
:on-open-close-contract-drawer="openCloseContractDrawer"
:on-open-create-contract-drawer="openCreateContractDrawer"
:on-update-contract-drawer-open="setContractDrawerOpen"
:on-update-create-contract-drawer-open="setCreateContractDrawerOpen"
:on-submit-close-contract="submitContractUpdate"
:on-submit-create-contract="submitCreateContract"
:suspension-forms="suspensionForms"
:is-suspension-submitting="isSuspensionSubmitting"
:on-submit-suspension="submitSuspension"
:on-add-suspension-form="addSuspensionForm"
:current-contract-period-id="currentActiveContractPeriodId"
/>
<div v-else-if="showLeaveTab && activeTab === 'leave'" class="h-full">
<div v-if="isLeaveLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Chargement...
</div>
<EmployeesLeaveTab
v-else
class="h-full"
:absences="employeeAbsences"
:summary="leaveSummary"
:public-holidays="publicHolidays"
@update-fractioned-days="submitFractionedDays"
/>
</div>
<div v-else-if="showRttTab && activeTab === 'rtt'" class="h-full">
<div v-if="isRttLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Chargement...
</div>
<EmployeesRttTab v-else class="h-full" :summary="rttSummary" @submit-rtt-payment="submitRttPayment" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const {
employee,
isLoading,
activeTab,
contracts,
employeeAbsences,
leaveSummary,
rttSummary,
publicHolidays,
showLeaveTab,
showRttTab,
contractHistory,
employeeContractWorkLabel,
contractForm,
createContractForm,
isContractDrawerOpen,
isContractSubmitting,
isCreateContractDrawerOpen,
isCreateContractSubmitting,
canCloseCurrentContract,
canCreateContract,
readonlyFieldClass,
closeContractWorkedHoursLabel,
contractEndDateFieldClass,
showContractEndDateError,
isContractEndDateValid,
createContractNatureFieldClass,
createContractFieldClass,
createContractStartDateFieldClass,
showsCreateContractEndDate,
requiresCreateContractEndDate,
createContractEndDateFieldClass,
isCreateContractFormValid,
contractNatureLabel,
contractHistoryLabel,
formatDate,
openCloseContractDrawer,
openCreateContractDrawer,
setContractDrawerOpen,
setCreateContractDrawerOpen,
submitContractUpdate,
submitCreateContract,
submitFractionedDays,
submitRttPayment,
suspensionForms,
isSuspensionSubmitting,
submitSuspension,
addSuspensionForm,
currentActiveContractPeriodId,
isLeaveLoading,
isRttLoading
} = useEmployeeDetailPage()
useHead(() => ({
title: employee.value
? `${employee.value.firstName} ${employee.value.lastName}`
: 'Détail employé'
}))
</script>

View File

@@ -0,0 +1,498 @@
<template>
<div class="flex-col">
<div class="shrink-0">
<div class="flex items-center justify-between">
<h1 class="text-4xl font-bold text-primary-500">Employés</h1>
<button
type="button"
class="flex items-center gap-2 rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
@click="openCreate"
>
+ Ajouter un employé
</button>
</div>
<div class="flex gap-10 py-7">
<div class="w-80">
<EmployeeNameFilterInput v-model="employeeFilter"/>
</div>
<SiteFilterSelector v-if="sites.length > 0" v-model="selectedSiteIds" :sites="sites"/>
</div>
</div>
<div
v-if="!isLoading && filteredEmployees.length === 0"
class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600"
>
Aucun employé pour le moment.
</div>
<div v-else class="grid gap-8 [grid-template-columns:repeat(auto-fill,minmax(260px,1fr))]">
<NuxtLink
v-for="employee in filteredEmployees"
:key="employee.id"
:to="`/employees/${employee.id}`"
target="_blank"
rel="noopener noreferrer"
class="group relative min-h-[328px] overflow-hidden rounded-lg bg-tertiary-500 p-4 transition-all duration-200 hover:shadow-md"
>
<div class="flex flex-col items-center gap-7 transition-opacity duration-200 group-hover:opacity-0">
<div class="rounded-full bg-primary-500 h-[175px] w-[175px] flex justify-center items-center text-white font-bold text-5xl">{{ employee.initials}}</div>
<div class="text-center text-[20px]">
<p class="text-primary-500 font-bold">{{ employee.firstName }} {{ employee.lastName }}</p>
<p>Nom du poste occupé</p>
<p>Site ({{ employee.site?.name ?? '-' }})</p>
</div>
</div>
<div
class="absolute inset-0 flex items-center justify-center bg-primary-500 p-4 text-white opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<div class="w-full rounded-md bg-white/15 p-4 text-sm">
<p class="text-base font-semibold">{{ employee.lastName }} {{ employee.firstName }}</p>
<p><strong>Type:</strong> {{ contractNatureLabel(employee.currentContractNature) }}</p>
<p><strong>Temps de travail:</strong> {{ employee.contract?.name ?? '-' }}</p>
<p><strong>Site:</strong> {{ employee.site?.name ?? '-' }}</p>
<p><strong>Date d'entrée :</strong> {{ employee.entryDate ? employee.entryDate.split('-').reverse().join('/') : '-' }}</p>
</div>
</div>
</NuxtLink>
</div>
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle">
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="first-name">
Prénom <span class="text-red-600">*</span>
</label>
<input
id="first-name"
v-model="form.firstName"
type="text"
:class="firstNameFieldClass"
/>
<p v-if="showFirstNameError" class="mt-1 text-sm text-red-600">
Le prénom est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="last-name">
Nom <span class="text-red-600">*</span>
</label>
<input
id="last-name"
v-model="form.lastName"
type="text"
:class="lastNameFieldClass"
/>
<p v-if="showLastNameError" class="mt-1 text-sm text-red-600">
Le nom est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="site">
Site <span class="text-red-600">*</span>
</label>
<select
id="site"
v-model="form.siteId"
:class="siteFieldClass"
>
<option value="">Aucun site</option>
<option v-for="site in sites" :key="site.id" :value="site.id">
{{ site.name }}
</option>
</select>
<p v-if="showSiteError" class="mt-1 text-sm text-red-600">
Le site est obligatoire.
</p>
</div>
<template v-if="!editingEmployee">
<div>
<label class="text-md font-semibold text-neutral-700" for="contract-nature">
Type de contrat <span class="text-red-600">*</span>
</label>
<select
id="contract-nature"
v-model="form.contractNature"
:class="contractNatureFieldClass"
>
<option value="CDI">CDI</option>
<option value="CDD">CDD</option>
<option value="INTERIM">Intérim</option>
</select>
<p v-if="showContractNatureError" class="mt-1 text-sm text-red-600">
Le type de contrat est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="contract">
Temps de travail <span class="text-red-600">*</span>
</label>
<select
id="contract"
v-model="form.contractId"
:class="contractFieldClass"
>
<option value="">Sélectionner un contrat</option>
<option v-for="contract in contracts" :key="contract.id" :value="contract.id">
{{ contract.name }}
</option>
</select>
<p v-if="showContractError" class="mt-1 text-sm text-red-600">
Le temps de travail est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="contract-start-date">
Début contrat <span class="text-red-600">*</span>
</label>
<input
id="contract-start-date"
v-model="form.contractStartDate"
type="date"
:class="contractStartDateFieldClass"
/>
<p v-if="showContractStartDateError" class="mt-1 text-sm text-red-600">
La date de début est obligatoire.
</p>
</div>
<div v-if="showsContractEndDateComputed">
<label class="text-md font-semibold text-neutral-700" for="contract-end-date">
Fin contrat
<span v-if="requiresContractEndDateComputed" class="text-red-600">*</span>
</label>
<input
id="contract-end-date"
v-model="form.contractEndDate"
type="date"
:class="contractEndDateFieldClass"
/>
<p v-if="showContractEndDateError" class="mt-1 text-sm text-red-600">
La date de fin est obligatoire pour un CDD.
</p>
</div>
</template>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
@click="isDrawerOpen = false"
>
Annuler
</button>
<button
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:class="submitButtonClass"
>
Enregistrer
</button>
</div>
</form>
</AppDrawer>
</div>
</template>
<script setup lang="ts">
import type {Contract} from '~/services/dto/contract'
import type {Employee} from '~/services/dto/employee'
import type {Site} from '~/services/dto/site'
import {listContracts} from '~/services/contracts'
import {createEmployee, deleteEmployee, listEmployees, updateEmployee} from '~/services/employees'
import {listSites} from '~/services/sites'
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
import {contractNatureLabel, isContractNature, requiresContractEndDate, showsContractEndDate} from '~/utils/contract'
useHead({
title: 'Employés'
})
const isDrawerOpen = ref(false)
const isSubmitting = ref(false)
const isLoading = ref(false)
const sitesInitialized = ref(false)
const editingEmployee = ref<Employee | null>(null)
const drawerTitle = computed(() =>
editingEmployee.value ? 'Modifier un employé' : 'Ajouter un employé'
)
const employees = ref<Employee[]>([])
const sites = ref<Site[]>([])
const contracts = ref<Contract[]>([])
const employeeFilter = ref('')
const selectedSiteIds = ref<number[]>([])
const filteredEmployees = computed<Employee[]>(() => {
if (selectedSiteIds.value.length === 0) return []
const filter = employeeFilter.value.trim().toLowerCase()
const bySite = employees.value.filter((employee) => {
const siteId = employee.site?.id
return !!siteId && selectedSiteIds.value.includes(siteId)
})
if (!filter) return bySite
return bySite.filter((employee) => {
const firstName = employee.firstName?.toLowerCase() ?? ''
const lastName = employee.lastName?.toLowerCase() ?? ''
return firstName.includes(filter) || lastName.includes(filter)
})
})
const form = reactive({
firstName: '',
lastName: '',
siteId: '' as number | '',
contractId: '' as number | '',
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
contractStartDate: '',
contractEndDate: ''
})
const validationTouched = reactive({
firstName: false,
lastName: false,
siteId: false,
contractId: false,
contractNature: false,
contractStartDate: false,
contractEndDate: false
})
const isFirstNameValid = computed(() => form.firstName.trim() !== '')
const isLastNameValid = computed(() => form.lastName.trim() !== '')
const isSiteValid = computed(() => form.siteId !== '')
const isContractValid = computed(() => form.contractId !== '')
const isContractNatureValid = computed(() => isContractNature(form.contractNature))
const isContractStartDateValid = computed(() => form.contractStartDate !== '')
const showsContractEndDateComputed = computed(() => showsContractEndDate(form.contractNature))
const requiresContractEndDateComputed = computed(() => requiresContractEndDate(form.contractNature))
const isContractEndDateValid = computed(() => {
if (!requiresContractEndDateComputed.value) return true
return form.contractEndDate !== ''
})
const isFormValid = computed(
() =>
isFirstNameValid.value &&
isLastNameValid.value &&
isSiteValid.value &&
(editingEmployee.value
? true
: (isContractValid.value &&
isContractNatureValid.value &&
isContractStartDateValid.value &&
isContractEndDateValid.value))
)
const showFirstNameError = computed(
() => validationTouched.firstName && !isFirstNameValid.value
)
const showLastNameError = computed(
() => validationTouched.lastName && !isLastNameValid.value
)
const showSiteError = computed(
() => validationTouched.siteId && !isSiteValid.value
)
const showContractError = computed(
() => validationTouched.contractId && !isContractValid.value
)
const showContractNatureError = computed(
() => !editingEmployee.value && validationTouched.contractNature && !isContractNatureValid.value
)
const showContractStartDateError = computed(
() => !editingEmployee.value && validationTouched.contractStartDate && !isContractStartDateValid.value
)
const showContractEndDateError = computed(
() => !editingEmployee.value && validationTouched.contractEndDate && !isContractEndDateValid.value
)
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
const firstNameFieldClass = computed(() => {
if (showFirstNameError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const lastNameFieldClass = computed(() => {
if (showLastNameError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const siteFieldClass = computed(() => {
const baseSelectClass =
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
if (showSiteError.value) {
return `${baseSelectClass} border-red-500`
}
return `${baseSelectClass} border-neutral-300`
})
const contractFieldClass = computed(() => {
const baseClass =
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
if (showContractError.value) {
return `${baseClass} border-red-500`
}
return `${baseClass} border-neutral-300`
})
const contractNatureFieldClass = computed(() => {
const baseClass =
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
if (showContractNatureError.value) {
return `${baseClass} border-red-500`
}
return `${baseClass} border-neutral-300`
})
const contractStartDateFieldClass = computed(() => {
if (showContractStartDateError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const contractEndDateFieldClass = computed(() => {
if (showContractEndDateError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const submitButtonClass = computed(() => {
if (isSubmitting.value || !isFormValid.value) {
return 'opacity-50 cursor-not-allowed'
}
return ''
})
const loadEmployees = async () => {
isLoading.value = true
try {
employees.value = await listEmployees()
} finally {
isLoading.value = false
}
}
const loadSites = async () => {
sites.value = await listSites()
}
const loadContracts = async () => {
contracts.value = await listContracts()
}
onMounted(async () => {
await Promise.all([loadEmployees(), loadSites(), loadContracts()])
if (form.contractStartDate === '') {
form.contractStartDate = new Date().toISOString().slice(0, 10)
}
})
watch(sites, (nextSites) => {
const currentSiteIds = nextSites.map((site) => site.id)
if (!sitesInitialized.value) {
if (currentSiteIds.length === 0) return
selectedSiteIds.value = currentSiteIds
sitesInitialized.value = true
return
}
selectedSiteIds.value = selectedSiteIds.value.filter((siteId) => currentSiteIds.includes(siteId))
}, {immediate: true})
const handleSubmit = async () => {
if (isSubmitting.value) return
validationTouched.firstName = true
validationTouched.lastName = true
validationTouched.siteId = true
if (!editingEmployee.value) {
validationTouched.contractId = true
validationTouched.contractNature = true
validationTouched.contractStartDate = true
validationTouched.contractEndDate = true
}
if (!isFormValid.value) return
isSubmitting.value = true
try {
if (editingEmployee.value) {
await updateEmployee(editingEmployee.value.id, {
firstName: form.firstName,
lastName: form.lastName,
siteId: form.siteId === '' ? null : Number(form.siteId),
contractId: editingEmployee.value.contract?.id ?? Number(form.contractId)
})
} else {
await createEmployee({
firstName: form.firstName,
lastName: form.lastName,
siteId: form.siteId === '' ? null : Number(form.siteId),
contractId: Number(form.contractId),
contractNature: form.contractNature,
contractStartDate: form.contractStartDate,
contractEndDate: form.contractEndDate || null
})
}
form.firstName = ''
form.lastName = ''
form.siteId = ''
form.contractId = ''
form.contractNature = 'CDI'
form.contractStartDate = new Date().toISOString().slice(0, 10)
form.contractEndDate = ''
editingEmployee.value = null
isDrawerOpen.value = false
await loadEmployees()
} finally {
isSubmitting.value = false
}
}
watch(isDrawerOpen, (isOpen) => {
if (!isOpen) {
validationTouched.firstName = false
validationTouched.lastName = false
validationTouched.siteId = false
validationTouched.contractId = false
validationTouched.contractNature = false
validationTouched.contractStartDate = false
validationTouched.contractEndDate = false
}
})
watch(showsContractEndDateComputed, (shows) => {
if (!shows) {
form.contractEndDate = ''
}
})
const openEdit = (employee: Employee) => {
editingEmployee.value = employee
form.firstName = employee.firstName
form.lastName = employee.lastName
form.siteId = employee.site?.id ?? ''
isDrawerOpen.value = true
}
const openCreate = () => {
editingEmployee.value = null
form.firstName = ''
form.lastName = ''
form.siteId = ''
form.contractId = ''
form.contractNature = 'CDI'
form.contractStartDate = new Date().toISOString().slice(0, 10)
form.contractEndDate = ''
isDrawerOpen.value = true
}
const confirmDelete = async (employee: Employee) => {
const ok = window.confirm(`Supprimer ${employee.firstName} ${employee.lastName} ?`)
if (!ok) return
await deleteEmployee(employee.id)
await loadEmployees()
}
</script>

192
frontend/pages/hours.vue Normal file
View File

@@ -0,0 +1,192 @@
<template>
<div class="h-full overflow-hidden flex flex-col">
<div class="flex flex-wrap items-center justify-between gap-4">
<h1 class="text-4xl font-bold text-primary-500">Heures</h1>
</div>
<HoursToolbar
v-model:selected-date="selectedDate"
v-model:view-mode="viewMode"
v-model:selected-site-ids="selectedSiteIds"
v-model:employee-filter="employeeFilter"
:is-admin="isAdmin"
:sites="sites"
:absence-types="absenceTypes"
:formatted-selected-date="formattedSelectedDate"
:shortcut-button-class="shortcutButtonClass"
:week-shortcut-button-class="weekShortcutButtonClass"
:get-week-shortcut-label="getWeekShortcutLabel"
@set-yesterday="setYesterday"
@set-today="setToday"
@set-tomorrow="setTomorrow"
@set-previous-week="setPreviousWeek"
@set-this-week="setThisWeek"
@set-next-week="setNextWeek"
@shift-date="shiftDate"
/>
<div v-if="isLoading" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Chargement...
</div>
<div v-else-if="employees.length === 0" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Aucun employé accessible.
</div>
<div v-else class="flex min-h-0 flex-col gap-4">
<div class="min-h-0 flex flex-col max-h-[calc(100vh-300px)]">
<HoursDayView
v-if="viewMode === 'day'"
v-model:rows="rows"
:employees="visibleEmployees"
:is-admin="isAdmin"
:is-site-manager="isSiteManager"
:day-grid-cols="dayGridCols"
:is-holiday="isSelectedDateHoliday"
:contract-label="contractLabel"
:is-time-tracking="isTimeTracking"
:is-presence-tracking="isPresenceTracking"
:is-row-locked="isRowLocked"
:is-half-locked-by-absence="isHalfLockedByAbsence"
:is-evening-locked-by-absence="isEveningLockedByAbsence"
:has-contract-at-selected-date="hasContractAtSelectedDate"
:is-validation-pending="isValidationPending"
:is-site-validation-pending="isSiteValidationPending"
:can-toggle-validation="canToggleValidation"
:can-toggle-site-validation="canToggleSiteValidation"
:can-create-site-validation-row-from-absence="canCreateSiteValidationRowFromAbsence"
:is-bulk-validation-checked="isBulkValidationChecked"
:is-bulk-validation-indeterminate="isBulkValidationIndeterminate"
:is-bulk-site-validation-checked="isBulkSiteValidationChecked"
:is-bulk-site-validation-indeterminate="isBulkSiteValidationIndeterminate"
:can-bulk-toggle-site-validation="canBulkToggleSiteValidation"
:on-toggle-validation="toggleValidation"
:on-toggle-site-validation="toggleSiteValidation"
:on-toggle-validation-bulk="toggleValidationBulk"
:on-toggle-site-validation-bulk="toggleSiteValidationBulk"
:get-row-metrics="getRowMetrics"
:get-row-absence-label="getRowAbsenceLabel"
:get-row-absence-style="getRowAbsenceStyle"
:get-row-updated-at="getRowUpdatedAt"
:get-presence-day-value="getPresenceDayValue"
:on-absence-click="openAbsenceDrawer"
:format-minutes="formatMinutes"
class="max-h-[calc(100vh-300px)]"
/>
<HoursWeekView
v-else-if="isAdmin && viewMode === 'week'"
:is-week-loading="isWeekLoading"
:week-grid-cols="weekGridCols"
:weekly-summary="filteredWeeklySummary"
:week-day-headers="weekDayHeaders"
:format-minutes="formatMinutes"
class="max-h-[calc(100vh-300px)]"
/>
</div>
<div v-if="viewMode === 'day'" class="shrink-0 px-4 pt-4 flex justify-center">
<button
type="button"
class="rounded-lg bg-primary-500 px-6 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:class="saveButtonClass"
:disabled="isSubmitting || visibleEmployees.length === 0"
@click="handleSave"
>
Enregistrer
</button>
</div>
</div>
<AbsenceFormDrawer
v-model="isAbsenceDrawerOpen"
:employees="employees"
:absence-types="absenceTypes"
:form="absenceForm"
:editing-absence="editingAbsence"
:is-submitting="isAbsenceSubmitting"
:lock-employee="true"
:lock-dates="true"
:show-comment="false"
@submit="submitAbsence"
@delete="deleteAbsenceFromDrawer"
@cancel="closeAbsenceDrawer"
/>
</div>
</template>
<script setup lang="ts">
const {
isAdmin,
isSiteManager,
viewMode,
selectedDate,
employeeFilter,
sites,
selectedSiteIds,
employees,
visibleEmployees,
rows,
absenceTypes,
absenceForm,
isAbsenceDrawerOpen,
isAbsenceSubmitting,
editingAbsence,
filteredWeeklySummary,
isLoading,
isWeekLoading,
isSubmitting,
dayGridCols,
isSelectedDateHoliday,
weekGridCols,
saveButtonClass,
formattedSelectedDate,
weekDayHeaders,
shortcutButtonClass,
weekShortcutButtonClass,
getWeekShortcutLabel,
setToday,
setYesterday,
setTomorrow,
setThisWeek,
setPreviousWeek,
setNextWeek,
shiftDate,
contractLabel,
isTimeTracking,
isPresenceTracking,
isRowLocked,
isHalfLockedByAbsence,
isEveningLockedByAbsence,
hasContractAtSelectedDate,
isValidationPending,
isSiteValidationPending,
canToggleValidation,
canToggleSiteValidation,
canCreateSiteValidationRowFromAbsence,
isBulkValidationChecked,
isBulkValidationIndeterminate,
isBulkSiteValidationChecked,
isBulkSiteValidationIndeterminate,
canBulkToggleSiteValidation,
toggleValidation,
toggleSiteValidation,
toggleValidationBulk,
toggleSiteValidationBulk,
getRowMetrics,
getRowAbsenceLabel,
getRowAbsenceStyle,
getRowUpdatedAt,
getPresenceDayValue,
openAbsenceDrawer,
submitAbsence,
deleteAbsenceFromDrawer,
closeAbsenceDrawer,
formatMinutes,
handleSave
} = useHoursPage()
useHead({
title: 'Heures'
})
</script>

View File

@@ -3,5 +3,7 @@
</template>
<script setup lang="ts">
useHead({
title: 'Tableau de bord'
})
</script>

View File

@@ -18,7 +18,7 @@
v-model="username"
type="text"
autocomplete="username"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
@@ -31,13 +31,13 @@
v-model="password"
type="password"
autocomplete="current-password"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<button
type="submit"
class="w-full rounded-md bg-primary-500 px-4 py-2 text-base font-semibold text-white transition hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-60"
class="w-full rounded-md bg-primary-500 px-4 py-2 text-base font-semibold text-white transition hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-60"
:disabled="isSubmitting"
>
Se connecter
@@ -49,6 +49,9 @@
<script setup lang="ts">
definePageMeta({layout: 'auth'})
useHead({
title: 'Connexion'
})
const router = useRouter()
const auth = useAuthStore()
@@ -65,7 +68,7 @@ const handleSubmit = async () => {
try {
await auth.login(username.value, password.value)
await router.push('/')
await router.push('/calendar')
} finally {
isSubmitting.value = false
}

View File

@@ -123,6 +123,10 @@
import type { Site } from '~/services/dto/site'
import { createSite, deleteSite, listSites, updateSite, updateSiteOrder } from '~/services/sites'
useHead({
title: 'Sites'
})
const isDrawerOpen = ref(false)
const isSubmitting = ref(false)
const isLoading = ref(false)
@@ -150,7 +154,7 @@ const isFormValid = computed(() => isNameValid.value)
const showNameError = computed(() => validationTouched.name && !isNameValid.value)
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200'
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
const nameFieldClass = computed(() => {
if (showNameError.value) {
return `${baseInputClass} border-red-500`

View File

@@ -103,7 +103,7 @@
<button
type="button"
class="rounded-full border px-3 py-1 text-sm font-semibold"
:class="form.accessMode === 'admin' ? 'border-primary-500 bg-primary-50 text-primary-700' : 'border-neutral-200 text-neutral-700'"
:class="form.accessMode === 'admin' ? 'border-primary-500 bg-tertiary-500 text-primary-500' : 'border-neutral-200 text-neutral-700'"
@click="selectAccessMode('admin')"
>
Admin
@@ -111,7 +111,7 @@
<button
type="button"
class="rounded-full border px-3 py-1 text-sm font-semibold"
:class="form.accessMode === 'self' ? 'border-primary-500 bg-primary-50 text-primary-700' : 'border-neutral-200 text-neutral-700'"
:class="form.accessMode === 'self' ? 'border-primary-500 bg-tertiary-500 text-primary-500' : 'border-neutral-200 text-neutral-700'"
@click="selectAccessMode('self')"
>
Accès personnel
@@ -119,7 +119,7 @@
<button
type="button"
class="rounded-full border px-3 py-1 text-sm font-semibold"
:class="form.accessMode === 'sites' ? 'border-primary-500 bg-primary-50 text-primary-700' : 'border-neutral-200 text-neutral-700'"
:class="form.accessMode === 'sites' ? 'border-primary-500 bg-tertiary-500 text-primary-500' : 'border-neutral-200 text-neutral-700'"
@click="selectAccessMode('sites')"
>
Sites
@@ -209,6 +209,9 @@ import { createUser, listUsers, updateUser } from '~/services/users'
import { createUserSiteRole, deleteUserSiteRole, listUserSiteRoles } from '~/services/user-site-roles'
definePageMeta({ middleware: ['admin'] })
useHead({
title: 'Utilisateurs'
})
const users = ref<User[]>([])
const employees = ref<Employee[]>([])
@@ -288,7 +291,7 @@ const getSiteLabels = (user: User) => {
}
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200'
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
const usernameFieldClass = computed(() => {
if (showUsernameError.value) {
return `${baseInputClass} border-red-500`

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

@@ -12,7 +12,7 @@ export const listAbsenceTypes = async () => {
}
export const createAbsenceType = async (
payload: Pick<AbsenceType, 'code' | 'label' | 'color'>
payload: Pick<AbsenceType, 'code' | 'label' | 'color' | 'countAsWorkedHours'>
) => {
const api = useApi()
return api.post<AbsenceType>('/absence_types', payload, {
@@ -23,7 +23,7 @@ export const createAbsenceType = async (
export const updateAbsenceType = async (
id: number,
payload: Pick<AbsenceType, 'code' | 'label' | 'color'>
payload: Pick<AbsenceType, 'code' | 'label' | 'color' | 'countAsWorkedHours'>
) => {
const api = useApi()
return api.patch<AbsenceType>(`/absence_types/${id}`, payload, {

View File

@@ -6,6 +6,7 @@ type ListAbsencesFilters = {
from?: string
to?: string
siteIds?: number[]
employeeId?: number
}
export const listAbsences = async (filters: ListAbsencesFilters = {}) => {
@@ -20,6 +21,9 @@ export const listAbsences = async (filters: ListAbsencesFilters = {}) => {
if (filters.siteIds && filters.siteIds.length > 0) {
query['employee.site[]'] = filters.siteIds.map((id) => `/api/sites/${id}`)
}
if (filters.employeeId) {
query.employee = `/api/employees/${filters.employeeId}`
}
const data = await api.get<Absence[] | { 'hydra:member'?: Absence[] }>(
'/absences',
query,

View File

@@ -8,6 +8,7 @@ export const getCurrentUser = () => {
export const login = (username: string, password: string) => {
const api = useApi()
return api.post('/login_check', { username, password }, {
toastOn401: true,
toastErrorKey: 'errors.auth.login'
})
}

View File

@@ -0,0 +1,38 @@
import type { ContractSuspension } from './dto/employee'
export const createSuspension = async (payload: {
contractPeriodId: number
startDate: string
endDate?: string | null
comment?: string | null
}) => {
const api = useApi()
return api.post<ContractSuspension>('/contract_suspensions', {
contractPeriodId: payload.contractPeriodId,
startDate: payload.startDate,
endDate: payload.endDate ?? null,
comment: payload.comment ?? null
}, {
toastSuccessKey: 'Suspension créée',
toastErrorKey: 'Erreur lors de la création de la suspension'
})
}
export const updateSuspension = async (
id: number,
payload: {
startDate: string
endDate?: string | null
comment?: string | null
}
) => {
const api = useApi()
return api.patch<ContractSuspension>(`/contract_suspensions/${id}`, {
startDate: payload.startDate,
endDate: payload.endDate ?? null,
comment: payload.comment ?? null
}, {
toastSuccessKey: 'Suspension modifiée',
toastErrorKey: 'Erreur lors de la modification de la suspension'
})
}

View File

@@ -0,0 +1,13 @@
import type { Contract } from './dto/contract'
import { extractItems } from '~/utils/api'
export const listContracts = async () => {
const api = useApi()
const data = await api.get<Contract[] | { 'hydra:member'?: Contract[] }>(
'/contracts',
{},
{ toast: false }
)
return extractItems<Contract>(data)
}

View File

@@ -3,4 +3,5 @@ export type AbsenceType = {
code: string
label: string
color: string
countAsWorkedHours: boolean
}

View File

@@ -0,0 +1,25 @@
export const TRACKING_MODES = {
TIME: 'TIME',
PRESENCE: 'PRESENCE'
} as const
export type TrackingMode = (typeof TRACKING_MODES)[keyof typeof TRACKING_MODES]
export const CONTRACT_TYPES = {
FORFAIT: 'FORFAIT',
H35: '35H',
H39: '39H',
INTERIM: 'INTERIM',
CUSTOM: 'CUSTOM'
} as const
export type ContractType = (typeof CONTRACT_TYPES)[keyof typeof CONTRACT_TYPES]
export type Contract = {
id: number
name: string
trackingMode: TrackingMode
type: ContractType
weeklyHours?: number | null
isActive?: boolean
}

View File

@@ -0,0 +1,18 @@
export type EmployeeLeaveSummary = {
year: number
isSupported: boolean
ruleCode: string
acquiredDays: number
remainingDays: number
takenDays: number
acquiredSaturdays: number
remainingSaturdays: number
takenSaturdays: number
fractionedDays: number
accruingDays: number
previousYearAcquiredDays: number
previousYearTakenDays: number
previousYearRemainingDays: number
presenceDaysByMonth: Record<string, number>
}

View File

@@ -0,0 +1,35 @@
export type EmployeeRttWeekSummary = {
month: number
weekNumber: number
weekStart: string
weekEnd: string
overtimeMinutes: number
base25Minutes: number
bonus25Minutes: number
base50Minutes: number
bonus50Minutes: number
totalMinutes: number
}
export type RttMonthPayment = {
month: number
paidBase25Minutes: number
paidBonus25Minutes: number
paidBase50Minutes: number
paidBonus50Minutes: number
}
export type EmployeeRttSummary = {
year: number
carryMonth: number
carryFromPreviousYearMinutes: number
carryBase25Minutes: number
carryBonus25Minutes: number
carryBase50Minutes: number
carryBonus50Minutes: number
currentYearRecoveryMinutes: number
totalPaidMinutes: number
availableMinutes: number
weeks: EmployeeRttWeekSummary[]
monthPayments: RttMonthPayment[]
}

View File

@@ -1,9 +1,36 @@
import type { Site } from './site'
import type { Contract } from './contract'
export type ContractSuspension = {
id: number
startDate: string
endDate?: string | null
comment?: string | null
}
export type ContractHistoryItem = {
contractId?: number | null
contractName?: string | null
weeklyHours?: number | null
contractNature: 'CDI' | 'CDD' | 'INTERIM'
startDate: string
endDate?: string | null
comment?: string | null
periodId?: number | null
suspensions?: ContractSuspension[]
}
export type Employee = {
id: number
firstName: string
lastName: string
site: Site
contract?: Contract | null
currentContractNature?: 'CDI' | 'CDD' | 'INTERIM'
currentContractStartDate?: string | null
currentContractEndDate?: string | null
contractHistory?: ContractHistoryItem[]
displayOrder?: number
entryDate?: string | null
currentSuspensions?: ContractSuspension[]
}

View File

@@ -0,0 +1,9 @@
export type NotificationItem = {
id: number
actorName: string
message: string
category: string
target: string
isRead: boolean
createdAt: string
}

View File

@@ -0,0 +1,85 @@
import type { Employee } from './employee'
import type { ContractType, TrackingMode } from './contract'
export type WorkHour = {
id: number
employee: Employee
workDate: string
morningFrom?: string | null
morningTo?: string | null
afternoonFrom?: string | null
afternoonTo?: string | null
eveningFrom?: string | null
eveningTo?: string | null
isPresentMorning?: boolean
isPresentAfternoon?: boolean
isSiteValid?: boolean
isValid?: boolean
updatedAt?: string | null
}
export type WorkHourEntryPayload = {
employeeId: number
morningFrom?: string | null
morningTo?: string | null
afternoonFrom?: string | null
afternoonTo?: string | null
eveningFrom?: string | null
eveningTo?: string | null
isPresentMorning?: boolean
isPresentAfternoon?: boolean
}
export type WeeklyWorkHourDailySummary = {
date: string
dayMinutes: number
nightMinutes: number
totalMinutes: number
present?: number | null
hasAbsence?: boolean
absenceLabel?: string | null
absenceColor?: string | null
}
export type WeeklyWorkHourRowSummary = {
employeeId: number
firstName: string
lastName: string
siteName?: string | null
contractName?: string | null
contractType?: ContractType | null
trackingMode?: TrackingMode | null
daily: WeeklyWorkHourDailySummary[]
weeklyDayMinutes: number
weeklyNightMinutes: number
weeklyTotalMinutes: number
weeklyPresenceCount?: number
weeklyOvertimeTotalMinutes?: number
weeklyOvertime25Minutes?: number
weeklyOvertime50Minutes?: number
weeklyRecoveryMinutes?: number
}
export type WeeklyWorkHourSummary = {
weekStart: string
weekEnd: string
days: string[]
rows: WeeklyWorkHourRowSummary[]
}
export type WorkHourDayContextRow = {
employeeId: number
hasContractAtDate: boolean
absenceLabel?: string | null
absenceColor?: string | null
absenceHalf?: 'AM' | 'PM' | null
absentMorning: boolean
absentAfternoon: boolean
creditedMinutes: number
creditedPresenceUnits: number
}
export type WorkHourDayContext = {
workDate: string
rows: WorkHourDayContextRow[]
}

View File

@@ -0,0 +1,18 @@
import type { EmployeeLeaveSummary } from './dto/employee-leave-summary'
export const getEmployeeLeaveSummary = async (employeeId: number, year?: number) => {
const api = useApi()
const query: Record<string, string> = {}
if (year) query.year = String(year)
return api.get<EmployeeLeaveSummary>(`/employees/${employeeId}/leave-summary`, query, { toast: false })
}
export const updateFractionedDays = async (employeeId: number, fractionedDays: number, year?: number) => {
const api = useApi()
const body: Record<string, unknown> = { fractionedDays }
if (year) body.year = year
return api.patch(`/employees/${employeeId}/fractioned-days`, body)
}

View File

@@ -0,0 +1,23 @@
import type { EmployeeRttSummary } from './dto/employee-rtt-summary'
export const getEmployeeRttSummary = async (employeeId: number, year?: number) => {
const api = useApi()
const query = year ? { year } : {}
return api.get<EmployeeRttSummary>(`/employees/${employeeId}/rtt-summary`, query, { toast: false })
}
export const createRttPayment = async (
employeeId: number,
month: number,
base25Minutes: number,
bonus25Minutes: number,
base50Minutes: number,
bonus50Minutes: number,
year?: number
) => {
const api = useApi()
const body: Record<string, unknown> = { month, base25Minutes, bonus25Minutes, base50Minutes, bonus50Minutes }
if (year) body.year = year
return api.patch(`/employees/${employeeId}/rtt-payments`, body)
}

View File

@@ -11,16 +11,39 @@ export const listEmployees = async () => {
return extractItems<Employee>(data)
}
export const listScopedEmployees = async () => {
const api = useApi()
const data = await api.get<Employee[] | { 'hydra:member'?: Employee[] }>(
'/employees/scoped',
{},
{ toast: false }
)
return extractItems<Employee>(data)
}
export const getEmployee = async (id: number) => {
const api = useApi()
return api.get<Employee>(`/employees/${id}`, {}, { toast: false })
}
export const createEmployee = async (payload: {
firstName: string
lastName: string
siteId?: number | null
contractId: number
contractNature?: 'CDI' | 'CDD' | 'INTERIM'
contractStartDate?: string
contractEndDate?: string | null
}) => {
const api = useApi()
return api.post<Employee>('/employees', {
firstName: payload.firstName,
lastName: payload.lastName,
site: payload.siteId ? `/api/sites/${payload.siteId}` : null
site: payload.siteId ? `/api/sites/${payload.siteId}` : null,
contract: `/api/contracts/${payload.contractId}`,
contractNature: payload.contractNature,
contractStartDate: payload.contractStartDate,
contractEndDate: payload.contractEndDate ?? null
}, {
toastSuccessKey: 'success.employee.create',
toastErrorKey: 'errors.employee.create'
@@ -33,16 +56,43 @@ export const updateEmployee = async (
firstName: string
lastName: string
siteId?: number | null
contractId?: number
contractNature?: 'CDI' | 'CDD' | 'INTERIM'
contractStartDate?: string
contractEndDate?: string | null
contractPaidLeaveSettled?: boolean
contractComment?: string | null
displayOrder?: number
}
) => {
const api = useApi()
return api.patch<Employee>(`/employees/${id}`, {
const body: Record<string, unknown> = {
firstName: payload.firstName,
lastName: payload.lastName,
site: payload.siteId ? `/api/sites/${payload.siteId}` : null,
displayOrder: payload.displayOrder
}, {
}
if (payload.contractId !== undefined) {
body.contract = `/api/contracts/${payload.contractId}`
}
if (payload.contractNature !== undefined) {
body.contractNature = payload.contractNature
}
if (payload.contractStartDate !== undefined) {
body.contractStartDate = payload.contractStartDate
}
if (payload.contractEndDate !== undefined) {
body.contractEndDate = payload.contractEndDate ?? null
}
if (payload.contractPaidLeaveSettled !== undefined) {
body.contractPaidLeaveSettled = payload.contractPaidLeaveSettled
}
if (payload.contractComment !== undefined) {
body.contractComment = payload.contractComment ?? null
}
return api.patch<Employee>(`/employees/${id}`, body, {
toastSuccessKey: 'success.employee.update',
toastErrorKey: 'errors.employee.update'
})

View File

@@ -0,0 +1,40 @@
import type { NotificationItem } from './dto/notification'
import { extractItems } from '~/utils/api'
export const listUnreadNotifications = async () => {
const api = useApi()
const data = await api.get<NotificationItem[] | { 'hydra:member'?: NotificationItem[] }>(
'/notifications/unread',
{},
{ toast: false }
)
return extractItems<NotificationItem>(data)
}
export const listTodayNotifications = async () => {
const api = useApi()
const data = await api.get<NotificationItem[] | { 'hydra:member'?: NotificationItem[] }>(
'/notifications/today',
{},
{ toast: false }
)
return extractItems<NotificationItem>(data)
}
export const listHistoryNotifications = async () => {
const api = useApi()
const data = await api.get<NotificationItem[] | { 'hydra:member'?: NotificationItem[] }>(
'/notifications/history',
{},
{ toast: false }
)
return extractItems<NotificationItem>(data)
}
export const markAllNotificationsRead = async () => {
const api = useApi()
return api.post('/notifications/mark-all-read', {}, { toast: false })
}

View File

@@ -0,0 +1,140 @@
import type {
WorkHourDayContext,
WorkHour,
WorkHourEntryPayload,
WeeklyWorkHourSummary
} from './dto/work-hour'
import { extractItems } from '~/utils/api'
export const listWorkHoursByDate = async (workDate: string) => {
const api = useApi()
const data = await api.get<WorkHour[] | { 'hydra:member'?: WorkHour[] }>(
'/work_hours',
{
'workDate[after]': workDate,
'workDate[before]': workDate
},
{ toast: false }
)
return extractItems<WorkHour>(data)
}
export const bulkUpsertWorkHours = async (payload: {
workDate: string
entries: WorkHourEntryPayload[]
}, options?: { toast?: boolean }) => {
const api = useApi()
return api.post<{
processed: number
created: number
updated: number
deleted: number
}>(
'/work-hours/bulk-upsert',
payload,
{
toast: options?.toast ?? true,
toastSuccessMessage: 'Horaires enregistrés.',
toastErrorMessage: "Impossible d'enregistrer les horaires."
}
)
}
export const updateWorkHourValidation = async (
id: number,
isValid: boolean,
options?: { toast?: boolean }
) => {
const api = useApi()
return api.patch<WorkHour>(
`/work_hours/${id}`,
{ isValid },
{
toast: options?.toast ?? true,
toastSuccessMessage: isValid ? 'Ligne validée.' : 'Validation retirée.',
toastErrorMessage: 'Impossible de mettre à jour la validation.'
}
)
}
export const bulkUpdateWorkHourValidation = async (payload: {
workDate: string
isValid: boolean
employeeIds: number[]
}, options?: { toast?: boolean }) => {
const api = useApi()
return api.post<{
requested: number
updated: number
skipped: number
updatedEmployeeIds: number[]
skippedEmployeeIds: number[]
}>(
'/work-hours/bulk-validation',
payload,
{
toast: options?.toast ?? true,
toastSuccessMessage: payload.isValid ? 'Validations enregistrées.' : 'Validations retirées.',
toastErrorMessage: "Impossible de mettre à jour les validations."
}
)
}
export const updateWorkHourSiteValidation = async (
id: number,
isSiteValid: boolean,
options?: { toast?: boolean }
) => {
const api = useApi()
return api.patch<WorkHour>(
`/work_hours/${id}/site-validation`,
{ isSiteValid },
{
toast: options?.toast ?? true,
toastSuccessMessage: isSiteValid ? 'Validation site enregistrée.' : 'Validation site retirée.',
toastErrorMessage: "Impossible de mettre à jour la validation site."
}
)
}
export const bulkUpdateWorkHourSiteValidation = async (payload: {
workDate: string
isSiteValid: boolean
employeeIds: number[]
}, options?: { toast?: boolean }) => {
const api = useApi()
return api.post<{
requested: number
updated: number
skipped: number
updatedEmployeeIds: number[]
skippedEmployeeIds: number[]
}>(
'/work-hours/site-bulk-validation',
payload,
{
toast: options?.toast ?? true,
toastSuccessMessage: payload.isSiteValid ? 'Validations site enregistrées.' : 'Validations site retirées.',
toastErrorMessage: "Impossible de mettre à jour les validations site."
}
)
}
export const getWeeklyWorkHourSummary = async (weekStart: string) => {
const api = useApi()
return api.get<WeeklyWorkHourSummary>(
'/work-hours/weekly-summary',
{ weekStart },
{ toast: false }
)
}
export const getWorkHourDayContext = async (workDate: string) => {
const api = useApi()
return api.get<WorkHourDayContext>(
'/work-hours/day-context',
{ workDate },
{ toast: false }
)
}

View File

@@ -4,7 +4,7 @@ export default <Partial<Config>>{
theme: {
extend: {
fontFamily: {
sans: ['"Helvetica Neue"', 'Helvetica', 'Arial', 'sans-serif']
sans: ['"Inter"', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif']
},
colors: {
primary: {
@@ -15,6 +15,9 @@ export default <Partial<Config>>{
},
tertiary: {
500: '#F3F4F8'
},
blue: {
500: '#056CF2'
}
}
}

View File

@@ -0,0 +1,21 @@
export const CONTRACT_NATURES = ['CDI', 'CDD', 'INTERIM'] as const
export type ContractNature = (typeof CONTRACT_NATURES)[number]
export const contractNatureLabel = (value?: ContractNature) => {
if (value === 'CDD') return 'CDD'
if (value === 'INTERIM') return 'Intérim'
return 'CDI'
}
export const showsContractEndDate = (nature: ContractNature) => {
return nature === 'CDD' || nature === 'INTERIM'
}
export const requiresContractEndDate = (nature: ContractNature) => {
return nature === 'CDD'
}
export const isContractNature = (value: string): value is ContractNature => {
return (CONTRACT_NATURES as readonly string[]).includes(value)
}

View File

@@ -6,6 +6,126 @@ export const toYmd = (year: number, month: number, day: number) => {
export const normalizeDate = (value: string) => value.slice(0, 10)
export const formatYmdToFr = (value: string) => {
const [year, month, day] = value.split('-')
if (!year || !month || !day) return value
return `${day}/${month}/${year}`
}
export const formatNullableYmdToFr = (value?: string | null, fallback = 'En cours') => {
if (!value) return fallback
return formatYmdToFr(value)
}
export const parseYmd = (value: string) => {
const [year, month, day] = value.split('-').map(Number)
if (!year || !month || !day) return null
return new Date(year, month - 1, day)
}
export const formatDateLongFr = (date: Date) => {
const label = new Intl.DateTimeFormat('fr-FR', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
}).format(date)
return label.charAt(0).toUpperCase() + label.slice(1)
}
export const formatWeekDayHeaderFr = (dateYmd: string) => {
const parsed = parseYmd(dateYmd)
if (!parsed) return dateYmd
return new Intl.DateTimeFormat('fr-FR', {
weekday: 'short',
day: '2-digit',
month: '2-digit'
}).format(parsed)
}
export const getWeekStartDate = (date: Date) => {
const copy = new Date(date)
const day = copy.getDay()
const diff = day === 0 ? -6 : 1 - day
copy.setDate(copy.getDate() + diff)
copy.setHours(0, 0, 0, 0)
return copy
}
export const getIsoWeekNumber = (date: Date) => {
const utc = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))
const day = utc.getUTCDay() || 7
utc.setUTCDate(utc.getUTCDate() + 4 - day)
const yearStart = new Date(Date.UTC(utc.getUTCFullYear(), 0, 1))
return Math.ceil((((utc.getTime() - yearStart.getTime()) / 86400000) + 1) / 7)
}
export const getIsoWeekYear = (date: Date) => {
const utc = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))
const day = utc.getUTCDay() || 7
utc.setUTCDate(utc.getUTCDate() + 4 - day)
return utc.getUTCFullYear()
}
export const ymdToWeekInputValue = (dateYmd: string) => {
const parsed = parseYmd(dateYmd)
if (!parsed) return ''
const weekDate = getWeekStartDate(parsed)
const weekNumber = getIsoWeekNumber(weekDate)
const weekYear = getIsoWeekYear(weekDate)
return `${weekYear}-W${String(weekNumber).padStart(2, '0')}`
}
export const weekInputValueToYmd = (weekValue: string) => {
const match = /^(\d{4})-W(\d{2})$/.exec(weekValue)
if (!match) return null
const year = Number(match[1])
const week = Number(match[2])
if (!Number.isInteger(year) || !Number.isInteger(week) || week < 1 || week > 53) return null
const jan4 = new Date(year, 0, 4)
const week1Monday = getWeekStartDate(jan4)
const monday = new Date(week1Monday)
monday.setDate(week1Monday.getDate() + ((week - 1) * 7))
return toYmd(monday.getFullYear(), monday.getMonth(), monday.getDate())
}
export const getTodayYmd = () => {
const date = new Date()
return toYmd(date.getFullYear(), date.getMonth(), date.getDate())
}
export const getOffsetFromTodayYmd = (offset: number) => {
const date = new Date()
date.setDate(date.getDate() + offset)
return toYmd(date.getFullYear(), date.getMonth(), date.getDate())
}
export const shiftYmd = (value: string, days: number) => {
const parsed = parseYmd(value)
if (!parsed) return null
parsed.setDate(parsed.getDate() + days)
return toYmd(parsed.getFullYear(), parsed.getMonth(), parsed.getDate())
}
export const formatWeekRangeFr = (date: Date) => {
const start = getWeekStartDate(date)
const end = new Date(start)
end.setDate(start.getDate() + 6)
const weekNumber = getIsoWeekNumber(start)
const formatter = new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
return `S${weekNumber} du ${formatter.format(start)} au ${formatter.format(end)}`
}
export const getDaysInMonth = (year: number, month: number) => {
const total = new Date(year, month + 1, 0).getDate()
const weekdays = ['Dim', 'Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam']

View File

@@ -0,0 +1,31 @@
import type { Employee } from '~/services/dto/employee'
export const compareEmployeesInSite = (employeeA: Employee, employeeB: Employee) => {
const orderA = employeeA.displayOrder ?? 0
const orderB = employeeB.displayOrder ?? 0
if (orderA !== orderB) return orderA - orderB
const lastNameA = employeeA.lastName ?? ''
const lastNameB = employeeB.lastName ?? ''
if (lastNameA !== lastNameB) return lastNameA.localeCompare(lastNameB, 'fr')
const firstNameA = employeeA.firstName ?? ''
const firstNameB = employeeB.firstName ?? ''
return firstNameA.localeCompare(firstNameB, 'fr')
}
export const compareEmployeesBySiteAndOrder = (employeeA: Employee, employeeB: Employee) => {
const siteOrderA = employeeA.site?.displayOrder ?? 0
const siteOrderB = employeeB.site?.displayOrder ?? 0
if (siteOrderA !== siteOrderB) return siteOrderA - siteOrderB
const siteNameA = employeeA.site?.name ?? ''
const siteNameB = employeeB.site?.name ?? ''
if (siteNameA !== siteNameB) return siteNameA.localeCompare(siteNameB, 'fr')
return compareEmployeesInSite(employeeA, employeeB)
}
export const sortEmployeesBySiteAndOrder = (employees: Employee[]) => {
return [...employees].sort(compareEmployeesBySiteAndOrder)
}

View File

@@ -77,7 +77,7 @@ migration-migrate:
$(SYMFONY_CONSOLE) doctrine:migrations:migrate --no-interaction
fixtures:
$(SYMFONY_CONSOLE) doctrine:fixtures:load
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load
# Attention, supprime votre bdd local
db-reset:

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260216100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create work_hours table';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE work_hours (id SERIAL NOT NULL, employee_id INT NOT NULL, work_date DATE NOT NULL, morning_from VARCHAR(5) DEFAULT NULL, morning_to VARCHAR(5) DEFAULT NULL, afternoon_from VARCHAR(5) DEFAULT NULL, afternoon_to VARCHAR(5) DEFAULT NULL, evening_from VARCHAR(5) DEFAULT NULL, evening_to VARCHAR(5) DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_WORK_HOURS_EMPLOYEE ON work_hours (employee_id)');
$this->addSql('CREATE INDEX IDX_WORK_HOURS_DATE ON work_hours (work_date)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_WORK_HOURS_EMPLOYEE_DATE ON work_hours (employee_id, work_date)');
$this->addSql('ALTER TABLE work_hours ADD CONSTRAINT FK_WORK_HOURS_EMPLOYEE FOREIGN KEY (employee_id) REFERENCES employees (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE work_hours DROP CONSTRAINT FK_WORK_HOURS_EMPLOYEE');
$this->addSql('DROP TABLE work_hours');
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260217161000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add contract_hours and is_forfait to employees';
}
public function up(Schema $schema): void
{
// Nettoie l'ancien modele "contracts" s'il a deja ete applique.
$this->addSql('ALTER TABLE employees DROP CONSTRAINT IF EXISTS FK_EMPLOYEES_CONTRACT');
$this->addSql('DROP INDEX IF EXISTS IDX_EMPLOYEES_CONTRACT');
$this->addSql('ALTER TABLE employees DROP COLUMN IF EXISTS contract_id');
$this->addSql('DROP TABLE IF EXISTS contracts');
$this->addSql('ALTER TABLE employees ADD COLUMN IF NOT EXISTS contract_hours INT DEFAULT NULL');
$this->addSql('ALTER TABLE employees ADD COLUMN IF NOT EXISTS is_forfait BOOLEAN DEFAULT FALSE NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE employees DROP COLUMN IF EXISTS contract_hours');
$this->addSql('ALTER TABLE employees DROP COLUMN IF EXISTS is_forfait');
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260217162000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add is_present and is_valid columns to work_hours';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE work_hours ADD COLUMN IF NOT EXISTS is_present BOOLEAN DEFAULT NULL');
$this->addSql('ALTER TABLE work_hours ADD COLUMN IF NOT EXISTS is_valid BOOLEAN DEFAULT FALSE NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE work_hours DROP COLUMN IF EXISTS is_present');
$this->addSql('ALTER TABLE work_hours DROP COLUMN IF EXISTS is_valid');
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260218120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Replace employee contract_hours/is_forfait with contracts table and employee.contract_id';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE contracts (id SERIAL NOT NULL, name VARCHAR(120) NOT NULL, tracking_mode VARCHAR(20) NOT NULL, weekly_hours INT DEFAULT NULL, is_active BOOLEAN DEFAULT TRUE NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_CONTRACTS_TRACKING_MODE ON contracts (tracking_mode)');
$this->addSql("INSERT INTO contracts (name, tracking_mode, weekly_hours, is_active) VALUES ('Forfait', 'PRESENCE', NULL, TRUE)");
$this->addSql("INSERT INTO contracts (name, tracking_mode, weekly_hours, is_active) SELECT DISTINCT CONCAT(contract_hours::text, 'h'), 'TIME', contract_hours, TRUE FROM employees WHERE contract_hours IS NOT NULL");
$this->addSql("INSERT INTO contracts (name, tracking_mode, weekly_hours, is_active) SELECT '35h', 'TIME', 35, TRUE WHERE NOT EXISTS (SELECT 1 FROM contracts WHERE tracking_mode = 'TIME' AND weekly_hours = 35)");
$this->addSql('ALTER TABLE employees ADD contract_id INT DEFAULT NULL');
$this->addSql('CREATE INDEX IDX_EMPLOYEES_CONTRACT ON employees (contract_id)');
$this->addSql("UPDATE employees e SET contract_id = c.id FROM contracts c WHERE e.is_forfait = TRUE AND c.tracking_mode = 'PRESENCE'");
$this->addSql("UPDATE employees e SET contract_id = c.id FROM contracts c WHERE e.is_forfait = FALSE AND e.contract_hours IS NOT NULL AND c.tracking_mode = 'TIME' AND c.weekly_hours = e.contract_hours");
$this->addSql("UPDATE employees e SET contract_id = c.id FROM contracts c WHERE e.contract_id IS NULL AND c.tracking_mode = 'TIME' AND c.weekly_hours = 35");
$this->addSql('ALTER TABLE employees ALTER COLUMN contract_id SET NOT NULL');
$this->addSql('ALTER TABLE employees ADD CONSTRAINT FK_EMPLOYEES_CONTRACT FOREIGN KEY (contract_id) REFERENCES contracts (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE employees DROP COLUMN contract_hours');
$this->addSql('ALTER TABLE employees DROP COLUMN is_forfait');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE employees ADD contract_hours INT DEFAULT NULL');
$this->addSql('ALTER TABLE employees ADD is_forfait BOOLEAN DEFAULT FALSE NOT NULL');
$this->addSql("UPDATE employees e SET is_forfait = CASE WHEN c.tracking_mode = 'PRESENCE' THEN TRUE ELSE FALSE END, contract_hours = CASE WHEN c.tracking_mode = 'TIME' THEN c.weekly_hours ELSE NULL END FROM contracts c WHERE e.contract_id = c.id");
$this->addSql('ALTER TABLE employees DROP CONSTRAINT FK_EMPLOYEES_CONTRACT');
$this->addSql('DROP INDEX IDX_EMPLOYEES_CONTRACT');
$this->addSql('ALTER TABLE employees DROP COLUMN contract_id');
$this->addSql('DROP INDEX IDX_CONTRACTS_TRACKING_MODE');
$this->addSql('DROP TABLE contracts');
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260218183000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Replace work_hours.is_present with is_present_morning and is_present_afternoon';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE work_hours DROP COLUMN IF EXISTS is_present');
$this->addSql('ALTER TABLE work_hours ADD COLUMN is_present_morning BOOLEAN DEFAULT FALSE NOT NULL');
$this->addSql('ALTER TABLE work_hours ADD COLUMN is_present_afternoon BOOLEAN DEFAULT FALSE NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE work_hours DROP COLUMN IF EXISTS is_present_morning');
$this->addSql('ALTER TABLE work_hours DROP COLUMN IF EXISTS is_present_afternoon');
$this->addSql('ALTER TABLE work_hours ADD COLUMN is_present BOOLEAN DEFAULT NULL');
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260218190000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add count_as_worked_hours on absence_types';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE absence_types ADD count_as_worked_hours BOOLEAN DEFAULT FALSE NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE absence_types DROP count_as_worked_hours');
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use DateInterval;
use DatePeriod;
use DateTimeImmutable;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
final class Version20260219180000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Decoupe les absences multi-jours en lignes journalieres.';
}
public function up(Schema $schema): void
{
$rows = $this->connection->fetchAllAssociative(
'SELECT id, employee_id, type_id, start_date, end_date, start_half, end_half, comment
FROM absences
WHERE start_date < end_date
ORDER BY id ASC'
);
foreach ($rows as $row) {
$start = DateTimeImmutable::createFromFormat('Y-m-d', (string) $row['start_date']);
$end = DateTimeImmutable::createFromFormat('Y-m-d', (string) $row['end_date']);
if (!$start instanceof DateTimeImmutable || !$end instanceof DateTimeImmutable) {
continue;
}
$startHalf = (string) $row['start_half'];
$endHalf = (string) $row['end_half'];
$days = new DatePeriod($start, new DateInterval('P1D'), $end->modify('+1 day'));
foreach ($days as $day) {
$isFirst = $day->format('Y-m-d') === $start->format('Y-m-d');
$isLast = $day->format('Y-m-d') === $end->format('Y-m-d');
if ($isFirst && 'PM' === $startHalf) {
$segmentStartHalf = 'PM';
$segmentEndHalf = 'PM';
} elseif ($isLast && 'AM' === $endHalf) {
$segmentStartHalf = 'AM';
$segmentEndHalf = 'AM';
} else {
$segmentStartHalf = 'AM';
$segmentEndHalf = 'PM';
}
$this->connection->insert('absences', [
'employee_id' => (int) $row['employee_id'],
'type_id' => (int) $row['type_id'],
'start_date' => $day,
'end_date' => $day,
'start_half' => $segmentStartHalf,
'end_half' => $segmentEndHalf,
'comment' => $row['comment'],
], [
'employee_id' => Types::INTEGER,
'type_id' => Types::INTEGER,
'start_date' => Types::DATE_IMMUTABLE,
'end_date' => Types::DATE_IMMUTABLE,
'start_half' => Types::STRING,
'end_half' => Types::STRING,
'comment' => Types::TEXT,
]);
}
$this->connection->delete('absences', ['id' => (int) $row['id']], ['id' => Types::INTEGER]);
}
}
public function down(Schema $schema): void
{
$this->throwIrreversibleMigrationException('Cette migration de decoupage est irreversible.');
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260220133000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add employee contract periods history table and seed current contracts';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE employee_contract_periods (id SERIAL NOT NULL, employee_id INT NOT NULL, contract_id INT NOT NULL, start_date DATE NOT NULL, end_date DATE DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX idx_emp_contract_period_employee_start ON employee_contract_periods (employee_id, start_date)');
$this->addSql('CREATE INDEX idx_emp_contract_period_employee_end ON employee_contract_periods (employee_id, end_date)');
$this->addSql('CREATE INDEX IDX_831EED7A8C03F15C ON employee_contract_periods (employee_id)');
$this->addSql('CREATE INDEX IDX_831EED7A2576E0FD ON employee_contract_periods (contract_id)');
$this->addSql('ALTER TABLE employee_contract_periods ADD CONSTRAINT FK_831EED7A8C03F15C FOREIGN KEY (employee_id) REFERENCES employees (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE employee_contract_periods ADD CONSTRAINT FK_831EED7A2576E0FD FOREIGN KEY (contract_id) REFERENCES contracts (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
// Initialise l\'historique avec le contrat actuel de chaque employé.
$this->addSql("INSERT INTO employee_contract_periods (employee_id, contract_id, start_date, end_date, created_at)
SELECT id, contract_id, DATE '1970-01-01', NULL, NOW()
FROM employees
WHERE contract_id IS NOT NULL");
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE employee_contract_periods DROP CONSTRAINT FK_831EED7A8C03F15C');
$this->addSql('ALTER TABLE employee_contract_periods DROP CONSTRAINT FK_831EED7A2576E0FD');
$this->addSql('DROP TABLE employee_contract_periods');
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260226183000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add site validation flag to work hours';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE work_hours ADD is_site_valid BOOLEAN DEFAULT FALSE NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE work_hours DROP is_site_valid');
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260226203000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add contract nature to employee contract periods';
}
public function up(Schema $schema): void
{
$this->addSql("ALTER TABLE employee_contract_periods ADD contract_nature VARCHAR(20) DEFAULT 'CDI' NOT NULL");
$this->addSql("
UPDATE employee_contract_periods p
SET contract_nature = 'INTERIM'
FROM contracts c
WHERE p.contract_id = c.id
AND LOWER(c.name) LIKE '%interim%'
");
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE employee_contract_periods DROP contract_nature');
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
use Doctrine\Migrations\Exception\IrreversibleMigration;
final class Version20260226210000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Reassign legacy INTERIM contract to a TIME 35h contract and remove legacy INTERIM contract row';
}
public function up(Schema $schema): void
{
$this->addSql(
<<<'SQL'
DO $$
DECLARE
legacy_interim_contract_id INT;
target_time_35h_contract_id INT;
BEGIN
-- Contrat legacy "Intérim" (ancien modèle).
SELECT c.id
INTO legacy_interim_contract_id
FROM contracts c
WHERE LOWER(c.name) LIKE '%interim%'
ORDER BY c.id
LIMIT 1;
-- Si déjà supprimé, on ne fait rien.
IF legacy_interim_contract_id IS NULL THEN
RETURN;
END IF;
-- Contrat cible: suivi horaire 35h.
SELECT c.id
INTO target_time_35h_contract_id
FROM contracts c
WHERE c.tracking_mode = 'TIME'
AND c.weekly_hours = 35
AND c.id <> legacy_interim_contract_id
ORDER BY
CASE WHEN LOWER(c.name) = '35h' THEN 0 ELSE 1 END,
c.id
LIMIT 1;
IF target_time_35h_contract_id IS NULL THEN
RAISE EXCEPTION 'No TIME 35h contract found to replace legacy INTERIM contract id=%', legacy_interim_contract_id;
END IF;
-- Ré-assigne l'historique de périodes.
UPDATE employee_contract_periods
SET contract_id = target_time_35h_contract_id
WHERE contract_id = legacy_interim_contract_id;
-- Ré-assigne le pointeur actuel employé (compat legacy / affichage).
UPDATE employees
SET contract_id = target_time_35h_contract_id
WHERE contract_id = legacy_interim_contract_id;
-- Garde-fou FK avant suppression.
IF EXISTS (SELECT 1 FROM employee_contract_periods p WHERE p.contract_id = legacy_interim_contract_id)
OR EXISTS (SELECT 1 FROM employees e WHERE e.contract_id = legacy_interim_contract_id) THEN
RAISE EXCEPTION 'Legacy INTERIM contract id=% is still referenced', legacy_interim_contract_id;
END IF;
DELETE FROM contracts WHERE id = legacy_interim_contract_id;
END $$;
SQL
);
}
public function down(Schema $schema): void
{
throw new IrreversibleMigration('This migration performs data reassignment and contract deletion.');
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260302110000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add notifications table for user notification center';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE notifications (id SERIAL NOT NULL, recipient_id INT NOT NULL, title VARCHAR(120) NOT NULL, message TEXT NOT NULL, is_read BOOLEAN DEFAULT FALSE NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX idx_notifications_recipient_read_created ON notifications (recipient_id, is_read, created_at)');
$this->addSql('CREATE INDEX IDX_6000B0D0E92F8F78 ON notifications (recipient_id)');
$this->addSql('ALTER TABLE notifications ADD CONSTRAINT FK_6000B0D0E92F8F78 FOREIGN KEY (recipient_id) REFERENCES users (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE notifications DROP CONSTRAINT FK_6000B0D0E92F8F78');
$this->addSql('DROP TABLE notifications');
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260304140000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add paid leave settled flag on employee contract periods';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE employee_contract_periods ADD paid_leave_settled BOOLEAN DEFAULT FALSE NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE employee_contract_periods DROP paid_leave_settled');
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260304153000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create employee leave balances table for annual rollover opening balances';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE employee_leave_balances (id SERIAL NOT NULL, employee_id INT NOT NULL, rule_code VARCHAR(64) NOT NULL, year INT NOT NULL, opening_days DOUBLE PRECISION NOT NULL, opening_saturdays DOUBLE PRECISION NOT NULL, accrued_days DOUBLE PRECISION NOT NULL, accrued_saturdays DOUBLE PRECISION NOT NULL, taken_days DOUBLE PRECISION NOT NULL, taken_saturdays DOUBLE PRECISION NOT NULL, closing_days DOUBLE PRECISION NOT NULL, closing_saturdays DOUBLE PRECISION NOT NULL, is_locked BOOLEAN DEFAULT FALSE NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE UNIQUE INDEX uniq_employee_leave_balance ON employee_leave_balances (employee_id, rule_code, year)');
$this->addSql('CREATE INDEX idx_leave_balance_employee_year ON employee_leave_balances (employee_id, year)');
$this->addSql('ALTER TABLE employee_leave_balances ADD CONSTRAINT FK_3834A8A18C03F15C FOREIGN KEY (employee_id) REFERENCES employees (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE employee_leave_balances');
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260304170000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add comments on employee_leave_balances table and columns';
}
public function up(Schema $schema): void
{
$this->addSql("COMMENT ON TABLE employee_leave_balances IS 'Soldes de conges par employe et exercice (ouverture, mouvements, cloture).'");
$this->addSql("COMMENT ON COLUMN employee_leave_balances.rule_code IS 'Code de regle de calcul des conges (CDI_CDD_NON_FORFAIT, FORFAIT_218, ...).'");
$this->addSql("COMMENT ON COLUMN employee_leave_balances.year IS 'Annee d exercice de reference (ex: 2026).'");
$this->addSql("COMMENT ON COLUMN employee_leave_balances.opening_days IS 'Report N-1 en jours a l ouverture de l exercice.'");
$this->addSql("COMMENT ON COLUMN employee_leave_balances.opening_saturdays IS 'Report N-1 en samedis a l ouverture (0 pour forfait).'");
$this->addSql("COMMENT ON COLUMN employee_leave_balances.accrued_days IS 'Droits jours acquis sur l exercice courant.'");
$this->addSql("COMMENT ON COLUMN employee_leave_balances.accrued_saturdays IS 'Droits samedis acquis sur l exercice courant.'");
$this->addSql("COMMENT ON COLUMN employee_leave_balances.taken_days IS 'Jours de conges consommes sur l exercice.'");
$this->addSql("COMMENT ON COLUMN employee_leave_balances.taken_saturdays IS 'Samedis consommes sur l exercice.'");
$this->addSql("COMMENT ON COLUMN employee_leave_balances.closing_days IS 'Solde de cloture jours sur l exercice.'");
$this->addSql("COMMENT ON COLUMN employee_leave_balances.closing_saturdays IS 'Solde de cloture samedis sur l exercice.'");
$this->addSql("COMMENT ON COLUMN employee_leave_balances.is_locked IS 'Indique si le solde de l exercice est fige (verrouille RH).'");
}
public function down(Schema $schema): void
{
$this->addSql('COMMENT ON TABLE employee_leave_balances IS NULL');
$this->addSql('COMMENT ON COLUMN employee_leave_balances.rule_code IS NULL');
$this->addSql('COMMENT ON COLUMN employee_leave_balances.year IS NULL');
$this->addSql('COMMENT ON COLUMN employee_leave_balances.opening_days IS NULL');
$this->addSql('COMMENT ON COLUMN employee_leave_balances.opening_saturdays IS NULL');
$this->addSql('COMMENT ON COLUMN employee_leave_balances.accrued_days IS NULL');
$this->addSql('COMMENT ON COLUMN employee_leave_balances.accrued_saturdays IS NULL');
$this->addSql('COMMENT ON COLUMN employee_leave_balances.taken_days IS NULL');
$this->addSql('COMMENT ON COLUMN employee_leave_balances.taken_saturdays IS NULL');
$this->addSql('COMMENT ON COLUMN employee_leave_balances.closing_days IS NULL');
$this->addSql('COMMENT ON COLUMN employee_leave_balances.closing_saturdays IS NULL');
$this->addSql('COMMENT ON COLUMN employee_leave_balances.is_locked IS NULL');
}
}

View File

@@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260304173000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add comments on all database tables and columns';
}
public function up(Schema $schema): void
{
// absence_types
$this->addSql("COMMENT ON TABLE absence_types IS 'Catalogue des types d absence (CP, RTT, maladie, etc.).'");
$this->addSql("COMMENT ON COLUMN absence_types.id IS 'Identifiant technique du type d absence.'");
$this->addSql("COMMENT ON COLUMN absence_types.code IS 'Code metier court du type d absence.'");
$this->addSql("COMMENT ON COLUMN absence_types.label IS 'Libelle metier du type d absence.'");
$this->addSql("COMMENT ON COLUMN absence_types.color IS 'Couleur d affichage du type d absence.'");
$this->addSql("COMMENT ON COLUMN absence_types.count_as_worked_hours IS 'Indique si l absence est comptee comme temps travaille.'");
// absences
$this->addSql("COMMENT ON TABLE absences IS 'Absences employees enregistrees par jour et demi journee.'");
$this->addSql("COMMENT ON COLUMN absences.id IS 'Identifiant technique de l absence.'");
$this->addSql("COMMENT ON COLUMN absences.employee_id IS 'Employe concerne par l absence.'");
$this->addSql("COMMENT ON COLUMN absences.type_id IS 'Type d absence applique.'");
$this->addSql("COMMENT ON COLUMN absences.comment IS 'Commentaire libre de l absence.'");
$this->addSql("COMMENT ON COLUMN absences.start_date IS 'Date de debut de l absence.'");
$this->addSql("COMMENT ON COLUMN absences.end_date IS 'Date de fin de l absence.'");
$this->addSql("COMMENT ON COLUMN absences.start_half IS 'Demi journee de debut (AM ou PM).'");
$this->addSql("COMMENT ON COLUMN absences.end_half IS 'Demi journee de fin (AM ou PM).'");
// contracts
$this->addSql("COMMENT ON TABLE contracts IS 'Referentiel des contrats de travail utilisables.'");
$this->addSql("COMMENT ON COLUMN contracts.id IS 'Identifiant technique du contrat.'");
$this->addSql("COMMENT ON COLUMN contracts.name IS 'Nom metier du contrat (35h, 39h, Forfait, etc.).'");
$this->addSql("COMMENT ON COLUMN contracts.tracking_mode IS 'Mode de suivi du contrat (TIME ou PRESENCE).'");
$this->addSql("COMMENT ON COLUMN contracts.weekly_hours IS 'Volume horaire hebdomadaire contractuel, si applicable.'");
$this->addSql("COMMENT ON COLUMN contracts.is_active IS 'Indique si le contrat est actif dans le referentiel.'");
// doctrine_migration_versions
$this->addSql("COMMENT ON TABLE doctrine_migration_versions IS 'Historique technique des migrations Doctrine appliquees.'");
$this->addSql("COMMENT ON COLUMN doctrine_migration_versions.version IS 'Version unique de migration appliquee.'");
$this->addSql("COMMENT ON COLUMN doctrine_migration_versions.executed_at IS 'Date et heure d execution de la migration.'");
$this->addSql("COMMENT ON COLUMN doctrine_migration_versions.execution_time IS 'Duree d execution de la migration en millisecondes.'");
// employee_contract_periods
$this->addSql("COMMENT ON TABLE employee_contract_periods IS 'Historique des periodes de contrat par employe.'");
$this->addSql("COMMENT ON COLUMN employee_contract_periods.id IS 'Identifiant technique de la periode contrat.'");
$this->addSql("COMMENT ON COLUMN employee_contract_periods.employee_id IS 'Employe concerne par la periode contrat.'");
$this->addSql("COMMENT ON COLUMN employee_contract_periods.contract_id IS 'Contrat applique sur la periode.'");
$this->addSql("COMMENT ON COLUMN employee_contract_periods.start_date IS 'Date de debut de la periode contrat.'");
$this->addSql("COMMENT ON COLUMN employee_contract_periods.end_date IS 'Date de fin de la periode contrat, null si en cours.'");
$this->addSql("COMMENT ON COLUMN employee_contract_periods.created_at IS 'Date de creation technique de la periode contrat.'");
$this->addSql("COMMENT ON COLUMN employee_contract_periods.contract_nature IS 'Nature du contrat (CDI, CDD, INTERIM).'");
$this->addSql("COMMENT ON COLUMN employee_contract_periods.paid_leave_settled IS 'Indique si les conges ont ete soldes a la cloture de cette periode.'");
// employee_leave_balances
$this->addSql("COMMENT ON TABLE employee_leave_balances IS 'Soldes de conges par employe et exercice (ouverture, mouvements, cloture).'");
$this->addSql("COMMENT ON COLUMN employee_leave_balances.id IS 'Identifiant technique du solde annuel.'");
$this->addSql("COMMENT ON COLUMN employee_leave_balances.employee_id IS 'Employe concerne par le solde annuel.'");
$this->addSql("COMMENT ON COLUMN employee_leave_balances.rule_code IS 'Code de regle de calcul des conges (CDI_CDD_NON_FORFAIT, FORFAIT_218, ...).'");
$this->addSql("COMMENT ON COLUMN employee_leave_balances.year IS 'Annee d exercice de reference (ex: 2026).'");
$this->addSql("COMMENT ON COLUMN employee_leave_balances.opening_days IS 'Report N-1 en jours a l ouverture de l exercice.'");
$this->addSql("COMMENT ON COLUMN employee_leave_balances.opening_saturdays IS 'Report N-1 en samedis a l ouverture (0 pour forfait).'");
$this->addSql("COMMENT ON COLUMN employee_leave_balances.accrued_days IS 'Droits jours acquis sur l exercice courant.'");
$this->addSql("COMMENT ON COLUMN employee_leave_balances.accrued_saturdays IS 'Droits samedis acquis sur l exercice courant.'");
$this->addSql("COMMENT ON COLUMN employee_leave_balances.taken_days IS 'Jours de conges consommes sur l exercice.'");
$this->addSql("COMMENT ON COLUMN employee_leave_balances.taken_saturdays IS 'Samedis consommes sur l exercice.'");
$this->addSql("COMMENT ON COLUMN employee_leave_balances.closing_days IS 'Solde de cloture jours sur l exercice.'");
$this->addSql("COMMENT ON COLUMN employee_leave_balances.closing_saturdays IS 'Solde de cloture samedis sur l exercice.'");
$this->addSql("COMMENT ON COLUMN employee_leave_balances.is_locked IS 'Indique si le solde de l exercice est fige (verrouille RH).'");
$this->addSql("COMMENT ON COLUMN employee_leave_balances.created_at IS 'Date de creation technique du solde.'");
$this->addSql("COMMENT ON COLUMN employee_leave_balances.updated_at IS 'Date de derniere mise a jour du solde.'");
// employees
$this->addSql("COMMENT ON TABLE employees IS 'Referentiel des employes.'");
$this->addSql("COMMENT ON COLUMN employees.id IS 'Identifiant technique de l employe.'");
$this->addSql("COMMENT ON COLUMN employees.first_name IS 'Prenom de l employe.'");
$this->addSql("COMMENT ON COLUMN employees.last_name IS 'Nom de l employe.'");
$this->addSql("COMMENT ON COLUMN employees.created_at IS 'Date de creation technique de l employe.'");
$this->addSql("COMMENT ON COLUMN employees.site_id IS 'Site principal de rattachement de l employe.'");
$this->addSql("COMMENT ON COLUMN employees.display_order IS 'Ordre d affichage de l employe dans les listes UI.'");
$this->addSql("COMMENT ON COLUMN employees.contract_id IS 'Contrat courant reference sur la fiche employe.'");
// notifications
$this->addSql("COMMENT ON TABLE notifications IS 'Notifications applicatives envoyees aux utilisateurs.'");
$this->addSql("COMMENT ON COLUMN notifications.id IS 'Identifiant technique de la notification.'");
$this->addSql("COMMENT ON COLUMN notifications.recipient_id IS 'Utilisateur destinataire de la notification.'");
$this->addSql("COMMENT ON COLUMN notifications.title IS 'Titre court de la notification.'");
$this->addSql("COMMENT ON COLUMN notifications.message IS 'Message detaille de la notification.'");
$this->addSql("COMMENT ON COLUMN notifications.is_read IS 'Indique si la notification a ete lue.'");
$this->addSql("COMMENT ON COLUMN notifications.created_at IS 'Date de creation de la notification.'");
// sites
$this->addSql("COMMENT ON TABLE sites IS 'Referentiel des sites de l entreprise.'");
$this->addSql("COMMENT ON COLUMN sites.id IS 'Identifiant technique du site.'");
$this->addSql("COMMENT ON COLUMN sites.name IS 'Nom du site.'");
$this->addSql("COMMENT ON COLUMN sites.color IS 'Couleur associee au site pour affichage UI.'");
$this->addSql("COMMENT ON COLUMN sites.display_order IS 'Ordre d affichage du site dans les listes UI.'");
// user_site_roles
$this->addSql("COMMENT ON TABLE user_site_roles IS 'Roles attribues aux utilisateurs sur des sites donnes.'");
$this->addSql("COMMENT ON COLUMN user_site_roles.id IS 'Identifiant technique de l attribution de role site.'");
$this->addSql("COMMENT ON COLUMN user_site_roles.user_id IS 'Utilisateur concerne par l attribution.'");
$this->addSql("COMMENT ON COLUMN user_site_roles.site_id IS 'Site concerne par l attribution.'");
$this->addSql("COMMENT ON COLUMN user_site_roles.role IS 'Role attribue sur le site (ex: ROLE_SITE_MANAGER).'");
// users
$this->addSql("COMMENT ON TABLE users IS 'Comptes utilisateurs de l application.'");
$this->addSql("COMMENT ON COLUMN users.id IS 'Identifiant technique du compte utilisateur.'");
$this->addSql("COMMENT ON COLUMN users.username IS 'Identifiant de connexion de l utilisateur.'");
$this->addSql("COMMENT ON COLUMN users.roles IS 'Liste des roles globaux de securite du compte.'");
$this->addSql("COMMENT ON COLUMN users.password IS 'Hash du mot de passe utilisateur.'");
$this->addSql("COMMENT ON COLUMN users.employee_id IS 'Lien optionnel vers la fiche employe associee.'");
// work_hours
$this->addSql("COMMENT ON TABLE work_hours IS 'Saisie des heures de travail et presences par employe et par jour.'");
$this->addSql("COMMENT ON COLUMN work_hours.id IS 'Identifiant technique de la ligne de saisie.'");
$this->addSql("COMMENT ON COLUMN work_hours.employee_id IS 'Employe concerne par la saisie horaire.'");
$this->addSql("COMMENT ON COLUMN work_hours.work_date IS 'Date de travail de la ligne de saisie.'");
$this->addSql("COMMENT ON COLUMN work_hours.morning_from IS 'Heure de debut du matin.'");
$this->addSql("COMMENT ON COLUMN work_hours.morning_to IS 'Heure de fin du matin.'");
$this->addSql("COMMENT ON COLUMN work_hours.afternoon_from IS 'Heure de debut de l apres midi.'");
$this->addSql("COMMENT ON COLUMN work_hours.afternoon_to IS 'Heure de fin de l apres midi.'");
$this->addSql("COMMENT ON COLUMN work_hours.evening_from IS 'Heure de debut du soir.'");
$this->addSql("COMMENT ON COLUMN work_hours.evening_to IS 'Heure de fin du soir.'");
$this->addSql("COMMENT ON COLUMN work_hours.is_valid IS 'Validation RH de la ligne horaire.'");
$this->addSql("COMMENT ON COLUMN work_hours.is_present_morning IS 'Presence declarative du matin (mode PRESENCE).'");
$this->addSql("COMMENT ON COLUMN work_hours.is_present_afternoon IS 'Presence declarative de l apres midi (mode PRESENCE).'");
$this->addSql("COMMENT ON COLUMN work_hours.is_site_valid IS 'Validation site (chef de site) de la ligne horaire.'");
}
public function down(Schema $schema): void
{
$tables = [
'absence_types',
'absences',
'contracts',
'doctrine_migration_versions',
'employee_contract_periods',
'employee_leave_balances',
'employees',
'notifications',
'sites',
'user_site_roles',
'users',
'work_hours',
];
foreach ($tables as $table) {
$this->addSql(sprintf('COMMENT ON TABLE %s IS NULL', $table));
}
$columns = [
['absence_types', 'id'],
['absence_types', 'code'],
['absence_types', 'label'],
['absence_types', 'color'],
['absence_types', 'count_as_worked_hours'],
['absences', 'id'],
['absences', 'employee_id'],
['absences', 'type_id'],
['absences', 'comment'],
['absences', 'start_date'],
['absences', 'end_date'],
['absences', 'start_half'],
['absences', 'end_half'],
['contracts', 'id'],
['contracts', 'name'],
['contracts', 'tracking_mode'],
['contracts', 'weekly_hours'],
['contracts', 'is_active'],
['doctrine_migration_versions', 'version'],
['doctrine_migration_versions', 'executed_at'],
['doctrine_migration_versions', 'execution_time'],
['employee_contract_periods', 'id'],
['employee_contract_periods', 'employee_id'],
['employee_contract_periods', 'contract_id'],
['employee_contract_periods', 'start_date'],
['employee_contract_periods', 'end_date'],
['employee_contract_periods', 'created_at'],
['employee_contract_periods', 'contract_nature'],
['employee_contract_periods', 'paid_leave_settled'],
['employee_leave_balances', 'id'],
['employee_leave_balances', 'employee_id'],
['employee_leave_balances', 'rule_code'],
['employee_leave_balances', 'year'],
['employee_leave_balances', 'opening_days'],
['employee_leave_balances', 'opening_saturdays'],
['employee_leave_balances', 'accrued_days'],
['employee_leave_balances', 'accrued_saturdays'],
['employee_leave_balances', 'taken_days'],
['employee_leave_balances', 'taken_saturdays'],
['employee_leave_balances', 'closing_days'],
['employee_leave_balances', 'closing_saturdays'],
['employee_leave_balances', 'is_locked'],
['employee_leave_balances', 'created_at'],
['employee_leave_balances', 'updated_at'],
['employees', 'id'],
['employees', 'first_name'],
['employees', 'last_name'],
['employees', 'created_at'],
['employees', 'site_id'],
['employees', 'display_order'],
['employees', 'contract_id'],
['notifications', 'id'],
['notifications', 'recipient_id'],
['notifications', 'title'],
['notifications', 'message'],
['notifications', 'is_read'],
['notifications', 'created_at'],
['sites', 'id'],
['sites', 'name'],
['sites', 'color'],
['sites', 'display_order'],
['user_site_roles', 'id'],
['user_site_roles', 'user_id'],
['user_site_roles', 'site_id'],
['user_site_roles', 'role'],
['users', 'id'],
['users', 'username'],
['users', 'roles'],
['users', 'password'],
['users', 'employee_id'],
['work_hours', 'id'],
['work_hours', 'employee_id'],
['work_hours', 'work_date'],
['work_hours', 'morning_from'],
['work_hours', 'morning_to'],
['work_hours', 'afternoon_from'],
['work_hours', 'afternoon_to'],
['work_hours', 'evening_from'],
['work_hours', 'evening_to'],
['work_hours', 'is_valid'],
['work_hours', 'is_present_morning'],
['work_hours', 'is_present_afternoon'],
['work_hours', 'is_site_valid'],
];
foreach ($columns as [$table, $column]) {
$this->addSql(sprintf('COMMENT ON COLUMN %s.%s IS NULL', $table, $column));
}
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260306120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create employee_rtt_balances table for RTT opening balances.';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE employee_rtt_balances (id SERIAL NOT NULL, employee_id INT NOT NULL, year INT NOT NULL, opening_minutes INT NOT NULL, is_locked BOOLEAN DEFAULT FALSE NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE UNIQUE INDEX uniq_employee_rtt_balance ON employee_rtt_balances (employee_id, year)');
$this->addSql('CREATE INDEX idx_rtt_balance_employee_year ON employee_rtt_balances (employee_id, year)');
$this->addSql('ALTER TABLE employee_rtt_balances ADD CONSTRAINT FK_rtt_balance_employee FOREIGN KEY (employee_id) REFERENCES employees (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE employee_rtt_balances');
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260306140000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add updated_at column to work_hours table.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE work_hours ADD COLUMN updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE work_hours DROP COLUMN updated_at');
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260306160000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add comment column to employee_contract_periods table.';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE employee_contract_periods ADD COLUMN comment TEXT DEFAULT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE employee_contract_periods DROP COLUMN comment');
}
}

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