Compare commits

..

44 Commits

Author SHA1 Message Date
gitea-actions
b541f9ded8 chore: bump version to v0.1.101
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 38s
2026-05-04 07:59:16 +00:00
47f9bea57d feat : sélecteur d'exercice sur l'onglet RTT de la fiche employé
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Permet de consulter les exercices passés (table hebdomadaire RTT) en
réutilisant le pattern de l'onglet Congés. Plage bornée par
max(début historique contrat, RTT_START_DATE). Bouton + Payer les RTT
verrouillé sur exercices clos. Onglet masqué pour FORFAIT (inchangé).

Backend : rttStartDate désormais toujours exposé sur EmployeeRttSummary
pour que le sélecteur conserve sa borne lors de la navigation vers un
exercice passé. Le masquage existant des lignes Report continue de
fonctionner (comparaison mois-à-mois).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 09:58:50 +02:00
7cadcfa362 feat : sélecteur d'année sur l'onglet Congés de la fiche employé
Permet de consulter les exercices passés (calendrier + compteurs) sur
l'onglet Congés. La plage proposée est bornée par max(début historique
contrat, RTT_START_DATE) pour ne pas remonter avant la mise en service
du logiciel. Édition des stocks N-1 et fractionnés verrouillée sur
exercices clos.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 09:51:19 +02:00
gitea-actions
3ec0d4b074 chore: bump version to v0.1.100
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 40s
2026-04-29 15:45:14 +00:00
eaf8a11e2b feat: ajout des commentaires à la semaine (#15)
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

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

Reviewed-on: #15
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-29 15:45:02 +00:00
gitea-actions
02fc94fbed chore: bump version to v0.1.99
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 39s
2026-04-29 15:28:10 +00:00
eb5910dffe feat : surlignage des jours fériés sur la vue semaine des heures
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Quand un employé n'a pas d'absence sur un jour férié, la cellule prend le fond bleu clair (#b3e5fc) et affiche le nom du férié au survol — cohérent avec la vue jour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 17:27:46 +02:00
78f73ed2e9 feat : ajout des jours fériés sur l'export PDF des heures
Affiche désormais une ligne dédiée pour chaque jour férié (Lun-Ven) avec la mention "Férié : {nom}" et le total créditant les heures contractuelles, comme sur l'écran Heures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 17:21:59 +02:00
eacf52425a fix : récap salaire chauffeur, comptage des repas (déjeuner + dîner)
Un jour avec déjeuner ET dîner cochés ne comptait qu'1 repas (||) au lieu de 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 15:27:00 +02:00
gitea-actions
6f43c3356f chore: bump version to v0.1.98
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 44s
2026-04-29 09:43:56 +00:00
13eeeb9c86 feat : ajout colonne Cumul sur l'écran RTT (#18)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Affiche le solde RTT à la fin de chaque semaine (report N-1 + somme
totalMinutes des semaines − paiements des mois antérieurs). Permet la
comparaison ligne à ligne avec un suivi RH externe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

| 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: #18
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-29 09:43:46 +00:00
gitea-actions
973de2d094 chore: bump version to v0.1.97
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 56s
2026-04-27 13:02:01 +00:00
74c109713c fix : malio UI
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-27 15:01:51 +02:00
gitea-actions
06173e7225 chore: bump version to v0.1.96
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 2m59s
2026-04-27 12:08:31 +00:00
cc868a1e82 feat: ajout malio UI + décompte des jours de présence forfait (#17)
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: #17
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-27 12:08:24 +00:00
gitea-actions
90843dd997 chore: bump version to v0.1.95
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 31s
2026-04-20 14:19:05 +00:00
8a449cf81b feat : paiement RTT en centièmes d'heures + auto-calcul bonus
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
- Input step passé de 0.5 à 0.01 pour accepter les centièmes (xx,xx)
- Labels mis à jour "(centièmes)" au lieu de "(heures)"
- Auto-remplissage du bonus 25% (base × 0.25) et 50% (base × 0.50)
- Ligne "Payé" affiche désormais les centièmes en gris comme les autres lignes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 16:18:54 +02:00
gitea-actions
3926946a5f chore: bump version to v0.1.94
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build & Push Docker Image / build (push) Successful in 33s
2026-04-20 10:12:10 +00:00
b9c3a8a84f [#SIRH-25] Version mobile (#16)
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: #16
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-20 10:12:05 +00:00
gitea-actions
b2f6fdf222 chore: bump version to v0.1.93
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 18s
2026-04-20 06:25:18 +00:00
0fe82c63c5 Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-20 08:25:11 +02:00
849d19f124 fix : autoriser docker/php/config/php.ini dans .dockerignore pour le build prod
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 08:24:39 +02:00
gitea-actions
d230a252b6 chore: bump version to v0.1.92
Some checks failed
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Failing after 6s
2026-04-20 06:21:05 +00:00
d46e7c04d5 fix : copier la config PHP custom (memory_limit 512M) dans l'image de prod
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 08:20:26 +02:00
gitea-actions
fe0910a661 chore: bump version to v0.1.91
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 33s
2026-04-17 14:58:36 +00:00
ff7566d4cd feat : export PDF heures groupé depuis la liste employés + memory_limit 256M
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
- Nouveau endpoint GET /yearly-hours/print-all (admin, par mois uniquement)
- Service YearlyHoursExportBuilder extrait du provider existant (logique partagée)
- EmployeeYearlyHoursPrintProvider refactorisé pour utiliser le builder
- Template print-all.html.twig avec saut de page entre chaque employé
- Drawer BulkYearlyHoursDrawer avec loader "Génération en cours..."
- Bouton "Export heures" ajouté sur la page liste employés
- PHP memory_limit passé de 128M à 256M dans php.ini (nécessaire pour Dompdf multi-employés)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 16:57:58 +02:00
gitea-actions
2f25a3cd52 chore: bump version to v0.1.90
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 31s
2026-04-17 09:47:42 +00:00
1fe7f2cdde feat : agence d'intérim sur les contrats INTERIM + renommage Types d'absence en Types de statut + colonne Absence en Statut
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
- Nouvelle entité InterimAgency (table interim_agencies, API lecture seule)
- Sélecteur agence conditionnel dans les formulaires création employé et ajout contrat
- Affichage "Intérim (NomAgence)" sur la liste employés et l'historique contrat
- Date de fin obligatoire côté frontend pour CDD et INTERIM (aligné backend)
- Renommage "Types d'absence" → "Types de statut" (sidebar, page, titre)
- Renommage en-tête "Absence" → "Statut" sur les vues jour heures et conducteurs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 11:47:14 +02:00
gitea-actions
9e411be3c3 chore: bump version to v0.1.89
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 32s
2026-04-17 09:05:24 +00:00
90e63a463e feat : autoriser la création d'absences sur les jours fériés depuis le calendrier
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 11:04:57 +02:00
gitea-actions
51bf155b0e chore: bump version to v0.1.88
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 56s
2026-04-17 06:59:10 +00:00
1095421424 feat : modification des exports PDF et affichage du type de contrat sur l'écran des heures
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-17 08:58:58 +02:00
gitea-actions
be7c16778a chore: bump version to v0.1.87
Some checks failed
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Failing after 46s
2026-04-16 13:52:31 +00:00
a8fe244b5c feat : modification de la gestion des jours fériés
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
2026-04-16 15:52:19 +02:00
gitea-actions
13c71abddc chore: bump version to v0.1.86
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 34s
2026-04-14 13:55:11 +00:00
9581f9d8d9 Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-14 15:55:03 +02:00
c2eaa06aff fix : écran du récap. congés ordre d'affichage + Calcule des jours ouvrés pour les FORFAIT 2026-04-14 15:54:57 +02:00
gitea-actions
187a634cc8 chore: bump version to v0.1.85
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 31s
2026-04-14 13:08:56 +00:00
0897154460 feat : ajout d'un écran pour le récap congés et RTT
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-14 15:08:45 +02:00
gitea-actions
11331da6a1 chore: bump version to v0.1.84
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 33s
2026-04-14 09:25:55 +00:00
399fd7335e fix : exclusion de certain jour férié et affichage différent des jours férié dans la page d'heure
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-14 11:25:44 +02:00
gitea-actions
46cb7f1a16 chore: bump version to v0.1.83
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 35s
2026-04-14 06:38:09 +00:00
b934f4d81f Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-14 08:38:01 +02:00
77c1cdcbbd fix : on masque la validation chef site 2026-04-14 08:37:54 +02:00
129 changed files with 7102 additions and 1778 deletions

View File

@@ -3,6 +3,7 @@
.env.local .env.local
.env.test .env.test
docker/ docker/
!docker/php/config/php.ini
deploy/docker/docker-compose.prod.yml deploy/docker/docker-compose.prod.yml
deploy/docker/deploy.sh deploy/docker/deploy.sh
deploy/docker/.env.example deploy/docker/.env.example

3
.env
View File

@@ -38,6 +38,9 @@ DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&ch
###> app ### ###> app ###
RTT_START_DATE=2026-02-23 RTT_START_DATE=2026-02-23
# Comma-separated list of public holiday labels to exclude from the government API response
# (typically the "journée de solidarité" worked in many companies)
EXCLUDED_PUBLIC_HOLIDAYS="Lundi de Pentecôte"
###< app ### ###< app ###
###> nelmio/cors-bundle ### ###> nelmio/cors-bundle ###

View File

@@ -15,6 +15,7 @@
## Stack ## Stack
- Backend: Symfony + API Platform + Doctrine ORM - Backend: Symfony + API Platform + Doctrine ORM
- Frontend: Nuxt 4 + Vue 3 + TypeScript + Tailwind CSS - Frontend: Nuxt 4 + Vue 3 + TypeScript + Tailwind CSS
- UI library: `@malio/layer-ui` (Nuxt layer, `extends: ['@malio/layer-ui']` dans `nuxt.config.ts`). Composants auto-importés avec préfixe `Malio*` (ex. `MalioSelectCheckbox`, `MalioInputText`…). Doc d'usage dans `node_modules/@malio/layer-ui/COMPONENTS.md`. Tokens Tailwind `m-*` (primary/muted/danger/success/…) et variables CSS `--m-*` fournies par la couche.
## Project Structure ## Project Structure
- `src/` — Symfony domain, API resources, state providers/processors, services - `src/` — Symfony domain, API resources, state providers/processors, services
@@ -30,11 +31,29 @@
- Contracts: `trackingMode` (TIME=hours, PRESENCE=half-days), `weeklyHours` - Contracts: `trackingMode` (TIME=hours, PRESENCE=half-days), `weeklyHours`
- Contract types: FORFAIT, THIRTY_FIVE_HOURS, THIRTY_NINE_HOURS, INTERIM, CUSTOM - Contract types: FORFAIT, THIRTY_FIVE_HOURS, THIRTY_NINE_HOURS, INTERIM, CUSTOM
- Contract nature (per period): CDI, CDD, INTERIM - Contract nature (per period): CDI, CDD, INTERIM
- **Agence d'intérim** (`InterimAgency` entity, table `interim_agencies`): optionnelle sur `EmployeeContractPeriod` quand nature = INTERIM. Pas de CRUD UI — gérée en BDD. API lecture seule `GET /interim_agencies`. Affichée "Intérim (NomAgence)" sur la liste employés et l'historique contrat.
- Employee contract history: `employee_contract_periods`, resolved by `EmployeeContractResolver` - Employee contract history: `employee_contract_periods`, resolved by `EmployeeContractResolver`
- **Écrans Heures / Heures Conducteurs (vue jour)** : le libellé nature (CDI/CDD/Intérim) sous le nom de l'employé est résolu **à la date filtrée** via `WorkHourDayContext.contractNature` (alimenté par `EmployeeContractResolver::resolveNatureForEmployeeAndDate`), pas via `Employee.currentContractNature` (qui est résolu à aujourd'hui).
- **Écran Calendrier** : un employé est affiché uniquement si au moins une de ses périodes de contrat (`employee.contractHistory`) intersecte le mois affiché (`[1er ; dernier jour]`). Filtre côté frontend dans `visibleEmployees` (`pages/calendar.vue`).
- **Planning jours travaillés** (`EmployeeContractPeriod.workDaysHours` : JSON `{iso_day: minutes}`) : obligatoire pour tout contrat TIME **hors 35h/39h/INTERIM** (ex. 4h, 25h, 28h). Somme = `weeklyHours × 60`. Utilisé par `HolidayVirtualHoursResolver` (crédit férié) et `WorkedHoursCreditPolicy` (crédit absence) pour ne créditer que les jours effectivement travaillés. Validation : `EmployeeContractPeriodValidator::assertWorkDaysHours`.
- Absences: stored per day (auto-split), AM/PM/full day, clear corresponding hour slots - Absences: stored per day (auto-split), AM/PM/full day, clear corresponding hour slots
- Absences with `countAsWorkedHours=true`: credit minutes (TIME) or nothing (PRESENCE) - Absences with `countAsWorkedHours=true`: credit minutes (TIME) or nothing (PRESENCE)
- Driver periods (`isDriver=true` on `EmployeeContractPeriod`): separate screen `/driver-hours`, uses `dayHoursMinutes`/`nightHoursMinutes` + meal/overnight flags on `WorkHour` - Driver periods (`isDriver=true` on `EmployeeContractPeriod`): separate screen `/driver-hours`, uses `dayHoursMinutes`/`nightHoursMinutes` + meal/overnight flags on `WorkHour`
## Fériés
- Source : API gouv via `PublicHolidayService` (cache 30j)
- Exclusions : env `EXCLUDED_PUBLIC_HOLIDAYS` (CSV de libellés), défaut `"Lundi de Pentecôte"`. Le filtre s'applique après le cache, côté service, donc frontend et calculs backend voient la même liste.
- Écrans Heures / Heures Conducteurs (vue jour) : le nom du férié est affiché en badge `#b3e5fc` avec icône `mdi:calendar-star` dans la colonne Absence (distinct du pill absence). Bouton "Modifier" absence masqué sur férié (comme pour les formations).
- Création/édition d'absence **autorisée** sur un férié (bouton Modifier visible). En présence d'absence, le crédit d'heures suit `absence.type.countAsWorkedHours` (WorkedHoursCreditPolicy), pas le crédit virtuel férié.
- Saisie d'heures (ou de jours de présence) autorisée sur un férié
- **Crédit automatique des heures contractuelles** sur un férié Lun-Ven pour tout contrat hors Forfait, **uniquement en l'absence d'absence déclarée** : le total journalier = `max(saisie + credited_absence, référence_contractuelle)`. Référence : 35h→7h, 39h→8h Lun-Jeu/7h Ven, CUSTOM→weeklyHours/5, INTERIM→idem 35h/39h/custom selon weeklyHours. Aucune ligne BDD créée (crédit virtuel). Drivers : crédité en `dayHoursMinutes`. Impacte directement le total hebdo RTT (tranches 25%/50%). Dès qu'une absence est posée sur le férié, le crédit virtuel saute — c'est le `countAsWorkedHours` du type d'absence qui pilote. Services : `App\Service\WorkHours\HolidayVirtualHoursResolver` + `DailyReferenceMinutesResolver`. Doc complète : `doc/holiday-virtual-hours.md`.
## Commentaires de semaine
- Entité `EmployeeWeekComment` : commentaire libre par employé et semaine ISO (unique `(employee_id, week_start_date)`). `week_start_date` = lundi.
- CRUD `/employee_week_comments` `ROLE_ADMIN`. Write processor audite via `AuditLogger`.
- Picto bulle vue semaine (HoursWeekView + DriverHoursWeekView) : fond bleu/rouge. Intégré dans `WeeklySummaryRow.comment/commentId`.
- Doc : `doc/week-comments.md`.
## Validation Rules ## Validation Rules
- `isValid` (RH): locks line for everyone (admin can only untoggle validation) - `isValid` (RH): locks line for everyone (admin can only untoggle validation)
- `isSiteValid` (site manager): locks for non-admin, admin can still edit - `isSiteValid` (site manager): locks for non-admin, admin can still edit
@@ -48,6 +67,32 @@
- INTERIM: no overtime bonuses, no recovery time - INTERIM: no overtime bonuses, no recovery time
- Driver contracts: RTT uses `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` instead of morning/afternoon/evening time ranges - Driver contracts: RTT uses `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` instead of morning/afternoon/evening time ranges
- FORFAIT weekend/holiday bonus: each weekend or public holiday day worked gives bonus leave (full day if morning+afternoon, 0.5 if only one). Added to acquired days, no cap. PRESENCE mode only. - FORFAIT weekend/holiday bonus: each weekend or public holiday day worked gives bonus leave (full day if morning+afternoon, 0.5 if only one). Added to acquired days, no cap. PRESENCE mode only.
- **FORFAIT — jours de présence et N-1** : les congés posés et imputés sur le stock N-1 ne décrémentent **pas** les jours de présence affichés (`presenceDaysByMonth` et `presenceDaysToToday`). Implémenté dans `EmployeeLeaveSummaryProvider::computePresenceDaysByMonth` via un budget N-1 (= `previousYearTakenDays`) consommé chronologiquement avant comptage des absences. Pour les non-forfait, ce budget vaut toujours 0 → comportement inchangé.
## Onglet Congés (fiche employé)
- Calendrier annuel des congés (`frontend/components/employees/LeaveTab.vue`) — période = Janvier→Décembre pour FORFAIT, Juin(N-1)→Mai(N) pour les autres contrats. Règle pilotée par le **contrat courant** (cf. `EmployeeLeaveSummaryProvider::resolveYear`), même quand on consulte une année passée.
- **Sélecteur d'année** en pied de calendrier (zone scrollable, à gauche). Plage : de l'exercice courant jusqu'à `max(floor_contrat, floor_data_start_date)``floor_contrat` = premier exercice avec contrat ouvert (`employee.contractHistory[].startDate`) ; `floor_data_start_date` = exercice contenant `RTT_START_DATE` (env, ex. `2026-02-23` → exercice 2026). Le double plancher empêche de remonter avant la mise en service du logiciel. Format : `2026` pour FORFAIT, `Juin 2025 → Mai 2026` sinon.
- Changement d'année → recharge complète de l'onglet via `useEmployeeLeave.setSelectedLeaveYear(year)` (reload de `getEmployeeLeaveSummary?year=YYYY` + `listAbsences` + `listPublicHolidays`). Backend : filtre `?year=YYYY` validé 2000-2100, et `EmployeeLeaveSummary` expose `dataStartDate` (env `RTT_START_DATE`, injecté via `services.yaml`).
- Sur un exercice passé (`selectedYear !== currentYear`), les boutons crayon **Jours fractionnés** et **Année N-1 payés** sont **désactivés** : pas d'édition rétroactive des stocks de report.
- Doc : `doc/leave-tab.md`.
## Onglet RTT (fiche employé)
- Tableau hebdomadaire (`frontend/components/employees/RttTab.vue`) — exercice fixe Juin(N-1)→Mai(N). Onglet **masqué pour les FORFAIT** (`showRttTab`).
- **Sélecteur d'année** sous le tableau dans la zone scrollable. Même mécanique que l'onglet Congés (double plancher) : `max(floor_contrat, floor_rttStartDate)`. Format unique : `Juin 2025 → Mai 2026`.
- Changement d'année → recharge via `useEmployeeRtt.setSelectedRttYear(year)` (`getEmployeeRttSummary?year=YYYY`). `EmployeeRttSummary.rttStartDate` est déjà exposé (champ existant) — il sert à la fois au floor du sélecteur et au masquage des lignes Report avant la mise en service.
- Sur un exercice passé, le bouton **+ Payer les RTT** est désactivé (pas de paiement rétroactif).
- Doc : `doc/rtt-tab.md`.
## Récap. congés (écran)
- Accès via sidebar `Récap. congés`, conditionné au flag `User.hasLeaveRecapAccess` (défaut `false`) — activé au create/edit user. Le flag s'applique à tous les profils, y compris admin.
- Scope : `ROLE_ADMIN` → tous les employés, `ROLE_USER` (chef de site) → employés de ses sites, `ROLE_SELF` → sa ligne
- Cutoff temporel : fin de la semaine S-2 (dimanche 23:59:59). Formule : `dimanche(lundi_semaine_courante 14j)`. Pas de gate `isValid`.
- Helper : `App\Util\LeaveRecapCutoff::resolveCutoff()`
- Colonnes : Nom, Prénom, Contrat, CP N-1 restant, CP N, Samedis, RTT — identiques au PDF
- Service partagé : `LeaveRecapRowBuilder` consommé par `LeaveRecapPrintProvider` (as-of today) et `EmployeeLeaveRecapProvider` (as-of cutoff)
- `EmployeeLeaveSummaryProvider::computeYearSummary()` accepte un `?DateTimeImmutable $asOfDate` qui cappe l'accrual et les absences sur l'année cible (`null` = comportement live inchangé)
- Pas d'export PDF depuis cet écran
- Doc détaillée : `doc/leave-recap-screen.md`
## Frais (MileageAllowance) ## Frais (MileageAllowance)
- Onglet "Frais" (anciennement "Frais Kms") sur la fiche employé - Onglet "Frais" (anciennement "Frais Kms") sur la fiche employé

View File

@@ -23,3 +23,5 @@ docker compose exec -T db psql -U root -d sirh < sirh.sql
```sql ```sql
UPDATE users SET roles = '["ROLE_ADMIN","ROLE_SUPER_ADMIN"]' WHERE username = 'emilie'; UPDATE users SET roles = '["ROLE_ADMIN","ROLE_SUPER_ADMIN"]' WHERE username = 'emilie';
``` ```
sudo -u postgres pg_dump --no-owner --no-privileges --clean --if-exists sirh_prod > /tmp/sirh_prod_$(date +%F).sql
scp user@<serveur>:/tmp/sirh_prod_2026-04-14.dump ~/workspace/

View File

@@ -25,6 +25,7 @@ services:
App\Service\PublicHolidayService: App\Service\PublicHolidayService:
arguments: arguments:
$holidayUrl: '%env(HOLIDAY_URL)%' $holidayUrl: '%env(HOLIDAY_URL)%'
$excludedLabels: '%env(default::EXCLUDED_PUBLIC_HOLIDAYS)%'
App\Service\Rtt\RttRecoveryComputationService: App\Service\Rtt\RttRecoveryComputationService:
arguments: arguments:
@@ -34,6 +35,10 @@ services:
arguments: arguments:
$rttStartDate: '%env(RTT_START_DATE)%' $rttStartDate: '%env(RTT_START_DATE)%'
App\State\EmployeeLeaveSummaryProvider:
arguments:
$dataStartDate: '%env(RTT_START_DATE)%'
App\Repository\Contract\AbsenceReadRepositoryInterface: '@App\Repository\AbsenceRepository' App\Repository\Contract\AbsenceReadRepositoryInterface: '@App\Repository\AbsenceRepository'
App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface: '@App\Repository\EmployeeContractPeriodRepository' App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface: '@App\Repository\EmployeeContractPeriodRepository'
App\Repository\Contract\EmployeeScopedRepositoryInterface: '@App\Repository\EmployeeRepository' App\Repository\Contract\EmployeeScopedRepositoryInterface: '@App\Repository\EmployeeRepository'

View File

@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.82' app.version: '0.1.101'

View File

@@ -23,3 +23,4 @@ DEFAULT_URI=https://sirh.malio-dev.fr
APP_SHARE_DIR=var/share APP_SHARE_DIR=var/share
RTT_START_DATE=2026-02-23 RTT_START_DATE=2026-02-23
HOLIDAY_URL="https://calendrier.api.gouv.fr/jours-feries/" HOLIDAY_URL="https://calendrier.api.gouv.fr/jours-feries/"
EXCLUDED_PUBLIC_HOLIDAYS="Lundi de Pentecôte"

View File

@@ -45,6 +45,7 @@ RUN apt-get update && apt-get install -y \
# PHP production config # PHP production config
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
COPY docker/php/config/php.ini "$PHP_INI_DIR/conf.d/99-app.ini"
# PHP-FPM: forward worker output to stderr for docker logs # PHP-FPM: forward worker output to stderr for docker logs
RUN echo "catch_workers_output = yes" >> /usr/local/etc/php-fpm.d/www.conf \ RUN echo "catch_workers_output = yes" >> /usr/local/etc/php-fpm.d/www.conf \

View File

@@ -58,6 +58,9 @@ Documents complementaires:
- mise à jour uniquement quand un employé (`ROLE_SELF`) modifie ses propres heures - mise à jour uniquement quand un employé (`ROLE_SELF`) modifie ses propres heures
- non mise à jour lors de modifications admin ou chef de site - non mise à jour lors de modifications admin ou chef de site
- affichée sous le nom de l'employé (visible admin uniquement) - affichée sous le nom de l'employé (visible admin uniquement)
- Libellé nature de contrat (CDI/CDD/Intérim) affiché sous le nom:
- résolu à la date filtrée (période de contrat couvrant ce jour), pas à aujourd'hui
- masqué si aucun contrat à cette date (cas rarissime en vue jour puisque l'employé est alors déjà filtré)
## 4) Absences ## 4) Absences
@@ -71,6 +74,10 @@ Documents complementaires:
- Calendrier congés: fond coloré selon la couleur du type d'absence (`AbsenceType.color`) - Calendrier congés: fond coloré selon la couleur du type d'absence (`AbsenceType.color`)
- demi-journée: dégradé diagonal - demi-journée: dégradé diagonal
- journée complète: fond plein - journée complète: fond plein
- Visibilité des employés dans le Calendrier:
- un employé est affiché si au moins une de ses périodes de contrat intersecte le mois affiché
- un employé dont toutes les périodes se terminent avant le 1er du mois (ou commencent après la fin du mois) est masqué
- même logique que l'écran Heures : « pas de contrat sur la période → masqué »
### Effet absence sur les heures ### Effet absence sur les heures
@@ -130,6 +137,7 @@ Documents complementaires:
- pas de bonus 25% - pas de bonus 25%
- pas de bonus 50% - pas de bonus 50%
- pas de total récup - pas de total récup
- agence d'intérim optionnelle (table `interim_agencies`): affichée sur la fiche employé et le détail contrat sous la forme "Intérim (NomAgence)"
## 6bis) Heures Conducteurs ## 6bis) Heures Conducteurs
@@ -161,10 +169,15 @@ Documents complementaires:
## 7) Fériés ## 7) Fériés
- Les jours fériés sont identifiés et affichés - Les jours fériés sont identifiés et affichés
- Source: API `calendrier.api.gouv.fr/jours-feries/` via `PublicHolidayService` (cache 30j)
- Exclusions configurables: variable d'env `EXCLUDED_PUBLIC_HOLIDAYS` (liste de libellés séparés par virgules). Par défaut `"Lundi de Pentecôte"` — journée de solidarité généralement travaillée. Le filtre s'applique à tous les consommateurs (frontend + calculs backend) en amont du retour du service.
- Onglet congés: jours fériés affichés sur le calendrier avec fond `rgb(179, 229, 252)` et nom au survol - Onglet congés: jours fériés affichés sur le calendrier avec fond `rgb(179, 229, 252)` et nom au survol
- Écran Heures et Heures Conducteurs (vue jour): le nom du férié est affiché dans la colonne Absence sous forme de pill (fond `#b3e5fc`, icône `mdi:calendar-star`), distinct du pill absence
- Écran Heures et Heures Conducteurs (vue semaine): la cellule du jour férié prend le fond `#b3e5fc` quand l'employé n'a pas d'absence ce jour-là, avec le nom du férié au survol (`title`). Si une absence est posée, la couleur de l'absence prime ; le `title` cumule les deux libellés (`Absence — Férié : Nom`).
- Règle courante: - Règle courante:
- absences bloquées sur jour férié - absences autorisées sur jour férié (création/édition depuis l'écran Heures et le Calendrier). Quand une absence est posée, le crédit virtuel férié est désactivé — c'est le `countAsWorkedHours` du type d'absence qui pilote
- saisie d'heures autorisée - saisie d'heures ou de jours de présence autorisée — les heures saisies comptent normalement dans le total hebdo et le calcul RTT
- la référence hebdomadaire n'est pas réduite par un férié: un salarié qui ne saisit rien sur un férié est en déficit de la journée correspondante
## 8) Impression absences (PDF) ## 8) Impression absences (PDF)
@@ -301,6 +314,7 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
- les heures payées sont soustraites du disponible RTT (`availableMinutes -= totalPaidMinutes`) - les heures payées sont soustraites du disponible RTT (`availableMinutes -= totalPaidMinutes`)
- affichage: 2 lignes par mois dans le tableau (25% et 50%) - affichage: 2 lignes par mois dans le tableau (25% et 50%)
- colonnes Total 25% et Total 50%: somme base + bonus de chaque tranche - colonnes Total 25% et Total 50%: somme base + bonus de chaque tranche
- colonne Cumul (dernière colonne): solde RTT à la fin de chaque semaine = `report N-1 + somme totalMinutes des semaines jusqu'à celle-ci paiements RTT des mois antérieurs au mois de la semaine`. Le paiement d'un mois M n'est déduit qu'à partir des semaines du mois M+1 (cohérent avec la logique de la ligne "Report mois précédent"). Permet la comparaison ligne à ligne avec un suivi RH externe (Excel)
- ligne Report N-1 (carry rollover): affichée en juin uniquement si carry > 0 - ligne Report N-1 (carry rollover): affichée en juin uniquement si carry > 0
- ligne Report mois précédent: solde cumulé (carry N-1 + semaines antérieures paiements antérieurs), affichée à partir de juillet (masquée si nul) - ligne Report mois précédent: solde cumulé (carry N-1 + semaines antérieures paiements antérieurs), affichée à partir de juillet (masquée si nul)
- Reste = Report cumulé + Total du mois Payé du mois (balance courante en fin de mois) - Reste = Report cumulé + Total du mois Payé du mois (balance courante en fin de mois)
@@ -323,9 +337,27 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
| Contrat | Contract.name | | Contrat | Contract.name |
| CP N-1 restant | CDI/CDD: acquis N-1 pris sur N-1. Forfait: report N-1 restant | | CP N-1 restant | CDI/CDD: acquis N-1 pris sur N-1. Forfait: report N-1 restant |
| Samedi restant | CDI/CDD: samedis acquis N-1 pris. Forfait: `-` | | Samedi restant | CDI/CDD: samedis acquis N-1 pris. Forfait: `-` |
| CP N | Forfait: jours acquis année civile. Non-forfait: en cours d'acquisition | | CP N | Forfait: restant sur quota année civile (acquis pris depuis N, sans toucher au stock N-1). Non-forfait: en cours d'acquisition |
| RTT | Minutes disponibles (report N-1 + acquis N - payés). Format `X h Y m`. Forfait et INTERIM: `-` | | RTT | Minutes disponibles (report N-1 + acquis N - payés). Format `X h Y m`. Forfait et INTERIM: `-` |
## 10bis) Écran Récap. congés (tableau)
- Complément de l'export PDF : même logique de calcul, mais accessible aux employés et chefs de site
- Endpoint: `GET /api/leave-recap`
- Accès conditionné au flag `User.hasLeaveRecapAccess` (défaut `false`, activé au create/edit user)
- Le flag s'applique à tous les profils, y compris admin (pas de bypass)
- Scoping :
- `ROLE_ADMIN` : tous les employés
- `ROLE_USER` (chef de site) : employés des sites autorisés (`UserSiteRole`)
- `ROLE_SELF` : uniquement son employé lié
- **Cutoff temporel** : le récap est figé à la fin de la semaine S-2 (dimanche 23:59:59)
- Formule : `cutoffDate = dimanche(lundi_semaine_courante 14 jours)`
- Exemple : mardi 14/04/2026 (S16) → dimanche 05/04/2026 (fin S14)
- `isValid` n'entre PAS en compte : cutoff purement temporel
- Les heures et absences postérieures au cutoff sont ignorées dans les calculs
- Colonnes identiques au PDF (voir §10)
- Détails techniques : voir `doc/leave-recap-screen.md`
## 11) Récapitulatif Salaire (PDF mensuel) ## 11) Récapitulatif Salaire (PDF mensuel)
- Accessible depuis la page Employés via le bouton "Récap. Salaire" (réservé `ROLE_ADMIN`) - Accessible depuis la page Employés via le bouton "Récap. Salaire" (réservé `ROLE_ADMIN`)
@@ -348,7 +380,7 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
| Maladie - Nombre | Absence code 'M' ou 'AT' | Jours (demi-journées = 0.5) | | Maladie - Nombre | Absence code 'M' ou 'AT' | Jours (demi-journées = 0.5) |
| Maladie - Date | Absence code 'M' ou 'AT' | Dates formatées dd/mm | | Maladie - Date | Absence code 'M' ou 'AT' | Dates formatées dd/mm |
| CHAUFFEUR - PDJ | WorkHour.hasBreakfast | Comptage mois (chauffeurs uniquement) | | CHAUFFEUR - PDJ | WorkHour.hasBreakfast | Comptage mois (chauffeurs uniquement) |
| CHAUFFEUR - REPAS | WorkHour.hasLunch + hasDinner | Comptage mois (chauffeurs uniquement) | | CHAUFFEUR - REPAS | WorkHour.hasLunch + hasDinner | Somme sur le mois : +1 par déjeuner coché et +1 par dîner coché (un jour avec les deux compte 2 repas, chauffeurs uniquement) |
| CHAUFFEUR - NUITEE | WorkHour.hasOvernight | Comptage mois (chauffeurs uniquement) | | CHAUFFEUR - NUITEE | WorkHour.hasOvernight | Comptage mois (chauffeurs uniquement) |
| CHAUFFEUR - samedi | WorkHour (samedi) | Samedis travaillés (chauffeurs uniquement) | | CHAUFFEUR - samedi | WorkHour (samedi) | Samedis travaillés (chauffeurs uniquement) |
| Observations | — | Colonne vide pour saisie manuelle | | Observations | — | Colonne vide pour saisie manuelle |
@@ -412,7 +444,8 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
- Accessible depuis la fiche employé (bouton imprimante à droite du nom) - Accessible depuis la fiche employé (bouton imprimante à droite du nom)
- Ouvre un drawer pour choisir l'année (civile, Jan-Déc) - Ouvre un drawer pour choisir l'année (civile, Jan-Déc)
- Génère un PDF avec le détail jour par jour des heures de l'employé - Génère un PDF avec le détail jour par jour des heures de l'employé
- Seuls les jours avec heures saisies ou absence sont affichés - Seuls les jours avec heures saisies, absence, week-end ou jour férié sont affichés
- Les jours fériés apparaissent toujours sur une ligne dédiée (fond bleu clair) avec la mention "Férié : {nom}" dans la colonne Absence (même si aucune saisie)
### Colonnes selon le mode de suivi ### Colonnes selon le mode de suivi
@@ -430,6 +463,7 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
- TIME non-chauffeur: somme des créneaux matin + après-midi + soir, plus minutes créditées des absences `countAsWorkedHours` - TIME non-chauffeur: somme des créneaux matin + après-midi + soir, plus minutes créditées des absences `countAsWorkedHours`
- Chauffeur: `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` + minutes créditées - Chauffeur: `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` + minutes créditées
- PRESENCE: 0.5 par demi-journée présente (matin/après-midi), max 1.0 - PRESENCE: 0.5 par demi-journée présente (matin/après-midi), max 1.0
- Jour férié Lun-Ven (hors Forfait, sans absence) : `total = max(saisie + crédit absence, référence contractuelle)` — même règle que l'écran Heures (cf. `HolidayVirtualHoursResolver`). Pour Forfait : pas de crédit virtuel, la ligne férié affiche juste l'éventuelle présence saisie.
### Nom du fichier ### Nom du fichier

View File

@@ -0,0 +1,110 @@
# Crédit automatique des heures sur jour férié (Lun-Ven)
## Règle
Tout jour férié du **lundi au vendredi** crédite automatiquement les **heures contractuelles attendues** pour ce jour, pour tout contrat **autre que Forfait** (`trackingMode``PRESENCE`). Les heures ainsi créditées sont dites *virtuelles* : aucune ligne n'est créée dans `work_hours`, elles sont injectées à l'affichage et au calcul.
### Référence contractuelle par jour
| Contrat | Lun-Jeu | Ven | Sam-Dim |
|-----------------|---------|-------|---------|
| 35h | 7h | 7h | 0 |
| 39h | 8h | 7h | 0 |
| CUSTOM (avec planning `workDaysHours`) | minutes du jour programmé, 0 sinon | idem | 0 |
| INTERIM 35h | 7h | 7h | 0 |
| FORFAIT | — | — | — |
La référence par jour est calculée par `App\Service\WorkHours\DailyReferenceMinutesResolver`.
### Planning `workDaysHours`
Tout contrat TIME **hors 35h/39h/INTERIM** (ex. 4h, 25h, 28h) doit déclarer un planning précis sur sa `EmployeeContractPeriod` : colonne JSON `work_days_hours = {"1": 120, "4": 120}` (iso day → minutes). La somme doit égaler `weeklyHours × 60`.
- **Sur un jour du planning** : crédit férié = minutes programmées (ex. Ewa Lun → 120 min).
- **Sur un jour hors planning** : crédit férié = 0 (elle n'aurait pas travaillé).
- Même logique appliquée par `WorkedHoursCreditPolicy::resolveContractDayMinutes` pour les crédits d'absence — un 4h en absence mardi (non programmée) = 0 crédit.
Validation à l'écriture : `EmployeeContractPeriodValidator::assertWorkDaysHours`. Le frontend expose un bloc « Jours travaillés » (cases Lun-Ven + input `HH:MM`) sur les formulaires de création employé + d'ajout de contrat, visible uniquement quand le contrat le requiert.
**Limitation actuelle** : l'édition in-place d'un schedule sur une période active existante n'est **pas exposée** via l'UI. Le drawer « Modifier le contrat » affiche le schedule en lecture seule à titre informatif. Pour corriger un schedule, la démarche est : clôturer le contrat en cours + créer un nouveau contrat avec le schedule corrigé. Si un besoin d'édition directe émerge, ajouter `workDaysHours` dans `EmployeeContractChangeRequest::hasPeriodChangeRequest()` et la logique d'update dans `EmployeeContractPeriodManager`.
### Fériés exclus
Les fériés listés dans l'env `EXCLUDED_PUBLIC_HOLIDAYS` (par défaut `Lundi de Pentecôte` — journée de solidarité) **ne donnent pas** de crédit virtuel : le `PublicHolidayService` les filtre en amont, donc `HolidayVirtualHoursResolver` ne les voit pas comme fériés.
### Interaction avec saisie
Quand l'employé saisit des heures ce jour-là :
- `heures finales = max(heures saisies + crédit d'absence éventuel, heures contractuelles de référence)`
Exemples avec un contrat 39h et un férié un lundi :
| Saisie employé | Total affiché | Interprétation |
|------------------|---------------|----------------|
| Aucune | 8h | Crédit 100% virtuel |
| Matin 09:00-13:00 (4h) | 8h | Le minimum contractuel l'emporte |
| 09:00-12:00 + 13:00-19:00 (9h) | 9h | Les heures saisies l'emportent |
### Interaction avec absences
La création d'absence sur un férié Lun-Ven est **autorisée** (bouton Modifier visible). Dès qu'une absence est déclarée sur le jour (matin et/ou après-midi), le crédit virtuel férié **est désactivé** pour ce jour : c'est `absence.type.countAsWorkedHours` qui pilote le crédit d'heures, via `WorkedHoursCreditPolicy`.
- `countAsWorkedHours = true` (ex. maladie payée) : crédit calculé normalement (7h/8h selon contrat × halfUnits/2). Même quantité que la référence virtuelle si journée complète, donc résultat identique — mais la source du crédit est l'absence, pas le férié.
- `countAsWorkedHours = false` (ex. congé sans solde) : crédit = 0. Le férié ne compense pas.
Cette règle évite le double-crédit (absence + férié virtuel) et respecte le paramétrage fonctionnel du type d'absence.
## Impact technique
### Affichage
- **Écran Heures (vue jour)** : sur un férié Lun-Ven non-Forfait, la colonne Total affiche la valeur effective (référence ou saisie, selon max). Un chip "Férié : Xh comptées" apparaît sous le pill bleu du férié.
- **Écran Heures Conducteurs (vue jour)** : idem, plus un indicateur `= Xh (férié)` sous l'input "Heures jour" pour signaler que le crédit est imputé au bucket jour.
- **Vues semaine** : les totaux hebdomadaires intègrent les minutes virtuelles. Un marqueur `F + Xh` apparaît dans la cellule du jour férié.
- **Onglet RTT** : les semaines contenant un férié Lun-Ven gagnent du temps crédité, ce qui peut générer des heures sup (25% / 50%) là où l'ancienne règle produisait un déficit.
### Calcul RTT
Le service `App\Service\WorkHours\HolidayVirtualHoursResolver` est injecté dans `RttRecoveryComputationService::computeRecoveryByWeek()`. Pour chaque jour ouvré :
```
effectiveMinutes = resolveEffectiveDailyMinutes(contract, date, metrics.totalMinutes + credited)
weeklyTotalMinutes += effectiveMinutes
```
Le reste du calcul (tranches +25%, +50%, base 25% à partir de 35h/39h) demeure inchangé ; seul le total hebdo injecté a évolué.
### Calcul hebdomadaire d'affichage
`WorkHourWeeklySummaryProvider` applique la même substitution sur `weeklyDayMinutes` et `weeklyTotalMinutes`. Le DTO `WeeklyDaySummary` expose désormais un champ `virtualHolidayMinutes` utilisé par les vues semaine.
### Contexte jour
`WorkHourDayContextProvider` expose `virtualHolidayMinutes` dans `DayContextRow` pour permettre au frontend de calculer le total journalier en temps réel pendant la saisie (sans aller-retour).
### Frontend
Le composable `frontend/composables/useHolidayVirtualHours.ts` réplique la règle côté client et est consommé par `useHoursPage.ts::getRowMetrics` et `useDriverHoursPage.ts::getRowMetrics`.
## Impact historique
La règle est appliquée **à chaque lecture** depuis les `WorkHour` — donc l'exercice courant et tout exercice recalculé live bénéficient automatiquement de la nouvelle règle sans migration.
Les reports N-1 stockés dans `employee_rtt_balances.opening_*_minutes` ont été saisis manuellement par la RH (valeurs officielles) et ne sont **pas recalculés** : ces snapshots restent la source de vérité pour les soldes d'ouverture.
## Services impliqués
| Composant | Rôle |
|-----------|------|
| `DailyReferenceMinutesResolver` | Résolution "minutes contractuelles par jour" (logique partagée, anciennement dupliquée). |
| `HolidayVirtualHoursResolver` | Décide si la règle s'applique et renvoie le crédit virtuel ou la valeur effective. |
| `RttRecoveryComputationService` | Applique la substitution dans le calcul hebdo RTT. |
| `WorkHourWeeklySummaryProvider` | Applique la substitution dans les totaux hebdo UI. |
| `WorkHourDayContextProvider` | Expose `virtualHolidayMinutes` par salarié/jour. |
| `useHolidayVirtualHours.ts` (frontend) | Réplique la règle en live côté client. |
## Tests
- `tests/Service/WorkHours/HolidayVirtualHoursResolverTest.php` couvre les scénarios par contrat + jours ouvrés/chômés.
- `make test` (PHPUnit) valide l'intégration RTT / hebdo / contexte jour.

73
doc/leave-recap-screen.md Normal file
View File

@@ -0,0 +1,73 @@
# Écran Récap. congés
## Objet
Vue tableau des soldes de congés par employé, figée à un cutoff temporel (fin de semaine S-2).
Complémentaire à l'export PDF admin : mêmes colonnes, accès étendu aux employés et chefs de site.
## Cutoff
La formule est : `cutoffDate = dimanche de (lundi de la semaine courante 14 jours)`.
Exemple : mardi 14/04/2026 (S16) → **dimanche 05/04/2026 23:59:59** (fin S14).
Le cutoff est purement temporel : l'état `isValid` des heures n'entre pas en compte. Les heures
et absences postérieures au cutoff sont ignorées dans le calcul des soldes.
Implémentation : `App\Util\LeaveRecapCutoff::resolveCutoff()` côté backend, helper `parseYmd` +
`getIsoWeekNumber` côté frontend pour l'affichage du badge.
## Colonnes
Identiques au PDF :
- Nom
- Prénom
- Contrat
- CP N-1 restant
- CP N
- Samedis acquis
- RTT
Pour les admins et chefs de site, une colonne **Site** est ajoutée à gauche.
## Scoping
| Profil | Données visibles |
|---------------|-----------------------------------------|
| `ROLE_ADMIN` | Tous les employés actifs, tous sites |
| `ROLE_USER` (chef de site) | Employés actifs des sites autorisés via `UserSiteRole` |
| `ROLE_SELF` | Uniquement l'employé lié à son compte |
## Flag d'accès
Le champ `User.hasLeaveRecapAccess` (boolean, défaut `false`) conditionne :
- L'affichage de l'entrée "Récap. congés" dans la sidebar
- L'accès à la route `/leave-recap` (middleware `leave-recap-access.ts`)
- L'endpoint API `GET /api/leave-recap` (le provider renvoie `403` si le flag est faux)
Le flag s'applique même aux admins : un admin sans le flag ne voit pas l'écran. Il se configure
dans le drawer de création/édition d'un utilisateur.
## Service partagé
`App\Service\Leave\LeaveRecapRowBuilder::build(Employee $employee, DateTimeImmutable $asOfDate)`
construit une ligne de récap. Il est utilisé par :
- `LeaveRecapPrintProvider` (PDF admin) avec `$asOfDate = today`
- `EmployeeLeaveRecapProvider` (écran) avec `$asOfDate = cutoff`
## Propagation du cutoff dans les calculs
`EmployeeLeaveSummaryProvider::computeYearSummary()` accepte un `?DateTimeImmutable $asOfDate`.
Lorsqu'il est fourni et appliqué à l'année cible, il remplace "today" dans :
- `resolveAccrualCalculationEndDate()` — la borne d'accrual devient le dernier jour du mois
précédant `asOfDate` (au lieu du mois précédent today).
- `resolveTakenCalculationEndDate()` — les absences postérieures à `asOfDate` sont ignorées.
Pour les années antérieures (carry forward), le comportement reste inchangé (pas de cap).
Le RTT est capé via `RttRecoveryComputationService::computeTotalRecoveryForExercise(..., $limitDate)`
qui existait déjà, en passant `cutoff` comme date de référence.

60
doc/leave-tab.md Normal file
View File

@@ -0,0 +1,60 @@
# Onglet "Congés" — fiche employé
## Vue d'ensemble
L'onglet **Congés** de la fiche employé (`frontend/components/employees/LeaveTab.vue`) affiche :
- un bandeau de compteurs (acquis, pris, reste, en cours d'acquisition, N-1 ou samedis selon le contrat) ;
- un calendrier annuel coloré des congés posés (12 mois en grille 4×3) ;
- pour chaque mois, le nombre de jours de présence (`presenceDaysByMonth`) ;
- un sélecteur d'année en pied de calendrier.
## Période affichée
La période dépend du **type de contrat actuel** de l'employé :
| Type de contrat | Période affichée |
|-------------------|--------------------------------|
| FORFAIT | Janvier → Décembre (année civile) |
| Autres | Juin (Y-1) → Mai (Y) (exercice CP) |
Cette règle suit `EmployeeLeaveSummaryProvider::resolveYear()` côté backend : la sélection FORFAIT vs non-FORFAIT se fait toujours sur le contrat **courant**, pas sur celui qui était en vigueur à l'année consultée.
## Sélecteur d'année
Position : **en bas du calendrier**, à gauche, à l'intérieur de la zone scrollable. Il scrolle donc avec les mois et apparaît sous la grille.
Plage proposée :
- du plus récent (= année courante) au plus ancien ;
- **double plancher** : l'année minimum est `max(floor_historique_contrat, floor_data_start_date)`
- **floor_historique_contrat** : dérivé de `employee.contractHistory[].startDate` — premier exercice où l'employé avait un contrat ouvert
- **floor_data_start_date** : dérivé de l'env `RTT_START_DATE` (date de mise en service du logiciel, ex. `2026-02-23` → exercice 2026 / année forfait 2026). Aucune donnée historique n'existe avant cette date, donc on ne propose pas d'années antérieures même si le contrat de l'employé est plus ancien.
- la valeur est exposée par l'API `GET /employees/{id}/leave-summary` via le champ `dataStartDate` (peuplé depuis l'env serveur).
- en cas d'historique manquant **et** d'env absente, la plage se réduit à l'année courante.
Format des libellés :
- FORFAIT : `2026`, `2025`, `2024`
- Autres : `Juin 2025 → Mai 2026`, `Juin 2024 → Mai 2025`
Comportement :
- changer d'année recharge l'intégralité de l'onglet (`getEmployeeLeaveSummary?year=YYYY` + `listAbsences` + `listPublicHolidays`) ;
- les compteurs du bandeau reflètent l'année sélectionnée.
## Verrouillage des éditions sur années passées
Quand `selectedYear !== currentYear` (consultation d'une année antérieure) :
- le bouton crayon **Jours fractionnés** (non-FORFAIT) est désactivé ;
- le bouton crayon **Année N-1 payés** (FORFAIT) est désactivé.
Justification : modifier rétroactivement les stocks de report ou les jours fractionnés d'un exercice clos décalerait silencieusement les soldes de toutes les années postérieures. La consultation reste possible, l'édition non.
## Implémentation
- Composable : `frontend/composables/useEmployeeLeave.ts`
- État : `selectedLeaveYear`, computed `currentLeaveYear`, `availableLeaveYears`
- API : `setSelectedLeaveYear(year)`, `loadLeaveData()`, `resetLoaded()`
- `resetLoaded()` (appelé au changement d'employé) remet `selectedLeaveYear = null` pour que la valeur par défaut soit recalculée à partir du nouveau contrat.
- Composant : `frontend/components/employees/LeaveTab.vue`
- Props : `selectedYear`, `availableYears`, `currentYear`
- Event : `update-selected-year`
- Page : `frontend/pages/employees/[id].vue` (câble le composable au composant)
- Backend : `EmployeeLeaveSummaryProvider` reçoit `RTT_START_DATE` via `services.yaml` (argument `$dataStartDate`) et l'expose dans la réponse `EmployeeLeaveSummary.dataStartDate`. Le filtrage `?year=YYYY` était déjà accepté (validation 20002100).

52
doc/rtt-tab.md Normal file
View File

@@ -0,0 +1,52 @@
# Onglet "RTT" — fiche employé
## Vue d'ensemble
L'onglet **RTT** de la fiche employé (`frontend/components/employees/RttTab.vue`) affiche un tableau hebdomadaire détaillé des heures supplémentaires accumulées et payées sur un exercice :
- bandeau de navigation par mois (chevrons gauche/droite) ;
- table semaine par semaine : Heure / Base 25% / 25% / Total 25% / Base 50% / 50% / Total 50% / Total / Cumul ;
- ligne Report (carry N-1 ou cumul mois précédents) ;
- ligne Total mois, ligne Payé, ligne Reste ;
- bouton « + Payer les RTT » dans le bandeau ;
- sélecteur d'exercice en pied de tableau.
L'onglet est **masqué pour les contrats FORFAIT** (filtre `showRttTab` dans `useEmployeeDetailPage`). Les FORFAIT n'accumulent pas de RTT.
## Période affichée
Toujours **Juin (Y-1) → Mai (Y)**. Le champ `EmployeeRttSummary.year` correspond à `Y` (année de fin d'exercice) ; ex. `year=2026` = `01/06/2025 → 31/05/2026`.
## Sélecteur d'année
Position : sous la table, à l'intérieur de la zone scrollable, à gauche.
Plage proposée :
- du plus récent (= exercice courant) au plus ancien ;
- **double plancher** : `max(floor_historique_contrat, floor_data_start_date)`
- **floor_historique_contrat** : dérivé de `employee.contractHistory[].startDate` — premier exercice où l'employé avait un contrat ouvert
- **floor_data_start_date** : exercice contenant `RTT_START_DATE` (env, ex. `2026-02-23` → exercice 2026)
- la valeur est exposée par l'API `GET /employees/{id}/rtt-summary` via le champ `rttStartDate` (déjà existant — mais peuplé uniquement quand la date tombe dans l'exercice retourné, donc le composable utilise la première réponse pour borner la plage).
- format unique : `Juin 2025 → Mai 2026`, `Juin 2024 → Mai 2025`
Comportement :
- changer d'exercice recharge `getEmployeeRttSummary?year=YYYY` (le backend valide 20002100) ;
- la table redéploie les semaines de l'exercice sélectionné, navigation par mois conservée.
## Verrouillage des édition sur exercices passés
Quand `selectedYear !== currentYear` (consultation d'un exercice antérieur), le bouton **+ Payer les RTT** est désactivé. Justification : un paiement rétroactif sur un exercice clos décalerait les soldes courants et le report N-1 calculé.
La consultation reste possible, l'édition non.
## Implémentation
- Composable : `frontend/composables/useEmployeeRtt.ts`
- État : `selectedRttYear`, computed `currentRttYear`, `availableRttYears`
- API : `setSelectedRttYear(year)`, `loadRttData()`, `resetLoaded()`
- `resetLoaded()` (appelé au changement d'employé) remet `selectedRttYear = null`.
- Composant : `frontend/components/employees/RttTab.vue`
- Props : `selectedYear`, `availableYears`, `currentYear`
- Event : `update-selected-year`
- Renommage `currentYear` (computed local de l'année du mois affiché) → `displayedMonthYear` pour éviter la collision avec la nouvelle prop.
- Page : `frontend/pages/employees/[id].vue`
- Backend : aucun changement — `EmployeeRttSummaryProvider` accepte déjà `?year=YYYY` (validation 20002100) et expose `rttStartDate`.

View File

@@ -1,4 +1,7 @@
[Date] [Date]
; Defines the default timezone used by the date functions ; Defines the default timezone used by the date functions
; http://php.net/date.timezone ; http://php.net/date.timezone
date.timezone = Europe/Paris date.timezone = Europe/Paris
[PHP]
memory_limit = 256M

1
frontend/.npmrc Normal file
View File

@@ -0,0 +1 @@
@malio:registry=https://gitea.malio.fr/api/packages/MALIO-DEV/npm/

View File

@@ -1,44 +1,26 @@
<template> <template>
<AppDrawer v-model="drawerOpen" title="Nouvelle absence"> <AppDrawer v-model="drawerOpen" title="Nouvelle absence">
<form class="space-y-4" @submit.prevent="handleSubmit"> <form class="space-y-4" @submit.prevent="handleSubmit">
<div> <MalioSelect
<label class="text-md font-semibold text-neutral-700" for="employee"> :model-value="absenceForm.employeeId === '' ? null : absenceForm.employeeId"
Employé <span class="text-red-600">*</span> :options="employeeOptions"
</label> label="Employé *"
<select empty-option-label="Choisir un employé"
id="employee" min-width=""
v-model="absenceForm.employeeId" :disabled="props.lockEmployee"
:class="employeeFieldClass" :error="showEmployeeError ? `L'employé est obligatoire.` : ''"
:disabled="props.lockEmployee" @update:model-value="onEmployeeChange"
> />
<option value="" disabled>Choisir un employé</option>
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
{{ employee.firstName }} {{ employee.lastName }}
</option>
</select>
<p v-if="showEmployeeError" class="mt-1 text-sm text-red-600">
L'employé est obligatoire.
</p>
</div>
<div> <MalioSelect
<label class="text-md font-semibold text-neutral-700" for="type"> :model-value="absenceForm.typeId === '' ? null : absenceForm.typeId"
Type d'absence <span class="text-red-600">*</span> :options="typeOptions"
</label> label="Type d'absence *"
<select empty-option-label="Choisir un type"
id="type" min-width=""
v-model="absenceForm.typeId" :error="showTypeError ? `Le type d'absence est obligatoire.` : ''"
:class="typeFieldClass" @update:model-value="onTypeChange"
> />
<option value="" disabled>Choisir un type</option>
<option v-for="type in absenceTypes" :key="type.id" :value="type.id">
{{ type.label }} ({{ type.code }})
</option>
</select>
<p v-if="showTypeError" class="mt-1 text-sm text-red-600">
Le type d'absence est obligatoire.
</p>
</div>
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
@@ -48,17 +30,15 @@
id="start-date" id="start-date"
v-model="absenceForm.startDate" v-model="absenceForm.startDate"
type="date" type="date"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900" :class="[dateInputBaseClass, absenceForm.startDate ? 'border-black' : 'border-m-muted']"
:disabled="props.lockDates" :disabled="props.lockDates"
/> />
<select <MalioSelect
v-model="absenceForm.startHalf" :model-value="absenceForm.startHalf"
class="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900" :options="halfDayOptions"
> min-width=""
<option v-for="half in HALF_DAYS" :key="half.value" :value="half.value"> @update:model-value="(v) => { if (v !== null) absenceForm.startHalf = v as HalfDay }"
{{ half.label }} />
</option>
</select>
</div> </div>
</div> </div>
<div> <div>
@@ -68,17 +48,15 @@
id="end-date" id="end-date"
v-model="absenceForm.endDate" v-model="absenceForm.endDate"
type="date" type="date"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900" :class="[dateInputBaseClass, absenceForm.endDate ? 'border-black' : 'border-m-muted']"
:disabled="props.lockDates" :disabled="props.lockDates"
/> />
<select <MalioSelect
v-model="absenceForm.endHalf" :model-value="absenceForm.endHalf"
class="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900" :options="halfDayOptions"
> min-width=""
<option v-for="half in HALF_DAYS" :key="half.value" :value="half.value"> @update:model-value="(v) => { if (v !== null) absenceForm.endHalf = v as HalfDay }"
{{ half.label }} />
</option>
</select>
</div> </div>
</div> </div>
</div> </div>
@@ -110,13 +88,12 @@
</button> </button>
</div> </div>
<div v-else class="flex justify-center pt-2"> <div v-else class="flex justify-center pt-2">
<button <MalioButton
type="submit" type="submit"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500" label="Valider"
:class="submitButtonClass" button-class="w-[200px]"
> :disabled="props.isSubmitting || !isFormValid"
+ Ajouter />
</button>
</div> </div>
</form> </form>
</AppDrawer> </AppDrawer>
@@ -189,20 +166,23 @@ const submitButtonClass = computed(() => {
return '' return ''
}) })
const baseSelectClass = const employeeOptions = computed(() =>
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900' props.employees.map((e) => ({ label: `${e.firstName} ${e.lastName}`, value: e.id }))
const employeeFieldClass = computed(() => { )
if (showEmployeeError.value) { const typeOptions = computed(() =>
return `${baseSelectClass} border-red-500` props.absenceTypes.map((t) => ({ label: `${t.label} (${t.code})`, value: t.id }))
} )
return `${baseSelectClass} border-neutral-300` const halfDayOptions = HALF_DAYS.map((h) => ({ label: h.label, value: h.value }))
})
const typeFieldClass = computed(() => { const dateInputBaseClass =
if (showTypeError.value) { 'h-10 w-full rounded-md border px-3 text-md text-black outline-none focus:border-2 focus:border-m-primary'
return `${baseSelectClass} border-red-500`
} const onEmployeeChange = (value: string | number | null) => {
return `${baseSelectClass} border-neutral-300` absenceForm.value.employeeId = value === null ? '' : Number(value)
}) }
const onTypeChange = (value: string | number | null) => {
absenceForm.value.typeId = value === null ? '' : Number(value)
}
watch( watch(
() => props.modelValue, () => props.modelValue,

View File

@@ -4,13 +4,20 @@
<div class="absolute inset-0 bg-black/40" @click="close" /> <div class="absolute inset-0 bg-black/40" @click="close" />
</Transition> </Transition>
<Transition name="drawer-panel"> <Transition name="drawer-panel">
<div class="absolute right-0 top-0 h-full w-full max-w-md bg-white shadow-xl"> <div class="absolute right-0 top-0 h-full w-full max-w-md bg-white shadow-xl flex flex-col">
<div class="flex items-center justify-between px-[20px] pt-8 pb-8"> <div class="shrink-0 flex items-center justify-between px-[20px] pt-8 pb-8">
<h2 class="text-[32px] font-semibold text-primary-500"> <h2 class="text-[32px] font-semibold text-primary-500">
{{ title }} {{ title }}
</h2> </h2>
<button
type="button"
class="rounded-md p-1 text-primary-500 hover:text-secondary-500"
@click="close"
>
<Icon name="mdi:close" size="24"/>
</button>
</div> </div>
<div class="overflow-y-auto px-[20px]" style="max-height: calc(100% - 65px)"> <div class="min-h-0 flex-1 overflow-y-auto px-[20px] pb-4 pt-1">
<slot /> <slot />
</div> </div>
</div> </div>

View File

@@ -1,7 +1,14 @@
<template> <template>
<header ref="headerRef" class="border-b border-neutral-200 bg-primary-500 p-5 text-white"> <header ref="headerRef" class="border-b border-neutral-200 bg-primary-500 px-4 py-3 text-white lg:p-5">
<div class="flex h-full items-center justify-end"> <div class="flex h-full items-center justify-between lg:justify-end">
<div class="flex gap-6 text-xl text-white"> <button
type="button"
class="rounded-md p-1 text-white hover:text-neutral-200 lg:hidden"
@click="$emit('toggleSidebar')"
>
<Icon name="mdi:menu" size="28"/>
</button>
<div class="flex gap-4 text-xl text-white lg:gap-6">
<div v-if="isAdmin" ref="bellRoot" class="relative"> <div v-if="isAdmin" ref="bellRoot" class="relative">
<button type="button" class="relative self-center cursor-pointer flex items-center" @click="toggleNotifications"> <button type="button" class="relative self-center cursor-pointer flex items-center" @click="toggleNotifications">
<Icon name="mdi:bell-plus" size="36"/> <Icon name="mdi:bell-plus" size="36"/>
@@ -15,8 +22,8 @@
<div <div
v-if="isNotificationsOpen" v-if="isNotificationsOpen"
class="fixed right-[20px] z-30 w-[400px] rounded-md border border-neutral-200 bg-white text-neutral-800 shadow-lg" class="fixed right-2 z-30 w-[calc(100vw-1rem)] max-w-[400px] rounded-md border border-neutral-200 bg-white text-neutral-800 shadow-lg lg:right-[20px]"
:style="{ top: `${navbarBottom + 20}px` }" :style="{ top: `${navbarBottom + 10}px` }"
> >
<div class="px-3 pt-3 pb-6 text-xl font-semibold"> <div class="px-3 pt-3 pb-6 text-xl font-semibold">
Notifications Notifications
@@ -66,7 +73,7 @@
<div ref="userMenuRoot" class="relative flex gap-4"> <div ref="userMenuRoot" class="relative flex gap-4">
<button type="button" class="flex items-center gap-4 cursor-pointer" @click="toggleUserMenu"> <button type="button" class="flex items-center gap-4 cursor-pointer" @click="toggleUserMenu">
<Icon name="mdi:account-circle-outline" class="self-center" size="36"/> <Icon name="mdi:account-circle-outline" class="self-center" size="36"/>
<p class="self-center">{{ user?.username }}</p> <p class="hidden self-center sm:block">{{ user?.username }}</p>
</button> </button>
<div <div
v-if="isUserMenuOpen" v-if="isUserMenuOpen"
@@ -103,6 +110,10 @@ defineProps<{
user?: User user?: User
}>() }>()
defineEmits<{
(event: 'toggleSidebar'): void
}>()
const formatTimeAgo = (dateString: string): string => { const formatTimeAgo = (dateString: string): string => {
const date = new Date(dateString) const date = new Date(dateString)
const now = new Date() const now = new Date()

View File

@@ -0,0 +1,108 @@
<template>
<AppDrawer v-model="drawerOpen" title="Export heures (tous les employés)">
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="bulk-yearly-hours-year">
Année <span class="text-red-600">*</span>
</label>
<select
id="bulk-yearly-hours-year"
v-model="selectedYear"
:class="selectFieldClass"
>
<option v-for="y in years" :key="y" :value="y">{{ y }}</option>
</select>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="bulk-yearly-hours-month">
Mois <span class="text-red-600">*</span>
</label>
<select
id="bulk-yearly-hours-month"
v-model="selectedMonth"
:class="selectFieldClass"
>
<option value="" disabled>Sélectionner un mois</option>
<option v-for="m in months" :key="m.value" :value="m.value">{{ m.label }}</option>
</select>
</div>
<div class="flex justify-center pt-2">
<button
type="submit"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="isLoading || selectedMonth === ''"
>
<template v-if="isLoading">
Génération en cours...
</template>
<template v-else>
Imprimer
</template>
</button>
</div>
</form>
</AppDrawer>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import AppDrawer from '~/components/AppDrawer.vue'
const props = defineProps<{
modelValue: boolean
isLoading?: boolean
}>()
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
(event: 'submit', payload: { year: number; month: number | null }): void
}>()
const drawerOpen = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const currentYear = new Date().getFullYear()
const years = Array.from({ length: 6 }, (_, i) => currentYear - i)
const months = [
{ value: 1, label: 'Janvier' },
{ value: 2, label: 'Février' },
{ value: 3, label: 'Mars' },
{ value: 4, label: 'Avril' },
{ value: 5, label: 'Mai' },
{ value: 6, label: 'Juin' },
{ value: 7, label: 'Juillet' },
{ value: 8, label: 'Août' },
{ value: 9, label: 'Septembre' },
{ value: 10, label: 'Octobre' },
{ value: 11, label: 'Novembre' },
{ value: 12, label: 'Décembre' }
]
const selectedYear = ref(currentYear)
const currentMonth = new Date().getMonth() + 1
const selectedMonth = ref<number | ''>(currentMonth)
const baseInputClass = 'mt-2 w-full rounded-md border px-3 py-2 text-md text-neutral-900'
const selectFieldClass = computed(() => `${baseInputClass} border-neutral-300`)
const handleSubmit = () => {
if (selectedMonth.value === '') return
emit('submit', {
year: selectedYear.value,
month: selectedMonth.value
})
}
watch(
() => props.modelValue,
(isOpen) => {
if (!isOpen) {
selectedYear.value = currentYear
selectedMonth.value = currentMonth
}
}
)
</script>

View File

@@ -45,9 +45,9 @@
<button <button
type="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" 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) || getCellInfo(employee.id, day.date)?.hasFormation ? 'cursor-not-allowed opacity-80' : ''" :class="getCellInfo(employee.id, day.date)?.hasFormation ? 'cursor-not-allowed opacity-80' : ''"
:style="getCellStyle(employee.id, day.date)" :style="getCellStyle(employee.id, day.date)"
:disabled="isHolidayDate(day.date) || getCellInfo(employee.id, day.date)?.hasFormation" :disabled="getCellInfo(employee.id, day.date)?.hasFormation"
@click="handleCellClick(employee, day.date)" @click="handleCellClick(employee, day.date)"
> >
<span v-if="!getCellInfo(employee.id, day.date)?.halfLabel"> <span v-if="!getCellInfo(employee.id, day.date)?.halfLabel">
@@ -80,9 +80,7 @@
<button <button
type="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" 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)" :style="getCellStyle(employee.id, day.date)"
:disabled="isHolidayDate(day.date)"
@click="handleCellClick(employee, day.date)" @click="handleCellClick(employee, day.date)"
> >
<span></span> <span></span>

View File

@@ -1,26 +0,0 @@
<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

@@ -1,5 +1,5 @@
<template> <template>
<AppDrawer v-model="drawerOpen" title="Export heures annuelles"> <AppDrawer v-model="drawerOpen" title="Export heures">
<form class="space-y-4" @submit.prevent="handleSubmit"> <form class="space-y-4" @submit.prevent="handleSubmit">
<div> <div>
<label class="text-md font-semibold text-neutral-700" for="yearly-hours-year"> <label class="text-md font-semibold text-neutral-700" for="yearly-hours-year">
@@ -14,6 +14,20 @@
</select> </select>
</div> </div>
<div>
<label class="text-md font-semibold text-neutral-700" for="yearly-hours-month">
Mois
</label>
<select
id="yearly-hours-month"
v-model="selectedMonth"
:class="selectFieldClass"
>
<option value="">Toute l'année</option>
<option v-for="m in months" :key="m.value" :value="m.value">{{ m.label }}</option>
</select>
</div>
<div class="flex justify-center pt-2"> <div class="flex justify-center pt-2">
<button <button
type="submit" type="submit"
@@ -37,7 +51,7 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void (event: 'update:modelValue', value: boolean): void
(event: 'submit', year: number): void (event: 'submit', payload: { year: number; month: number | null }): void
}>() }>()
const drawerOpen = computed({ const drawerOpen = computed({
@@ -47,13 +61,31 @@ const drawerOpen = computed({
const currentYear = new Date().getFullYear() const currentYear = new Date().getFullYear()
const years = Array.from({ length: 6 }, (_, i) => currentYear - i) const years = Array.from({ length: 6 }, (_, i) => currentYear - i)
const months = [
{ value: 1, label: 'Janvier' },
{ value: 2, label: 'Février' },
{ value: 3, label: 'Mars' },
{ value: 4, label: 'Avril' },
{ value: 5, label: 'Mai' },
{ value: 6, label: 'Juin' },
{ value: 7, label: 'Juillet' },
{ value: 8, label: 'Août' },
{ value: 9, label: 'Septembre' },
{ value: 10, label: 'Octobre' },
{ value: 11, label: 'Novembre' },
{ value: 12, label: 'Décembre' }
]
const selectedYear = ref(currentYear) const selectedYear = ref(currentYear)
const selectedMonth = ref<number | ''>('')
const baseInputClass = 'mt-2 w-full rounded-md border px-3 py-2 text-md text-neutral-900' const baseInputClass = 'mt-2 w-full rounded-md border px-3 py-2 text-md text-neutral-900'
const selectFieldClass = computed(() => `${baseInputClass} border-neutral-300`) const selectFieldClass = computed(() => `${baseInputClass} border-neutral-300`)
const handleSubmit = () => { const handleSubmit = () => {
emit('submit', selectedYear.value) emit('submit', {
year: selectedYear.value,
month: selectedMonth.value === '' ? null : selectedMonth.value
})
} }
watch( watch(
@@ -61,6 +93,7 @@ watch(
(isOpen) => { (isOpen) => {
if (!isOpen) { if (!isOpen) {
selectedYear.value = currentYear selectedYear.value = currentYear
selectedMonth.value = ''
} }
} }
) )

View File

@@ -1,69 +0,0 @@
<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="z-50 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

@@ -6,7 +6,7 @@
:style="{ gridTemplateColumns: dayGridCols }" :style="{ gridTemplateColumns: dayGridCols }"
> >
<span>Nom</span> <span>Nom</span>
<span class="pl-2">Absence</span> <span class="pl-2">Statut</span>
<span class="pl-4">Heure de jour</span> <span class="pl-4">Heure de jour</span>
<span class="pl-2">Heure de nuit</span> <span class="pl-2">Heure de nuit</span>
<span class="pl-2">Heure atelier</span> <span class="pl-2">Heure atelier</span>
@@ -25,19 +25,7 @@
@change="onBulkValidationChange" @change="onBulkValidationChange"
/> />
</span> </span>
<span v-else-if="isSiteManager" class="inline-flex items-center gap-2"> <span v-else-if="!isSiteManager">Site <Icon name="mdi:check-bold" class="ml-1"/></span>
<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> <span v-if="!isAdmin">RH <Icon name="mdi:check-bold" class="ml-1"/></span>
</div> </div>
@@ -54,7 +42,9 @@
<span class="font-normal text-neutral-600">({{ contractLabel(employee) }})</span> <span class="font-normal text-neutral-600">({{ contractLabel(employee) }})</span>
</p> </p>
<p class="text-neutral-500 truncate inline-flex items-center gap-2"> <p class="text-neutral-500 truncate inline-flex items-center gap-2">
<span>{{ employee.site?.name ?? 'Sans site' }}</span> <span>
{{ employee.site?.name ?? 'Sans site' }}<span v-if="getRowContractNature(employee.id)"> {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }}</span>
</span>
<span <span
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)" v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
class="rounded-full bg-green-500 flex justify-center item-center text-white p-0.5" class="rounded-full bg-green-500 flex justify-center item-center text-white p-0.5"
@@ -68,19 +58,30 @@
</p> </p>
</div> </div>
<div class="pl-2 min-w-0 self-stretch flex flex-col gap-1 justify-between py-0.5"> <div class="pl-2 min-w-0 self-stretch flex flex-col gap-1 justify-between py-0.5">
<p <div class="flex flex-col gap-1 min-w-0">
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate" <p
:class="getRowAbsenceLabel(employee.id) ? 'text-white' : 'invisible'" class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate"
:title="getRowAbsenceLabel(employee.id) || ''" :class="getRowAbsenceLabel(employee.id) ? 'text-white' : 'invisible'"
:style="getRowAbsenceStyle(employee.id)" :title="getRowAbsenceLabel(employee.id) || ''"
> :style="getRowAbsenceStyle(employee.id)"
{{ getRowAbsenceLabel(employee.id) || '—' }} >
</p> {{ getRowAbsenceLabel(employee.id) || '—' }}
</p>
<p
v-if="isHoliday"
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate text-sky-900 inline-flex items-center gap-1"
style="background-color: #b3e5fc"
:title="holidayLabel || 'Férié'"
>
<Icon name="mdi:calendar-star" size="14" class="shrink-0"/>
<span class="truncate">{{ holidayLabel || 'Férié' }}</span>
</p>
</div>
<button <button
type="button" type="button"
class="self-start text-left text-xs font-semibold underline" 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'" :class="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)" :disabled="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
@click="onAbsenceClick(employee.id)" @click="onAbsenceClick(employee.id)"
> >
Modifier Modifier
@@ -91,6 +92,12 @@
v-model="rows[employee.id].dayHours" v-model="rows[employee.id].dayHours"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id)" :disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id)"
/> />
<p
v-if="isHoliday && getRowMetrics(employee.id).virtualHolidayMinutes > 0"
class="mt-1 text-xs font-semibold text-sky-700"
>
= {{ formatMinutes(getRowMetrics(employee.id).dayMinutes) }} (férié)
</p>
</div> </div>
<div class="pl-2"> <div class="pl-2">
<TimeSelect <TimeSelect
@@ -147,16 +154,8 @@
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)" @change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
/> />
</div> </div>
<div v-else class="text-right p-5"> <div v-else-if="!isSiteManager" class="text-right p-5">
<input <span v-if="rows[employee.id]?.isSiteValid" class="text-xs font-semibold text-neutral-700">Validé</span>
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> <span v-else class="text-xs text-neutral-500">-</span>
</div> </div>
<div v-if="!isAdmin"> <div v-if="!isAdmin">
@@ -173,6 +172,7 @@
import type { Employee } from '~/services/dto/employee' import type { Employee } from '~/services/dto/employee'
import TimeSelect from '~/components/ui/TimeSelect.vue' import TimeSelect from '~/components/ui/TimeSelect.vue'
import type { DriverHourRow } from '~/services/dto/work-hour' import type { DriverHourRow } from '~/services/dto/work-hour'
import { contractNatureLabel } from '~/utils/contract'
const rows = defineModel<Record<number, DriverHourRow>>('rows', { required: true }) const rows = defineModel<Record<number, DriverHourRow>>('rows', { required: true })
const bulkValidationInput = ref<HTMLInputElement | null>(null) const bulkValidationInput = ref<HTMLInputElement | null>(null)
@@ -184,6 +184,7 @@ const props = defineProps<{
isSiteManager: boolean isSiteManager: boolean
dayGridCols: string dayGridCols: string
isHoliday: boolean isHoliday: boolean
holidayLabel: string
contractLabel: (employee: Employee) => string contractLabel: (employee: Employee) => string
isRowLocked: (employeeId: number) => boolean isRowLocked: (employeeId: number) => boolean
hasContractAtSelectedDate: (employeeId: number) => boolean hasContractAtSelectedDate: (employeeId: number) => boolean
@@ -201,9 +202,10 @@ const props = defineProps<{
onToggleSiteValidation: (employeeId: number, checked: boolean) => void onToggleSiteValidation: (employeeId: number, checked: boolean) => void
onToggleValidationBulk: (checked: boolean) => Promise<void> | void onToggleValidationBulk: (checked: boolean) => Promise<void> | void
onToggleSiteValidationBulk: (checked: boolean) => Promise<void> | void onToggleSiteValidationBulk: (checked: boolean) => Promise<void> | void
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number } getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; workshopMinutes: number; totalMinutes: number; virtualHolidayMinutes: number }
getRowAbsenceLabel: (employeeId: number) => string getRowAbsenceLabel: (employeeId: number) => string
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
getRowContractNature: (employeeId: number) => 'CDI' | 'CDD' | 'INTERIM' | null
getRowUpdatedAt: (employeeId: number) => string getRowUpdatedAt: (employeeId: number) => string
onAbsenceClick: (employeeId: number) => void onAbsenceClick: (employeeId: number) => void
formatMinutes: (minutes: number) => string formatMinutes: (minutes: number) => string

View File

@@ -33,7 +33,12 @@
{{ row.firstName }} {{ row.lastName }} {{ row.firstName }} {{ row.lastName }}
<span class="font-normal text-neutral-600">({{ row.contractName ?? '-' }})</span> <span class="font-normal text-neutral-600">({{ row.contractName ?? '-' }})</span>
</p> </p>
<p class="text-[11px] text-neutral-500 truncate">{{ row.siteName ?? 'Sans site' }}</p> <p class="text-[11px] text-neutral-500 truncate inline-flex items-center gap-2">
<span>{{ row.siteName ?? 'Sans site' }}<span v-if="row.contractNature"> {{ contractNatureLabel(row.contractNature) }}</span></span>
<button v-if="isAdmin" type="button" class="inline-flex items-center justify-center rounded-md p-1 text-white transition-colors" :class="row.comment ? 'bg-red-500 hover:bg-red-600' : 'bg-primary-500 hover:bg-secondary-500'" :title="row.comment ?? 'Ajouter un commentaire'" @click="$emit('open-comment', row)">
<Icon name="mdi:comment-text-outline" size="12"/>
</button>
</p>
</div> </div>
<div <div
@@ -42,7 +47,7 @@
class="text-left leading-4 rounded-md px-2 py-1" class="text-left leading-4 rounded-md px-2 py-1"
:class="daily.hasAbsence ? 'text-white' : ''" :class="daily.hasAbsence ? 'text-white' : ''"
:style="getDailyCellStyle(daily)" :style="getDailyCellStyle(daily)"
:title="daily.absenceLabel ?? ''" :title="cellTitle(daily)"
> >
<div>J {{ formatMinutes(daily.dayMinutes) }}</div> <div>J {{ formatMinutes(daily.dayMinutes) }}</div>
<div>N {{ formatMinutes(daily.nightMinutes) }}</div> <div>N {{ formatMinutes(daily.nightMinutes) }}</div>
@@ -89,20 +94,39 @@
<script setup lang="ts"> <script setup lang="ts">
import type { WeeklyWorkHourSummary } from '~/services/dto/work-hour' import type { WeeklyWorkHourSummary } from '~/services/dto/work-hour'
import { contractNatureLabel } from '~/utils/contract'
const HOLIDAY_BG_COLOR = '#b3e5fc'
const getDailyCellStyle = (daily: { const getDailyCellStyle = (daily: {
hasAbsence?: boolean hasAbsence?: boolean
absenceColor?: string | null absenceColor?: string | null
holidayLabel?: string | null
}) => { }) => {
if (!daily.hasAbsence) return undefined if (daily.hasAbsence) return { backgroundColor: daily.absenceColor || '#dc2626' }
return { backgroundColor: daily.absenceColor || '#dc2626' } if (daily.holidayLabel) return { backgroundColor: HOLIDAY_BG_COLOR }
return undefined
}
const cellTitle = (daily: {
hasAbsence?: boolean
absenceLabel?: string | null
holidayLabel?: string | null
}) => {
const parts: string[] = []
if (daily.absenceLabel) parts.push(daily.absenceLabel)
if (daily.holidayLabel) parts.push(`Férié : ${daily.holidayLabel}`)
return parts.join(' — ')
} }
defineProps<{ defineProps<{
isWeekLoading: boolean isWeekLoading: boolean
isAdmin: boolean
weekGridCols: string weekGridCols: string
weeklySummary: WeeklyWorkHourSummary | null weeklySummary: WeeklyWorkHourSummary | null
weekDayHeaders: Array<{ date: string; weekday: string; dayDate: string }> weekDayHeaders: Array<{ date: string; weekday: string; dayDate: string }>
formatMinutes: (minutes: number) => string formatMinutes: (minutes: number) => string
}>() }>()
defineEmits<{ (e: 'open-comment', row: WeeklyWorkHourSummary['rows'][number]): void }>()
</script> </script>

View File

@@ -16,7 +16,7 @@
:key="`${item.startDate}-${item.endDate ?? 'open'}-${item.contractId ?? item.contractName}`" :key="`${item.startDate}-${item.endDate ?? 'open'}-${item.contractId ?? item.contractName}`"
class="grid grid-cols-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 hover:bg-tertiary-500" class="grid grid-cols-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 hover:bg-tertiary-500"
> >
<p>{{ contractNatureLabel(item.contractNature) }}</p> <p>{{ item.interimAgencyName ? `${contractNatureLabel(item.contractNature)} (${item.interimAgencyName})` : contractNatureLabel(item.contractNature) }}</p>
<p>{{ contractHistoryLabel(item) }}</p> <p>{{ contractHistoryLabel(item) }}</p>
<p>{{ formatDate(item.startDate) }}</p> <p>{{ formatDate(item.startDate) }}</p>
<p>{{ formatDate(item.endDate) }}</p> <p>{{ formatDate(item.endDate) }}</p>
@@ -108,6 +108,13 @@
<p v-if="showContractEndDateError" class="mt-1 text-sm text-red-600">La date de fin est obligatoire.</p> <p v-if="showContractEndDateError" class="mt-1 text-sm text-red-600">La date de fin est obligatoire.</p>
</div> </div>
<WorkDaysHoursInput
v-if="contractForm.workDaysHours"
:model-value="contractForm.workDaysHours"
:contract-weekly-hours="contractForm.weeklyHours ?? null"
disabled
/>
<div> <div>
<label class="text-md font-semibold text-neutral-700" for="contract-comment"> <label class="text-md font-semibold text-neutral-700" for="contract-comment">
Commentaire Commentaire
@@ -214,6 +221,22 @@
</select> </select>
</div> </div>
<div v-if="createContractForm.contractNature === 'INTERIM'">
<label class="text-md font-semibold text-neutral-700" for="create-interim-agency">
Agence d'intérim
</label>
<select
id="create-interim-agency"
v-model="createContractForm.interimAgencyId"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
>
<option value="">Aucune</option>
<option v-for="agency in interimAgencies" :key="agency.id" :value="agency.id">
{{ agency.name }}
</option>
</select>
</div>
<div> <div>
<label class="text-md font-semibold text-neutral-700" for="create-contract-id"> <label class="text-md font-semibold text-neutral-700" for="create-contract-id">
Temps de travail <span class="text-red-600">*</span> Temps de travail <span class="text-red-600">*</span>
@@ -252,7 +275,13 @@
</label> </label>
</div> </div>
<div class="flex justify-center pt-2"> <WorkDaysHoursInput
v-if="requiresCreateWorkDaysHours"
v-model="createContractForm.workDaysHours"
:contract-weekly-hours="selectedCreateContract?.weeklyHours ?? null"
/>
<div class="sticky bottom-0 -mx-[20px] bg-white px-[20px] py-3 flex justify-center">
<button <button
type="submit" type="submit"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50" class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
@@ -269,6 +298,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Contract } from '~/services/dto/contract' import type { Contract } from '~/services/dto/contract'
import type { ContractHistoryItem } from '~/services/dto/employee' import type { ContractHistoryItem } from '~/services/dto/employee'
import type { InterimAgency } from '~/services/interim-agencies'
import WorkDaysHoursInput from '~/components/employees/WorkDaysHoursInput.vue'
type SuspensionForm = { type SuspensionForm = {
id: number | null id: number | null
@@ -286,6 +317,7 @@ type ContractForm = {
endDate: string endDate: string
paidLeaveSettled: boolean paidLeaveSettled: boolean
comment: string comment: string
workDaysHours: Record<number, number> | null
} }
type CreateContractForm = { type CreateContractForm = {
@@ -294,6 +326,8 @@ type CreateContractForm = {
startDate: string startDate: string
endDate: string endDate: string
isDriver: boolean isDriver: boolean
workDaysHours: Record<number, number> | null
interimAgencyId: number | ''
} }
const props = defineProps<{ const props = defineProps<{
@@ -322,6 +356,8 @@ const props = defineProps<{
requiresCreateContractEndDate: boolean requiresCreateContractEndDate: boolean
createContractEndDateFieldClass: string createContractEndDateFieldClass: string
isCreateContractFormValid: boolean isCreateContractFormValid: boolean
requiresCreateWorkDaysHours: boolean
selectedCreateContract: Contract | null
onOpenCloseContractDrawer: () => void onOpenCloseContractDrawer: () => void
onOpenCreateContractDrawer: () => void onOpenCreateContractDrawer: () => void
onUpdateContractDrawerOpen: (open: boolean) => void onUpdateContractDrawerOpen: (open: boolean) => void
@@ -333,6 +369,7 @@ const props = defineProps<{
onSubmitSuspension: (index: number) => void onSubmitSuspension: (index: number) => void
onAddSuspensionForm: () => void onAddSuspensionForm: () => void
currentContractPeriodId?: number | null currentContractPeriodId?: number | null
interimAgencies: InterimAgency[]
}>() }>()
const drawerTab = ref<'close' | 'suspend'>('close') const drawerTab = ref<'close' | 'suspend'>('close')

View File

@@ -39,6 +39,8 @@
</div> </div>
<button <button
class="flex items-center" class="flex items-center"
:class="isHistoricalYear ? 'opacity-40 cursor-not-allowed' : ''"
:disabled="isHistoricalYear"
@click="openPaidLeaveDrawer" @click="openPaidLeaveDrawer"
> >
<Icon name="mdi:edit-box" size="24"/> <Icon name="mdi:edit-box" size="24"/>
@@ -51,6 +53,8 @@
</div> </div>
<button <button
class="flex items-center" class="flex items-center"
:class="isHistoricalYear ? 'opacity-40 cursor-not-allowed' : ''"
:disabled="isHistoricalYear"
@click="openFractionedDrawer" @click="openFractionedDrawer"
> >
<Icon name="mdi:edit-box" size="24"/> <Icon name="mdi:edit-box" size="24"/>
@@ -90,6 +94,22 @@
</div> </div>
</div> </div>
</div> </div>
<div class="mt-6 flex items-center gap-3">
<label for="leave-year-select" class="text-md font-semibold text-primary-500 uppercase">
{{ isForfaitRule ? 'Année :' : 'Exercice :' }}
</label>
<select
id="leave-year-select"
:value="selectedYear ?? ''"
:disabled="!availableYears.length"
class="border border-primary-500 rounded-md px-3 py-1 text-md font-semibold text-primary-500 bg-white focus:outline-none focus:ring-2 focus:ring-secondary-500/20 disabled:opacity-50"
@change="handleYearChange"
>
<option v-for="option in availableYears" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
</div> </div>
<AppDrawer v-model="isFractionedDrawerOpen" title="Jours fractionnés"> <AppDrawer v-model="isFractionedDrawerOpen" title="Jours fractionnés">
<form class="space-y-4" @submit.prevent="handleSubmitFractioned"> <form class="space-y-4" @submit.prevent="handleSubmitFractioned">
@@ -173,17 +193,39 @@ type DayLeaveState = {
colors: string[] colors: string[]
} }
type LeaveYearOption = {
value: number
label: string
}
const props = defineProps<{ const props = defineProps<{
absences: Absence[] absences: Absence[]
summary: EmployeeLeaveSummary | null summary: EmployeeLeaveSummary | null
publicHolidays: Record<string, string> publicHolidays: Record<string, string>
selectedYear: number | null
availableYears: LeaveYearOption[]
currentYear: number | null
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
(event: 'update-fractioned-days', days: number): void (event: 'update-fractioned-days', days: number): void
(event: 'update-paid-leave-days', days: number): void (event: 'update-paid-leave-days', days: number): void
(event: 'update-selected-year', year: number): void
}>() }>()
const isHistoricalYear = computed(() =>
props.selectedYear !== null
&& props.currentYear !== null
&& props.selectedYear !== props.currentYear
)
const handleYearChange = (event: Event) => {
const target = event.target as HTMLSelectElement
const value = Number(target.value)
if (Number.isNaN(value)) return
emit('update-selected-year', value)
}
const isFractionedDrawerOpen = ref(false) const isFractionedDrawerOpen = ref(false)
const fractionedForm = reactive({days: 0}) const fractionedForm = reactive({days: 0})
@@ -239,6 +281,7 @@ const currentYearTakenDays = computed(() => {
}) })
const displayedYear = computed(() => { const displayedYear = computed(() => {
if (props.selectedYear) return props.selectedYear
if (props.summary?.year) return props.summary.year if (props.summary?.year) return props.summary.year
const today = new Date() const today = new Date()
const year = today.getFullYear() const year = today.getFullYear()

View File

@@ -11,7 +11,7 @@
<Icon name="mdi:chevron-left" size="24"/> <Icon name="mdi:chevron-left" size="24"/>
</button> </button>
<span class="text-lg font-bold tracking-wide min-w-[170px] text-center"> <span class="text-lg font-bold tracking-wide min-w-[170px] text-center">
{{ currentMonthLabel }} {{ currentYear }} {{ currentMonthLabel }} {{ displayedMonthYear }}
</span> </span>
<button <button
class="rounded px-2 py-1 font-bold hover:bg-primary-600 disabled:opacity-40 disabled:cursor-not-allowed flex items-center" class="rounded px-2 py-1 font-bold hover:bg-primary-600 disabled:opacity-40 disabled:cursor-not-allowed flex items-center"
@@ -27,7 +27,8 @@
</p> </p>
<div class="flex justify-center"> <div class="flex justify-center">
<button <button
class="rounded-md bg-primary-500 px-8 py-2 font-bold text-white hover:bg-primary-600" class="rounded-md bg-primary-500 px-8 py-2 font-bold text-white hover:bg-primary-600 disabled:opacity-40 disabled:cursor-not-allowed"
:disabled="isHistoricalYear"
@click="openPaymentDrawer" @click="openPaymentDrawer"
> >
+ Payer les RTT + Payer les RTT
@@ -40,14 +41,15 @@
<table class="w-full table-fixed border-collapse text-[18px]"> <table class="w-full table-fixed border-collapse text-[18px]">
<colgroup> <colgroup>
<col /> <col />
<col class="w-[11%]" /> <col class="w-[10%]" />
<col class="w-[11%]" /> <col class="w-[10%]" />
<col class="w-[11%]" /> <col class="w-[10%]" />
<col class="w-[11%]" /> <col class="w-[10%]" />
<col class="w-[11%]" /> <col class="w-[10%]" />
<col class="w-[11%]" /> <col class="w-[10%]" />
<col class="w-[11%]" /> <col class="w-[10%]" />
<col class="w-[11%]" /> <col class="w-[10%]" />
<col class="w-[10%]" />
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
@@ -59,7 +61,8 @@
<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">Base</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">50%</th> <th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">50%</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">Total 50%</th> <th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">Total 50%</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">Total</th> <th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">Total</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">Cumul</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -73,6 +76,7 @@
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBase50Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBase50Minutes) }}</span></td> <td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBase50Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBase50Minutes) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBonus50Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBonus50Minutes) }}</span></td> <td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBonus50Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBonus50Minutes) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(summary!.carryBase50Minutes + summary!.carryBonus50Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBase50Minutes + summary!.carryBonus50Minutes) }}</span></td> <td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(summary!.carryBase50Minutes + summary!.carryBonus50Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBase50Minutes + summary!.carryBonus50Minutes) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(summary!.carryFromPreviousYearMinutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryFromPreviousYearMinutes) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryFromPreviousYearMinutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryFromPreviousYearMinutes) }}</span></td> <td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryFromPreviousYearMinutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryFromPreviousYearMinutes) }}</span></td>
</tr> </tr>
@@ -86,6 +90,7 @@
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.base50) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.base50) }}</span></td> <td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.base50) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.base50) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.bonus50) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.bonus50) }}</span></td> <td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.bonus50) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.bonus50) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(monthReport.total50) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.total50) }}</span></td> <td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(monthReport.total50) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.total50) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(monthReport.total) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.total) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.total) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.total) }}</span></td> <td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.total) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.total) }}</span></td>
</tr> </tr>
@@ -126,10 +131,14 @@
<span v-if="week">{{ formatMinutes(week.base50Minutes + week.bonus50Minutes) }}</span> <span v-if="week">{{ formatMinutes(week.base50Minutes + week.bonus50Minutes) }}</span>
<span v-else>0 h</span> <span v-else>0 h</span>
</td> </td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500"> <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.totalMinutes) }}</span> <span v-if="week">{{ formatMinutes(week.totalMinutes) }}</span>
<span v-else>0 h</span> <span v-else>0 h</span>
</td> </td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
<span v-if="week">{{ formatMinutes(week.cumulativeBalanceMinutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(week.cumulativeBalanceMinutes) }}</span></span>
<span v-else>&nbsp;</span>
</td>
</tr> </tr>
<!-- Total row --> <!-- Total row -->
@@ -142,20 +151,22 @@
<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-t-2">{{ formatMinutes(totals.base50) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 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.bonus50) }}</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.total50) }}</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.total50) }}</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> <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.total) }}</td>
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-t-2">-</td>
</tr> </tr>
<!-- Payé row --> <!-- Payé row -->
<tr> <tr>
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">Payé</td> <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 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">{{ currentPayment ? formatMinutes(-currentPayment.paidBase25Minutes) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -currentPayment.paidBase25Minutes : 0) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ 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.paidBonus25Minutes) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -currentPayment.paidBonus25Minutes : 0) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ currentPayment ? formatMinutes(-(currentPayment.paidBase25Minutes + currentPayment.paidBonus25Minutes)) : '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.paidBase25Minutes + currentPayment.paidBonus25Minutes)) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -(currentPayment.paidBase25Minutes + currentPayment.paidBonus25Minutes) : 0) }}</span></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">{{ currentPayment ? formatMinutes(-currentPayment.paidBase50Minutes) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -currentPayment.paidBase50Minutes : 0) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBonus50Minutes) : '0 h' }}</td> <td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBonus50Minutes) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -currentPayment.paidBonus50Minutes : 0) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ currentPayment ? formatMinutes(-(currentPayment.paidBase50Minutes + currentPayment.paidBonus50Minutes)) : '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.paidBase50Minutes + currentPayment.paidBonus50Minutes)) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -(currentPayment.paidBase50Minutes + currentPayment.paidBonus50Minutes) : 0) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(paidTotal) }}</td> <td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(paidTotal) }} <span class="text-neutral-400">/ {{ formatCentiemes(paidTotal) }}</span></td>
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500">-</td>
</tr> </tr>
<!-- Reste row --> <!-- Reste row -->
@@ -168,10 +179,27 @@
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.base50) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.base50) }}</span></td> <td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.base50) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.base50) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.bonus50) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.bonus50) }}</span></td> <td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.bonus50) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.bonus50) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(reste.total50) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.total50) }}</span></td> <td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(reste.total50) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.total50) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.total) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.total) }}</span></td> <td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(reste.total) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.total) }}</span></td>
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500">-</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<div class="mt-6 flex items-center gap-3">
<label for="rtt-year-select" class="text-md font-semibold text-primary-500 uppercase">
Exercice :
</label>
<select
id="rtt-year-select"
:value="selectedYear ?? ''"
:disabled="!availableYears.length"
class="border border-primary-500 rounded-md px-3 py-1 text-md font-semibold text-primary-500 bg-white focus:outline-none focus:ring-2 focus:ring-secondary-500/20 disabled:opacity-50"
@change="handleYearChange"
>
<option v-for="option in availableYears" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
</div> </div>
<!-- Payment Drawer --> <!-- Payment Drawer -->
@@ -187,41 +215,41 @@
</select> </select>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label class="block text-sm font-medium text-neutral-700">Base 25% (heures)</label> <label class="block text-sm font-medium text-neutral-700">Base 25% (centièmes)</label>
<input <input
v-model.number="paymentForm.base25Hours" v-model.number="paymentForm.base25Hours"
type="number" type="number"
step="0.5" step="0.01"
min="0" 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" 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>
<div class="mb-4"> <div class="mb-4">
<label class="block text-sm font-medium text-neutral-700">Heures 25% (heures)</label> <label class="block text-sm font-medium text-neutral-700">Heures 25% (centièmes)</label>
<input <input
v-model.number="paymentForm.bonus25Hours" v-model.number="paymentForm.bonus25Hours"
type="number" type="number"
step="0.5" step="0.01"
min="0" 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" 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>
<div class="mb-4"> <div class="mb-4">
<label class="block text-sm font-medium text-neutral-700">Base 50% (heures)</label> <label class="block text-sm font-medium text-neutral-700">Base 50% (centièmes)</label>
<input <input
v-model.number="paymentForm.base50Hours" v-model.number="paymentForm.base50Hours"
type="number" type="number"
step="0.5" step="0.01"
min="0" 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" 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>
<div class="mb-6"> <div class="mb-6">
<label class="block text-sm font-medium text-neutral-700">Heures 50% (heures)</label> <label class="block text-sm font-medium text-neutral-700">Heures 50% (centièmes)</label>
<input <input
v-model.number="paymentForm.bonus50Hours" v-model.number="paymentForm.bonus50Hours"
type="number" type="number"
step="0.5" step="0.01"
min="0" 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" 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"
/> />
@@ -250,14 +278,36 @@
import type { EmployeeRttSummary, EmployeeRttWeekSummary } from '~/services/dto/employee-rtt-summary' import type { EmployeeRttSummary, EmployeeRttWeekSummary } from '~/services/dto/employee-rtt-summary'
import AppDrawer from '~/components/AppDrawer.vue' import AppDrawer from '~/components/AppDrawer.vue'
type RttYearOption = {
value: number
label: string
}
const props = defineProps<{ const props = defineProps<{
summary: EmployeeRttSummary | null summary: EmployeeRttSummary | null
selectedYear: number | null
availableYears: RttYearOption[]
currentYear: number | null
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
(event: 'submit-rtt-payment', month: number, base25Minutes: number, bonus25Minutes: number, base50Minutes: number, bonus50Minutes: number): void (event: 'submit-rtt-payment', month: number, base25Minutes: number, bonus25Minutes: number, base50Minutes: number, bonus50Minutes: number): void
(event: 'update-selected-year', year: number): void
}>() }>()
const isHistoricalYear = computed(() =>
props.selectedYear !== null
&& props.currentYear !== null
&& props.selectedYear !== props.currentYear
)
const handleYearChange = (event: Event) => {
const target = event.target as HTMLSelectElement
const value = Number(target.value)
if (Number.isNaN(value)) return
emit('update-selected-year', value)
}
// --- Last complete week number --- // --- Last complete week number ---
const lastCompleteWeek = computed(() => { const lastCompleteWeek = computed(() => {
@@ -313,7 +363,7 @@ const currentMonth = computed(() => orderedMonths[currentMonthIndex.value])
const currentMonthLabel = computed(() => monthLabels[currentMonth.value]) const currentMonthLabel = computed(() => monthLabels[currentMonth.value])
const currentYear = computed(() => { const displayedMonthYear = computed(() => {
if (!props.summary) return '' if (!props.summary) return ''
return currentMonth.value >= 6 ? props.summary.year - 1 : props.summary.year return currentMonth.value >= 6 ? props.summary.year - 1 : props.summary.year
}) })
@@ -500,10 +550,10 @@ const paymentForm = reactive({
const prefillFromExistingPayment = (month: number) => { const prefillFromExistingPayment = (month: number) => {
const existing = props.summary?.monthPayments.find((p) => p.month === month) ?? null const existing = props.summary?.monthPayments.find((p) => p.month === month) ?? null
if (existing) { if (existing) {
paymentForm.base25Hours = existing.paidBase25Minutes / 60 paymentForm.base25Hours = Math.round(existing.paidBase25Minutes / 60 * 100) / 100
paymentForm.bonus25Hours = existing.paidBonus25Minutes / 60 paymentForm.bonus25Hours = Math.round(existing.paidBonus25Minutes / 60 * 100) / 100
paymentForm.base50Hours = existing.paidBase50Minutes / 60 paymentForm.base50Hours = Math.round(existing.paidBase50Minutes / 60 * 100) / 100
paymentForm.bonus50Hours = existing.paidBonus50Minutes / 60 paymentForm.bonus50Hours = Math.round(existing.paidBonus50Minutes / 60 * 100) / 100
} else { } else {
paymentForm.base25Hours = 0 paymentForm.base25Hours = 0
paymentForm.bonus25Hours = 0 paymentForm.bonus25Hours = 0
@@ -516,6 +566,14 @@ watch(() => paymentForm.month, (newMonth) => {
prefillFromExistingPayment(newMonth) prefillFromExistingPayment(newMonth)
}) })
watch(() => paymentForm.base25Hours, (value) => {
paymentForm.bonus25Hours = Math.round(value * 0.25 * 100) / 100
})
watch(() => paymentForm.base50Hours, (value) => {
paymentForm.bonus50Hours = Math.round(value * 0.50 * 100) / 100
})
const openPaymentDrawer = () => { const openPaymentDrawer = () => {
paymentForm.month = currentMonth.value paymentForm.month = currentMonth.value
prefillFromExistingPayment(currentMonth.value) prefillFromExistingPayment(currentMonth.value)

View File

@@ -0,0 +1,113 @@
<template>
<div class="rounded-md border border-neutral-200 bg-neutral-50 p-3 space-y-2">
<div class="flex items-center justify-between">
<p class="text-md font-semibold text-neutral-700">
Jours travaillés <span v-if="!disabled" class="text-red-600">*</span>
</p>
<p class="text-sm" :class="totalIsValid ? 'text-green-700' : 'text-red-600'">
{{ formatTotal(totalMinutes) }} / {{ formatTotal(expectedMinutes) }}
</p>
</div>
<p v-if="!disabled" class="text-xs text-neutral-500">Somme requise = {{ expectedMinutes / 60 }}h (total hebdo du contrat).</p>
<div class="space-y-1">
<div v-for="day in days" :key="day.iso" class="flex items-center gap-3">
<label class="inline-flex items-center gap-2 min-w-[120px]">
<input
:checked="day.active"
type="checkbox"
class="h-4 w-4 rounded border-neutral-300 text-primary-500 focus:ring-primary-500"
:disabled="disabled"
@change="onToggleDay(day.iso, ($event.target as HTMLInputElement).checked)"
/>
<span class="text-md text-neutral-700">{{ day.label }}</span>
</label>
<input
:value="day.time"
type="time"
step="60"
class="rounded-md border border-neutral-300 bg-white px-2 py-1 text-md text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20 disabled:bg-neutral-100 disabled:text-neutral-400"
:disabled="disabled || !day.active"
@input="onChangeTime(day.iso, ($event.target as HTMLInputElement).value)"
/>
</div>
</div>
<p v-if="!totalIsValid" class="text-sm text-red-600">
La somme des heures par jour doit égaler exactement {{ expectedMinutes / 60 }}h.
</p>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(defineProps<{
modelValue: Record<number, number> | null
contractWeeklyHours: number | null
disabled?: boolean
}>(), { disabled: false })
const emit = defineEmits<{
'update:modelValue': [value: Record<number, number>]
}>()
const DAY_LABELS: Record<number, string> = { 1: 'Lundi', 2: 'Mardi', 3: 'Mercredi', 4: 'Jeudi', 5: 'Vendredi' }
const expectedMinutes = computed(() => (props.contractWeeklyHours ?? 0) * 60)
const days = computed(() => {
const raw = props.modelValue ?? {}
return [1, 2, 3, 4, 5].map((iso) => {
const active = Object.prototype.hasOwnProperty.call(raw, iso)
const minutes = Number(raw[iso] ?? 0)
return {
iso,
label: DAY_LABELS[iso],
active,
time: active ? minutesToTime(minutes) : '00:00',
}
})
})
const totalMinutes = computed(() => {
const raw = props.modelValue ?? {}
return Object.values(raw).reduce((sum, n) => sum + (Number(n) || 0), 0)
})
const totalIsValid = computed(() => totalMinutes.value === expectedMinutes.value && expectedMinutes.value > 0)
function minutesToTime(minutes: number): string {
const h = Math.floor(minutes / 60)
const m = minutes % 60
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
}
function timeToMinutes(value: string): number {
const [h, m] = value.split(':').map(Number)
return (h || 0) * 60 + (m || 0)
}
function onToggleDay(iso: number, active: boolean) {
const next = { ...(props.modelValue ?? {}) }
if (active) {
next[iso] = next[iso] ?? 0
} else {
delete next[iso]
}
emit('update:modelValue', next)
}
function onChangeTime(iso: number, value: string) {
const next = { ...(props.modelValue ?? {}) }
const minutes = timeToMinutes(value)
next[iso] = minutes
emit('update:modelValue', next)
}
function formatTotal(min: number): string {
const h = Math.floor(min / 60)
const m = min % 60
return m === 0 ? `${h}h` : `${h}h${String(m).padStart(2, '0')}`
}
defineExpose({ totalIsValid, totalMinutes })
</script>

View File

@@ -1,12 +1,180 @@
<template> <template>
<div class="bg-white overflow-hidden flex min-h-0 flex-col"> <div class="bg-white overflow-hidden flex min-h-0 flex-col">
<div class="overflow-y-auto min-h-0"> <!-- Mobile card layout -->
<div class="overflow-y-auto min-h-0 space-y-3 lg:hidden">
<div
v-for="employee in employees"
:key="'m-' + employee.id"
class="rounded-md border border-primary-500 bg-white p-4"
>
<!-- Employee name + site -->
<div class="mb-3">
<p class="text-md font-bold text-primary-500 truncate">
{{ employee.firstName }} {{ employee.lastName }}
<span class="font-normal text-neutral-600 text-sm">({{ contractLabel(employee) }})</span>
</p>
<p class="text-sm text-neutral-500 truncate">
{{ employee.site?.name ?? 'Sans site' }}<span v-if="getRowContractNature(employee.id)"> {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }}</span>
</p>
</div>
<!-- Absence / Holiday / Formation pills -->
<div class="mb-3 flex flex-col gap-1">
<p
v-if="getRowAbsenceLabel(employee.id)"
class="rounded-md px-2 py-1 text-xs text-white truncate"
:style="getRowAbsenceStyle(employee.id)"
>
{{ getRowAbsenceLabel(employee.id) }}
</p>
<p
v-else
class="text-xs text-neutral-400"
>
Aucune absence
</p>
<p
v-if="isHoliday"
class="rounded-md px-2 py-1 text-xs text-sky-900 inline-flex items-center gap-1"
style="background-color: #b3e5fc"
>
<Icon name="mdi:calendar-star" size="14" class="shrink-0"/>
<span class="truncate">{{ holidayLabel || 'Férié' }}</span>
</p>
<p
v-if="hasRowFormation(employee.id)"
class="rounded-md px-2 py-1 text-xs text-white bg-indigo-500 inline-flex items-center gap-1"
>
<Icon name="mdi:school-outline" size="14" class="shrink-0"/>
<span class="truncate">{{ getRowFormationLabel(employee.id) }}</span>
</p>
<button
v-if="!hasRowFormation(employee.id)"
type="button"
class="self-start text-xs font-semibold underline"
:class="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
:disabled="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
@click="onAbsenceClick(employee.id)"
>
Modifier
</button>
</div>
<!-- Time inputs (TIME tracking) -->
<div v-if="isTimeTracking(employee)" class="space-y-2">
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-xs text-neutral-500">Début matin</label>
<TimeSelect
v-model="rows[employee.id].morningFrom"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
/>
</div>
<div>
<label class="text-xs text-neutral-500">Fin matin</label>
<TimeSelect
v-model="rows[employee.id].morningTo"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-xs text-neutral-500">Début après-midi</label>
<TimeSelect
v-model="rows[employee.id].afternoonFrom"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
/>
</div>
<div>
<label class="text-xs text-neutral-500">Fin après-midi</label>
<TimeSelect
v-model="rows[employee.id].afternoonTo"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-xs text-neutral-500">Début soir</label>
<TimeSelect
v-model="rows[employee.id].eveningFrom"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
/>
</div>
<div>
<label class="text-xs text-neutral-500">Fin soir</label>
<TimeSelect
v-model="rows[employee.id].eveningTo"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
/>
</div>
</div>
<div class="flex gap-4 pt-1 text-sm font-semibold text-primary-500">
<span>Jour : {{ formatMinutes(getRowMetrics(employee.id).dayMinutes) }}</span>
<span>Nuit : {{ formatMinutes(getRowMetrics(employee.id).nightMinutes) }}</span>
<span>Total : {{ formatMinutes(getRowMetrics(employee.id).totalMinutes) }}</span>
</div>
</div>
<!-- Presence tracking -->
<div v-else-if="isPresenceTracking(employee)" class="space-y-2">
<div class="flex items-center gap-4">
<label class="flex items-center gap-2 text-sm">
<input
v-model="rows[employee.id].isPresentMorning"
type="checkbox"
class="h-4 w-4 cursor-pointer"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
/>
Matin
</label>
<label class="flex items-center gap-2 text-sm">
<input
v-model="rows[employee.id].isPresentAfternoon"
type="checkbox"
class="h-4 w-4 cursor-pointer"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
/>
Après-midi
</label>
</div>
<p class="text-sm font-semibold text-primary-500">Total : {{ getPresenceDayValue(employee.id) }}</p>
</div>
<!-- Validation status (non-admin) -->
<div v-if="!isAdmin" class="mt-3 flex gap-4 text-xs border-t border-neutral-200 pt-2">
<span v-if="!isSiteManager" class="flex items-center gap-1">
<Icon name="mdi:check-circle-outline" size="14" :class="rows[employee.id]?.isSiteValid ? 'text-green-600' : 'text-neutral-400'"/>
Validation site : <span :class="rows[employee.id]?.isSiteValid ? 'font-semibold text-green-600' : 'text-neutral-500'">{{ rows[employee.id]?.isSiteValid ? 'Validé' : 'En attente' }}</span>
</span>
<span class="flex items-center gap-1">
<Icon name="mdi:check-circle-outline" size="14" :class="rows[employee.id]?.isValid ? 'text-green-600' : 'text-neutral-400'"/>
Validation RH : <span :class="rows[employee.id]?.isValid ? 'font-semibold text-green-600' : 'text-neutral-500'">{{ rows[employee.id]?.isValid ? 'Validé' : 'En attente' }}</span>
</span>
</div>
<!-- Validation checkbox (admin) -->
<div v-if="isAdmin" class="mt-3 flex items-center gap-2 text-sm">
<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)"
/>
<span class="text-neutral-700 font-semibold">Valider</span>
</div>
</div>
</div>
<!-- Desktop table layout -->
<div class="overflow-y-auto min-h-0 hidden lg:block">
<div <div
class="grid w-full min-w-0 gap-1 border border-black bg-tertiary-500 px-4 py-3 text-sm font-semibold text-black rounded-t-md sticky top-0 z-10" class="grid w-full min-w-0 gap-1 border border-black bg-tertiary-500 px-4 py-3 text-sm font-semibold text-black rounded-t-md sticky top-0 z-10"
:style="{ gridTemplateColumns: dayGridCols }" :style="{ gridTemplateColumns: dayGridCols }"
> >
<span>Nom</span> <span>Nom</span>
<span class="pl-2">Absence</span> <span class="pl-2">Statut</span>
<span class="pl-4">Début matin</span> <span class="pl-4">Début matin</span>
<span class="pr-2">Fin matin</span> <span class="pr-2">Fin matin</span>
<span class="pl-2">Début après-midi</span> <span class="pl-2">Début après-midi</span>
@@ -26,19 +194,7 @@
@change="onBulkValidationChange" @change="onBulkValidationChange"
/> />
</span> </span>
<span v-else-if="isSiteManager" class="inline-flex items-center gap-2"> <span v-else-if="!isSiteManager">Site <Icon name="mdi:check-bold" class="ml-1"/></span>
<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> <span v-if="!isAdmin">RH <Icon name="mdi:check-bold" class="ml-1"/></span>
</div> </div>
@@ -55,7 +211,9 @@
<span class="font-normal text-neutral-600">({{ contractLabel(employee) }})</span> <span class="font-normal text-neutral-600">({{ contractLabel(employee) }})</span>
</p> </p>
<p class="text-neutral-500 truncate inline-flex items-center gap-2"> <p class="text-neutral-500 truncate inline-flex items-center gap-2">
<span>{{ employee.site?.name ?? 'Sans site' }}</span> <span>
{{ employee.site?.name ?? 'Sans site' }}<span v-if="getRowContractNature(employee.id)"> {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }}</span>
</span>
<span <span
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)" v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
class="rounded-full bg-green-500 flex justify-center item-center text-white p-0.5" class="rounded-full bg-green-500 flex justify-center item-center text-white p-0.5"
@@ -78,6 +236,15 @@
> >
{{ getRowAbsenceLabel(employee.id) || '—' }} {{ getRowAbsenceLabel(employee.id) || '—' }}
</p> </p>
<p
v-if="isHoliday"
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate text-sky-900 inline-flex items-center gap-1"
style="background-color: #b3e5fc"
:title="holidayLabel || 'Férié'"
>
<Icon name="mdi:calendar-star" size="14" class="shrink-0"/>
<span class="truncate">{{ holidayLabel || 'Férié' }}</span>
</p>
<p <p
v-if="hasRowFormation(employee.id)" v-if="hasRowFormation(employee.id)"
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate text-white bg-indigo-500 inline-flex items-center gap-1" class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate text-white bg-indigo-500 inline-flex items-center gap-1"
@@ -91,8 +258,8 @@
v-if="!hasRowFormation(employee.id)" v-if="!hasRowFormation(employee.id)"
type="button" type="button"
class="self-start text-left text-xs font-semibold underline" 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'" :class="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)" :disabled="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
@click="onAbsenceClick(employee.id)" @click="onAbsenceClick(employee.id)"
> >
Modifier Modifier
@@ -181,16 +348,8 @@
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)" @change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
/> />
</div> </div>
<div v-else class="text-right p-5"> <div v-else-if="!isSiteManager" class="text-right p-5">
<input <span v-if="rows[employee.id]?.isSiteValid" class="text-xs font-semibold text-neutral-700">Validé</span>
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> <span v-else class="text-xs text-neutral-500">-</span>
</div> </div>
<div v-if="!isAdmin"> <div v-if="!isAdmin">
@@ -207,6 +366,7 @@
import type {Employee} from '~/services/dto/employee' import type {Employee} from '~/services/dto/employee'
import TimeSelect from '~/components/ui/TimeSelect.vue' import TimeSelect from '~/components/ui/TimeSelect.vue'
import type {HourRow} from './types' import type {HourRow} from './types'
import { contractNatureLabel } from '~/utils/contract'
const rows = defineModel<Record<number, HourRow>>('rows', {required: true}) const rows = defineModel<Record<number, HourRow>>('rows', {required: true})
const bulkValidationInput = ref<HTMLInputElement | null>(null) const bulkValidationInput = ref<HTMLInputElement | null>(null)
@@ -218,6 +378,7 @@ const props = defineProps<{
isSiteManager: boolean isSiteManager: boolean
dayGridCols: string dayGridCols: string
isHoliday: boolean isHoliday: boolean
holidayLabel: string
contractLabel: (employee: Employee) => string contractLabel: (employee: Employee) => string
isTimeTracking: (employee: Employee) => boolean isTimeTracking: (employee: Employee) => boolean
isPresenceTracking: (employee: Employee) => boolean isPresenceTracking: (employee: Employee) => boolean
@@ -239,11 +400,12 @@ const props = defineProps<{
onToggleSiteValidation: (employeeId: number, checked: boolean) => void onToggleSiteValidation: (employeeId: number, checked: boolean) => void
onToggleValidationBulk: (checked: boolean) => Promise<void> | void onToggleValidationBulk: (checked: boolean) => Promise<void> | void
onToggleSiteValidationBulk: (checked: boolean) => Promise<void> | void onToggleSiteValidationBulk: (checked: boolean) => Promise<void> | void
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number } getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number; virtualHolidayMinutes: number }
getRowAbsenceLabel: (employeeId: number) => string getRowAbsenceLabel: (employeeId: number) => string
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
hasRowFormation: (employeeId: number) => boolean hasRowFormation: (employeeId: number) => boolean
getRowFormationLabel: (employeeId: number) => string getRowFormationLabel: (employeeId: number) => string
getRowContractNature: (employeeId: number) => 'CDI' | 'CDD' | 'INTERIM' | null
getRowUpdatedAt: (employeeId: number) => string getRowUpdatedAt: (employeeId: number) => string
getPresenceDayValue: (employeeId: number) => string getPresenceDayValue: (employeeId: number) => string
onAbsenceClick: (employeeId: number) => void onAbsenceClick: (employeeId: number) => void

View File

@@ -1,17 +1,90 @@
<template> <template>
<div class="py-6 flex flex-col gap-3"> <div class="py-4 flex flex-col gap-3 lg:py-6">
<div class="flex gap-4"> <!-- Desktop: filters row -->
<SiteFilterSelector v-if="sites.length > 0 && isAdmin" v-model="selectedSiteIds" :sites="sites" /> <div class="hidden lg:flex lg:items-center lg:gap-4">
<div v-if="isAdmin" class="w-80 max-w-full"> <div v-if="sites.length > 0 && isAdmin" class="relative z-50 w-80">
<EmployeeNameFilterInput v-model="employeeFilter" /> <MalioSelectCheckbox
v-model="selectedSiteIds"
:options="siteOptions"
groupClass="w-80"
label="Sites"
display-select-all
/>
</div>
<div v-if="isAdmin" class="w-80">
<MalioInputText
v-model="employeeFilter"
label="Recherche d'un employé"
icon-name="mdi:magnify"
/>
</div> </div>
</div> </div>
<div class="flex justify-between items-center gap-4"> <!-- Mobile: search + filter button -->
<div class="flex gap-4 flex-wrap"> <div v-if="isAdmin" class="flex items-center gap-2 lg:hidden">
<div class="flex-1 min-w-0">
<MalioInputText
v-model="employeeFilter"
label="Recherche d'un employé"
icon-name="mdi:magnify"
/>
</div>
<button
type="button"
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-primary-500 bg-white text-primary-500"
@click="filtersDrawerOpen = true"
>
<Icon name="mdi:filter-variant" size="22"/>
</button>
</div>
<!-- Mobile filters drawer -->
<AppDrawer v-model="filtersDrawerOpen" title="Filtres">
<div class="space-y-6">
<div v-if="sites.length > 0 && isAdmin">
<label class="text-md font-semibold text-neutral-700">Sites</label>
<div class="mt-2">
<MalioSelectCheckbox
v-model="selectedSiteIds"
:options="siteOptions"
groupClass="w-80"
label="Sites"
display-select-all
/>
</div>
</div>
<div v-if="isAdmin">
<label class="text-md font-semibold text-neutral-700">Vue</label>
<div class="mt-2 inline-flex h-10 w-full overflow-hidden rounded-md border border-primary-500 bg-white">
<button
type="button"
class="flex-1 inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-semibold"
:class="viewModeButtonClass('day')"
@click="viewMode = 'day'; filtersDrawerOpen = false"
>
<Icon name="mdi:calendar-clock" />
Jour
</button>
<button
type="button"
class="flex-1 inline-flex items-center justify-center gap-2 border-l border-primary-500 px-4 py-2 text-sm font-semibold"
:class="viewModeButtonClass('week')"
@click="viewMode = 'week'; filtersDrawerOpen = false"
>
<Icon name="mdi:calendar-week" />
Semaine
</button>
</div>
</div>
</div>
</AppDrawer>
<!-- Date navigation -->
<div class="flex flex-col gap-3 lg:flex-row lg:justify-between lg:items-center lg:gap-4">
<div class="flex flex-col gap-3 lg:flex-row lg:flex-wrap lg:gap-4">
<div <div
v-if="viewMode === 'day'" v-if="viewMode === 'day'"
class="inline-flex h-10 w-[320px] overflow-hidden rounded-md border border-primary-500 bg-white" class="inline-flex h-10 w-full overflow-hidden rounded-md border border-primary-500 bg-white lg:w-[320px]"
> >
<button <button
type="button" type="button"
@@ -41,7 +114,7 @@
<div <div
v-else v-else
class="inline-flex h-10 w-[320px] overflow-hidden rounded-md border border-primary-500 bg-white" class="inline-flex h-10 w-full overflow-hidden rounded-md border border-primary-500 bg-white lg:w-[320px]"
> >
<button <button
type="button" type="button"
@@ -70,7 +143,7 @@
</div> </div>
<PeriodStepperPicker <PeriodStepperPicker
width-class="w-[320px]" width-class="w-full lg:w-[320px]"
:label="formattedSelectedDate" :label="formattedSelectedDate"
:picker-type="viewMode === 'week' ? 'week' : 'date'" :picker-type="viewMode === 'week' ? 'week' : 'date'"
:picker-value="pickerValue" :picker-value="pickerValue"
@@ -82,7 +155,8 @@
/> />
</div> </div>
<div v-if="isAdmin" class="inline-flex h-10 overflow-hidden rounded-md border border-primary-500 bg-white"> <!-- Desktop: view mode toggle -->
<div v-if="isAdmin" class="hidden lg:inline-flex h-10 overflow-hidden rounded-md border border-primary-500 bg-white">
<button <button
type="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="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"
@@ -106,7 +180,7 @@
<div <div
v-if="isAdmin && viewMode === 'week' && absenceTypes.length > 0" v-if="isAdmin && viewMode === 'week' && absenceTypes.length > 0"
class="flex flex-wrap items-center gap-6" class="hidden lg:flex flex-wrap items-center gap-6"
> >
<p class="font-bold">Légende :</p> <p class="font-bold">Légende :</p>
<div v-for="type in absenceTypes" :key="type.id" class="flex items-center gap-2"> <div v-for="type in absenceTypes" :key="type.id" class="flex items-center gap-2">
@@ -120,9 +194,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Site } from '~/services/dto/site' import type { Site } from '~/services/dto/site'
import type { AbsenceType } from '~/services/dto/absence-type' 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 PeriodStepperPicker from '~/components/PeriodStepperPicker.vue'
import AppDrawer from '~/components/AppDrawer.vue'
import { weekInputValueToYmd, ymdToWeekInputValue } from '~/utils/date' import { weekInputValueToYmd, ymdToWeekInputValue } from '~/utils/date'
const selectedDate = defineModel<string>('selectedDate', { required: true }) const selectedDate = defineModel<string>('selectedDate', { required: true })
@@ -130,7 +203,7 @@ const viewMode = defineModel<'day' | 'week'>('viewMode', { required: true })
const selectedSiteIds = defineModel<number[]>('selectedSiteIds', { required: true }) const selectedSiteIds = defineModel<number[]>('selectedSiteIds', { required: true })
const employeeFilter = defineModel<string>('employeeFilter', { required: true }) const employeeFilter = defineModel<string>('employeeFilter', { required: true })
defineProps<{ const props = defineProps<{
isAdmin: boolean isAdmin: boolean
sites: Site[] sites: Site[]
absenceTypes: AbsenceType[] absenceTypes: AbsenceType[]
@@ -140,6 +213,8 @@ defineProps<{
getWeekShortcutLabel: (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => string getWeekShortcutLabel: (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => string
}>() }>()
const siteOptions = computed(() => props.sites.map((site) => ({ label: site.name, value: site.id })))
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'set-yesterday'): void (e: 'set-yesterday'): void
(e: 'set-today'): void (e: 'set-today'): void
@@ -150,6 +225,8 @@ const emit = defineEmits<{
(e: 'shift-date', value: number): void (e: 'shift-date', value: number): void
}>() }>()
const filtersDrawerOpen = ref(false)
const pickerValue = computed(() => { const pickerValue = computed(() => {
if (viewMode.value === 'week') return ymdToWeekInputValue(selectedDate.value) if (viewMode.value === 'week') return ymdToWeekInputValue(selectedDate.value)
return selectedDate.value return selectedDate.value

View File

@@ -1,7 +1,72 @@
<template> <template>
<div class="bg-white overflow-hidden flex min-h-0 flex-col"> <div class="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-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">
<!-- Mobile cards -->
<div v-else class="overflow-y-auto min-h-0 space-y-3 lg:hidden">
<div
v-for="row in weeklySummary?.rows ?? []"
:key="'m-' + row.employeeId"
class="rounded-md border border-primary-500 bg-white p-4"
>
<div class="mb-3">
<p class="text-md font-bold text-primary-500 truncate">
{{ row.firstName }} {{ row.lastName }}
<span class="font-normal text-neutral-600 text-sm">({{ row.contractName ?? '-' }})</span>
</p>
<p class="text-xs text-neutral-500 truncate">
{{ row.siteName ?? 'Sans site' }}<span v-if="row.contractNature"> {{ contractNatureLabel(row.contractNature) }}</span>
</p>
</div>
<!-- Daily breakdown -->
<div class="mb-3 space-y-1">
<div
v-for="(daily, i) in row.daily"
:key="daily.date"
class="flex items-center justify-between rounded-md px-2 py-1 text-xs"
:class="daily.hasAbsence ? 'text-white' : 'text-primary-500'"
:style="getDailyCellStyle(daily)"
:title="cellTitle(daily)"
>
<span class="font-semibold">{{ weekDayHeaders[i]?.label ?? '' }}</span>
<span v-if="row.trackingMode === 'PRESENCE'">{{ daily.present ?? 0 }}</span>
<span v-else>J {{ formatMinutes(daily.dayMinutes) }} / N {{ formatMinutes(daily.nightMinutes) }}</span>
</div>
</div>
<!-- Weekly totals -->
<div class="border-t border-neutral-200 pt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
<div class="flex justify-between">
<span class="text-neutral-500">Total sem.</span>
<span class="font-bold text-primary-500">{{ row.trackingMode === 'PRESENCE' ? (row.weeklyPresenceCount ?? 0) : formatMinutes(row.weeklyTotalMinutes) }}</span>
</div>
<div class="flex justify-between">
<span class="text-neutral-500">H. supp.</span>
<span class="font-bold text-primary-500">{{ row.trackingMode === 'PRESENCE' ? '-' : formatMinutes(row.weeklyOvertimeTotalMinutes ?? 0) }}</span>
</div>
<div v-if="row.trackingMode !== 'PRESENCE' && !isInterimContract(row.contractType)" class="flex justify-between">
<span class="text-neutral-500">+25%</span>
<span class="font-bold text-primary-500">{{ formatMinutes(row.weeklyOvertime25Minutes ?? 0) }}</span>
</div>
<div v-if="row.trackingMode !== 'PRESENCE' && !isInterimContract(row.contractType)" class="flex justify-between">
<span class="text-neutral-500">+50%</span>
<span class="font-bold text-primary-500">{{ formatMinutes(row.weeklyOvertime50Minutes ?? 0) }}</span>
</div>
<div v-if="row.trackingMode !== 'PRESENCE' && !isInterimContract(row.contractType)" class="flex justify-between">
<span class="text-neutral-500">Récup.</span>
<span class="font-bold text-primary-500">{{ formatMinutes(row.weeklyRecoveryMinutes ?? 0) }}</span>
</div>
<div v-if="(row.weeklyNightBasketCount ?? 0) > 0" class="flex justify-between">
<span class="text-neutral-500">Panier nuit</span>
<span class="font-bold text-primary-500">{{ row.weeklyNightBasketCount }}</span>
</div>
</div>
</div>
</div>
<!-- Desktop table -->
<div v-if="!isWeekLoading" class="overflow-y-auto min-h-0 hidden lg:block">
<div <div
class="grid w-full min-w-0 gap-1 border border-black bg-tertiary-500 px-4 py-3 text-sm font-semibold text-black rounded-t-md sticky top-0 z-10" class="grid w-full min-w-0 gap-1 border border-black bg-tertiary-500 px-4 py-3 text-sm font-semibold text-black rounded-t-md sticky top-0 z-10"
:style="{ gridTemplateColumns: weekGridCols }" :style="{ gridTemplateColumns: weekGridCols }"
@@ -29,7 +94,12 @@
{{ row.firstName }} {{ row.lastName }} {{ row.firstName }} {{ row.lastName }}
<span class="font-normal text-neutral-600">({{ row.contractName ?? '-' }})</span> <span class="font-normal text-neutral-600">({{ row.contractName ?? '-' }})</span>
</p> </p>
<p class="text-[11px] text-neutral-500 truncate">{{ row.siteName ?? 'Sans site' }}</p> <p class="text-[11px] text-neutral-500 truncate inline-flex items-center gap-2">
<span>{{ row.siteName ?? 'Sans site' }}<span v-if="row.contractNature"> {{ contractNatureLabel(row.contractNature) }}</span></span>
<button v-if="isAdmin" type="button" class="flex items-center text-white p-1" :class="row.comment ? 'bg-red-500 hover:bg-red-600' : 'bg-primary-500 hover:bg-secondary-500'" :title="row.comment ?? 'Ajouter un commentaire'" @click="$emit('open-comment', row)">
<Icon name="mdi:comment-text-outline" size="12"/>
</button>
</p>
</div> </div>
<div <div
@@ -38,7 +108,7 @@
class="text-left leading-4 rounded-md px-2 py-1" class="text-left leading-4 rounded-md px-2 py-1"
:class="daily.hasAbsence ? 'text-white' : ''" :class="daily.hasAbsence ? 'text-white' : ''"
:style="getDailyCellStyle(daily)" :style="getDailyCellStyle(daily)"
:title="daily.absenceLabel ?? ''" :title="cellTitle(daily)"
> >
<template v-if="row.trackingMode === 'PRESENCE'">{{ daily.present ?? 0 }}</template> <template v-if="row.trackingMode === 'PRESENCE'">{{ daily.present ?? 0 }}</template>
<template v-else> <template v-else>
@@ -81,24 +151,43 @@
<script setup lang="ts"> <script setup lang="ts">
import type { WeeklyWorkHourSummary } from '~/services/dto/work-hour' import type { WeeklyWorkHourSummary } from '~/services/dto/work-hour'
import { CONTRACT_TYPES, type ContractType } from '~/services/dto/contract' import { CONTRACT_TYPES, type ContractType } from '~/services/dto/contract'
import { contractNatureLabel } from '~/utils/contract'
const isInterimContract = (contractType?: ContractType | null) => { const isInterimContract = (contractType?: ContractType | null) => {
return contractType === CONTRACT_TYPES.INTERIM return contractType === CONTRACT_TYPES.INTERIM
} }
const HOLIDAY_BG_COLOR = '#b3e5fc'
const getDailyCellStyle = (daily: { const getDailyCellStyle = (daily: {
hasAbsence?: boolean hasAbsence?: boolean
absenceColor?: string | null absenceColor?: string | null
holidayLabel?: string | null
}) => { }) => {
if (!daily.hasAbsence) return undefined if (daily.hasAbsence) return { backgroundColor: daily.absenceColor || '#dc2626' }
return { backgroundColor: daily.absenceColor || '#dc2626' } if (daily.holidayLabel) return { backgroundColor: HOLIDAY_BG_COLOR }
return undefined
}
const cellTitle = (daily: {
hasAbsence?: boolean
absenceLabel?: string | null
holidayLabel?: string | null
}) => {
const parts: string[] = []
if (daily.absenceLabel) parts.push(daily.absenceLabel)
if (daily.holidayLabel) parts.push(`Férié : ${daily.holidayLabel}`)
return parts.join(' — ')
} }
defineProps<{ defineProps<{
isWeekLoading: boolean isWeekLoading: boolean
isAdmin: boolean
weekGridCols: string weekGridCols: string
weeklySummary: WeeklyWorkHourSummary | null weeklySummary: WeeklyWorkHourSummary | null
weekDayHeaders: Array<{ date: string; label: string }> weekDayHeaders: Array<{ date: string; label: string }>
formatMinutes: (minutes: number) => string formatMinutes: (minutes: number) => string
}>() }>()
defineEmits<{ (e: 'open-comment', row: WeeklyWorkHourSummary['rows'][number]): void }>()
</script> </script>

View File

@@ -0,0 +1,81 @@
<template>
<MalioDrawer v-model="drawerOpen" title="Commentaire">
<form class="space-y-4" @submit.prevent="onSave">
<div v-if="employeeLabel" class="text-md font-semibold text-neutral-700">{{ employeeLabel }}</div>
<div class="text-md font-semibold text-neutral-700">{{ formatWeekRange }}</div>
<MalioInputTextArea
v-model="content"
label="Commentaire"
:size="8"
:max-length="5000"
:show-counter="true"
resize="vertical"
/>
<div class="sticky bottom-0 -mx-[20px] bg-white px-[20px] py-3 flex justify-between gap-3">
<MalioButton
v-if="commentId"
label="Supprimer"
variant="danger"
:disabled="isSubmitting"
@click="onDelete"
/>
<MalioButton
label="Enregistrer"
button-class="ml-auto"
:disabled="isSubmitting || !canSubmit"
@click="onSave"
/>
</div>
</form>
</MalioDrawer>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { createWeekComment, deleteWeekComment, updateWeekComment } from '~/services/employee-week-comments'
import { getIsoWeekNumber, parseYmd } from '~/utils/date'
const props = defineProps<{
modelValue: boolean
employeeId: number | null
weekStart: string
weekEnd: string
initialContent: string
commentId: number | null
employeeLabel?: string
}>()
const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void; (e: 'saved'): void }>()
const drawerOpen = computed({ get: () => props.modelValue, set: (v) => emit('update:modelValue', v) })
const content = ref('')
const isSubmitting = ref(false)
watch(() => [props.modelValue, props.initialContent] as const, ([open, init]) => { if (open) content.value = init ?? '' }, { immediate: true })
const formatWeekRange = computed(() => {
const fmt = (ymd: string) => { const p = ymd.split('-'); return p.length === 3 ? `${p[2]}/${p[1]}/${p[0]}` : ymd }
const start = parseYmd(props.weekStart)
const weekLabel = start ? `S${getIsoWeekNumber(start)}` : ''
return weekLabel ? `${weekLabel} du ${fmt(props.weekStart)} au ${fmt(props.weekEnd)}` : `${fmt(props.weekStart)}${fmt(props.weekEnd)}`
})
const canSubmit = computed(() => content.value.trim().length > 0 || props.commentId !== null)
const onSave = async () => {
if (!props.employeeId || isSubmitting.value) return
const trimmed = content.value.trim()
isSubmitting.value = true
try {
if (trimmed === '' && props.commentId) await deleteWeekComment(props.commentId)
else if (trimmed !== '' && props.commentId) await updateWeekComment(props.commentId, trimmed)
else if (trimmed !== '') await createWeekComment({ employeeId: props.employeeId, weekStartDate: props.weekStart, content: trimmed })
emit('saved'); drawerOpen.value = false
} finally { isSubmitting.value = false }
}
const onDelete = async () => {
if (!props.commentId || isSubmitting.value) return
isSubmitting.value = true
try { await deleteWeekComment(props.commentId); emit('saved'); drawerOpen.value = false } finally { isSubmitting.value = false }
}
</script>

View File

@@ -23,7 +23,7 @@
<button <button
type="button" type="button"
tabindex="-1" 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" class="hidden lg: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" :disabled="props.disabled"
@mousedown.prevent @mousedown.prevent
@click="toggleOpen" @click="toggleOpen"
@@ -149,8 +149,11 @@ const toggleOpen = () => {
} }
} }
const isMobile = () => window.innerWidth < 1024
const openMenu = () => { const openMenu = () => {
if (props.disabled) return if (props.disabled) return
if (isMobile()) return
if (!isOpen.value) { if (!isOpen.value) {
isOpen.value = true isOpen.value = true
nextTick(updateMenuPosition) nextTick(updateMenuPosition)
@@ -165,8 +168,28 @@ const closeMenu = () => {
isOpen.value = false isOpen.value = false
} }
const snapToNearest15 = (time: string): string => {
const [h, m] = time.split(':').map(Number)
const snapped = Math.round(m / 15) * 15
if (snapped === 60) {
const newH = h + 1
if (newH > 23) return '23:45'
return `${String(newH).padStart(2, '0')}:00`
}
return `${String(h).padStart(2, '0')}:${String(snapped).padStart(2, '0')}`
}
const commitInput = () => { const commitInput = () => {
const normalized = normalizeTypedTime(inputValue.value) let value = inputValue.value
if (isMobile()) {
value = clampTime(value)
const normalized = normalizeTypedTime(value)
if (normalized !== null && normalized !== '') {
value = snapToNearest15(normalized)
}
inputValue.value = value
}
const normalized = normalizeTypedTime(value)
if (normalized === null || (normalized !== '' && !timeSlots.value.includes(normalized))) { if (normalized === null || (normalized !== '' && !timeSlots.value.includes(normalized))) {
emit('update:modelValue', '') emit('update:modelValue', '')
inputValue.value = '' inputValue.value = ''
@@ -184,13 +207,26 @@ const onInput = (event: Event) => {
if (masked !== inputValue.value) { if (masked !== inputValue.value) {
inputValue.value = masked inputValue.value = masked
} }
openMenu() if (!isMobile()) {
openMenu()
}
}
const clampTime = (value: string): string => {
const normalized = normalizeTypedTime(value)
if (normalized === null || normalized === '') return value
const [h, m] = normalized.split(':').map(Number)
if (h > 23 || (h === 23 && m > 45)) return '23:45'
return normalized
} }
const onInputBlur = () => { const onInputBlur = () => {
// Laisse le temps au click menu de passer avant fermeture. // Laisse le temps au click menu de passer avant fermeture.
setTimeout(() => { setTimeout(() => {
if (menu.value?.contains(document.activeElement)) return if (menu.value?.contains(document.activeElement)) return
if (isMobile()) {
inputValue.value = clampTime(inputValue.value)
}
commitInput() commitInput()
}, 50) }, 50)
} }

View File

@@ -71,7 +71,7 @@ export const useDriverHoursPage = () => {
const dayGridCols = computed(() => { const dayGridCols = computed(() => {
const metricCol = '0.4fr' const metricCol = '0.4fr'
const validationCols = isAdmin.value ? `${metricCol}` : `${metricCol} ${metricCol}` const validationCols = isAdmin.value || isSiteManager.value ? `${metricCol}` : `${metricCol} ${metricCol}`
return `1.2fr 0.6fr 0.8fr 0.8fr 0.8fr ${metricCol} ${metricCol} ${metricCol} ${metricCol} ${metricCol} ${validationCols}` return `1.2fr 0.6fr 0.8fr 0.8fr 0.8fr ${metricCol} ${metricCol} ${metricCol} ${metricCol} ${metricCol} ${validationCols}`
}) })
@@ -368,12 +368,23 @@ export const useDriverHoursPage = () => {
const getRowMetrics = (employeeId: number) => { const getRowMetrics = (employeeId: number) => {
const row = rows.value[employeeId] ?? emptyRow() const row = rows.value[employeeId] ?? emptyRow()
const credited = dayContextByEmployeeId.value.get(employeeId)?.creditedMinutes ?? 0 const dayRow = dayContextByEmployeeId.value.get(employeeId)
const dayMinutes = toMinutes(row.dayHours) + credited const credited = dayRow?.creditedMinutes ?? 0
let dayMinutes = toMinutes(row.dayHours) + credited
const nightMinutes = toMinutes(row.nightHours) const nightMinutes = toMinutes(row.nightHours)
const workshopMinutes = toMinutes(row.workshopHours) const workshopMinutes = toMinutes(row.workshopHours)
const totalMinutes = dayMinutes + nightMinutes + workshopMinutes let totalMinutes = dayMinutes + nightMinutes + workshopMinutes
return { dayMinutes, nightMinutes, workshopMinutes, totalMinutes }
// Virtual holiday credit: backend already applies the contract-period
// schedule and absence-override rule; consume the value as-is.
const virtualHolidayMinutes = dayRow?.virtualHolidayMinutes ?? 0
if (virtualHolidayMinutes > totalMinutes) {
const delta = virtualHolidayMinutes - totalMinutes
dayMinutes += delta
totalMinutes = virtualHolidayMinutes
}
return { dayMinutes, nightMinutes, workshopMinutes, totalMinutes, virtualHolidayMinutes }
} }
const getRowAbsenceLabel = (employeeId: number) => { const getRowAbsenceLabel = (employeeId: number) => {
@@ -381,7 +392,6 @@ export const useDriverHoursPage = () => {
if (dayRow && dayRow.hasContractAtDate === false) { if (dayRow && dayRow.hasContractAtDate === false) {
return 'Contrat non démarré' return 'Contrat non démarré'
} }
if (isSelectedDateHoliday.value) return 'Férié'
if (!dayRow?.absenceLabel) return '' if (!dayRow?.absenceLabel) return ''
if (dayRow.absenceHalf === 'AM' || dayRow.absenceHalf === 'PM') { if (dayRow.absenceHalf === 'AM' || dayRow.absenceHalf === 'PM') {
const halfLabel = dayRow.absenceHalf === 'AM' ? 'Matin' : 'ap.-m.' const halfLabel = dayRow.absenceHalf === 'AM' ? 'Matin' : 'ap.-m.'
@@ -407,6 +417,10 @@ export const useDriverHoursPage = () => {
return date.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) return date.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
} }
const getRowContractNature = (employeeId: number): 'CDI' | 'CDD' | 'INTERIM' | null => {
return dayContextByEmployeeId.value.get(employeeId)?.contractNature ?? null
}
const hasContractAtSelectedDate = (employeeId: number) => { const hasContractAtSelectedDate = (employeeId: number) => {
const dayRow = dayContextByEmployeeId.value.get(employeeId) const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (!dayRow) return true if (!dayRow) return true
@@ -467,7 +481,6 @@ export const useDriverHoursPage = () => {
const openAbsenceDrawer = (employeeId: number) => { const openAbsenceDrawer = (employeeId: number) => {
if (!hasContractAtSelectedDate(employeeId)) return if (!hasContractAtSelectedDate(employeeId)) return
if (isSelectedDateHoliday.value) return
const existing = absences.value.find((absence) => { const existing = absences.value.find((absence) => {
if (absence.employee?.id !== employeeId) return false if (absence.employee?.id !== employeeId) return false
@@ -913,6 +926,15 @@ export const useDriverHoursPage = () => {
} }
} }
const isWeekCommentDrawerOpen = ref(false)
const weekCommentContext = ref<{ employeeId: number; employeeLabel: string; weekStart: string; weekEnd: string; content: string; commentId: number | null } | null>(null)
const openWeekCommentDrawer = (row: { employeeId: number; firstName: string; lastName: string; comment?: string | null; commentId?: number | null }) => {
if (!weeklySummary.value) return
weekCommentContext.value = { employeeId: row.employeeId, employeeLabel: `${row.firstName} ${row.lastName}`.trim(), weekStart: weeklySummary.value.weekStart, weekEnd: weeklySummary.value.weekEnd, content: row.comment ?? '', commentId: row.commentId ?? null }
isWeekCommentDrawerOpen.value = true
}
const reloadWeeklySummary = async () => { await loadWeeklySummary() }
return { return {
isAdmin, isAdmin,
isSelfUser, isSelfUser,
@@ -941,6 +963,7 @@ export const useDriverHoursPage = () => {
saveButtonClass, saveButtonClass,
formattedSelectedDate, formattedSelectedDate,
isSelectedDateHoliday, isSelectedDateHoliday,
selectedHolidayLabel,
weekDayHeaders, weekDayHeaders,
shortcutButtonClass, shortcutButtonClass,
weekShortcutButtonClass, weekShortcutButtonClass,
@@ -972,12 +995,17 @@ export const useDriverHoursPage = () => {
getRowMetrics, getRowMetrics,
getRowAbsenceLabel, getRowAbsenceLabel,
getRowAbsenceStyle, getRowAbsenceStyle,
getRowContractNature,
getRowUpdatedAt, getRowUpdatedAt,
openAbsenceDrawer, openAbsenceDrawer,
submitAbsence, submitAbsence,
deleteAbsenceFromDrawer, deleteAbsenceFromDrawer,
closeAbsenceDrawer, closeAbsenceDrawer,
formatMinutes, formatMinutes,
handleSave handleSave,
isWeekCommentDrawerOpen,
weekCommentContext,
openWeekCommentDrawer,
reloadWeeklySummary
} }
} }

View File

@@ -4,8 +4,9 @@ import type { ContractHistoryItem, Employee } from '~/services/dto/employee'
import { listContracts } from '~/services/contracts' import { listContracts } from '~/services/contracts'
import { updateEmployee } from '~/services/employees' import { updateEmployee } from '~/services/employees'
import { createSuspension, updateSuspension } from '~/services/contractSuspensions' import { createSuspension, updateSuspension } from '~/services/contractSuspensions'
import { listInterimAgencies, type InterimAgency } from '~/services/interim-agencies'
import { formatNullableYmdToFr, getTodayYmd, shiftYmd } from '~/utils/date' import { formatNullableYmdToFr, getTodayYmd, shiftYmd } from '~/utils/date'
import { contractNatureLabel, isContractNature, requiresContractEndDate, showsContractEndDate } from '~/utils/contract' import { contractNatureLabel, formatWorkDaysHoursSummary, isContractNature, requiresContractEndDate, requiresWorkDaysHours, showsContractEndDate } from '~/utils/contract'
type SuspensionForm = { type SuspensionForm = {
id: number | null id: number | null
@@ -17,6 +18,7 @@ type SuspensionForm = {
export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => { export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
const toast = useToast() const toast = useToast()
const contracts = ref<Contract[]>([]) const contracts = ref<Contract[]>([])
const interimAgencies = ref<InterimAgency[]>([])
const isContractDrawerOpen = ref(false) const isContractDrawerOpen = ref(false)
const isContractSubmitting = ref(false) const isContractSubmitting = ref(false)
const isCreateContractDrawerOpen = ref(false) const isCreateContractDrawerOpen = ref(false)
@@ -32,7 +34,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
startDate: '', startDate: '',
endDate: '', endDate: '',
paidLeaveSettled: false, paidLeaveSettled: false,
comment: '' comment: '',
workDaysHours: null as Record<number, number> | null
}) })
const validationTouched = reactive({ const validationTouched = reactive({
@@ -44,7 +47,9 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM', contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
startDate: '', startDate: '',
endDate: '', endDate: '',
isDriver: false isDriver: false,
workDaysHours: null as Record<number, number> | null,
interimAgencyId: '' as number | ''
}) })
const createValidationTouched = reactive({ const createValidationTouched = reactive({
@@ -59,10 +64,11 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
const formatDate = (value?: string | null) => formatNullableYmdToFr(value) const formatDate = (value?: string | null) => formatNullableYmdToFr(value)
const contractHistoryLabel = (item: ContractHistoryItem) => { const contractHistoryLabel = (item: ContractHistoryItem) => {
if (item.weeklyHours !== null && item.weeklyHours !== undefined) { const base = item.weeklyHours !== null && item.weeklyHours !== undefined
return `${item.weeklyHours} heures` ? `${item.weeklyHours} heures`
} : (item.contractName ?? '-')
return item.contractName ?? '-' const scheduleSummary = formatWorkDaysHoursSummary(item.workDaysHours)
return scheduleSummary ? `${base} (${scheduleSummary})` : base
} }
const currentActiveContractPeriod = computed(() => { const currentActiveContractPeriod = computed(() => {
@@ -111,11 +117,27 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
const isCreateContractNatureValid = computed(() => isContractNature(createContractForm.contractNature)) const isCreateContractNatureValid = computed(() => isContractNature(createContractForm.contractNature))
const isCreateContractStartDateValid = computed(() => createContractForm.startDate !== '') const isCreateContractStartDateValid = computed(() => createContractForm.startDate !== '')
const isCreateContractEndDateValid = computed(() => !requiresCreateContractEndDate.value || createContractForm.endDate !== '') const isCreateContractEndDateValid = computed(() => !requiresCreateContractEndDate.value || createContractForm.endDate !== '')
const selectedCreateContract = computed<Contract | null>(() =>
contracts.value.find((c) => c.id === Number(createContractForm.contractId)) ?? null
)
const requiresCreateWorkDaysHours = computed(() =>
requiresWorkDaysHours(selectedCreateContract.value, createContractForm.contractNature)
)
const createScheduleTotalMinutes = computed(() => {
const raw = createContractForm.workDaysHours ?? {}
return Object.values(raw).reduce((s, n) => s + (Number(n) || 0), 0)
})
const isCreateScheduleValid = computed(() => {
if (!requiresCreateWorkDaysHours.value) return true
const expected = (selectedCreateContract.value?.weeklyHours ?? 0) * 60
return expected > 0 && createScheduleTotalMinutes.value === expected
})
const isCreateContractFormValid = computed(() => const isCreateContractFormValid = computed(() =>
isCreateContractValid.value && isCreateContractValid.value &&
isCreateContractNatureValid.value && isCreateContractNatureValid.value &&
isCreateContractStartDateValid.value && isCreateContractStartDateValid.value &&
isCreateContractEndDateValid.value isCreateContractEndDateValid.value &&
isCreateScheduleValid.value
) )
const baseInputClass = const baseInputClass =
@@ -159,6 +181,7 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
contractForm.endDate = period.endDate ?? getTodayYmd() contractForm.endDate = period.endDate ?? getTodayYmd()
contractForm.paidLeaveSettled = false contractForm.paidLeaveSettled = false
contractForm.comment = '' contractForm.comment = ''
contractForm.workDaysHours = period.workDaysHours ?? null
} }
const openCloseContractDrawer = () => { const openCloseContractDrawer = () => {
@@ -186,6 +209,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
createContractForm.contractNature = 'CDI' createContractForm.contractNature = 'CDI'
createContractForm.endDate = '' createContractForm.endDate = ''
createContractForm.isDriver = false createContractForm.isDriver = false
createContractForm.workDaysHours = null
createContractForm.interimAgencyId = ''
createContractForm.startDate = editableContractPeriod.value?.endDate createContractForm.startDate = editableContractPeriod.value?.endDate
? (shiftYmd(editableContractPeriod.value.endDate, 1) ?? editableContractPeriod.value.endDate) ? (shiftYmd(editableContractPeriod.value.endDate, 1) ?? editableContractPeriod.value.endDate)
: getTodayYmd() : getTodayYmd()
@@ -261,7 +286,9 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
contractNature: createContractForm.contractNature, contractNature: createContractForm.contractNature,
contractStartDate: createContractForm.startDate, contractStartDate: createContractForm.startDate,
contractEndDate: createContractForm.endDate || null, contractEndDate: createContractForm.endDate || null,
isDriverInput: createContractForm.isDriver isDriverInput: createContractForm.isDriver,
workDaysHoursInput: requiresCreateWorkDaysHours.value ? createContractForm.workDaysHours : null,
interimAgencyId: createContractForm.contractNature === 'INTERIM' && createContractForm.interimAgencyId !== '' ? Number(createContractForm.interimAgencyId) : null
}) })
isCreateContractDrawerOpen.value = false isCreateContractDrawerOpen.value = false
await reloadEmployee() await reloadEmployee()
@@ -313,12 +340,28 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
contracts.value = await listContracts() contracts.value = await listContracts()
} }
const loadInterimAgencies = async () => {
interimAgencies.value = await listInterimAgencies()
}
watch(() => createContractForm.contractNature, (nature) => {
if (nature !== 'INTERIM') {
createContractForm.interimAgencyId = ''
}
})
watch(showsCreateContractEndDate, (shows) => { watch(showsCreateContractEndDate, (shows) => {
if (!shows) { if (!shows) {
createContractForm.endDate = '' createContractForm.endDate = ''
} }
}) })
watch(requiresCreateWorkDaysHours, (required) => {
if (!required) {
createContractForm.workDaysHours = null
}
})
return { return {
contracts, contracts,
contractHistory, contractHistory,
@@ -342,6 +385,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
requiresCreateContractEndDate, requiresCreateContractEndDate,
createContractEndDateFieldClass, createContractEndDateFieldClass,
isCreateContractFormValid, isCreateContractFormValid,
requiresCreateWorkDaysHours,
selectedCreateContract,
contractNatureLabel, contractNatureLabel,
contractHistoryLabel, contractHistoryLabel,
formatDate, formatDate,
@@ -356,6 +401,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
submitSuspension, submitSuspension,
addSuspensionForm, addSuspensionForm,
currentActiveContractPeriodId, currentActiveContractPeriodId,
loadContracts interimAgencies,
loadContracts,
loadInterimAgencies
} }
} }

View File

@@ -10,10 +10,11 @@ export const useEmployeeDetailPage = () => {
const showLeaveTab = computed(() => employee.value?.currentContractNature !== 'INTERIM') const showLeaveTab = computed(() => employee.value?.currentContractNature !== 'INTERIM')
const showRttTab = computed(() => employee.value?.contract?.type !== CONTRACT_TYPES.FORFAIT) const showRttTab = computed(() => employee.value?.contract?.type !== CONTRACT_TYPES.FORFAIT)
const isForfait = computed(() => employee.value?.contract?.type === CONTRACT_TYPES.FORFAIT)
const employeeContractWorkLabel = computed(() => { const employeeContractWorkLabel = computed(() => {
const contract = employee.value?.contract const contract = employee.value?.contract
if (!contract) return '-' if (!contract) return '-'
if (contract.type === CONTRACT_TYPES.FORFAIT) return 'Forfait' if (contract.type === CONTRACT_TYPES.FORFAIT) return 'Forfait - 218 jours'
if (contract.weeklyHours !== null && contract.weeklyHours !== undefined) return `${contract.weeklyHours} heures` if (contract.weeklyHours !== null && contract.weeklyHours !== undefined) return `${contract.weeklyHours} heures`
return contract.name || '-' return contract.name || '-'
}) })
@@ -55,6 +56,9 @@ export const useEmployeeDetailPage = () => {
await bonus.loadBonusData() await bonus.loadBonusData()
} else if (activeTab.value === 'observation') { } else if (activeTab.value === 'observation') {
await observation.loadObservationData() await observation.loadObservationData()
} else if (isForfait.value && showLeaveTab.value) {
// Eager load: needed for the "X jours restants" header label on forfait employees.
await leave.loadLeaveData()
} }
} finally { } finally {
isLoading.value = false isLoading.value = false
@@ -63,6 +67,13 @@ export const useEmployeeDetailPage = () => {
const contract = useEmployeeContract(employee, loadEmployee) const contract = useEmployeeContract(employee, loadEmployee)
const leave = useEmployeeLeave(employee, loadEmployee) const leave = useEmployeeLeave(employee, loadEmployee)
const forfaitRemainingDaysLabel = computed(() => {
if (!isForfait.value) return ''
const presence = leave.leaveSummary.value?.presenceDaysToToday
if (presence === undefined || presence === null) return ''
const remaining = 218 - presence
return ` (${remaining} restants)`
})
const rtt = useEmployeeRtt(employee, loadEmployee) const rtt = useEmployeeRtt(employee, loadEmployee)
const mileage = useEmployeeMileage(employee, loadEmployee) const mileage = useEmployeeMileage(employee, loadEmployee)
const formation = useEmployeeFormation(employee, loadEmployee) const formation = useEmployeeFormation(employee, loadEmployee)
@@ -86,7 +97,7 @@ export const useEmployeeDetailPage = () => {
}) })
onMounted(async () => { onMounted(async () => {
await contract.loadContracts() await Promise.all([contract.loadContracts(), contract.loadInterimAgencies()])
await loadEmployee() await loadEmployee()
}) })
@@ -97,6 +108,7 @@ export const useEmployeeDetailPage = () => {
showLeaveTab, showLeaveTab,
showRttTab, showRttTab,
employeeContractWorkLabel, employeeContractWorkLabel,
forfaitRemainingDaysLabel,
...contract, ...contract,
...leave, ...leave,
...rtt, ...rtt,

View File

@@ -7,27 +7,91 @@ import { listAbsences } from '~/services/absences'
import { getEmployeeLeaveSummary, updateFractionedDays, updatePaidLeaveDays } from '~/services/employee-leave-summary' import { getEmployeeLeaveSummary, updateFractionedDays, updatePaidLeaveDays } from '~/services/employee-leave-summary'
import { listPublicHolidays } from '~/services/public-holidays' import { listPublicHolidays } from '~/services/public-holidays'
export type LeaveYearOption = {
value: number
label: string
}
export const useEmployeeLeave = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => { export const useEmployeeLeave = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
const employeeAbsences = ref<Absence[]>([]) const employeeAbsences = ref<Absence[]>([])
const leaveSummary = ref<EmployeeLeaveSummary | null>(null) const leaveSummary = ref<EmployeeLeaveSummary | null>(null)
const publicHolidays = ref<Record<string, string>>({}) const publicHolidays = ref<Record<string, string>>({})
const isLeaveLoading = ref(false) const isLeaveLoading = ref(false)
const leaveDataLoaded = ref(false) const leaveDataLoaded = ref(false)
const selectedLeaveYear = ref<number | null>(null)
const getLeaveYear = () => { const isForfaitContract = (emp: Employee | null) =>
const now = new Date() emp?.contract?.type === CONTRACT_TYPES.FORFAIT
const isForfait = employee.value?.contract?.type === CONTRACT_TYPES.FORFAIT
return isForfait const computeLeaveYearForDate = (emp: Employee | null, date: Date): number => {
? now.getFullYear() if (isForfaitContract(emp)) return date.getFullYear()
: (now.getMonth() >= 5 ? now.getFullYear() + 1 : now.getFullYear()) return date.getMonth() >= 5 ? date.getFullYear() + 1 : date.getFullYear()
}
const currentLeaveYear = computed<number | null>(() => {
if (!employee.value) return null
return computeLeaveYearForDate(employee.value, new Date())
})
const formatLeaveYearLabel = (year: number, isForfait: boolean): string => {
if (isForfait) return String(year)
return `Juin ${year - 1} → Mai ${year}`
}
const availableLeaveYears = computed<LeaveYearOption[]>(() => {
if (!employee.value || currentLeaveYear.value === null) return []
const isForfait = isForfaitContract(employee.value)
const current = currentLeaveYear.value
const startDates: string[] = []
for (const period of employee.value.contractHistory ?? []) {
if (period.startDate) startDates.push(period.startDate)
}
if (employee.value.entryDate) startDates.push(employee.value.entryDate)
let contractFloor = current
for (const raw of startDates) {
const date = new Date(`${raw.substring(0, 10)}T00:00:00`)
if (Number.isNaN(date.getTime())) continue
const leaveYear = computeLeaveYearForDate(employee.value, date)
if (leaveYear < contractFloor) contractFloor = leaveYear
}
// Hard floor : data-start-date (env RTT_START_DATE) — le logiciel n'a pas
// d'historique avant cette date, inutile de proposer des années antérieures.
let dataFloor: number | null = null
const dataStart = leaveSummary.value?.dataStartDate
if (dataStart) {
const dataStartDate = new Date(`${dataStart.substring(0, 10)}T00:00:00`)
if (!Number.isNaN(dataStartDate.getTime())) {
dataFloor = computeLeaveYearForDate(employee.value, dataStartDate)
}
}
const minYear = dataFloor !== null ? Math.max(contractFloor, dataFloor) : contractFloor
const years: LeaveYearOption[] = []
for (let y = current; y >= minYear; y -= 1) {
years.push({ value: y, label: formatLeaveYearLabel(y, isForfait) })
}
return years
})
const initSelectedLeaveYear = () => {
if (selectedLeaveYear.value !== null) return
if (currentLeaveYear.value !== null) {
selectedLeaveYear.value = currentLeaveYear.value
}
} }
const loadLeaveData = async () => { const loadLeaveData = async () => {
if (!employee.value || isLeaveLoading.value) return if (!employee.value || isLeaveLoading.value) return
initSelectedLeaveYear()
if (selectedLeaveYear.value === null) return
isLeaveLoading.value = true isLeaveLoading.value = true
try { try {
const isForfait = employee.value.contract?.type === CONTRACT_TYPES.FORFAIT const isForfait = isForfaitContract(employee.value)
const leaveYear = getLeaveYear() const leaveYear = selectedLeaveYear.value
const from = isForfait ? `${leaveYear}-01-01` : `${leaveYear - 1}-06-01` const from = isForfait ? `${leaveYear}-01-01` : `${leaveYear - 1}-06-01`
const to = isForfait ? `${leaveYear}-12-31` : `${leaveYear}-05-31` const to = isForfait ? `${leaveYear}-12-31` : `${leaveYear}-05-31`
const holidayYears = isForfait ? [leaveYear] : [leaveYear - 1, leaveYear] const holidayYears = isForfait ? [leaveYear] : [leaveYear - 1, leaveYear]
@@ -46,8 +110,16 @@ export const useEmployeeLeave = (employee: Ref<Employee | null>, reloadEmployee:
} }
} }
const setSelectedLeaveYear = async (year: number) => {
if (selectedLeaveYear.value === year) return
selectedLeaveYear.value = year
leaveDataLoaded.value = false
await loadLeaveData()
}
const resetLoaded = () => { const resetLoaded = () => {
leaveDataLoaded.value = false leaveDataLoaded.value = false
selectedLeaveYear.value = null
} }
const submitFractionedDays = async (days: number) => { const submitFractionedDays = async (days: number) => {
@@ -70,6 +142,10 @@ export const useEmployeeLeave = (employee: Ref<Employee | null>, reloadEmployee:
publicHolidays, publicHolidays,
isLeaveLoading, isLeaveLoading,
leaveDataLoaded, leaveDataLoaded,
selectedLeaveYear,
currentLeaveYear,
availableLeaveYears,
setSelectedLeaveYear,
loadLeaveData, loadLeaveData,
resetLoaded, resetLoaded,
submitFractionedDays, submitFractionedDays,

View File

@@ -3,25 +3,94 @@ import type { EmployeeRttSummary } from '~/services/dto/employee-rtt-summary'
import type { Employee } from '~/services/dto/employee' import type { Employee } from '~/services/dto/employee'
import { getEmployeeRttSummary, createRttPayment } from '~/services/employee-rtt-summary' import { getEmployeeRttSummary, createRttPayment } from '~/services/employee-rtt-summary'
export type RttYearOption = {
value: number
label: string
}
export const useEmployeeRtt = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => { export const useEmployeeRtt = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
const rttSummary = ref<EmployeeRttSummary | null>(null) const rttSummary = ref<EmployeeRttSummary | null>(null)
const isRttLoading = ref(false) const isRttLoading = ref(false)
const rttDataLoaded = ref(false) const rttDataLoaded = ref(false)
const selectedRttYear = ref<number | null>(null)
// Exercice RTT : Juin (Y-1) → Mai (Y). Toujours, peu importe le type de contrat
// (l'onglet RTT est masqué pour les FORFAIT côté page).
const computeRttYearForDate = (date: Date): number =>
date.getMonth() >= 5 ? date.getFullYear() + 1 : date.getFullYear()
const currentRttYear = computed<number | null>(() => {
if (!employee.value) return null
return computeRttYearForDate(new Date())
})
const availableRttYears = computed<RttYearOption[]>(() => {
if (!employee.value || currentRttYear.value === null) return []
const current = currentRttYear.value
const startDates: string[] = []
for (const period of employee.value.contractHistory ?? []) {
if (period.startDate) startDates.push(period.startDate)
}
if (employee.value.entryDate) startDates.push(employee.value.entryDate)
let contractFloor = current
for (const raw of startDates) {
const date = new Date(`${raw.substring(0, 10)}T00:00:00`)
if (Number.isNaN(date.getTime())) continue
const rttYear = computeRttYearForDate(date)
if (rttYear < contractFloor) contractFloor = rttYear
}
// Hard floor : rttStartDate (env RTT_START_DATE) — pas d'historique avant.
let dataFloor: number | null = null
const dataStart = rttSummary.value?.rttStartDate
if (dataStart) {
const dataStartDate = new Date(`${dataStart.substring(0, 10)}T00:00:00`)
if (!Number.isNaN(dataStartDate.getTime())) {
dataFloor = computeRttYearForDate(dataStartDate)
}
}
const minYear = dataFloor !== null ? Math.max(contractFloor, dataFloor) : contractFloor
const years: RttYearOption[] = []
for (let y = current; y >= minYear; y -= 1) {
years.push({ value: y, label: `Juin ${y - 1} → Mai ${y}` })
}
return years
})
const initSelectedRttYear = () => {
if (selectedRttYear.value !== null) return
if (currentRttYear.value !== null) {
selectedRttYear.value = currentRttYear.value
}
}
const loadRttData = async () => { const loadRttData = async () => {
if (!employee.value || isRttLoading.value) return if (!employee.value || isRttLoading.value) return
initSelectedRttYear()
if (selectedRttYear.value === null) return
isRttLoading.value = true isRttLoading.value = true
try { try {
const rttYear = new Date().getMonth() >= 5 ? new Date().getFullYear() + 1 : new Date().getFullYear() rttSummary.value = await getEmployeeRttSummary(employee.value.id, selectedRttYear.value)
rttSummary.value = await getEmployeeRttSummary(employee.value.id, rttYear)
rttDataLoaded.value = true rttDataLoaded.value = true
} finally { } finally {
isRttLoading.value = false isRttLoading.value = false
} }
} }
const setSelectedRttYear = async (year: number) => {
if (selectedRttYear.value === year) return
selectedRttYear.value = year
rttDataLoaded.value = false
await loadRttData()
}
const resetLoaded = () => { const resetLoaded = () => {
rttDataLoaded.value = false rttDataLoaded.value = false
selectedRttYear.value = null
} }
const submitRttPayment = async (month: number, base25Minutes: number, bonus25Minutes: number, base50Minutes: number, bonus50Minutes: number) => { const submitRttPayment = async (month: number, base25Minutes: number, bonus25Minutes: number, base50Minutes: number, bonus50Minutes: number) => {
@@ -35,6 +104,10 @@ export const useEmployeeRtt = (employee: Ref<Employee | null>, reloadEmployee: (
rttSummary, rttSummary,
isRttLoading, isRttLoading,
rttDataLoaded, rttDataLoaded,
selectedRttYear,
currentRttYear,
availableRttYears,
setSelectedRttYear,
loadRttData, loadRttData,
resetLoaded, resetLoaded,
submitRttPayment submitRttPayment

View File

@@ -73,7 +73,7 @@ export const useHoursPage = () => {
const dayGridCols = computed(() => { const dayGridCols = computed(() => {
const metricCol = '0.4fr' const metricCol = '0.4fr'
const validationCols = isAdmin.value ? `${metricCol}` : `${metricCol} ${metricCol}` const validationCols = isAdmin.value || isSiteManager.value ? `${metricCol}` : `${metricCol} ${metricCol}`
return `1.2fr 0.6fr repeat(6, 0.8fr) ${metricCol} ${metricCol} ${metricCol} ${validationCols}` return `1.2fr 0.6fr repeat(6, 0.8fr) ${metricCol} ${metricCol} ${metricCol} ${validationCols}`
}) })
@@ -447,10 +447,21 @@ export const useHoursPage = () => {
nightMinutes += nightIntervalMinutes(from, to) nightMinutes += nightIntervalMinutes(from, to)
} }
const creditedMinutes = dayContextByEmployeeId.value.get(employeeId)?.creditedMinutes ?? 0 const dayRow = dayContextByEmployeeId.value.get(employeeId)
const creditedMinutes = dayRow?.creditedMinutes ?? 0
totalMinutes += creditedMinutes totalMinutes += creditedMinutes
const dayMinutes = Math.max(0, totalMinutes - nightMinutes) let dayMinutes = Math.max(0, totalMinutes - nightMinutes)
return { dayMinutes, nightMinutes, totalMinutes }
// Virtual holiday credit: the backend already applies the contract-period
// schedule (workDaysHours) and the absence-override rule, so just use the
// computed value instead of recomputing on the client.
const virtualHolidayMinutes = dayRow?.virtualHolidayMinutes ?? 0
if (virtualHolidayMinutes > totalMinutes) {
dayMinutes += virtualHolidayMinutes - totalMinutes
totalMinutes = virtualHolidayMinutes
}
return { dayMinutes, nightMinutes, totalMinutes, virtualHolidayMinutes }
} }
const getRowAbsenceLabel = (employeeId: number) => { const getRowAbsenceLabel = (employeeId: number) => {
@@ -458,7 +469,6 @@ export const useHoursPage = () => {
if (dayRow && dayRow.hasContractAtDate === false) { if (dayRow && dayRow.hasContractAtDate === false) {
return 'Contrat non démarré' return 'Contrat non démarré'
} }
if (isSelectedDateHoliday.value) return 'Férié'
if (!dayRow?.absenceLabel) return '' if (!dayRow?.absenceLabel) return ''
if (dayRow.absenceHalf === 'AM' || dayRow.absenceHalf === 'PM') { if (dayRow.absenceHalf === 'AM' || dayRow.absenceHalf === 'PM') {
const halfLabel = dayRow.absenceHalf === 'AM' ? 'Matin' : 'ap.-m.' const halfLabel = dayRow.absenceHalf === 'AM' ? 'Matin' : 'ap.-m.'
@@ -484,6 +494,10 @@ export const useHoursPage = () => {
return dayContextByEmployeeId.value.get(employeeId)?.formationLabel ?? '' return dayContextByEmployeeId.value.get(employeeId)?.formationLabel ?? ''
} }
const getRowContractNature = (employeeId: number): 'CDI' | 'CDD' | 'INTERIM' | null => {
return dayContextByEmployeeId.value.get(employeeId)?.contractNature ?? null
}
const getRowUpdatedAt = (employeeId: number): string => { const getRowUpdatedAt = (employeeId: number): string => {
const raw = rows.value[employeeId]?.updatedAt const raw = rows.value[employeeId]?.updatedAt
if (!raw) return '' if (!raw) return ''
@@ -584,7 +598,6 @@ export const useHoursPage = () => {
const openAbsenceDrawer = (employeeId: number) => { const openAbsenceDrawer = (employeeId: number) => {
if (!hasContractAtSelectedDate(employeeId)) return if (!hasContractAtSelectedDate(employeeId)) return
if (isSelectedDateHoliday.value) return
const existing = absences.value.find((absence) => { const existing = absences.value.find((absence) => {
if (absence.employee?.id !== employeeId) return false if (absence.employee?.id !== employeeId) return false
@@ -1099,6 +1112,15 @@ export const useHoursPage = () => {
} }
} }
const isWeekCommentDrawerOpen = ref(false)
const weekCommentContext = ref<{ employeeId: number; employeeLabel: string; weekStart: string; weekEnd: string; content: string; commentId: number | null } | null>(null)
const openWeekCommentDrawer = (row: { employeeId: number; firstName: string; lastName: string; comment?: string | null; commentId?: number | null }) => {
if (!weeklySummary.value) return
weekCommentContext.value = { employeeId: row.employeeId, employeeLabel: `${row.firstName} ${row.lastName}`.trim(), weekStart: weeklySummary.value.weekStart, weekEnd: weeklySummary.value.weekEnd, content: row.comment ?? '', commentId: row.commentId ?? null }
isWeekCommentDrawerOpen.value = true
}
const reloadWeeklySummary = async () => { await loadWeeklySummary() }
return { return {
isAdmin, isAdmin,
isSelfUser, isSelfUser,
@@ -1127,6 +1149,7 @@ export const useHoursPage = () => {
saveButtonClass, saveButtonClass,
formattedSelectedDate, formattedSelectedDate,
isSelectedDateHoliday, isSelectedDateHoliday,
selectedHolidayLabel,
weekDayHeaders, weekDayHeaders,
shortcutButtonClass, shortcutButtonClass,
weekShortcutButtonClass, weekShortcutButtonClass,
@@ -1164,6 +1187,7 @@ export const useHoursPage = () => {
getRowAbsenceStyle, getRowAbsenceStyle,
hasRowFormation, hasRowFormation,
getRowFormationLabel, getRowFormationLabel,
getRowContractNature,
getRowUpdatedAt, getRowUpdatedAt,
getPresenceDayValue, getPresenceDayValue,
openAbsenceDrawer, openAbsenceDrawer,
@@ -1171,6 +1195,10 @@ export const useHoursPage = () => {
deleteAbsenceFromDrawer, deleteAbsenceFromDrawer,
closeAbsenceDrawer, closeAbsenceDrawer,
formatMinutes, formatMinutes,
handleSave handleSave,
isWeekCommentDrawerOpen,
weekCommentContext,
openWeekCommentDrawer,
reloadWeeklySummary
} }
} }

View File

@@ -28,6 +28,7 @@ export const documentationSections: DocSection[] = [
{ type: 'paragraph', content: 'La vue jour est votre écran principal. Elle affiche les heures de travail pour une date donnée.' }, { type: 'paragraph', content: 'La vue jour est votre écran principal. Elle affiche les heures de travail pour une date donnée.' },
{ type: 'list', content: 'Boutons "Hier" / "Aujourd\'hui" / "Demain" pour naviguer rapidement\nSélecteur de date pour choisir une date spécifique\nFiltrage par site si vous avez accès à plusieurs sites' }, { type: 'list', content: 'Boutons "Hier" / "Aujourd\'hui" / "Demain" pour naviguer rapidement\nSélecteur de date pour choisir une date spécifique\nFiltrage par site si vous avez accès à plusieurs sites' },
{ type: 'paragraph', content: 'Seuls les employés ayant un contrat actif à la date sélectionnée sont affichés.' }, { type: 'paragraph', content: 'Seuls les employés ayant un contrat actif à la date sélectionnée sont affichés.' },
{ type: 'note', content: 'Sous le nom de l\'employé, la nature du contrat (CDI / CDD / Intérim) affichée correspond à la période couvrant la date filtrée, et non à aujourd\'hui.' },
], ],
}, },
{ {
@@ -56,6 +57,9 @@ export const documentationSections: DocSection[] = [
{ type: 'list', content: 'Matin : heure de début et heure de fin\nAprès-midi : heure de début et heure de fin\nSoir : heure de début et heure de fin' }, { type: 'list', content: 'Matin : heure de début et heure de fin\nAprès-midi : heure de début et heure de fin\nSoir : heure de début et heure de fin' },
{ type: 'paragraph', content: 'Le sélecteur de temps fonctionne par tranches de 15 minutes (00, 15, 30, 45). La saisie libre est possible mais sera corrigée automatiquement.' }, { type: 'paragraph', content: 'Le sélecteur de temps fonctionne par tranches de 15 minutes (00, 15, 30, 45). La saisie libre est possible mais sera corrigée automatiquement.' },
{ type: 'note', content: 'Les calculs sont mis à jour automatiquement : heures de jour (06:0021:00), heures de nuit (00:0006:00 et 21:0024:00) et total.' }, { type: 'note', content: 'Les calculs sont mis à jour automatiquement : heures de jour (06:0021:00), heures de nuit (00:0006:00 et 21:0024:00) et total.' },
{ type: 'note', content: 'Jours fériés : le nom du férié apparaît en badge bleu dans la colonne Absence. La saisie d\'heures (ou de jours de présence) et la création d\'absences sont autorisées.' },
{ type: 'note', content: 'Crédit automatique sur jour férié Lun-Ven : pour tout contrat hors Forfait et s\'il n\'y a pas d\'absence déclarée, un jour férié compte au minimum les heures contractuelles attendues (35h → 7h, 39h → 8h Lun-Jeu / 7h Ven). Si vous saisissez des heures supérieures à cette référence, ce sont vos heures qui sont comptées ; sinon c\'est la référence. Les conducteurs reçoivent ce crédit dans leur bucket "Heures jour". **Si une absence est posée sur le férié**, c\'est le paramétrage du type d\'absence (compte les heures oui/non) qui pilote les heures comptées, le crédit virtuel férié ne s\'applique plus.' },
{ type: 'note', content: 'Contrats non-standards (4h, 25h, 28h, etc.) : un planning par jour travaillé doit être saisi à la création/modification du contrat (bloc « Jours travaillés » avec case à cocher + horaire par jour). Le crédit férié et le crédit d\'absence ne s\'appliquent que sur les jours programmés, avec les heures programmées. Ex. un 4h Lundi 2h + Jeudi 2h : férié le lundi → +2h, férié le mardi → 0h.' },
], ],
}, },
{ {
@@ -76,6 +80,16 @@ export const documentationSections: DocSection[] = [
{ type: 'list', content: 'Jour : total des heures dans la plage 06:0021:00\nNuit : total des heures dans les plages 00:0006:00 et 21:0024:00\nTotal : somme des heures de jour et de nuit' }, { type: 'list', content: 'Jour : total des heures dans la plage 06:0021:00\nNuit : total des heures dans les plages 00:0006:00 et 21:0024:00\nTotal : somme des heures de jour et de nuit' },
], ],
}, },
{
id: 'commentaire-semaine',
title: 'Commentaires de semaine (admin)',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Sur la vue semaine, un picto bulle permet d\'attacher un commentaire libre sur la semaine d\'un employé.' },
{ type: 'list', content: 'Bulle bleue : pas de commentaire\nBulle rouge : un commentaire existe\nClic : ouvre le drawer avec textarea' },
{ type: 'note', content: 'Les commentaires n\'affectent aucun calcul. Pour supprimer, videz la textarea puis Enregistrer, ou bouton Supprimer.' },
],
},
], ],
}, },
{ {
@@ -204,10 +218,10 @@ export const documentationSections: DocSection[] = [
}, },
{ {
id: 'gestion-types-absence', id: 'gestion-types-absence',
title: 'Gestion des types d\'absence', title: 'Gestion des types de statut',
requiredLevel: 'admin', requiredLevel: 'admin',
blocks: [ blocks: [
{ type: 'paragraph', content: 'Les types d\'absence définissent les catégories disponibles lors de la pose d\'une absence.' }, { type: 'paragraph', content: 'Les types de statut définissent les catégories disponibles lors de la pose d\'une absence.' },
{ type: 'list', content: 'Code : identifiant court (max 10 caractères), ex: C, M, AT\nLibellé : nom affiché, ex: Congé, Maladie, Accident du travail\nCouleur : code couleur pour le calendrier et la vue jour\nOption "Compté comme travaillé" : si activé, l\'absence crédite des heures en mode TIME' }, { type: 'list', content: 'Code : identifiant court (max 10 caractères), ex: C, M, AT\nLibellé : nom affiché, ex: Congé, Maladie, Accident du travail\nCouleur : code couleur pour le calendrier et la vue jour\nOption "Compté comme travaillé" : si activé, l\'absence crédite des heures en mode TIME' },
{ type: 'note', content: 'L\'option "Compté comme travaillé" impacte le calcul des heures supplémentaires. En mode TIME, les minutes sont créditées selon le contrat. En mode PRESENCE, aucun crédit n\'est appliqué.' }, { type: 'note', content: 'L\'option "Compté comme travaillé" impacte le calcul des heures supplémentaires. En mode TIME, les minutes sont créditées selon le contrat. En mode PRESENCE, aucun crédit n\'est appliqué.' },
], ],
@@ -255,7 +269,7 @@ export const documentationSections: DocSection[] = [
requiredLevel: 'admin', requiredLevel: 'admin',
blocks: [ blocks: [
{ type: 'paragraph', content: 'La création d\'un employé se fait via le drawer d\'ajout.' }, { type: 'paragraph', content: 'La création d\'un employé se fait via le drawer d\'ajout.' },
{ type: 'list', content: 'Champs : prénom, nom, site\nNature du contrat : CDI, CDD ou INTERIM\nType de contrat / temps de travail (Forfait, 35h, 39h, etc.)\nDate de début (obligatoire)\nDate de fin (obligatoire pour CDD et INTERIM)' }, { type: 'list', content: 'Champs : prénom, nom, site\nNature du contrat : CDI, CDD ou INTERIM\nAgence d\'intérim (visible uniquement pour INTERIM, optionnel)\nType de contrat / temps de travail (Forfait, 35h, 39h, etc.)\nDate de début (obligatoire)\nDate de fin (obligatoire pour CDD et INTERIM)' },
], ],
}, },
{ {
@@ -328,7 +342,7 @@ export const documentationSections: DocSection[] = [
requiredLevel: 'admin', requiredLevel: 'admin',
blocks: [ blocks: [
{ type: 'paragraph', content: 'La vue semaine est réservée aux administrateurs. Elle affiche une synthèse hebdomadaire par employé avec les heures supplémentaires calculées.' }, { type: 'paragraph', content: 'La vue semaine est réservée aux administrateurs. Elle affiche une synthèse hebdomadaire par employé avec les heures supplémentaires calculées.' },
{ type: 'list', content: 'Filtrage par site et par employé\nDétail par jour avec totaux hebdomadaires\nColonnes de calcul : base, heures sup 25%, heures sup 50%, total récupération' }, { type: 'list', content: 'Filtrage par site et par employé\nDétail par jour avec totaux hebdomadaires\nColonnes de calcul : base, heures sup 25%, heures sup 50%, total récupération\nLes jours fériés sont signalés sur la cellule du jour : fond bleu clair quand pas d\'absence, nom du férié au survol' },
], ],
}, },
{ {
@@ -384,7 +398,9 @@ export const documentationSections: DocSection[] = [
requiredLevel: 'admin', requiredLevel: 'admin',
blocks: [ blocks: [
{ type: 'paragraph', content: 'Le calendrier offre une vue d\'ensemble mensuelle des absences de tous les employés.' }, { type: 'paragraph', content: 'Le calendrier offre une vue d\'ensemble mensuelle des absences de tous les employés.' },
{ type: 'list', content: 'Code couleur par type d\'absence\nDemi-journée : affichage en dégradé diagonal\nJournée complète : fond plein\nJours fériés : fond bleu clair\nFiltres par site et par employé\nNavigation par mois (précédent / suivant)' }, { type: 'list', content: 'Code couleur par type d\'absence\nDemi-journée : affichage en dégradé diagonal\nJournée complète : fond plein\nJours fériés : fond bleu clair (cliquable pour créer une absence)\nFiltres par site et par employé\nNavigation par mois (précédent / suivant)' },
{ type: 'note', content: 'Seuls les employés ayant au moins un jour de contrat sur le mois affiché apparaissent. Un employé dont le contrat s\'est terminé avant le 1er du mois (ou qui commence après la fin du mois) est masqué.' },
{ type: 'note', content: 'Les absences peuvent être créées sur les jours fériés. Quand une absence est posée sur un férié, elle remplace l\'affichage « Férié » dans la cellule.' },
], ],
}, },
], ],
@@ -441,6 +457,28 @@ export const documentationSections: DocSection[] = [
{ type: 'paragraph', content: 'Compteurs visibles sur l\'onglet Congé de la fiche employé : acquis, en cours d\'acquisition, pris, restant.' }, { type: 'paragraph', content: 'Compteurs visibles sur l\'onglet Congé de la fiche employé : acquis, en cours d\'acquisition, pris, restant.' },
], ],
}, },
{
id: 'onglet-conges-fiche-employe',
title: 'Onglet Congés (fiche employé)',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'L\'onglet "Congés" sur la fiche employé affiche un calendrier annuel des congés posés (12 mois en grille 4×3) ainsi que les compteurs (acquis, pris, reste, en cours d\'acquisition, N-1 ou samedis selon le contrat).' },
{ type: 'paragraph', content: 'La période affichée dépend du type de contrat actuel : Janvier → Décembre pour FORFAIT, Juin (N-1) → Mai (N) pour les autres contrats.' },
{ type: 'paragraph', content: 'Un sélecteur d\'année est disponible en bas du calendrier (zone scrollable, à gauche). Il permet de consulter les exercices passés. La plage proposée part de l\'exercice courant et remonte jusqu\'au plus récent entre (a) le premier exercice où l\'employé avait un contrat ouvert et (b) l\'exercice de mise en service du logiciel — il est inutile de remonter plus loin, aucune donnée n\'a été saisie avant.' },
{ type: 'note', content: 'Sur un exercice passé, les boutons d\'édition "Jours fractionnés" et "Année N-1 payés" sont désactivés. La consultation reste possible, mais on n\'autorise pas la modification rétroactive d\'un exercice clos.' },
],
},
{
id: 'ecran-recap-conges',
title: 'Écran Récap. congés',
requiredLevel: 'employee',
blocks: [
{ type: 'paragraph', content: 'L\'écran "Récap. congés" affiche un tableau figé des soldes de congés et RTT par employé. Il est accessible via la sidebar lorsque l\'accès a été activé sur le compte utilisateur.' },
{ type: 'list', content: 'Employé : voit uniquement sa propre ligne\nChef de site : voit les employés des sites qui lui sont rattachés\nAdmin : voit tous les employés, groupés par site' },
{ type: 'note', content: 'Le récap est arrêté à la fin de la semaine S-2 (dimanche). Exemple : un mardi en S16, les soldes sont calculés jusqu\'au dimanche de la S14 inclus. Les heures et absences postérieures ne sont pas comptées.' },
{ type: 'paragraph', content: 'Les colonnes affichées sont identiques à l\'export PDF admin (Nom, Prénom, Contrat, CP N-1 restant, CP N, Samedis, RTT). L\'accès à cet écran est géré par un flag sur l\'utilisateur, activé depuis le drawer de création/édition d\'un utilisateur par un admin.' },
],
},
], ],
}, },
{ {
@@ -465,6 +503,7 @@ export const documentationSections: DocSection[] = [
blocks: [ blocks: [
{ type: 'list', content: 'Report N-1 : solde de l\'exercice précédent\nAcquis : cumul des heures supplémentaires de l\'exercice en cours\nDisponible : report + acquis payé\nPayé : RTT convertis en salaire (soustraits du disponible)' }, { type: 'list', content: 'Report N-1 : solde de l\'exercice précédent\nAcquis : cumul des heures supplémentaires de l\'exercice en cours\nDisponible : report + acquis payé\nPayé : RTT convertis en salaire (soustraits du disponible)' },
{ type: 'note', content: 'Les contrats INTERIM et le mode PRESENCE n\'accumulent pas de RTT (affiché à 0).' }, { type: 'note', content: 'Les contrats INTERIM et le mode PRESENCE n\'accumulent pas de RTT (affiché à 0).' },
{ type: 'paragraph', content: 'La colonne "Cumul" affiche le solde RTT à la fin de chaque semaine : Report N-1 + somme des heures hebdomadaires jusqu\'à la semaine concernée paiements RTT des mois précédents. Un paiement enregistré sur le mois M n\'est déduit qu\'à partir des semaines du mois M+1. Permet la comparaison ligne à ligne avec un suivi RH externe (Excel).' },
], ],
}, },
{ {
@@ -476,6 +515,15 @@ export const documentationSections: DocSection[] = [
{ type: 'list', content: 'Saisie : mois, nombre de minutes, taux (25% ou 50%)\nPlusieurs paiements possibles par mois\nLes heures payées sont soustraites du solde disponible' }, { type: 'list', content: 'Saisie : mois, nombre de minutes, taux (25% ou 50%)\nPlusieurs paiements possibles par mois\nLes heures payées sont soustraites du solde disponible' },
], ],
}, },
{
id: 'rtt-selecteur-exercice',
title: 'Consulter un exercice passé',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Un sélecteur d\'exercice est disponible en bas du tableau RTT (zone scrollable, à gauche). Il permet de consulter les exercices passés (Juin → Mai). La plage proposée part de l\'exercice courant et remonte jusqu\'au plus récent entre (a) le premier exercice où l\'employé avait un contrat ouvert et (b) l\'exercice de mise en service du logiciel.' },
{ type: 'note', content: 'Sur un exercice passé, le bouton « + Payer les RTT » est désactivé. Aucun paiement rétroactif n\'est autorisé pour préserver la cohérence du report N-1.' },
],
},
{ {
id: 'rtt-semaines-mois', id: 'rtt-semaines-mois',
title: 'Attribution des semaines aux mois', title: 'Attribution des semaines aux mois',
@@ -553,7 +601,7 @@ export const documentationSections: DocSection[] = [
requiredLevel: 'admin', requiredLevel: 'admin',
blocks: [ blocks: [
{ type: 'paragraph', content: 'Génère un PDF A4 paysage avec le détail mensuel pour la paie.' }, { type: 'paragraph', content: 'Génère un PDF A4 paysage avec le détail mensuel pour la paie.' },
{ type: 'list', content: 'Sélecteur de mois (défaut = mois courant)\nDonnées groupées par site\nColonnes : nom, base contrat, jours de présence cadre, heures de nuit, panier de nuit, heures RTT payées, congés (nombre + dates), maladie/AT (nombre + dates), primes conducteur (PDJ, repas, nuitée, samedi), observations' }, { type: 'list', content: 'Sélecteur de mois (défaut = mois courant)\nDonnées groupées par site\nColonnes : nom, base contrat, jours de présence cadre, heures de nuit, panier de nuit, heures RTT payées, congés (nombre + dates), maladie/AT (nombre + dates), primes conducteur (PDJ, repas, nuitée, samedi), observations\nColonne « Repas » chauffeur : somme déjeuner + dîner sur le mois (un jour avec les deux compte 2 repas)' },
], ],
}, },
{ {
@@ -571,7 +619,7 @@ export const documentationSections: DocSection[] = [
requiredLevel: 'admin', requiredLevel: 'admin',
blocks: [ blocks: [
{ type: 'paragraph', content: 'Génère un PDF par employé avec le détail jour par jour de ses heures sur une année.' }, { type: 'paragraph', content: 'Génère un PDF par employé avec le détail jour par jour de ses heures sur une année.' },
{ type: 'list', content: 'Accessible depuis la fiche employé (bouton imprimante)\nChoix de l\'année civile (janvier à décembre)\nColonnes adaptées au mode de suivi (TIME, PRESENCE, conducteur)\nSections séparées en cas de changement de contrat en cours d\'année' }, { type: 'list', content: 'Accessible depuis la fiche employé (bouton imprimante)\nChoix de l\'année civile (janvier à décembre)\nColonnes adaptées au mode de suivi (TIME, PRESENCE, conducteur)\nSections séparées en cas de changement de contrat en cours d\'année\nLes jours fériés apparaissent toujours (ligne bleue) avec la mention « Férié : {nom} » dans la colonne Absence ; le total reprend les heures contractuelles créditées (hors Forfait)' },
], ],
}, },
], ],

View File

@@ -56,6 +56,13 @@
"create": "Impossible de créer l'observation.", "create": "Impossible de créer l'observation.",
"update": "Impossible de mettre à jour l'observation.", "update": "Impossible de mettre à jour l'observation.",
"delete": "Impossible de supprimer l'observation." "delete": "Impossible de supprimer l'observation."
},
"leaveRecap": {
"load": "Impossible de charger le récap des congés."
},
"weekComment": {
"save": "Impossible d'enregistrer le commentaire de semaine.",
"delete": "Impossible de supprimer le commentaire de semaine."
} }
}, },
"success": { "success": {
@@ -107,6 +114,10 @@
"create": "Observation créée.", "create": "Observation créée.",
"update": "Observation mise à jour.", "update": "Observation mise à jour.",
"delete": "Observation supprimée." "delete": "Observation supprimée."
},
"weekComment": {
"save": "Commentaire enregistré.",
"delete": "Commentaire supprimé."
} }
} }
} }

View File

@@ -1,11 +1,40 @@
<template> <template>
<div class="h-screen overflow-hidden"> <div class="h-screen overflow-hidden">
<div class="flex h-full"> <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"> <!-- Mobile overlay -->
<div class="h-[75px]"> <Transition
enter-active-class="transition-opacity duration-300"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-300"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="sidebarOpen"
class="fixed inset-0 z-40 bg-black/50 lg:hidden"
@click="sidebarOpen = false"
/>
</Transition>
<!-- Sidebar -->
<aside
:class="[
'fixed inset-y-0 left-0 z-50 flex w-64 flex-col border-r border-neutral-200 bg-tertiary-500 transition-transform duration-300 lg:static lg:translate-x-0 lg:flex-shrink-0',
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
]"
>
<div class="flex h-[75px] items-center justify-between">
<img src="/malio.png" alt="Logo" class="w-auto"/> <img src="/malio.png" alt="Logo" class="w-auto"/>
<button
type="button"
class="mr-3 rounded-md p-1 text-primary-500 hover:text-secondary-500 lg:hidden"
@click="sidebarOpen = false"
>
<Icon name="mdi:close" size="24"/>
</button>
</div> </div>
<nav class="flex-1 px-4 pb-6"> <nav class="flex-1 overflow-y-auto px-4 pb-6">
<template v-if="isAdmin"> <template v-if="isAdmin">
<NuxtLink <NuxtLink
to="/calendar" to="/calendar"
@@ -13,6 +42,7 @@
:class="route.path.startsWith('/calendar') :class="route.path.startsWith('/calendar')
? 'bg-tertiary-500 text-primary-500 font-bold' ? 'bg-tertiary-500 text-primary-500 font-bold'
: ''" : ''"
@click="closeSidebarOnMobile"
> >
<Icon name="mdi:calendar-blank" size="24"/> <Icon name="mdi:calendar-blank" size="24"/>
<p>Calendrier</p> <p>Calendrier</p>
@@ -26,6 +56,7 @@
route.path.startsWith('/hours') ? 'bg-tertiary-500 text-primary-500 font-bold' : '', route.path.startsWith('/hours') ? 'bg-tertiary-500 text-primary-500 font-bold' : '',
!isAdmin ? 'border-t border-secondary-500 pt-3' : '' !isAdmin ? 'border-t border-secondary-500 pt-3' : ''
]" ]"
@click="closeSidebarOnMobile"
> >
<Icon name="mdi:clock-time-four-outline" size="24"/> <Icon name="mdi:clock-time-four-outline" size="24"/>
<p>Heures</p> <p>Heures</p>
@@ -38,6 +69,7 @@
route.path.startsWith('/driver-hours') ? 'bg-tertiary-500 text-primary-500 font-bold' : '', route.path.startsWith('/driver-hours') ? 'bg-tertiary-500 text-primary-500 font-bold' : '',
!isAdmin && isDriver ? 'border-t border-secondary-500 pt-3' : '' !isAdmin && isDriver ? 'border-t border-secondary-500 pt-3' : ''
]" ]"
@click="closeSidebarOnMobile"
> >
<Icon name="mdi:truck-outline" size="24"/> <Icon name="mdi:truck-outline" size="24"/>
<p>Heures Conducteurs</p> <p>Heures Conducteurs</p>
@@ -49,16 +81,30 @@
:class="route.path.startsWith('/employees') :class="route.path.startsWith('/employees')
? 'bg-tertiary-500 text-primary-500 font-bold' ? 'bg-tertiary-500 text-primary-500 font-bold'
: ''" : ''"
@click="closeSidebarOnMobile"
> >
<Icon name="mdi:account-group-outline" size="24"/> <Icon name="mdi:account-group-outline" size="24"/>
<p>Employés</p> <p>Employés</p>
</NuxtLink> </NuxtLink>
<NuxtLink
v-if="hasLeaveRecapAccess"
to="/leave-recap"
class="flex items-center gap-2 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
:class="route.path.startsWith('/leave-recap')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
@click="closeSidebarOnMobile"
>
<Icon name="mdi:beach" size="24"/>
<p>Récap. congés</p>
</NuxtLink>
<NuxtLink <NuxtLink
to="/sites" to="/sites"
class="flex items-center gap-2 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500" 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') :class="route.path.startsWith('/sites')
? 'bg-tertiary-500 text-primary-500 font-bold' ? 'bg-tertiary-500 text-primary-500 font-bold'
: ''" : ''"
@click="closeSidebarOnMobile"
> >
<Icon name="mdi:business" size="24"/> <Icon name="mdi:business" size="24"/>
<p>Sites</p> <p>Sites</p>
@@ -69,9 +115,10 @@
:class="route.path.startsWith('/absence-types') :class="route.path.startsWith('/absence-types')
? 'bg-tertiary-500 text-primary-500 font-bold' ? 'bg-tertiary-500 text-primary-500 font-bold'
: ''" : ''"
@click="closeSidebarOnMobile"
> >
<Icon name="mdi:umbrella-beach-outline" size="24"/> <Icon name="mdi:umbrella-beach-outline" size="24"/>
<p>Types d'absence</p> <p>Types de statut</p>
</NuxtLink> </NuxtLink>
<NuxtLink <NuxtLink
to="/users" to="/users"
@@ -79,11 +126,22 @@
:class="route.path.startsWith('/users') :class="route.path.startsWith('/users')
? 'bg-tertiary-500 text-primary-500 font-bold' ? 'bg-tertiary-500 text-primary-500 font-bold'
: ''" : ''"
@click="closeSidebarOnMobile"
> >
<Icon name="mdi:account-outline" size="24"/> <Icon name="mdi:account-outline" size="24"/>
<p>Utilisateurs</p> <p>Utilisateurs</p>
</NuxtLink> </NuxtLink>
</template> </template>
<NuxtLink
v-if="hasLeaveRecapAccess && !isAdmin"
to="/leave-recap"
class="flex items-center gap-2 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500 pt-3"
:class="route.path.startsWith('/leave-recap') ? 'bg-tertiary-500 text-primary-500 font-bold' : ''"
@click="closeSidebarOnMobile"
>
<Icon name="mdi:beach" size="24"/>
<p>Récap. congés</p>
</NuxtLink>
<NuxtLink <NuxtLink
v-if="isSuperAdmin" v-if="isSuperAdmin"
to="/audit-logs" to="/audit-logs"
@@ -91,6 +149,7 @@
:class="route.path.startsWith('/audit-logs') :class="route.path.startsWith('/audit-logs')
? 'bg-tertiary-500 text-primary-500 font-bold' ? 'bg-tertiary-500 text-primary-500 font-bold'
: ''" : ''"
@click="closeSidebarOnMobile"
> >
<Icon name="mdi:clipboard-text-clock-outline" size="24"/> <Icon name="mdi:clipboard-text-clock-outline" size="24"/>
<p>Journal</p> <p>Journal</p>
@@ -101,6 +160,7 @@
:class="route.path.startsWith('/documentation') :class="route.path.startsWith('/documentation')
? 'bg-tertiary-500 text-primary-500 font-bold' ? 'bg-tertiary-500 text-primary-500 font-bold'
: ''" : ''"
@click="closeSidebarOnMobile"
> >
<Icon name="mdi:book-open-page-variant-outline" size="24"/> <Icon name="mdi:book-open-page-variant-outline" size="24"/>
<p>Documentation</p> <p>Documentation</p>
@@ -112,9 +172,9 @@
</div> </div>
</aside> </aside>
<div class="h-full flex-1 overflow-hidden flex flex-col"> <div class="h-full flex-1 overflow-hidden flex flex-col min-w-0">
<AppTopNav :user="auth.user" /> <AppTopNav :user="auth.user" @toggle-sidebar="sidebarOpen = !sidebarOpen" />
<main class="flex-1 overflow-y-auto px-8 py-12"> <main class="flex-1 overflow-y-auto [scrollbar-gutter:stable] px-4 py-6 lg:px-8 lg:py-12">
<slot/> <slot/>
</main> </main>
</div> </div>
@@ -128,5 +188,11 @@ const {version} = useAppVersion()
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false) const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
const isSuperAdmin = computed(() => auth.user?.roles?.includes('ROLE_SUPER_ADMIN') ?? false) const isSuperAdmin = computed(() => auth.user?.roles?.includes('ROLE_SUPER_ADMIN') ?? false)
const isDriver = computed(() => auth.user?.isDriver ?? false) const isDriver = computed(() => auth.user?.isDriver ?? false)
const hasLeaveRecapAccess = computed(() => auth.user?.hasLeaveRecapAccess ?? false)
const route = useRoute() const route = useRoute()
const sidebarOpen = ref(false)
const closeSidebarOnMobile = () => {
sidebarOpen.value = false
}
</script> </script>

View File

@@ -0,0 +1,11 @@
export default defineNuxtRouteMiddleware(async () => {
const auth = useAuthStore()
if (!auth.checked) {
await auth.ensureSession()
}
if (!auth.user?.hasLeaveRecapAccess) {
return navigateTo('/')
}
})

View File

@@ -2,6 +2,7 @@ export default defineNuxtConfig({
compatibilityDate: '2025-07-15', compatibilityDate: '2025-07-15',
devtools: {enabled: false}, devtools: {enabled: false},
ssr: false, ssr: false,
extends: ['@malio/layer-ui'],
app: { app: {
baseURL: process.env.NODE_ENV === 'production' baseURL: process.env.NODE_ENV === 'production'
? (process.env.NUXT_PUBLIC_APP_BASE || '/') ? (process.env.NUXT_PUBLIC_APP_BASE || '/')

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@
"dependencies": { "dependencies": {
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.1", "@nuxtjs/i18n": "^10.2.1",
"@malio/layer-ui": "^1.4.6",
"@pinia/nuxt": "^0.11.3", "@pinia/nuxt": "^0.11.3",
"nuxt": "^4.3.0", "nuxt": "^4.3.0",
"nuxt-toast": "^1.4.0", "nuxt-toast": "^1.4.0",

View File

@@ -1,14 +1,13 @@
<template> <template>
<div class="h-full flex flex-col overflow-hidden"> <div class="h-full flex flex-col overflow-hidden">
<div class="flex items-center justify-between pb-6"> <div class="flex items-center justify-between pb-6">
<h1 class="text-4xl font-bold text-primary-500">Types d'absence</h1> <h1 class="text-4xl font-bold text-primary-500">Types de statut</h1>
<button <MalioButton
type="button" label="Ajouter un type"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500" icon-name="mdi:plus"
icon-position="left"
@click="openCreate" @click="openCreate"
> />
+ Ajouter un type
</button>
</div> </div>
<div <div
@@ -56,60 +55,40 @@
</div> </div>
</div> </div>
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle"> <MalioDrawer v-model="isDrawerOpen" :title="drawerTitle">
<form class="space-y-4" @submit.prevent="handleSubmit"> <form class="space-y-4" @submit.prevent="handleSubmit">
<div> <MalioInputText
<label class="text-md font-semibold text-neutral-700" for="code"> v-model="form.code"
Code <span class="text-red-600">*</span> label="Code *"
</label> group-class="mt-2"
<input :max-length="10"
id="code" :error="showCodeError ? 'Le code est obligatoire.' : ''"
v-model="form.code" />
type="text" <MalioInputText
maxlength="10" v-model="form.label"
:class="codeFieldClass" label="Libellé *"
/> group-class="mt-2"
<p v-if="showCodeError" class="mt-1 text-sm text-red-600"> :error="showLabelError ? 'Le libellé est obligatoire.' : ''"
Le code est obligatoire. />
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="label">
Libellé <span class="text-red-600">*</span>
</label>
<input
id="label"
v-model="form.label"
type="text"
:class="labelFieldClass"
/>
<p v-if="showLabelError" class="mt-1 text-sm text-red-600">
Le libellé est obligatoire.
</p>
</div>
<div> <div>
<label class="text-md font-semibold text-neutral-700"> <label class="text-md font-semibold text-neutral-700">
Compté comme travaillé Compté comme travaillé
</label> </label>
<div class="mt-2 flex items-center gap-6"> <div class="mt-2 flex items-center gap-6">
<label class="inline-flex items-center gap-2 text-md text-neutral-800"> <MalioRadioButton
<input v-model="form.countAsWorkedHours"
v-model="form.countAsWorkedHours" name="countAsWorkedHours"
type="radio" :value="true"
class="h-4 w-4" label="Oui"
:value="true" group-class="w-auto mt-0"
/> />
Oui <MalioRadioButton
</label> v-model="form.countAsWorkedHours"
<label class="inline-flex items-center gap-2 text-md text-neutral-800"> name="countAsWorkedHours"
<input :value="false"
v-model="form.countAsWorkedHours" label="Non"
type="radio" group-class="w-auto mt-0"
class="h-4 w-4" />
:value="false"
/>
Non
</label>
</div> </div>
</div> </div>
<div> <div>
@@ -130,32 +109,29 @@
</p> </p>
</div> </div>
<div v-if="editingType" class="grid grid-cols-2 gap-3 pt-2"> <div v-if="editingType" class="grid grid-cols-2 gap-3 pt-2">
<button <MalioButton
type="button" label="Supprimer"
class="flex items-center justify-center rounded-md bg-red-500 px-4 py-2 text-md font-semibold text-white hover:bg-red-600" variant="danger"
button-class="w-full"
@click="confirmDelete(editingType)" @click="confirmDelete(editingType)"
> />
Supprimer <MalioButton
</button>
<button
type="submit" type="submit"
class="flex items-center justify-center rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500" label="Modifier"
:class="submitButtonClass" button-class="w-full"
> :disabled="isSubmitting || !isFormValid"
Modifier />
</button>
</div> </div>
<div v-else class="flex justify-center pt-2"> <div v-else class="flex justify-center pt-2">
<button <MalioButton
type="submit" type="submit"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500" label="Valider"
:class="submitButtonClass" button-class="w-[200px]"
> :disabled="isSubmitting || !isFormValid"
+ Ajouter />
</button>
</div> </div>
</form> </form>
</AppDrawer> </MalioDrawer>
</div> </div>
</template> </template>
@@ -164,7 +140,7 @@ import type { AbsenceType } from '~/services/dto/absence-type'
import { createAbsenceType, deleteAbsenceType, listAbsenceTypes, updateAbsenceType } from '~/services/absence-types' import { createAbsenceType, deleteAbsenceType, listAbsenceTypes, updateAbsenceType } from '~/services/absence-types'
useHead({ useHead({
title: 'Types d\'absences' title: 'Types de statut'
}) })
const isDrawerOpen = ref(false) const isDrawerOpen = ref(false)
@@ -202,20 +178,6 @@ const showCodeError = computed(() => validationTouched.code && !isCodeValid.valu
const showLabelError = computed(() => validationTouched.label && !isLabelValid.value) const showLabelError = computed(() => validationTouched.label && !isLabelValid.value)
const showColorError = computed(() => validationTouched.color && !isColorValid.value) 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-secondary-500/20'
const codeFieldClass = computed(() => {
if (showCodeError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const labelFieldClass = computed(() => {
if (showLabelError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const colorFieldClass = computed(() => { const colorFieldClass = computed(() => {
const baseColorClass = 'h-10 w-16 cursor-pointer rounded-md border bg-white p-1' const baseColorClass = 'h-10 w-16 cursor-pointer rounded-md border bg-white p-1'
if (showColorError.value) { if (showColorError.value) {
@@ -224,13 +186,6 @@ const colorFieldClass = computed(() => {
return `${baseColorClass} border-neutral-300` return `${baseColorClass} border-neutral-300`
}) })
const submitButtonClass = computed(() => {
if (isSubmitting.value || !isFormValid.value) {
return 'opacity-50 cursor-not-allowed'
}
return ''
})
const loadAbsenceTypes = async () => { const loadAbsenceTypes = async () => {
isLoading.value = true isLoading.value = true
try { try {

View File

@@ -5,30 +5,37 @@
</div> </div>
<div class="flex flex-col gap-3 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 justify-between gap-4">
<div class="flex items-center gap-4"> <MalioSelectCheckbox
<SiteFilterSelector v-model="selectedSiteIds" :sites="sites"/> v-model="selectedSiteIds"
</div> :options="siteOptions"
label="Sites"
groupClass="relative z-50 w-80 h-10"
display-select-all
/>
<div class="flex gap-4"> <div class="flex gap-4">
<button <MalioButton
type="button" label="Ajouter une absence"
class="h-10 rounded-lg bg-primary-500 px-4 text-md font-semibold text-white hover:bg-secondary-500" icon-name="mdi:plus"
icon-position="left"
@click="openCreateFromToday" @click="openCreateFromToday"
> />
+ Ajouter une absence <MalioButton
</button> label="Imprimer"
<button variant="secondary"
type="button" icon-name="mdi:printer"
class="h-10 rounded-lg bg-primary-500 px-4 text-md font-semibold text-white hover:bg-secondary-500" icon-position="left"
@click="openPrint" @click="openPrint"
> />
Imprimer
</button>
</div> </div>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<div class="w-80"> <div class="w-80">
<EmployeeNameFilterInput v-model="employeeFilter"/> <MalioInputText
v-model="employeeFilter"
label="Recherche d'un employé"
icon-name="mdi:magnify"
/>
</div> </div>
<PeriodStepperPicker <PeriodStepperPicker
width-class="w-[260px]" width-class="w-[260px]"
@@ -111,9 +118,7 @@ import {compareEmployeesInSite, sortEmployeesBySiteAndOrder} from '~/utils/emplo
import CalendarGrid from '~/components/CalendarGrid.vue' import CalendarGrid from '~/components/CalendarGrid.vue'
import AbsenceFormDrawer from '~/components/AbsenceFormDrawer.vue' import AbsenceFormDrawer from '~/components/AbsenceFormDrawer.vue'
import AbsencePrintDrawer from '~/components/AbsencePrintDrawer.vue' import AbsencePrintDrawer from '~/components/AbsencePrintDrawer.vue'
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
import PeriodStepperPicker from '~/components/PeriodStepperPicker.vue' import PeriodStepperPicker from '~/components/PeriodStepperPicker.vue'
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
useHead({ useHead({
title: 'Calendrier' title: 'Calendrier'
@@ -136,6 +141,8 @@ const sites = computed(() => {
}) })
}) })
const siteOptions = computed(() => sites.value.map((site) => ({ label: site.name, value: site.id })))
// Filtres de sites (par défaut: tous sélectionnés à l'init). // Filtres de sites (par défaut: tous sélectionnés à l'init).
const selectedSiteIds = ref<number[]>([]) const selectedSiteIds = ref<number[]>([])
const sitesInitialized = ref(false) const sitesInitialized = ref(false)
@@ -154,12 +161,27 @@ const sortedEmployees = computed(() => {
// Employés visibles selon le filtre de sites. // Employés visibles selon le filtre de sites.
const employeeFilter = ref('') const employeeFilter = ref('')
// Un employé est considéré "présent" sur le mois affiché si au moins une de ses
// périodes de contrat intersecte [début du mois ; fin du mois]. Sinon il est masqué.
const hasContractInSelectedMonth = (employee: Employee): boolean => {
const monthStart = toYmd(selectedYear.value, selectedMonth.value, 1)
const monthEnd = toYmd(selectedYear.value, selectedMonth.value + 1, 0)
const history = employee.contractHistory ?? []
if (history.length === 0) return false
return history.some((period) => {
const start = period.startDate
const end = period.endDate ?? '9999-12-31'
return start <= monthEnd && end >= monthStart
})
}
const visibleEmployees = computed(() => { const visibleEmployees = computed(() => {
if (selectedSiteIds.value.length === 0) return [] if (selectedSiteIds.value.length === 0) return []
const filter = employeeFilter.value.trim().toLowerCase() const filter = employeeFilter.value.trim().toLowerCase()
return sortedEmployees.value.filter((employee) => { return sortedEmployees.value.filter((employee) => {
const siteOk = employee.site?.id && selectedSiteIds.value.includes(employee.site.id) const siteOk = employee.site?.id && selectedSiteIds.value.includes(employee.site.id)
if (!siteOk) return false if (!siteOk) return false
if (!hasContractInSelectedMonth(employee)) return false
if (!filter) return true if (!filter) return true
const first = employee.firstName?.toLowerCase() ?? '' const first = employee.firstName?.toLowerCase() ?? ''
const last = employee.lastName?.toLowerCase() ?? '' const last = employee.lastName?.toLowerCase() ?? ''
@@ -490,14 +512,15 @@ const hasFormationOn = (employeeId: number, date: string): boolean => {
return cellFormationMap.value.has(`${employeeId}-${date}`) return cellFormationMap.value.has(`${employeeId}-${date}`)
} }
// Jours fériés (interdit pour la création). // Jours fériés.
const isHolidayDate = (date: string) => { const isHolidayDate = (date: string) => {
return Boolean(publicHolidays.value[date]) return Boolean(publicHolidays.value[date])
} }
// Renvoie l'absence effective pour une cellule (ou un "Férié"). // Renvoie l'absence effective pour une cellule (ou un "Férié" si pas d'absence).
const getCellAbsence = (employeeId: number, date: string) => { const getCellAbsence = (employeeId: number, date: string) => {
if (isHolidayDate(date)) { const absence = cellAbsenceMap.value.get(`${employeeId}-${date}`)
if (!absence && isHolidayDate(date)) {
return { return {
id: 0, id: 0,
code: 'Férié', code: 'Férié',
@@ -505,7 +528,6 @@ const getCellAbsence = (employeeId: number, date: string) => {
textColor: '#0f172a' textColor: '#0f172a'
} }
} }
const absence = cellAbsenceMap.value.get(`${employeeId}-${date}`)
if (absence) return { ...absence, hasFormation: hasFormationOn(employeeId, date) } if (absence) return { ...absence, hasFormation: hasFormationOn(employeeId, date) }
if (hasFormationOn(employeeId, date)) { if (hasFormationOn(employeeId, date)) {
return { return {
@@ -549,11 +571,6 @@ const getCellInfo = (employeeId: number, date: string) => {
// Ouverture du drawer depuis une cellule. // Ouverture du drawer depuis une cellule.
const openCreate = (employee: Employee, date: string) => { const openCreate = (employee: Employee, date: string) => {
if (isHolidayDate(date)) {
window.alert("Impossible de creer une absence un jour ferie.")
return
}
const existing = absences.value.find((absence) => { const existing = absences.value.find((absence) => {
const start = normalizeDate(absence.startDate) const start = normalizeDate(absence.startDate)
const end = normalizeDate(absence.endDate) const end = normalizeDate(absence.endDate)
@@ -590,10 +607,6 @@ const openCreateFromToday = () => {
form.typeId = '' form.typeId = ''
const now = new Date() const now = new Date()
const today = toYmd(now.getFullYear(), now.getMonth(), now.getDate()) const today = toYmd(now.getFullYear(), now.getMonth(), now.getDate())
if (isHolidayDate(today)) {
window.alert("Impossible de creer une absence un jour ferie.")
return
}
form.startDate = today form.startDate = today
form.endDate = today form.endDate = today
form.startHalf = 'AM' form.startHalf = 'AM'

View File

@@ -43,6 +43,7 @@
:is-site-manager="isSiteManager" :is-site-manager="isSiteManager"
:day-grid-cols="dayGridCols" :day-grid-cols="dayGridCols"
:is-holiday="isSelectedDateHoliday" :is-holiday="isSelectedDateHoliday"
:holiday-label="selectedHolidayLabel"
:contract-label="contractLabel" :contract-label="contractLabel"
:is-row-locked="isRowLocked" :is-row-locked="isRowLocked"
:has-contract-at-selected-date="hasContractAtSelectedDate" :has-contract-at-selected-date="hasContractAtSelectedDate"
@@ -63,6 +64,7 @@
:get-row-metrics="getRowMetrics" :get-row-metrics="getRowMetrics"
:get-row-absence-label="getRowAbsenceLabel" :get-row-absence-label="getRowAbsenceLabel"
:get-row-absence-style="getRowAbsenceStyle" :get-row-absence-style="getRowAbsenceStyle"
:get-row-contract-nature="getRowContractNature"
:get-row-updated-at="getRowUpdatedAt" :get-row-updated-at="getRowUpdatedAt"
:on-absence-click="openAbsenceDrawer" :on-absence-click="openAbsenceDrawer"
:format-minutes="formatMinutes" :format-minutes="formatMinutes"
@@ -72,11 +74,13 @@
<DriverHoursWeekView <DriverHoursWeekView
v-else-if="isAdmin && viewMode === 'week'" v-else-if="isAdmin && viewMode === 'week'"
:is-week-loading="isWeekLoading" :is-week-loading="isWeekLoading"
:is-admin="isAdmin"
:week-grid-cols="weekGridCols" :week-grid-cols="weekGridCols"
:weekly-summary="filteredWeeklySummary" :weekly-summary="filteredWeeklySummary"
:week-day-headers="weekDayHeaders" :week-day-headers="weekDayHeaders"
:format-minutes="formatMinutes" :format-minutes="formatMinutes"
class="max-h-[calc(100vh-300px)]" class="max-h-[calc(100vh-300px)]"
@open-comment="openWeekCommentDrawer"
/> />
</div> </div>
@@ -107,6 +111,18 @@
@delete="deleteAbsenceFromDrawer" @delete="deleteAbsenceFromDrawer"
@cancel="closeAbsenceDrawer" @cancel="closeAbsenceDrawer"
/> />
<HoursWeekCommentDrawer
v-if="weekCommentContext"
v-model="isWeekCommentDrawerOpen"
:employee-id="weekCommentContext.employeeId"
:employee-label="weekCommentContext.employeeLabel"
:week-start="weekCommentContext.weekStart"
:week-end="weekCommentContext.weekEnd"
:initial-content="weekCommentContext.content"
:comment-id="weekCommentContext.commentId"
@saved="reloadWeeklySummary"
/>
</div> </div>
</template> </template>
@@ -167,6 +183,7 @@ const {
getRowMetrics, getRowMetrics,
getRowAbsenceLabel, getRowAbsenceLabel,
getRowAbsenceStyle, getRowAbsenceStyle,
getRowContractNature,
getRowUpdatedAt, getRowUpdatedAt,
openAbsenceDrawer, openAbsenceDrawer,
submitAbsence, submitAbsence,
@@ -174,7 +191,12 @@ const {
closeAbsenceDrawer, closeAbsenceDrawer,
formatMinutes, formatMinutes,
isSelectedDateHoliday, isSelectedDateHoliday,
handleSave selectedHolidayLabel,
handleSave,
isWeekCommentDrawerOpen,
weekCommentContext,
openWeekCommentDrawer,
reloadWeeklySummary
} = useDriverHoursPage() } = useDriverHoursPage()
useHead({ useHead({

View File

@@ -17,7 +17,7 @@
<h1 class="text-[32px] font-bold">{{ employee.firstName }} {{ employee.lastName }}</h1> <h1 class="text-[32px] font-bold">{{ employee.firstName }} {{ employee.lastName }}</h1>
<button <button
class="inline-flex items-center justify-center rounded-md p-1 transition-colors duration-150 focus:outline-none focus-visible:ring-2 bg-primary-500 hover:bg-secondary-500 active:bg-primary-500 text-white cursor-pointer" class="inline-flex items-center justify-center rounded-md p-1 transition-colors duration-150 focus:outline-none focus-visible:ring-2 bg-primary-500 hover:bg-secondary-500 active:bg-primary-500 text-white cursor-pointer"
title="Export heures annuelles" title="Export heures"
@click="isYearlyHoursDrawerOpen = true" @click="isYearlyHoursDrawerOpen = true"
> >
<Icon name="mdi:printer" size="24" /> <Icon name="mdi:printer" size="24" />
@@ -26,7 +26,7 @@
<p>Date d'entrée : {{ employee.entryDate ? employee.entryDate.split('-').reverse().join('/') : '-' }}</p> <p>Date d'entrée : {{ employee.entryDate ? employee.entryDate.split('-').reverse().join('/') : '-' }}</p>
</div> </div>
<div class="text-right"> <div class="text-right">
<p class="font-bold text-[22px]">{{ contractNatureLabel(employee.currentContractNature) }} {{ employeeContractWorkLabel }}</p> <p class="font-bold text-[22px]">{{ contractNatureLabel(employee.currentContractNature) }} {{ employeeContractWorkLabel }}{{ forfaitRemainingDaysLabel }}</p>
<p class="text-[18px]">{{ employee.site?.name ?? '-' }}</p> <p class="text-[18px]">{{ employee.site?.name ?? '-' }}</p>
</div> </div>
</div> </div>
@@ -135,6 +135,8 @@
:requires-create-contract-end-date="requiresCreateContractEndDate" :requires-create-contract-end-date="requiresCreateContractEndDate"
:create-contract-end-date-field-class="createContractEndDateFieldClass" :create-contract-end-date-field-class="createContractEndDateFieldClass"
:is-create-contract-form-valid="isCreateContractFormValid" :is-create-contract-form-valid="isCreateContractFormValid"
:requires-create-work-days-hours="requiresCreateWorkDaysHours"
:selected-create-contract="selectedCreateContract"
:on-open-close-contract-drawer="openCloseContractDrawer" :on-open-close-contract-drawer="openCloseContractDrawer"
:on-open-create-contract-drawer="openCreateContractDrawer" :on-open-create-contract-drawer="openCreateContractDrawer"
:on-update-contract-drawer-open="setContractDrawerOpen" :on-update-contract-drawer-open="setContractDrawerOpen"
@@ -146,6 +148,7 @@
:on-submit-suspension="submitSuspension" :on-submit-suspension="submitSuspension"
:on-add-suspension-form="addSuspensionForm" :on-add-suspension-form="addSuspensionForm"
:current-contract-period-id="currentActiveContractPeriodId" :current-contract-period-id="currentActiveContractPeriodId"
:interim-agencies="interimAgencies"
/> />
<div v-else-if="showLeaveTab && activeTab === 'leave'" class="h-full"> <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"> <div v-if="isLeaveLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
@@ -157,15 +160,28 @@
:absences="employeeAbsences" :absences="employeeAbsences"
:summary="leaveSummary" :summary="leaveSummary"
:public-holidays="publicHolidays" :public-holidays="publicHolidays"
:selected-year="selectedLeaveYear"
:available-years="availableLeaveYears"
:current-year="currentLeaveYear"
@update-fractioned-days="submitFractionedDays" @update-fractioned-days="submitFractionedDays"
@update-paid-leave-days="submitPaidLeaveDays" @update-paid-leave-days="submitPaidLeaveDays"
@update-selected-year="setSelectedLeaveYear"
/> />
</div> </div>
<div v-else-if="showRttTab && activeTab === 'rtt'" class="h-full"> <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"> <div v-if="isRttLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Chargement... Chargement...
</div> </div>
<EmployeesRttTab v-else class="h-full" :summary="rttSummary" @submit-rtt-payment="submitRttPayment" /> <EmployeesRttTab
v-else
class="h-full"
:summary="rttSummary"
:selected-year="selectedRttYear"
:available-years="availableRttYears"
:current-year="currentRttYear"
@submit-rtt-payment="submitRttPayment"
@update-selected-year="setSelectedRttYear"
/>
</div> </div>
<div v-else-if="activeTab === 'mileage'" class="h-full"> <div v-else-if="activeTab === 'mileage'" class="h-full">
<div v-if="isMileageLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600"> <div v-if="isMileageLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
@@ -250,10 +266,19 @@ const {
leaveSummary, leaveSummary,
rttSummary, rttSummary,
publicHolidays, publicHolidays,
selectedLeaveYear,
currentLeaveYear,
availableLeaveYears,
setSelectedLeaveYear,
selectedRttYear,
currentRttYear,
availableRttYears,
setSelectedRttYear,
showLeaveTab, showLeaveTab,
showRttTab, showRttTab,
contractHistory, contractHistory,
employeeContractWorkLabel, employeeContractWorkLabel,
forfaitRemainingDaysLabel,
contractForm, contractForm,
createContractForm, createContractForm,
isContractDrawerOpen, isContractDrawerOpen,
@@ -274,6 +299,8 @@ const {
requiresCreateContractEndDate, requiresCreateContractEndDate,
createContractEndDateFieldClass, createContractEndDateFieldClass,
isCreateContractFormValid, isCreateContractFormValid,
requiresCreateWorkDaysHours,
selectedCreateContract,
contractNatureLabel, contractNatureLabel,
contractHistoryLabel, contractHistoryLabel,
formatDate, formatDate,
@@ -291,6 +318,7 @@ const {
submitSuspension, submitSuspension,
addSuspensionForm, addSuspensionForm,
currentActiveContractPeriodId, currentActiveContractPeriodId,
interimAgencies,
isLeaveLoading, isLeaveLoading,
isRttLoading, isRttLoading,
mileageAllowances, mileageAllowances,
@@ -317,9 +345,10 @@ const {
submitDeleteObservation submitDeleteObservation
} = useEmployeeDetailPage() } = useEmployeeDetailPage()
const handleYearlyHoursPrint = async (year: number) => { const handleYearlyHoursPrint = async (payload: { year: number; month: number | null }) => {
if (!employee.value) return if (!employee.value) return
await printPdf(`/yearly-hours/print?employeeId=${employee.value.id}&year=${year}`) const monthParam = null !== payload.month ? `&month=${payload.month}` : ''
await printPdf(`/yearly-hours/print?employeeId=${employee.value.id}&year=${payload.year}${monthParam}`)
isYearlyHoursDrawerOpen.value = false isYearlyHoursDrawerOpen.value = false
} }

View File

@@ -4,42 +4,45 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h1 class="text-4xl font-bold text-primary-500">Employés</h1> <h1 class="text-4xl font-bold text-primary-500">Employés</h1>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button <MalioButton
type="button" label="Export"
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" variant="secondary"
@click="handleLeaveRecapPrint" icon-name="mdi:download"
> icon-position="left"
Export récap. congés @click="openExportDrawer"
</button> />
<button <MalioButton
type="button" label="Ajouter un employé"
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" icon-name="mdi:plus"
@click="isSalaryRecapOpen = true" icon-position="left"
>
Export récap. salaire
</button>
<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" @click="openCreate"
> />
+ Ajouter un employé
</button>
</div> </div>
</div> </div>
<div class="flex gap-3 py-7"> <div class="flex items-center gap-3 py-7">
<div class="w-80"> <div class="w-80">
<EmployeeNameFilterInput v-model="employeeFilter"/> <MalioInputText
v-model="employeeFilter"
label="Recherche d'un employé"
icon-name="mdi:magnify"
/>
</div> </div>
<SiteFilterSelector v-if="sites.length > 0" v-model="selectedSiteIds" :sites="sites"/> <div v-if="sites.length > 0" class="relative z-50 w-80">
<select <MalioSelectCheckbox
v-model="selectedSiteIds"
:options="siteOptions"
groupClass="w-80"
label="Sites"
display-select-all
/>
</div>
<MalioSelect
v-model="contractStatusFilter" v-model="contractStatusFilter"
class="rounded-md border border-primary-500 bg-white px-3 py-2 text-md font-semibold text-primary-500 cursor-pointer" label="Statut contrat"
> :options="contractStatusOptions"
<option value="active">Avec contrat</option> group-class="w-40"
<option value="inactive">Sans contrat</option> />
<option value="all">Tous</option>
</select>
</div> </div>
</div> </div>
@@ -72,7 +75,7 @@
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"> 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"> <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 class="text-base font-semibold">{{ employee.lastName }} {{ employee.firstName }}</p>
<p><strong>Type:</strong> {{ contractNatureLabel(employee.currentContractNature) }}</p> <p><strong>Type:</strong> {{ employee.currentInterimAgencyName ? `${contractNatureLabel(employee.currentContractNature)} (${employee.currentInterimAgencyName})` : contractNatureLabel(employee.currentContractNature) }}</p>
<p><strong>Temps de travail:</strong> {{ employee.contract?.name ?? '-' }}</p> <p><strong>Temps de travail:</strong> {{ employee.contract?.name ?? '-' }}</p>
<p><strong>Site:</strong> {{ employee.site?.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> <p><strong>Date d'entrée :</strong> {{ employee.entryDate ? employee.entryDate.split('-').reverse().join('/') : '-' }}</p>
@@ -81,90 +84,53 @@
</NuxtLink> </NuxtLink>
</div> </div>
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle"> <MalioDrawer v-model="isDrawerOpen" :title="drawerTitle">
<form class="space-y-4" @submit.prevent="handleSubmit"> <form class="space-y-4" @submit.prevent="handleSubmit">
<div> <MalioInputText
<label class="text-md font-semibold text-neutral-700" for="first-name"> v-model="form.firstName"
Prénom <span class="text-red-600">*</span> label="Prénom *"
</label> group-class="mt-2"
<input :error="showFirstNameError ? 'Le prénom est obligatoire.' : ''"
id="first-name" />
v-model="form.firstName" <MalioInputText
type="text" v-model="form.lastName"
:class="firstNameFieldClass" label="Nom *"
/> group-class="mt-2"
<p v-if="showFirstNameError" class="mt-1 text-sm text-red-600"> :error="showLastNameError ? 'Le nom est obligatoire.' : ''"
Le prénom est obligatoire. />
</p> <MalioSelect
</div> :model-value="form.siteId === '' ? null : form.siteId"
<div> :options="formSiteOptions"
<label class="text-md font-semibold text-neutral-700" for="last-name"> label="Site *"
Nom <span class="text-red-600">*</span> min-width=""
</label> :error="showSiteError ? 'Le site est obligatoire.' : ''"
<input @update:model-value="(v) => { form.siteId = v === null ? '' : Number(v) }"
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"> <template v-if="!editingEmployee">
<div> <MalioSelect
<label class="text-md font-semibold text-neutral-700" for="contract-nature"> :model-value="form.contractNature"
Type de contrat <span class="text-red-600">*</span> :options="contractNatureFormOptions"
</label> label="Type de contrat *"
<select min-width=""
id="contract-nature" :error="showContractNatureError ? 'Le type de contrat est obligatoire.' : ''"
v-model="form.contractNature" @update:model-value="(v) => { if (v !== null) form.contractNature = v as 'CDI' | 'CDD' | 'INTERIM' }"
:class="contractNatureFieldClass" />
> <MalioSelect
<option value="CDI">CDI</option> v-if="form.contractNature === 'INTERIM'"
<option value="CDD">CDD</option> :model-value="form.interimAgencyId === '' ? null : form.interimAgencyId"
<option value="INTERIM">Intérim</option> :options="interimAgencyOptions"
</select> label="Agence d'intérim"
<p v-if="showContractNatureError" class="mt-1 text-sm text-red-600"> min-width=""
Le type de contrat est obligatoire. @update:model-value="(v) => { form.interimAgencyId = v === null ? '' : Number(v) }"
</p> />
</div> <MalioSelect
<div> :model-value="form.contractId === '' ? null : form.contractId"
<label class="text-md font-semibold text-neutral-700" for="contract"> :options="contractFormOptions"
Temps de travail <span class="text-red-600">*</span> label="Temps de travail *"
</label> min-width=""
<select :error="showContractError ? 'Le temps de travail est obligatoire.' : ''"
id="contract" @update:model-value="(v) => { form.contractId = v === null ? '' : Number(v) }"
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> <div>
<label class="text-md font-semibold text-neutral-700" for="contract-start-date"> <label class="text-md font-semibold text-neutral-700" for="contract-start-date">
Début contrat <span class="text-red-600">*</span> Début contrat <span class="text-red-600">*</span>
@@ -173,7 +139,7 @@
id="contract-start-date" id="contract-start-date"
v-model="form.contractStartDate" v-model="form.contractStartDate"
type="date" type="date"
:class="contractStartDateFieldClass" :class="[dateInputBaseClass, form.contractStartDate ? 'border-black' : 'border-m-muted', showContractStartDateError ? '!border-m-danger' : '']"
/> />
<p v-if="showContractStartDateError" class="mt-1 text-sm text-red-600"> <p v-if="showContractStartDateError" class="mt-1 text-sm text-red-600">
La date de début est obligatoire. La date de début est obligatoire.
@@ -188,59 +154,105 @@
id="contract-end-date" id="contract-end-date"
v-model="form.contractEndDate" v-model="form.contractEndDate"
type="date" type="date"
:class="contractEndDateFieldClass" :class="[dateInputBaseClass, form.contractEndDate ? 'border-black' : 'border-m-muted', showContractEndDateError ? '!border-m-danger' : '']"
/> />
<p v-if="showContractEndDateError" class="mt-1 text-sm text-red-600"> <p v-if="showContractEndDateError" class="mt-1 text-sm text-red-600">
La date de fin est obligatoire pour un CDD. La date de fin est obligatoire pour un CDD ou un Intérim.
</p> </p>
</div> </div>
<div class="rounded-md border border-neutral-200 bg-neutral-50 p-3"> <div class="flex h-10 items-center rounded-md border border-neutral-200 bg-neutral-50 px-3">
<label class="inline-flex items-center gap-2 text-md font-semibold text-neutral-700" for="is-driver"> <MalioCheckbox
<input v-model="form.isDriver"
id="is-driver" label="Chauffeur"
v-model="form.isDriver" group-class="flex items-center"
type="checkbox" />
class="h-4 w-4 rounded border-neutral-300 text-primary-500 focus:ring-primary-500"
/>
Chauffeur
</label>
</div> </div>
<WorkDaysHoursInput
v-if="requiresSchedule"
v-model="form.workDaysHours"
:contract-weekly-hours="selectedContract?.weeklyHours ?? null"
/>
</template> </template>
<div class="flex justify-end gap-3 pt-2"> <div class="flex justify-end gap-3 pt-2">
<button <MalioButton
type="button" label="Annuler"
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100" variant="tertiary"
@click="isDrawerOpen = false" @click="isDrawerOpen = false"
> />
Annuler <MalioButton
</button>
<button
type="submit" type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500" label="Enregistrer"
:class="submitButtonClass" :disabled="isSubmitting || !isFormValid"
> />
Enregistrer
</button>
</div> </div>
</form> </form>
</AppDrawer> </MalioDrawer>
<SalaryRecapDrawer <MalioDrawer v-model="isExportDrawerOpen" title="Export">
v-model="isSalaryRecapOpen" <div class="space-y-4">
@submit="handleSalaryRecapPrint" <MalioSelect
/> :model-value="exportChoice === '' ? null : exportChoice"
:options="exportTypeOptions"
label="Type d'export"
empty-option-label="Choisir un export"
group-class="mt-2"
min-width=""
@update:model-value="onExportChoiceChange"
/>
<div v-if="exportChoice === 'salary-recap'">
<label class="text-md font-semibold text-neutral-700" for="export-salary-month">
Mois <span class="text-red-600">*</span>
</label>
<input
id="export-salary-month"
v-model="exportSalaryMonth"
type="month"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
/>
</div>
<template v-else-if="exportChoice === 'yearly-hours'">
<MalioSelect
:model-value="exportYear"
:options="exportYearOptions"
label="Année *"
min-width=""
@update:model-value="(v) => { if (v !== null) exportYear = Number(v) }"
/>
<MalioSelect
:model-value="exportMonth === '' ? null : exportMonth"
:options="exportMonthOptions"
label="Mois *"
empty-option-label="Choisir un mois"
min-width=""
@update:model-value="(v) => { exportMonth = v === null ? '' : Number(v) }"
/>
</template>
<div class="flex justify-center pt-2">
<MalioButton
label="Valider"
button-class="w-[200px]"
:disabled="!isExportValid"
@click="handleExportValidate"
/>
</div>
</div>
</MalioDrawer>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type {Contract} from '~/services/dto/contract' import type {Contract} from '~/services/dto/contract'
import WorkDaysHoursInput from '~/components/employees/WorkDaysHoursInput.vue'
import { requiresWorkDaysHours } from '~/utils/contract'
import type {Employee} from '~/services/dto/employee' import type {Employee} from '~/services/dto/employee'
import type {Site} from '~/services/dto/site' import type {Site} from '~/services/dto/site'
import {listContracts} from '~/services/contracts' import {listContracts} from '~/services/contracts'
import {createEmployee, deleteEmployee, listEmployees, updateEmployee} from '~/services/employees' import {createEmployee, deleteEmployee, listEmployees, updateEmployee} from '~/services/employees'
import {listSites} from '~/services/sites' import {listSites} from '~/services/sites'
import SiteFilterSelector from '~/components/SiteFilterSelector.vue' import {listInterimAgencies, type InterimAgency} from '~/services/interim-agencies'
import SalaryRecapDrawer from '~/components/SalaryRecapDrawer.vue'
import {contractNatureLabel, isContractNature, requiresContractEndDate, showsContractEndDate} from '~/utils/contract' import {contractNatureLabel, isContractNature, requiresContractEndDate, showsContractEndDate} from '~/utils/contract'
import {usePdfPrinter} from '~/composables/usePdfPrinter' import {usePdfPrinter} from '~/composables/usePdfPrinter'
@@ -251,7 +263,50 @@ useHead({
const isDrawerOpen = ref(false) const isDrawerOpen = ref(false)
const isSubmitting = ref(false) const isSubmitting = ref(false)
const isLoading = ref(false) const isLoading = ref(false)
const isSalaryRecapOpen = ref(false) const isExportDrawerOpen = ref(false)
const exportChoice = ref<'leave-recap' | 'salary-recap' | 'yearly-hours' | ''>('')
const exportYear = ref<number>(new Date().getFullYear())
const exportMonth = ref<number | ''>(new Date().getMonth() + 1)
const exportSalaryMonth = ref<string>(`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`)
const exportTypeOptions = [
{ label: 'Récap. congés', value: 'leave-recap' },
{ label: 'Récap. salaire', value: 'salary-recap' },
{ label: 'Heures annuelles', value: 'yearly-hours' }
]
const exportYearOptions = computed(() => {
const current = new Date().getFullYear()
return Array.from({ length: 6 }, (_, i) => ({ label: String(current - i), value: current - i }))
})
const exportMonthOptions = [
{ label: 'Janvier', value: 1 },
{ label: 'Février', value: 2 },
{ label: 'Mars', value: 3 },
{ label: 'Avril', value: 4 },
{ label: 'Mai', value: 5 },
{ label: 'Juin', value: 6 },
{ label: 'Juillet', value: 7 },
{ label: 'Août', value: 8 },
{ label: 'Septembre', value: 9 },
{ label: 'Octobre', value: 10 },
{ label: 'Novembre', value: 11 },
{ label: 'Décembre', value: 12 }
]
const isExportValid = computed(() => {
if (!exportChoice.value) return false
if (exportChoice.value === 'salary-recap') {
return exportSalaryMonth.value.trim() !== ''
}
if (exportChoice.value === 'yearly-hours') {
return exportYear.value > 0 && exportMonth.value !== ''
}
return true
})
const onExportChoiceChange = (value: string | number | null) => {
exportChoice.value = (value === null ? '' : String(value)) as 'leave-recap' | 'salary-recap' | 'yearly-hours' | ''
}
const { printPdf } = usePdfPrinter() const { printPdf } = usePdfPrinter()
const sitesInitialized = ref(false) const sitesInitialized = ref(false)
const editingEmployee = ref<Employee | null>(null) const editingEmployee = ref<Employee | null>(null)
@@ -262,9 +317,16 @@ const drawerTitle = computed(() =>
const employees = ref<Employee[]>([]) const employees = ref<Employee[]>([])
const sites = ref<Site[]>([]) const sites = ref<Site[]>([])
const contracts = ref<Contract[]>([]) const contracts = ref<Contract[]>([])
const interimAgencies = ref<InterimAgency[]>([])
const employeeFilter = ref('') const employeeFilter = ref('')
const contractStatusFilter = ref<'active' | 'inactive' | 'all'>('active') const contractStatusFilter = ref<'active' | 'inactive' | 'all'>('active')
const contractStatusOptions = [
{ label: 'Avec contrat', value: 'active' },
{ label: 'Sans contrat', value: 'inactive' },
{ label: 'Tous', value: 'all' }
]
const selectedSiteIds = ref<number[]>([]) const selectedSiteIds = ref<number[]>([])
const siteOptions = computed(() => sites.value.map((site) => ({ label: site.name, value: site.id })))
const filteredEmployees = computed<Employee[]>(() => { const filteredEmployees = computed<Employee[]>(() => {
if (selectedSiteIds.value.length === 0) return [] if (selectedSiteIds.value.length === 0) return []
@@ -292,7 +354,9 @@ const form = reactive({
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM', contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
contractStartDate: '', contractStartDate: '',
contractEndDate: '', contractEndDate: '',
isDriver: false isDriver: false,
workDaysHours: null as Record<number, number> | null,
interimAgencyId: '' as number | ''
}) })
const validationTouched = reactive({ const validationTouched = reactive({
@@ -310,6 +374,21 @@ const isLastNameValid = computed(() => form.lastName.trim() !== '')
const isSiteValid = computed(() => form.siteId !== '') const isSiteValid = computed(() => form.siteId !== '')
const isContractValid = computed(() => form.contractId !== '') const isContractValid = computed(() => form.contractId !== '')
const isContractNatureValid = computed(() => isContractNature(form.contractNature)) const isContractNatureValid = computed(() => isContractNature(form.contractNature))
const selectedContract = computed<Contract | null>(() =>
contracts.value.find((c) => c.id === Number(form.contractId)) ?? null
)
const requiresSchedule = computed(() =>
!editingEmployee.value && requiresWorkDaysHours(selectedContract.value, form.contractNature)
)
const scheduleTotalMinutes = computed(() => {
const raw = form.workDaysHours ?? {}
return Object.values(raw).reduce((s, n) => s + (Number(n) || 0), 0)
})
const isScheduleValid = computed(() => {
if (!requiresSchedule.value) return true
const expected = (selectedContract.value?.weeklyHours ?? 0) * 60
return expected > 0 && scheduleTotalMinutes.value === expected
})
const isContractStartDateValid = computed(() => form.contractStartDate !== '') const isContractStartDateValid = computed(() => form.contractStartDate !== '')
const showsContractEndDateComputed = computed(() => showsContractEndDate(form.contractNature)) const showsContractEndDateComputed = computed(() => showsContractEndDate(form.contractNature))
const requiresContractEndDateComputed = computed(() => requiresContractEndDate(form.contractNature)) const requiresContractEndDateComputed = computed(() => requiresContractEndDate(form.contractNature))
@@ -327,7 +406,8 @@ const isFormValid = computed(
: (isContractValid.value && : (isContractValid.value &&
isContractNatureValid.value && isContractNatureValid.value &&
isContractStartDateValid.value && isContractStartDateValid.value &&
isContractEndDateValid.value)) isContractEndDateValid.value &&
isScheduleValid.value))
) )
const showFirstNameError = computed( const showFirstNameError = computed(
@@ -352,63 +432,23 @@ const showContractEndDateError = computed(
() => !editingEmployee.value && validationTouched.contractEndDate && !isContractEndDateValid.value () => !editingEmployee.value && validationTouched.contractEndDate && !isContractEndDateValid.value
) )
const baseInputClass = const dateInputBaseClass =
'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' 'mt-2 h-10 w-full rounded-md border px-3 text-md text-black outline-none focus:border-2 focus:border-m-primary'
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(() => { const formSiteOptions = computed(() =>
if (isSubmitting.value || !isFormValid.value) { sites.value.map((site) => ({ label: site.name, value: site.id }))
return 'opacity-50 cursor-not-allowed' )
} const interimAgencyOptions = computed(() =>
return '' interimAgencies.value.map((agency) => ({ label: agency.name, value: agency.id }))
}) )
const contractFormOptions = computed(() =>
contracts.value.map((contract) => ({ label: contract.name, value: contract.id }))
)
const contractNatureFormOptions = [
{ label: 'CDI', value: 'CDI' },
{ label: 'CDD', value: 'CDD' },
{ label: 'Intérim', value: 'INTERIM' }
]
const loadEmployees = async () => { const loadEmployees = async () => {
isLoading.value = true isLoading.value = true
@@ -427,8 +467,12 @@ const loadContracts = async () => {
contracts.value = await listContracts() contracts.value = await listContracts()
} }
const loadInterimAgencies = async () => {
interimAgencies.value = await listInterimAgencies()
}
onMounted(async () => { onMounted(async () => {
await Promise.all([loadEmployees(), loadSites(), loadContracts()]) await Promise.all([loadEmployees(), loadSites(), loadContracts(), loadInterimAgencies()])
if (form.contractStartDate === '') { if (form.contractStartDate === '') {
form.contractStartDate = new Date().toISOString().slice(0, 10) form.contractStartDate = new Date().toISOString().slice(0, 10)
} }
@@ -478,7 +522,9 @@ const handleSubmit = async () => {
contractNature: form.contractNature, contractNature: form.contractNature,
contractStartDate: form.contractStartDate, contractStartDate: form.contractStartDate,
contractEndDate: form.contractEndDate || null, contractEndDate: form.contractEndDate || null,
isDriverInput: form.isDriver isDriverInput: form.isDriver,
workDaysHoursInput: requiresSchedule.value ? form.workDaysHours : null,
interimAgencyId: form.contractNature === 'INTERIM' && form.interimAgencyId !== '' ? Number(form.interimAgencyId) : null
}) })
} }
@@ -490,6 +536,8 @@ const handleSubmit = async () => {
form.contractStartDate = new Date().toISOString().slice(0, 10) form.contractStartDate = new Date().toISOString().slice(0, 10)
form.contractEndDate = '' form.contractEndDate = ''
form.isDriver = false form.isDriver = false
form.workDaysHours = null
form.interimAgencyId = ''
editingEmployee.value = null editingEmployee.value = null
isDrawerOpen.value = false isDrawerOpen.value = false
await loadEmployees() await loadEmployees()
@@ -516,6 +564,18 @@ watch(showsContractEndDateComputed, (shows) => {
} }
}) })
watch(() => form.contractNature, (nature) => {
if (nature !== 'INTERIM') {
form.interimAgencyId = ''
}
})
watch(requiresSchedule, (required) => {
if (!required) {
form.workDaysHours = null
}
})
const openEdit = (employee: Employee) => { const openEdit = (employee: Employee) => {
editingEmployee.value = employee editingEmployee.value = employee
form.firstName = employee.firstName form.firstName = employee.firstName
@@ -534,18 +594,34 @@ const openCreate = () => {
form.contractStartDate = new Date().toISOString().slice(0, 10) form.contractStartDate = new Date().toISOString().slice(0, 10)
form.contractEndDate = '' form.contractEndDate = ''
form.isDriver = false form.isDriver = false
form.workDaysHours = null
form.interimAgencyId = ''
isDrawerOpen.value = true isDrawerOpen.value = true
} }
const handleLeaveRecapPrint = async () => { const openExportDrawer = () => {
await printPdf('/leave-recap/print') exportChoice.value = ''
const now = new Date()
exportYear.value = now.getFullYear()
exportMonth.value = now.getMonth() + 1
exportSalaryMonth.value = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
isExportDrawerOpen.value = true
} }
const handleSalaryRecapPrint = async (month: string) => { const handleExportValidate = async () => {
await printPdf(`/salary-recap/print?month=${month}`) if (!isExportValid.value) return
isSalaryRecapOpen.value = false const choice = exportChoice.value
isExportDrawerOpen.value = false
if (choice === 'leave-recap') {
await printPdf('/leave-recap/print')
} else if (choice === 'salary-recap') {
await printPdf(`/salary-recap/print?month=${exportSalaryMonth.value}`)
} else if (choice === 'yearly-hours') {
await printPdf(`/yearly-hours/print-all?year=${exportYear.value}&month=${exportMonth.value}`)
}
} }
const confirmDelete = async (employee: Employee) => { const confirmDelete = async (employee: Employee) => {
const ok = window.confirm(`Supprimer ${employee.firstName} ${employee.lastName} ?`) const ok = window.confirm(`Supprimer ${employee.firstName} ${employee.lastName} ?`)
if (!ok) return if (!ok) return

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="h-full overflow-hidden flex flex-col"> <div class="h-full overflow-hidden flex flex-col">
<div class="flex flex-wrap items-center justify-between gap-4"> <div class="flex flex-wrap items-center justify-between gap-4">
<h1 class="text-4xl font-bold text-primary-500">Heures</h1> <h1 class="text-2xl font-bold text-primary-500 lg:text-4xl">Heures</h1>
</div> </div>
<HoursToolbar <HoursToolbar
@@ -43,6 +43,7 @@
:is-site-manager="isSiteManager" :is-site-manager="isSiteManager"
:day-grid-cols="dayGridCols" :day-grid-cols="dayGridCols"
:is-holiday="isSelectedDateHoliday" :is-holiday="isSelectedDateHoliday"
:holiday-label="selectedHolidayLabel"
:contract-label="contractLabel" :contract-label="contractLabel"
:is-time-tracking="isTimeTracking" :is-time-tracking="isTimeTracking"
:is-presence-tracking="isPresenceTracking" :is-presence-tracking="isPresenceTracking"
@@ -69,6 +70,7 @@
:get-row-absence-style="getRowAbsenceStyle" :get-row-absence-style="getRowAbsenceStyle"
:has-row-formation="hasRowFormation" :has-row-formation="hasRowFormation"
:get-row-formation-label="getRowFormationLabel" :get-row-formation-label="getRowFormationLabel"
:get-row-contract-nature="getRowContractNature"
:get-row-updated-at="getRowUpdatedAt" :get-row-updated-at="getRowUpdatedAt"
:get-presence-day-value="getPresenceDayValue" :get-presence-day-value="getPresenceDayValue"
:on-absence-click="openAbsenceDrawer" :on-absence-click="openAbsenceDrawer"
@@ -79,11 +81,13 @@
<HoursWeekView <HoursWeekView
v-else-if="isAdmin && viewMode === 'week'" v-else-if="isAdmin && viewMode === 'week'"
:is-week-loading="isWeekLoading" :is-week-loading="isWeekLoading"
:is-admin="isAdmin"
:week-grid-cols="weekGridCols" :week-grid-cols="weekGridCols"
:weekly-summary="filteredWeeklySummary" :weekly-summary="filteredWeeklySummary"
:week-day-headers="weekDayHeaders" :week-day-headers="weekDayHeaders"
:format-minutes="formatMinutes" :format-minutes="formatMinutes"
class="max-h-[calc(100vh-300px)]" class="max-h-[calc(100vh-300px)]"
@open-comment="openWeekCommentDrawer"
/> />
</div> </div>
@@ -114,6 +118,18 @@
@delete="deleteAbsenceFromDrawer" @delete="deleteAbsenceFromDrawer"
@cancel="closeAbsenceDrawer" @cancel="closeAbsenceDrawer"
/> />
<HoursWeekCommentDrawer
v-if="weekCommentContext"
v-model="isWeekCommentDrawerOpen"
:employee-id="weekCommentContext.employeeId"
:employee-label="weekCommentContext.employeeLabel"
:week-start="weekCommentContext.weekStart"
:week-end="weekCommentContext.weekEnd"
:initial-content="weekCommentContext.content"
:comment-id="weekCommentContext.commentId"
@saved="reloadWeeklySummary"
/>
</div> </div>
</template> </template>
@@ -141,6 +157,7 @@ const {
isSubmitting, isSubmitting,
dayGridCols, dayGridCols,
isSelectedDateHoliday, isSelectedDateHoliday,
selectedHolidayLabel,
weekGridCols, weekGridCols,
saveButtonClass, saveButtonClass,
formattedSelectedDate, formattedSelectedDate,
@@ -181,6 +198,7 @@ const {
getRowAbsenceStyle, getRowAbsenceStyle,
hasRowFormation, hasRowFormation,
getRowFormationLabel, getRowFormationLabel,
getRowContractNature,
getRowUpdatedAt, getRowUpdatedAt,
getPresenceDayValue, getPresenceDayValue,
openAbsenceDrawer, openAbsenceDrawer,
@@ -188,7 +206,11 @@ const {
deleteAbsenceFromDrawer, deleteAbsenceFromDrawer,
closeAbsenceDrawer, closeAbsenceDrawer,
formatMinutes, formatMinutes,
handleSave handleSave,
isWeekCommentDrawerOpen,
weekCommentContext,
openWeekCommentDrawer,
reloadWeeklySummary
} = useHoursPage() } = useHoursPage()
useHead({ useHead({

View File

@@ -0,0 +1,163 @@
<template>
<div class="h-full flex flex-col overflow-hidden">
<div class="flex flex-wrap items-center justify-between gap-4 pb-8">
<h1 class="text-2xl font-bold text-primary-500 lg:text-4xl">Récap. congés</h1>
<span
v-if="cutoffLabel"
class="inline-flex items-center gap-2 rounded-full bg-tertiary-500 px-4 py-1 text-sm font-semibold text-primary-500"
>
<Icon name="mdi:calendar-check-outline" size="18"/>
{{ cutoffLabel }}
</span>
</div>
<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="rows.length === 0"
class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600"
>
Aucun employé à afficher.
</div>
<!-- Desktop table -->
<div v-else class="min-h-0 overflow-auto rounded-md bg-white hidden lg:block">
<div
:class="`grid ${gridColsClass} gap-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10`"
>
<span v-if="showSiteColumn" class="text-left">Site</span>
<span class="text-left">Nom</span>
<span class="text-left">Prénom</span>
<span class="text-left">Contrat</span>
<span class="text-right">CP N-1 restant</span>
<span class="text-right">Samedis</span>
<span class="text-right">CP N</span>
<span class="text-right">RTT</span>
</div>
<div class="border-x border-b border-primary-500 rounded-b-md">
<div
v-for="row in rows"
:key="row.employeeId"
:class="`grid ${gridColsClass} items-center gap-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0`"
>
<span v-if="showSiteColumn" class="truncate">
<span
v-if="row.siteName"
class="inline-block rounded-full px-3 py-1 text-sm"
:style="{ backgroundColor: row.siteColor || '#ffd7d7', color: '#1a1a1a' }"
>
{{ row.siteName }}
</span>
<span v-else class="text-neutral-500">-</span>
</span>
<span class="truncate">{{ row.lastName }}</span>
<span class="truncate">{{ row.firstName }}</span>
<span class="truncate">{{ row.contractName ?? '-' }}</span>
<span class="text-right tabular-nums">{{ formatNumber(row.cpN1Remaining) }}</span>
<span class="text-right tabular-nums">{{ row.acquiredSaturdays }}</span>
<span class="text-right tabular-nums">{{ row.cpN }}</span>
<span class="text-right tabular-nums">{{ row.rtt }}</span>
</div>
</div>
</div>
<!-- Mobile cards -->
<div v-if="!isLoading && rows.length > 0" class="min-h-0 overflow-auto space-y-3 lg:hidden">
<div
v-for="row in rows"
:key="'m-' + row.employeeId"
class="rounded-md border border-primary-500 bg-white p-4"
>
<div class="mb-3 flex items-center justify-between gap-2">
<p class="text-md font-bold text-primary-500 truncate">
{{ row.lastName }} {{ row.firstName }}
</p>
<span
v-if="showSiteColumn && row.siteName"
class="inline-block shrink-0 rounded-full px-3 py-1 text-sm"
:style="{ backgroundColor: row.siteColor || '#ffd7d7', color: '#1a1a1a' }"
>
{{ row.siteName }}
</span>
</div>
<p v-if="row.contractName" class="mb-3 text-sm text-neutral-600">{{ row.contractName }}</p>
<div class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<div class="flex justify-between">
<span class="text-neutral-500">CP N-1</span>
<span class="font-bold text-primary-500 tabular-nums">{{ formatNumber(row.cpN1Remaining) }}</span>
</div>
<div class="flex justify-between">
<span class="text-neutral-500">Samedis</span>
<span class="font-bold text-primary-500 tabular-nums">{{ row.acquiredSaturdays }}</span>
</div>
<div class="flex justify-between">
<span class="text-neutral-500">CP N</span>
<span class="font-bold text-primary-500 tabular-nums">{{ formatNumber(row.cpN) }}</span>
</div>
<div class="flex justify-between">
<span class="text-neutral-500">RTT</span>
<span class="font-bold text-primary-500 tabular-nums">{{ row.rtt }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { LeaveRecapRow } from '~/services/dto/leave-recap'
import { fetchLeaveRecap } from '~/services/leave-recap'
import { formatYmdToFr, getIsoWeekNumber, parseYmd } from '~/utils/date'
definePageMeta({ middleware: ['leave-recap-access'] })
useHead({ title: 'Récap. congés' })
const auth = useAuthStore()
const rows = ref<LeaveRecapRow[]>([])
const isLoading = ref(false)
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
const isSelfOnly = computed(() => {
const roles = auth.user?.roles ?? []
return roles.includes('ROLE_SELF') && !roles.includes('ROLE_ADMIN')
})
const showSiteColumn = computed(() => !isSelfOnly.value)
const gridColsClass = computed(() =>
showSiteColumn.value
? 'grid-cols-[1.2fr_1fr_1fr_1.2fr_140px_100px_100px_120px]'
: 'grid-cols-[1fr_1fr_1.2fr_140px_100px_100px_120px]'
)
const cutoffLabel = computed(() => {
const ymd = rows.value[0]?.cutoffDate
if (!ymd) return ''
const parsed = parseYmd(ymd)
if (!parsed) return ''
const week = getIsoWeekNumber(parsed)
return `Arrêté au ${formatYmdToFr(ymd)} (fin S${week})`
})
const formatNumber = (value: number) => {
if (!Number.isFinite(value)) return '-'
return value.toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 2 })
}
const load = async () => {
isLoading.value = true
try {
rows.value = await fetchLeaveRecap()
} finally {
isLoading.value = false
}
}
onMounted(load)
// Silence unused linter warning for isAdmin (kept for future site grouping)
void isAdmin
</script>

View File

@@ -9,31 +9,18 @@
class="mt-8 space-y-6 rounded-lg border border-neutral-200 bg-white p-6 shadow-sm" class="mt-8 space-y-6 rounded-lg border border-neutral-200 bg-white p-6 shadow-sm"
@submit.prevent="handleSubmit" @submit.prevent="handleSubmit"
> >
<div> <MalioInputText
<label class="text-sm font-semibold text-neutral-700" for="username"> v-model="username"
Nom d'utilisateur label="Nom d'utilisateur"
</label> autocomplete="username"
<input group-class="mt-2"
id="username" />
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-secondary-500/20"
/>
</div>
<div> <MalioInputPassword
<label class="text-sm font-semibold text-neutral-700" for="password"> v-model="password"
Mot de passe label="Mot de passe"
</label> autocomplete="current-password"
<input />
id="password"
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-secondary-500/20"
/>
</div>
<button <button
type="submit" type="submit"

View File

@@ -2,13 +2,12 @@
<div class="h-full flex flex-col overflow-hidden"> <div class="h-full flex flex-col overflow-hidden">
<div class="flex items-center justify-between pb-6"> <div class="flex items-center justify-between pb-6">
<h1 class="text-4xl font-bold text-primary-500">Sites</h1> <h1 class="text-4xl font-bold text-primary-500">Sites</h1>
<button <MalioButton
type="button" label="Ajouter un site"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500" icon-name="mdi:plus"
icon-position="left"
@click="openCreate" @click="openCreate"
> />
+ Ajouter un site
</button>
</div> </div>
<div <div
@@ -52,22 +51,14 @@
</div> </div>
</div> </div>
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle"> <MalioDrawer v-model="isDrawerOpen" :title="drawerTitle">
<form class="space-y-4" @submit.prevent="handleSubmit"> <form class="space-y-4" @submit.prevent="handleSubmit">
<div> <MalioInputText
<label class="text-md font-semibold text-neutral-700" for="name"> v-model="form.name"
Nom <span class="text-red-600">*</span> label="Nom *"
</label> group-class="mt-2"
<input :error="showNameError ? 'Le nom du site est obligatoire.' : ''"
id="name" />
v-model="form.name"
type="text"
:class="nameFieldClass"
/>
<p v-if="showNameError" class="mt-1 text-sm text-red-600">
Le nom du site est obligatoire.
</p>
</div>
<div> <div>
<label class="text-md font-semibold text-neutral-700" for="color"> <label class="text-md font-semibold text-neutral-700" for="color">
Couleur <span class="text-red-600">*</span> Couleur <span class="text-red-600">*</span>
@@ -83,32 +74,29 @@
</div> </div>
</div> </div>
<div v-if="editingSite" class="grid grid-cols-2 gap-3 pt-2"> <div v-if="editingSite" class="grid grid-cols-2 gap-3 pt-2">
<button <MalioButton
type="button" label="Supprimer"
class="flex items-center justify-center rounded-md bg-red-500 px-4 py-2 text-md font-semibold text-white hover:bg-red-600" variant="danger"
button-class="w-full"
@click="confirmDelete(editingSite)" @click="confirmDelete(editingSite)"
> />
Supprimer <MalioButton
</button>
<button
type="submit" type="submit"
class="flex items-center justify-center rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500" label="Modifier"
:class="submitButtonClass" button-class="w-full"
> :disabled="isSubmitting || !isFormValid"
Modifier />
</button>
</div> </div>
<div v-else class="flex justify-center pt-2"> <div v-else class="flex justify-center pt-2">
<button <MalioButton
type="submit" type="submit"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500" label="Valider"
:class="submitButtonClass" button-class="w-[200px]"
> :disabled="isSubmitting || !isFormValid"
+ Ajouter />
</button>
</div> </div>
</form> </form>
</AppDrawer> </MalioDrawer>
</div> </div>
</template> </template>
@@ -146,22 +134,6 @@ const isFormValid = computed(() => isNameValid.value)
const showNameError = computed(() => validationTouched.name && !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-secondary-500/20'
const nameFieldClass = computed(() => {
if (showNameError.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 loadSites = async () => { const loadSites = async () => {
isLoading.value = true isLoading.value = true
try { try {

View File

@@ -1,14 +1,13 @@
<template> <template>
<div class="h-full flex flex-col overflow-hidden"> <div class="h-full flex flex-col overflow-hidden">
<div class="flex items-center justify-between pb-6"> <div class="flex items-center justify-between pb-6">
<h1 class="text-4xl font-bold text-primary-500">Utilisateurs</h1> <h1 class="text-2xl font-bold text-primary-500 lg:text-4xl">Utilisateurs</h1>
<button <MalioButton
type="button" label="Ajouter"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500" icon-name="mdi:plus"
icon-position="left"
@click="openCreate" @click="openCreate"
> />
+ Ajouter un utilisateur
</button>
</div> </div>
<div <div
@@ -18,7 +17,8 @@
Aucun utilisateur pour le moment. Aucun utilisateur pour le moment.
</div> </div>
<div v-else class="min-h-0 overflow-auto rounded-md bg-white"> <!-- Desktop table -->
<div v-else class="min-h-0 overflow-auto rounded-md bg-white hidden lg:block">
<div class="grid grid-cols-5 gap-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10"> <div class="grid grid-cols-5 gap-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10">
<span class="text-left">Utilisateur</span> <span class="text-left">Utilisateur</span>
<span class="text-left">Employé</span> <span class="text-left">Employé</span>
@@ -56,43 +56,61 @@
</div> </div>
</div> </div>
<AppDrawer <!-- Mobile cards -->
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500 lg:hidden">
Chargement...
</div>
<div v-else-if="users.length > 0" class="min-h-0 overflow-auto space-y-3 lg:hidden">
<div
v-for="user in users"
:key="'m-' + user.id"
class="rounded-md border border-primary-500 bg-white p-4 cursor-pointer active:bg-tertiary-500"
@click="openEdit(user)"
>
<div class="flex items-center justify-between gap-2 mb-2">
<p class="text-md font-bold text-primary-500 truncate">{{ user.username }}</p>
<span
v-if="user.isLocked"
class="shrink-0 inline-block rounded-full bg-red-100 px-3 py-1 text-xs font-semibold text-red-700"
>Verrouillé</span>
<span
v-else
class="shrink-0 inline-block rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-700"
>Actif</span>
</div>
<div class="space-y-1 text-sm">
<p v-if="user.employee" class="text-neutral-600">
{{ user.employee.firstName }} {{ user.employee.lastName }}
</p>
<p class="text-neutral-500">
Accès : <span class="font-semibold text-primary-500">{{ getAccessLabel(user) }}</span>
</p>
<p v-if="getSiteLabels(user) !== '-'" class="text-neutral-500 truncate">
Sites : <span class="font-semibold text-primary-500">{{ getSiteLabels(user) }}</span>
</p>
</div>
</div>
</div>
<MalioDrawer
v-model="isDrawerOpen" v-model="isDrawerOpen"
:title="editingUser ? 'Modifier un utilisateur' : 'Ajouter un utilisateur'" :title="editingUser ? 'Modifier un utilisateur' : 'Ajouter un utilisateur'"
> >
<form class="space-y-4" @submit.prevent="handleSubmit"> <form class="space-y-4" @submit.prevent="handleSubmit">
<div> <MalioInputText
<label class="text-md font-semibold text-neutral-700" for="username"> v-model="form.username"
Nom d'utilisateur <span class="text-red-600">*</span> :label="editingUser ? `Nom d'utilisateur` : `Nom d'utilisateur *`"
</label> group-class="mt-2"
<input :error="showUsernameError ? `Le nom d'utilisateur est obligatoire.` : ''"
id="username" />
v-model="form.username"
type="text"
:class="usernameFieldClass"
/>
<p v-if="showUsernameError" class="mt-1 text-sm text-red-600">
Le nom d'utilisateur est obligatoire.
</p>
</div>
<div> <div>
<label class="text-md font-semibold text-neutral-700" for="password"> <MalioInputPassword
Mot de passe
<span v-if="!editingUser" class="text-red-600">*</span>
</label>
<input
id="password"
v-model="form.password" v-model="form.password"
type="password" :label="editingUser ? 'Mot de passe' : 'Mot de passe *'"
:class="passwordFieldClass" :hint="editingUser ? 'Laisse vide pour ne pas changer le mot de passe.' : ''"
:error="!editingUser && showPasswordError ? 'Le mot de passe est obligatoire.' : ''"
/> />
<p v-if="editingUser" class="mt-1 text-sm text-neutral-500">
Laisse vide pour ne pas changer le mot de passe.
</p>
<p v-else-if="showPasswordError" class="mt-1 text-sm text-red-600">
Le mot de passe est obligatoire.
</p>
</div> </div>
<div> <div>
@@ -135,40 +153,32 @@
</div> </div>
<div v-if="form.accessMode === 'self'"> <div v-if="form.accessMode === 'self'">
<label class="text-md font-semibold text-neutral-700" for="employee"> <MalioSelect
Employé lié :model-value="form.employeeId === '' ? null : form.employeeId"
</label> :options="employeeOptions"
<select label="Employé lié"
id="employee" empty-option-label="Aucun"
v-model="form.employeeId" min-width=""
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900" :error="showSelfEmployeeError ? 'Sélectionne un employé.' : ''"
> @update:model-value="onEmployeeChange"
<option value="">Aucun</option> />
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
{{ employee.firstName }} {{ employee.lastName }}
</option>
</select>
<p v-if="showSelfEmployeeError" class="mt-1 text-sm text-red-600">
Sélectionne un employé.
</p>
</div> </div>
<div v-if="form.accessMode === 'sites'"> <div v-if="form.accessMode === 'sites'">
<p class="text-md font-semibold text-neutral-700">Sites autorisés</p> <p class="text-md font-semibold text-neutral-700">Sites autorisés</p>
<div class="mt-2 grid gap-2 sm:grid-cols-2"> <div class="mt-2 grid gap-2 sm:grid-cols-2">
<label <div
v-for="site in sites" v-for="site in sites"
:key="site.id" :key="site.id"
class="flex items-center gap-2 rounded-md border border-neutral-200 px-3 py-2 text-sm text-neutral-700 cursor-pointer" class="flex h-10 items-center rounded-md border border-neutral-200 px-3"
> >
<input <MalioCheckbox
type="checkbox" :model-value="form.siteIds.includes(site.id)"
class="cursor-pointer" :label="site.name"
:checked="form.siteIds.includes(site.id)" group-class="flex items-center"
@change="toggleSite(site.id)" @update:model-value="toggleSite(site.id)"
/> />
<span>{{ site.name }}</span> </div>
</label>
</div> </div>
<p v-if="showSitesError" class="mt-1 text-sm text-red-600"> <p v-if="showSitesError" class="mt-1 text-sm text-red-600">
Sélectionne au moins un site. Sélectionne au moins un site.
@@ -176,30 +186,31 @@
</div> </div>
<div> <div>
<label class="flex items-center gap-2 cursor-pointer"> <MalioCheckbox
<input v-model="form.isLocked"
v-model="form.isLocked" label="Verrouiller le compte"
type="checkbox" hint="Un compte verrouillé ne peut plus se connecter."
class="cursor-pointer" />
/> </div>
<span class="text-md font-semibold text-neutral-700">Verrouiller le compte</span>
</label> <div>
<p class="mt-1 text-sm text-neutral-500"> <MalioCheckbox
Un compte verrouillé ne peut plus se connecter. v-model="form.hasLeaveRecapAccess"
</p> label="Accès à l'écran Récap. congés"
hint="Affiche l'onglet dans la sidebar et donne accès au tableau récap."
/>
</div> </div>
<div class="flex justify-center pt-2"> <div class="flex justify-center pt-2">
<button <MalioButton
type="submit" type="submit"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500" :label="editingUser ? 'Modifier' : 'Valider'"
:class="submitButtonClass" button-class="w-[200px]"
> :disabled="isSubmitting || !isFormValid"
{{ editingUser ? 'Modifier' : '+ Ajouter' }} />
</button>
</div> </div>
</form> </form>
</AppDrawer> </MalioDrawer>
</div> </div>
</template> </template>
@@ -233,7 +244,8 @@ const form = reactive({
accessMode: 'admin' as 'admin' | 'self' | 'sites', accessMode: 'admin' as 'admin' | 'self' | 'sites',
employeeId: '' as number | '', employeeId: '' as number | '',
siteIds: [] as number[], siteIds: [] as number[],
isLocked: false isLocked: false,
hasLeaveRecapAccess: false
}) })
const validationTouched = reactive({ const validationTouched = reactive({
@@ -296,27 +308,13 @@ const getSiteLabels = (user: User) => {
return names.length > 0 ? names.join(', ') : 'Sites sélectionnés' return names.length > 0 ? names.join(', ') : 'Sites sélectionnés'
} }
const baseInputClass = const employeeOptions = computed(() =>
'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' employees.value.map((e) => ({ label: `${e.firstName} ${e.lastName}`, value: e.id }))
const usernameFieldClass = computed(() => { )
if (showUsernameError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const passwordFieldClass = computed(() => {
if (showPasswordError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const submitButtonClass = computed(() => { const onEmployeeChange = (value: string | number | null) => {
if (isSubmitting.value || !isFormValid.value) { form.employeeId = value === null ? '' : Number(value)
return 'opacity-50 cursor-not-allowed' }
}
return ''
})
const loadData = async () => { const loadData = async () => {
isLoading.value = true isLoading.value = true
@@ -345,6 +343,7 @@ const resetForm = () => {
form.accessMode = 'admin' form.accessMode = 'admin'
form.siteIds = [] form.siteIds = []
form.isLocked = false form.isLocked = false
form.hasLeaveRecapAccess = false
editingUser.value = null editingUser.value = null
validationTouched.username = false validationTouched.username = false
validationTouched.password = false validationTouched.password = false
@@ -373,6 +372,7 @@ const openEdit = (user: User) => {
form.employeeId = user.employee?.id ?? '' form.employeeId = user.employee?.id ?? ''
form.isLocked = user.isLocked form.isLocked = user.isLocked
form.hasLeaveRecapAccess = user.hasLeaveRecapAccess ?? false
const siteRoles = userAccessById.value.get(user.id) ?? [] const siteRoles = userAccessById.value.get(user.id) ?? []
form.siteIds = siteRoles.map((role) => role.site?.id).filter((id): id is number => typeof id === 'number') form.siteIds = siteRoles.map((role) => role.site?.id).filter((id): id is number => typeof id === 'number')
@@ -427,7 +427,8 @@ const handleSubmit = async () => {
plainPassword: form.password.trim() ? form.password : undefined, plainPassword: form.password.trim() ? form.password : undefined,
roles, roles,
employeeId, employeeId,
isLocked: form.isLocked isLocked: form.isLocked,
hasLeaveRecapAccess: form.hasLeaveRecapAccess
}) })
const existingSiteRoles = userAccessById.value.get(editingUser.value.id) ?? [] const existingSiteRoles = userAccessById.value.get(editingUser.value.id) ?? []
@@ -452,7 +453,8 @@ const handleSubmit = async () => {
plainPassword: form.password, plainPassword: form.password,
roles, roles,
employeeId, employeeId,
isLocked: form.isLocked isLocked: form.isLocked,
hasLeaveRecapAccess: form.hasLeaveRecapAccess
}) })
if (form.accessMode === 'sites' && form.siteIds.length > 0) { if (form.accessMode === 'sites' && form.siteIds.length > 0) {

View File

@@ -15,5 +15,7 @@ export type EmployeeLeaveSummary = {
previousYearRemainingDays: number previousYearRemainingDays: number
previousYearPaidDays: number previousYearPaidDays: number
presenceDaysByMonth: Record<string, number> presenceDaysByMonth: Record<string, number>
presenceDaysToToday: number
dataStartDate: string | null
} }

View File

@@ -9,6 +9,7 @@ export type EmployeeRttWeekSummary = {
base50Minutes: number base50Minutes: number
bonus50Minutes: number bonus50Minutes: number
totalMinutes: number totalMinutes: number
cumulativeBalanceMinutes: number
} }
export type RttMonthPayment = { export type RttMonthPayment = {

View File

@@ -19,6 +19,9 @@ export type ContractHistoryItem = {
periodId?: number | null periodId?: number | null
suspensions?: ContractSuspension[] suspensions?: ContractSuspension[]
isDriver?: boolean isDriver?: boolean
workDaysHours?: Record<number, number> | null
interimAgencyId?: number | null
interimAgencyName?: string | null
} }
export type Employee = { export type Employee = {
@@ -36,4 +39,6 @@ export type Employee = {
displayOrder?: number displayOrder?: number
entryDate?: string | null entryDate?: string | null
currentSuspensions?: ContractSuspension[] currentSuspensions?: ContractSuspension[]
currentInterimAgencyId?: number | null
currentInterimAgencyName?: string | null
} }

View File

@@ -0,0 +1,14 @@
export type LeaveRecapRow = {
employeeId: number
lastName: string
firstName: string
siteId: number | null
siteName: string | null
siteColor: string | null
contractName: string | null
cpN1Remaining: number
cpN: string
acquiredSaturdays: string
rtt: string
cutoffDate: string
}

View File

@@ -3,4 +3,5 @@ export type UserData = {
username: string username: string
roles: string[] roles: string[]
isDriver: boolean isDriver: boolean
hasLeaveRecapAccess: boolean
} }

View File

@@ -5,5 +5,6 @@ export type User = {
username: string username: string
roles: string[] roles: string[]
isLocked: boolean isLocked: boolean
hasLeaveRecapAccess: boolean
employee?: Employee | null employee?: Employee | null
} }

View File

@@ -59,6 +59,8 @@ export type WeeklyWorkHourDailySummary = {
hasLunch?: boolean hasLunch?: boolean
hasDinner?: boolean hasDinner?: boolean
hasOvernight?: boolean hasOvernight?: boolean
virtualHolidayMinutes?: number
holidayLabel?: string | null
} }
export type WeeklyWorkHourRowSummary = { export type WeeklyWorkHourRowSummary = {
@@ -86,6 +88,9 @@ export type WeeklyWorkHourRowSummary = {
weeklyDinnerCount?: number weeklyDinnerCount?: number
weeklyOvernightCount?: number weeklyOvernightCount?: number
hasContractForWeek?: boolean hasContractForWeek?: boolean
contractNature?: 'CDI' | 'CDD' | 'INTERIM' | null
comment?: string | null
commentId?: number | null
} }
export type WeeklyWorkHourSummary = { export type WeeklyWorkHourSummary = {
@@ -108,6 +113,8 @@ export type WorkHourDayContextRow = {
isDriverContract?: boolean isDriverContract?: boolean
hasFormation?: boolean hasFormation?: boolean
formationLabel?: string | null formationLabel?: string | null
virtualHolidayMinutes?: number
contractNature?: 'CDI' | 'CDD' | 'INTERIM' | null
} }
export type WorkHourDayContext = { export type WorkHourDayContext = {

View File

@@ -0,0 +1,24 @@
export type EmployeeWeekComment = {
id: number
weekStartDate: string
content: string
}
export const createWeekComment = async (payload: { employeeId: number; weekStartDate: string; content: string }) => {
const api = useApi()
return api.post<EmployeeWeekComment>('/employee_week_comments', {
employee: `/api/employees/${payload.employeeId}`,
weekStartDate: payload.weekStartDate,
content: payload.content
}, { toastSuccessKey: 'success.weekComment.save', toastErrorKey: 'errors.weekComment.save' })
}
export const updateWeekComment = async (id: number, content: string) => {
const api = useApi()
return api.patch<EmployeeWeekComment>(`/employee_week_comments/${id}`, { content }, { toastSuccessKey: 'success.weekComment.save', toastErrorKey: 'errors.weekComment.save' })
}
export const deleteWeekComment = async (id: number) => {
const api = useApi()
await api.delete(`/employee_week_comments/${id}`, {}, { toastSuccessKey: 'success.weekComment.delete', toastErrorKey: 'errors.weekComment.delete' })
}

View File

@@ -35,6 +35,8 @@ export const createEmployee = async (payload: {
contractStartDate?: string contractStartDate?: string
contractEndDate?: string | null contractEndDate?: string | null
isDriverInput?: boolean isDriverInput?: boolean
workDaysHoursInput?: Record<number, number> | null
interimAgencyId?: number | null
}) => { }) => {
const api = useApi() const api = useApi()
return api.post<Employee>('/employees', { return api.post<Employee>('/employees', {
@@ -45,7 +47,9 @@ export const createEmployee = async (payload: {
contractNature: payload.contractNature, contractNature: payload.contractNature,
contractStartDate: payload.contractStartDate, contractStartDate: payload.contractStartDate,
contractEndDate: payload.contractEndDate ?? null, contractEndDate: payload.contractEndDate ?? null,
isDriverInput: payload.isDriverInput ?? false isDriverInput: payload.isDriverInput ?? false,
workDaysHoursInput: payload.workDaysHoursInput ?? null,
interimAgencyId: payload.interimAgencyId ?? null
}, { }, {
toastSuccessKey: 'success.employee.create', toastSuccessKey: 'success.employee.create',
toastErrorKey: 'errors.employee.create' toastErrorKey: 'errors.employee.create'
@@ -66,6 +70,8 @@ export const updateEmployee = async (
contractComment?: string | null contractComment?: string | null
displayOrder?: number displayOrder?: number
isDriverInput?: boolean isDriverInput?: boolean
workDaysHoursInput?: Record<number, number> | null
interimAgencyId?: number | null
} }
) => { ) => {
const api = useApi() const api = useApi()
@@ -97,6 +103,12 @@ export const updateEmployee = async (
if (payload.isDriverInput !== undefined) { if (payload.isDriverInput !== undefined) {
body.isDriverInput = payload.isDriverInput body.isDriverInput = payload.isDriverInput
} }
if (payload.workDaysHoursInput !== undefined) {
body.workDaysHoursInput = payload.workDaysHoursInput
}
if (payload.interimAgencyId !== undefined) {
body.interimAgencyId = payload.interimAgencyId
}
return api.patch<Employee>(`/employees/${id}`, body, { return api.patch<Employee>(`/employees/${id}`, body, {
toastSuccessKey: 'success.employee.update', toastSuccessKey: 'success.employee.update',

View File

@@ -0,0 +1,16 @@
import { extractItems } from '~/utils/api'
export type InterimAgency = {
id: number
name: string
}
export const listInterimAgencies = async (): Promise<InterimAgency[]> => {
const api = useApi()
const data = await api.get<InterimAgency[] | { 'hydra:member'?: InterimAgency[] }>(
'/interim_agencies',
{},
{ toast: false }
)
return extractItems<InterimAgency>(data)
}

View File

@@ -0,0 +1,12 @@
import type { LeaveRecapRow } from './dto/leave-recap'
import { extractItems } from '~/utils/api'
export const fetchLeaveRecap = async (): Promise<LeaveRecapRow[]> => {
const api = useApi()
const data = await api.get<LeaveRecapRow[] | { 'hydra:member'?: LeaveRecapRow[] }>(
'/leave-recap',
{},
{ toastErrorKey: 'errors.leaveRecap.load' }
)
return extractItems<LeaveRecapRow>(data)
}

View File

@@ -17,6 +17,7 @@ export const createUser = async (payload: {
roles: string[] roles: string[]
employeeId?: number | null employeeId?: number | null
isLocked?: boolean isLocked?: boolean
hasLeaveRecapAccess?: boolean
}) => { }) => {
const api = useApi() const api = useApi()
return api.post<User>( return api.post<User>(
@@ -26,7 +27,8 @@ export const createUser = async (payload: {
plainPassword: payload.plainPassword, plainPassword: payload.plainPassword,
roles: payload.roles, roles: payload.roles,
employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null, employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null,
isLocked: payload.isLocked ?? false isLocked: payload.isLocked ?? false,
hasLeaveRecapAccess: payload.hasLeaveRecapAccess ?? false
}, },
{ {
toastSuccessKey: 'success.user.create', toastSuccessKey: 'success.user.create',
@@ -41,13 +43,15 @@ export const updateUser = async (id: number, payload: {
roles: string[] roles: string[]
employeeId?: number | null employeeId?: number | null
isLocked?: boolean isLocked?: boolean
hasLeaveRecapAccess?: boolean
}) => { }) => {
const api = useApi() const api = useApi()
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
username: payload.username, username: payload.username,
roles: payload.roles, roles: payload.roles,
employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null, employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null,
isLocked: payload.isLocked ?? false isLocked: payload.isLocked ?? false,
hasLeaveRecapAccess: payload.hasLeaveRecapAccess ?? false
} }
if (payload.plainPassword) { if (payload.plainPassword) {

View File

@@ -13,9 +13,49 @@ export const showsContractEndDate = (nature: ContractNature) => {
} }
export const requiresContractEndDate = (nature: ContractNature) => { export const requiresContractEndDate = (nature: ContractNature) => {
return nature === 'CDD' return nature === 'CDD' || nature === 'INTERIM'
} }
export const isContractNature = (value: string): value is ContractNature => { export const isContractNature = (value: string): value is ContractNature => {
return (CONTRACT_NATURES as readonly string[]).includes(value) return (CONTRACT_NATURES as readonly string[]).includes(value)
} }
/**
* Whether a contract + nature pair requires the per-day schedule (workDaysHours).
* Mirrors EmployeeContractPeriodValidator::assertWorkDaysHours on the backend.
*/
export const requiresWorkDaysHours = (
contract: { trackingMode?: string | null; weeklyHours?: number | null } | null | undefined,
nature: ContractNature
): boolean => {
if (!contract) return false
if (nature === 'INTERIM') return false
if (contract.trackingMode === 'PRESENCE') return false
if (contract.weeklyHours === 35 || contract.weeklyHours === 39) return false
return true
}
const DAY_SHORT_LABELS: Record<number, string> = { 1: 'Lun', 2: 'Mar', 3: 'Mer', 4: 'Jeu', 5: 'Ven' }
/**
* Compact human-readable summary of a per-day schedule, e.g. "Lun 2h, Jeu 2h".
* Returns null when the schedule is empty/unset.
*/
export const formatWorkDaysHoursSummary = (
workDaysHours: Record<number, number> | null | undefined
): string | null => {
if (!workDaysHours) return null
const entries = Object.entries(workDaysHours)
.map(([iso, minutes]) => [Number(iso), Number(minutes)] as const)
.filter(([iso, minutes]) => iso >= 1 && iso <= 5 && minutes > 0)
.sort(([a], [b]) => a - b)
if (entries.length === 0) return null
return entries
.map(([iso, minutes]) => {
const h = Math.floor(minutes / 60)
const m = minutes % 60
const suffix = m === 0 ? `${h}h` : `${h}h${String(m).padStart(2, '0')}`
return `${DAY_SHORT_LABELS[iso]} ${suffix}`
})
.join(', ')
}

View File

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

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260416100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add work_days_hours JSON on employee_contract_periods (schedule for non-standard contracts) + seed Ewa and Nadia';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE employee_contract_periods ADD work_days_hours JSON DEFAULT NULL');
// Seed the two known 4h employees currently in production.
// Ewa DALEMBA: Lundi 2h + Jeudi 2h
// Nadia GARRAUD: Mardi 2h + Vendredi 2h
// Filter on last_name + first_name (not ids) to stay safe across environments,
// and only on periods without an already-set schedule to remain idempotent.
$this->addSql(
"UPDATE employee_contract_periods ecp SET work_days_hours = '{\"1\":120,\"4\":120}' "
.'FROM employees e '
.'WHERE ecp.employee_id = e.id '
."AND e.last_name = 'DALEMBA' AND e.first_name = 'Ewa' "
.'AND ecp.end_date IS NULL AND ecp.work_days_hours IS NULL'
);
$this->addSql(
"UPDATE employee_contract_periods ecp SET work_days_hours = '{\"2\":120,\"5\":120}' "
.'FROM employees e '
.'WHERE ecp.employee_id = e.id '
."AND e.last_name = 'GARRAUD' AND e.first_name = 'Nadia' "
.'AND ecp.end_date IS NULL AND ecp.work_days_hours IS NULL'
);
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE employee_contract_periods DROP work_days_hours');
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260417100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create employee_week_comments table for per-week admin annotations on the hours weekly view';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE employee_week_comments (id SERIAL NOT NULL, employee_id INT NOT NULL, week_start_date DATE NOT NULL, content TEXT 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_week_comment ON employee_week_comments (employee_id, week_start_date)');
$this->addSql('CREATE INDEX idx_ewc_week_start ON employee_week_comments (week_start_date)');
$this->addSql('ALTER TABLE employee_week_comments ADD CONSTRAINT fk_ewc_employee FOREIGN KEY (employee_id) REFERENCES employees(id) ON DELETE CASCADE');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE employee_week_comments');
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260417120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create interim_agencies table and add interim_agency_id to employee_contract_periods';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE interim_agencies (id SERIAL PRIMARY KEY, name VARCHAR(150) NOT NULL UNIQUE)');
$this->addSql('ALTER TABLE employee_contract_periods ADD interim_agency_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE employee_contract_periods ADD CONSTRAINT fk_ecp_interim_agency FOREIGN KEY (interim_agency_id) REFERENCES interim_agencies (id) ON DELETE SET NULL');
$this->addSql('CREATE INDEX idx_ecp_interim_agency ON employee_contract_periods (interim_agency_id)');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE employee_contract_periods DROP CONSTRAINT IF EXISTS fk_ecp_interim_agency');
$this->addSql('DROP INDEX IF EXISTS idx_ecp_interim_agency');
$this->addSql('ALTER TABLE employee_contract_periods DROP COLUMN interim_agency_id');
$this->addSql('DROP TABLE interim_agencies');
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use App\State\EmployeeLeaveRecapProvider;
#[ApiResource(
operations: [
new GetCollection(
uriTemplate: '/leave-recap',
paginationEnabled: false,
security: "is_granted('ROLE_USER')",
provider: EmployeeLeaveRecapProvider::class,
),
],
)]
final class EmployeeLeaveRecap
{
public int $employeeId = 0;
public string $lastName = '';
public string $firstName = '';
public ?int $siteId = null;
public ?string $siteName = null;
public ?string $siteColor = null;
public ?string $contractName = null;
public int $contractSortKey = 99;
public float $cpN1Remaining = 0.0;
public string $cpN = '-';
public string $acquiredSaturdays = '-';
public string $rtt = '-';
public string $cutoffDate = '';
}

View File

@@ -38,4 +38,10 @@ final class EmployeeLeaveSummary
/** @var array<string, float> YYYY-MM => count (0.5 for half-days) */ /** @var array<string, float> YYYY-MM => count (0.5 for half-days) */
public array $presenceDaysByMonth = []; public array $presenceDaysByMonth = [];
/** Cumul des jours de présence depuis le début de l'année de congé jusqu'à aujourd'hui (forfait). */
public float $presenceDaysToToday = 0.0;
/** Date de mise en service du logiciel (env RTT_START_DATE) — borne minimale pour les sélecteurs d'historique. */
public ?string $dataStartDate = null;
} }

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\QueryParameter;
use App\State\EmployeeYearlyHoursBulkPrintProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/yearly-hours/print-all',
provider: EmployeeYearlyHoursBulkPrintProvider::class,
parameters: [
new QueryParameter(key: 'year', required: true),
],
security: "is_granted('ROLE_ADMIN')"
),
]
)]
final class EmployeeYearlyHoursBulkPrint {}

View File

@@ -0,0 +1,805 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Dto\Rtt\EmployeeRttWeekSummary;
use App\Dto\Rtt\WeekRecoveryDetail;
use App\Entity\Employee;
use App\Enum\ContractType;
use App\Enum\TrackingMode;
use App\Repository\AbsenceRepository;
use App\Repository\EmployeeContractPeriodRepository;
use App\Repository\EmployeeRepository;
use App\Repository\EmployeeRttBalanceRepository;
use App\Repository\EmployeeRttPaymentRepository;
use App\Repository\WorkHourRepository;
use App\Service\Leave\LeaveRecapRowBuilder;
use App\Service\Rtt\RttRecoveryComputationService;
use App\State\EmployeeLeaveSummaryProvider;
use DateTimeImmutable;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
#[AsCommand(
name: 'app:verification:snapshot',
description: 'Dump per-employee Markdown snapshot of RTT (monthly tab view) and leave balances, to serve as a regression baseline before business-rule refactors.'
)]
final class DumpVerificationSnapshotCommand extends Command
{
private const array MONTH_LABELS = [
1 => 'Janvier', 2 => 'Février', 3 => 'Mars', 4 => 'Avril',
5 => 'Mai', 6 => 'Juin', 7 => 'Juillet', 8 => 'Août',
9 => 'Septembre', 10 => 'Octobre', 11 => 'Novembre', 12 => 'Décembre',
];
public function __construct(
private readonly EmployeeRepository $employeeRepository,
private readonly EmployeeContractPeriodRepository $contractPeriodRepository,
private readonly EmployeeLeaveSummaryProvider $leaveSummaryProvider,
private readonly LeaveRecapRowBuilder $leaveRecapRowBuilder,
private readonly RttRecoveryComputationService $rttRecoveryService,
private readonly EmployeeRttBalanceRepository $rttBalanceRepository,
private readonly EmployeeRttPaymentRepository $rttPaymentRepository,
private readonly WorkHourRepository $workHourRepository,
private readonly AbsenceRepository $absenceRepository,
#[Autowire('%kernel.project_dir%')]
private readonly string $projectDir,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument(
'employee_ids',
InputArgument::IS_ARRAY | InputArgument::REQUIRED,
'Employee IDs to snapshot (space-separated).'
)
->addOption(
'output-dir',
null,
InputOption::VALUE_OPTIONAL,
'Output directory (relative to project root, or absolute).',
'docs/verifications'
)
->addOption(
'rtt-year',
null,
InputOption::VALUE_OPTIONAL,
'RTT exercise year (ending year, e.g. 2026 = June 2025 → May 2026). Defaults to current exercise.'
)
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$ids = array_map('intval', $input->getArgument('employee_ids'));
$outputDirOpt = (string) $input->getOption('output-dir');
$outputDir = str_starts_with($outputDirOpt, '/')
? $outputDirOpt
: $this->projectDir.'/'.$outputDirOpt;
if (!is_dir($outputDir) && !mkdir($outputDir, 0o755, true) && !is_dir($outputDir)) {
$io->error('Could not create output directory: '.$outputDir);
return Command::FAILURE;
}
$today = new DateTimeImmutable('today');
$rttYearOpt = $input->getOption('rtt-year');
$rttYear = null !== $rttYearOpt && '' !== (string) $rttYearOpt
? (int) $rttYearOpt
: $this->resolveCurrentRttExerciseYear($today);
foreach ($ids as $id) {
$employee = $this->employeeRepository->find($id);
if (!$employee instanceof Employee) {
$io->warning(sprintf('Employee id=%d not found — skipped.', $id));
continue;
}
$markdown = $this->buildEmployeeDoc($employee, $rttYear, $today);
$slug = $this->slugify($employee->getFirstName().'-'.$employee->getLastName());
$filename = sprintf('%s/verification-rtt-conges-%s.md', $outputDir, $slug);
file_put_contents($filename, $markdown);
$io->success(sprintf('Wrote %s', $filename));
}
return Command::SUCCESS;
}
private function buildEmployeeDoc(Employee $employee, int $rttYear, DateTimeImmutable $today): string
{
$parts = [];
$parts[] = $this->buildHeader($employee, $rttYear, $today);
$parts[] = $this->buildProfileSection($employee);
$parts[] = $this->buildLeaveSection($employee, $today);
$parts[] = $this->buildRecapRowSection($employee, $today);
$parts[] = $this->buildRttSection($employee, $rttYear, $today);
return implode("\n\n", $parts)."\n";
}
private function buildHeader(Employee $employee, int $rttYear, DateTimeImmutable $today): string
{
$rttFrom = sprintf('01/06/%d', $rttYear - 1);
$rttTo = sprintf('31/05/%d', $rttYear);
return sprintf(
"# Vérification RTT & Congés — %s %s (id=%d)\n\n"
."Généré le %s. \n"
."Exercice RTT de référence : **%d** (%s → %s). \n"
."Pour les contrats Forfait, l'exercice de congés est l'année civile.",
$employee->getFirstName(),
$employee->getLastName(),
(int) $employee->getId(),
$today->format('Y-m-d'),
$rttYear,
$rttFrom,
$rttTo
);
}
private function buildProfileSection(Employee $employee): string
{
$contract = $employee->getContract();
$contractName = $contract?->getName() ?? '—';
$tracking = $contract?->getTrackingMode() ?? '—';
$weekly = $contract?->getWeeklyHours();
$weeklyLabel = null === $weekly ? '—' : ($weekly.'h');
$nature = $employee->getCurrentContractNature();
$lines = [];
$lines[] = '## 1. Profil';
$lines[] = '';
$lines[] = sprintf('- **ID** : %d', (int) $employee->getId());
$lines[] = sprintf('- **Nom / Prénom** : %s %s', $employee->getLastName(), $employee->getFirstName());
$lines[] = sprintf('- **Contrat actif** : %s — tracking `%s` — %s', $contractName, $tracking, $weeklyLabel);
$lines[] = sprintf('- **Nature** : %s', $nature);
$lines[] = '';
$lines[] = '### Périodes de contrat';
$lines[] = '';
$lines[] = '| Début | Fin | Contrat | Nature | Conducteur | Solde CP soldé | Commentaire |';
$lines[] = '|-------|-----|---------|--------|------------|----------------|-------------|';
$periods = $this->contractPeriodRepository->findBy(['employee' => $employee], ['startDate' => 'ASC']);
foreach ($periods as $period) {
$lines[] = sprintf(
'| %s | %s | %s | %s | %s | %s | %s |',
$period->getStartDate()->format('Y-m-d'),
null !== $period->getEndDate() ? $period->getEndDate()->format('Y-m-d') : '—',
$period->getContract()?->getName() ?? '—',
$period->getContractNature(),
$period->getIsDriver() ? 'oui' : 'non',
$period->isPaidLeaveSettled() ? 'oui' : 'non',
str_replace("\n", ' ', (string) ($period->getComment() ?? ''))
);
}
return implode("\n", $lines);
}
private function buildLeaveSection(Employee $employee, DateTimeImmutable $today): string
{
$lines = [];
$lines[] = '## 2. Congés';
$lines[] = '';
$leaveYear = $this->leaveSummaryProvider->resolveLeaveYearForToday($employee);
$isForfait = ContractType::FORFAIT === $employee->getContract()?->getType();
$yearSummary = $this->leaveSummaryProvider->computeYearSummary($employee, $leaveYear);
if (null === $yearSummary) {
$lines[] = '_Aucun résumé congés disponible (contrat non supporté : INTERIM ou autre)._';
return implode("\n", $lines);
}
// Forfait: recompute with paid leave days if any.
$paidLeaveDays = $this->leaveSummaryProvider->resolvePaidLeaveDays($employee, $yearSummary['ruleCode'], $leaveYear);
if ($paidLeaveDays > 0.0) {
$recomputed = $this->leaveSummaryProvider->computeYearSummary($employee, $leaveYear, $paidLeaveDays);
if (null !== $recomputed) {
$yearSummary = $recomputed;
}
}
[$from, $to] = $isForfait
? [
new DateTimeImmutable(sprintf('%d-01-01', $leaveYear)),
new DateTimeImmutable(sprintf('%d-12-31', $leaveYear)),
]
: [
new DateTimeImmutable(sprintf('%d-06-01', $leaveYear - 1)),
new DateTimeImmutable(sprintf('%d-05-31', $leaveYear)),
];
$lines[] = sprintf('**Règle applicable** : `%s`', $yearSummary['ruleCode']);
$lines[] = sprintf('**Période** : %s → %s', $from->format('Y-m-d'), $to->format('Y-m-d'));
$lines[] = '';
$lines[] = '### 2.1 Soldes (tels que calculés aujourd\'hui)';
$lines[] = '';
$lines[] = '| Indicateur | Valeur |';
$lines[] = '|------------|--------|';
$lines[] = sprintf('| Acquis (report N-1) | %s j |', $this->fmtDays($yearSummary['acquiredDays']));
$lines[] = sprintf('| Acquis samedis | %s j |', $this->fmtDays($yearSummary['acquiredSaturdays']));
$lines[] = sprintf('| En cours d\'acquisition | %s j |', $this->fmtDays($yearSummary['accruingDays']));
$lines[] = sprintf('| Pris | %s j |', $this->fmtDays($yearSummary['takenDays']));
$lines[] = sprintf('| Pris samedis | %s j |', $this->fmtDays($yearSummary['takenSaturdays']));
$lines[] = sprintf('| Restant (report N-1) | %s j |', $this->fmtDays($yearSummary['remainingDays']));
$lines[] = sprintf('| Restant samedis | %s j |', $this->fmtDays($yearSummary['remainingSaturdays']));
if ($isForfait) {
$lines[] = sprintf('| N-1 acquis | %s j |', $this->fmtDays($yearSummary['previousYearAcquiredDays']));
$lines[] = sprintf('| N-1 pris | %s j |', $this->fmtDays($yearSummary['previousYearTakenDays']));
$lines[] = sprintf('| N-1 restant | %s j |', $this->fmtDays($yearSummary['previousYearRemainingDays']));
$lines[] = sprintf('| N-1 payés | %s j |', $this->fmtDays($paidLeaveDays));
}
$lines[] = '';
$lines[] = '### 2.2 Absences de la période';
$lines[] = '';
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to);
if ([] === $absences) {
$lines[] = '_Aucune absence sur la période._';
} else {
$lines[] = '| Début | Fin | Demi-début | Demi-fin | Type | Commentaire |';
$lines[] = '|-------|-----|------------|----------|------|-------------|';
foreach ($absences as $absence) {
$lines[] = sprintf(
'| %s | %s | %s | %s | %s (%s) | %s |',
$absence->getStartDate()->format('Y-m-d'),
$absence->getEndDate()->format('Y-m-d'),
$absence->getStartHalf()->value,
$absence->getEndHalf()->value,
$absence->getType()?->getCode() ?? '—',
$absence->getType()?->getLabel() ?? '—',
str_replace("\n", ' ', (string) ($absence->getComment() ?? ''))
);
}
}
$lines[] = '';
$lines[] = '### 2.3 Jours de présence par mois (calcul provider)';
$lines[] = '';
$presenceDaysByMonth = $this->computePresenceDaysByMonth($employee, $leaveYear);
if ([] === $presenceDaysByMonth) {
$lines[] = '_Aucun jour de présence sur la période._';
} else {
$lines[] = '| Mois | Jours de présence |';
$lines[] = '|------|-------------------|';
ksort($presenceDaysByMonth);
foreach ($presenceDaysByMonth as $monthKey => $days) {
$lines[] = sprintf('| %s | %s |', $monthKey, $this->fmtDays($days));
}
}
return implode("\n", $lines);
}
/**
* @return array<string, float>
*/
private function computePresenceDaysByMonth(Employee $employee, int $leaveYear): array
{
// The provider method is private; we re-invoke `provide()` via its public path by
// calling computeYearSummary then reading $summary->presenceDaysByMonth.
// But computeYearSummary doesn't populate that. So we call the provider publicly
// through LeaveRecapRowBuilder? No — we just call the summary API resource directly
// via a small helper below.
//
// Workaround: reuse the provider's provide() would require security; instead we
// rebuild the map from WorkHour/absences here, mirroring the provider logic.
$isForfait = ContractType::FORFAIT === $employee->getContract()?->getType();
[$from, $to] = $isForfait
? [
new DateTimeImmutable(sprintf('%d-01-01', $leaveYear)),
new DateTimeImmutable(sprintf('%d-12-31', $leaveYear)),
]
: [
new DateTimeImmutable(sprintf('%d-06-01', $leaveYear - 1)),
new DateTimeImmutable(sprintf('%d-05-31', $leaveYear)),
];
// Leave this aggregated figure available only for forfait (this is where the UI
// shows it). For non-forfait we skip — the UI doesn't show presence per month.
if (!$isForfait) {
return [];
}
$weekendWorkedDays = $this->workHourRepository->countWeekendWorkedDaysByMonth($employee, $from, $to);
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to);
$absenceDaysByMonth = [];
foreach ($absences as $absence) {
$start = DateTimeImmutable::createFromInterface($absence->getStartDate())->setTime(0, 0);
$end = DateTimeImmutable::createFromInterface($absence->getEndDate())->setTime(0, 0);
for ($day = $start; $day <= $end; $day = $day->modify('+1 day')) {
if ((int) $day->format('N') >= 6) {
continue;
}
$startDate = $absence->getStartDate()->format('Y-m-d');
$endDate = $absence->getEndDate()->format('Y-m-d');
$startHalf = $absence->getStartHalf()->value;
$endHalf = $absence->getEndHalf()->value;
$dateStr = $day->format('Y-m-d');
$isStart = $dateStr === $startDate;
$isEnd = $dateStr === $endDate;
if ($startDate === $endDate) {
$am = 'AM' === $startHalf;
$pm = 'PM' === $endHalf;
} elseif ($isStart) {
$am = 'AM' === $startHalf;
$pm = true;
} elseif ($isEnd) {
$am = true;
$pm = 'PM' === $endHalf;
} else {
$am = true;
$pm = true;
}
$dayAmount = ($am ? 0.5 : 0.0) + ($pm ? 0.5 : 0.0);
if ($dayAmount <= 0.0) {
continue;
}
$mk = $day->format('Y-m');
$absenceDaysByMonth[$mk] = ($absenceDaysByMonth[$mk] ?? 0.0) + $dayAmount;
}
}
$result = [];
$cursor = $from->modify('first day of this month')->setTime(0, 0);
while ($cursor <= $to) {
$monthKey = $cursor->format('Y-m');
$monthStart = $cursor < $from ? $from : $cursor;
$monthEnd = $cursor->modify('last day of this month')->setTime(0, 0);
if ($monthEnd > $to) {
$monthEnd = $to;
}
$businessDays = 0;
for ($day = $monthStart; $day <= $monthEnd; $day = $day->modify('+1 day')) {
if ((int) $day->format('N') <= 5) {
++$businessDays;
}
}
$weekend = $weekendWorkedDays[$monthKey] ?? 0.0;
$absenced = $absenceDaysByMonth[$monthKey] ?? 0.0;
$presence = max(0.0, (float) $businessDays + $weekend - $absenced);
if ($presence > 0.0) {
$result[$monthKey] = $presence;
}
$cursor = $cursor->modify('first day of next month');
}
return $result;
}
private function buildRecapRowSection(Employee $employee, DateTimeImmutable $today): string
{
$row = $this->leaveRecapRowBuilder->build($employee);
$lines = [];
$lines[] = '## 3. Ligne écran « Récap. congés » (live, as of today)';
$lines[] = '';
$lines[] = '| CP N-1 restant | CP N | Samedis | RTT |';
$lines[] = '|----------------|------|---------|-----|';
$lines[] = sprintf(
'| %s | %s | %s | %s |',
(string) $row['cpN1Remaining'],
$row['cpN'],
$row['acquiredSaturdays'],
$row['rtt']
);
return implode("\n", $lines);
}
private function buildRttSection(Employee $employee, int $rttYear, DateTimeImmutable $today): string
{
$lines = [];
$lines[] = '## 4. RTT — Onglet par mois';
$lines[] = '';
$contract = $employee->getContract();
$trackingMode = $contract?->getTrackingMode();
if (TrackingMode::PRESENCE->value === $trackingMode) {
$lines[] = '_Contrat en mode `PRESENCE` (Forfait) : aucun calcul RTT (heures supplémentaires)._';
$lines[] = '_Sur l\'UI, l\'onglet RTT ne contient aucune donnée exploitable._';
$lines[] = '';
$lines[] = '> Voir toutefois la section Congés pour les bonus week-end / jours fériés travaillés intégrés au stock Forfait (acquisDays).';
return implode("\n", $lines);
}
[$periodFrom, $periodTo] = $this->rttRecoveryService->resolveExerciseBounds($rttYear);
$weeks = $this->rttRecoveryService->buildWeeksForExercise($periodFrom, $periodTo);
$weekRanges = array_map(
static fn (array $w): array => ['weekNumber' => (int) $w['weekNumber'], 'start' => $w['start'], 'end' => $w['end']],
$weeks
);
$currentExerciseYear = $this->resolveCurrentRttExerciseYear($today);
if ($rttYear > $currentExerciseYear) {
$limitDate = $periodFrom->modify('-1 day');
} else {
$isoDay = (int) $today->format('N');
$limitDate = 7 === $isoDay ? $today : $today->modify('last sunday');
if (7 !== $isoDay) {
$currentWeekStart = $today->modify('monday this week');
$currentWeekEnd = $currentWeekStart->modify('+6 days');
$checkEnd = $this->resolveWeekEndForEmployee($employee, $currentWeekStart, $currentWeekEnd, $today);
if ($this->workHourRepository->isWeekFullyValidated($employee, $currentWeekStart, $checkEnd)) {
$limitDate = $currentWeekEnd;
}
}
}
$recoveryByWeek = $this->rttRecoveryService->computeRecoveryByWeek($employee, $weekRanges, $periodFrom, $periodTo, $limitDate);
[$carry, $carryMonth] = $this->resolveCarry($employee, $rttYear);
$weekSummaries = $this->buildWeekSummaries($weekRanges, $recoveryByWeek, $periodFrom, $periodTo);
$weekSummaries = $this->distributeDeficits($weekSummaries, $carry);
// Aggregate payments per month.
$paymentsByMonth = [];
foreach ($this->rttPaymentRepository->findByEmployeeAndYear($employee, $rttYear) as $payment) {
$m = $payment->getMonth();
if (!isset($paymentsByMonth[$m])) {
$paymentsByMonth[$m] = ['base25' => 0, 'bonus25' => 0, 'base50' => 0, 'bonus50' => 0];
}
$paymentsByMonth[$m]['base25'] += $payment->getBase25Minutes();
$paymentsByMonth[$m]['bonus25'] += $payment->getBonus25Minutes();
$paymentsByMonth[$m]['base50'] += $payment->getBase50Minutes();
$paymentsByMonth[$m]['bonus50'] += $payment->getBonus50Minutes();
}
$lines[] = sprintf('**Limite des semaines prises en compte** : %s (exclut la semaine en cours incomplète)', $limitDate->format('Y-m-d'));
$lines[] = sprintf('**Report N-1 (carry)** : `Base 25%%=%s` / `+25%%=%s` / `Base 50%%=%s` / `+50%%=%s` — **Total %s**', $this->fmtMin($carry->base25Minutes), $this->fmtMin($carry->bonus25Minutes), $this->fmtMin($carry->base50Minutes), $this->fmtMin($carry->bonus50Minutes), $this->fmtMin($carry->totalMinutes));
$lines[] = '';
// Iterate the 12 exercise months (June → May).
$cumulativeCarry = [
'base25' => $carry->base25Minutes,
'bonus25' => $carry->bonus25Minutes,
'base50' => $carry->base50Minutes,
'bonus50' => $carry->bonus50Minutes,
];
$monthsInExercise = [6, 7, 8, 9, 10, 11, 12, 1, 2, 3, 4, 5];
foreach ($monthsInExercise as $i => $month) {
$calYear = $month >= 6 ? $rttYear - 1 : $rttYear;
$label = self::MONTH_LABELS[$month].' '.$calYear;
$lines[] = '### '.$label;
$lines[] = '';
$lines[] = '| Ligne | Heure | Base 25% | +25% | Total 25% | Base 50% | +50% | Total 50% | Total |';
$lines[] = '|-------|-------|----------|------|-----------|----------|------|-----------|-------|';
// Report line only on the first month (June).
if (6 === $month) {
$lines[] = sprintf(
'| Report N-1 | | %s | %s | %s | %s | %s | %s | %s |',
$this->fmtMin($carry->base25Minutes),
$this->fmtMin($carry->bonus25Minutes),
$this->fmtMin($carry->base25Minutes + $carry->bonus25Minutes),
$this->fmtMin($carry->base50Minutes),
$this->fmtMin($carry->bonus50Minutes),
$this->fmtMin($carry->base50Minutes + $carry->bonus50Minutes),
$this->fmtMin($carry->totalMinutes),
);
}
$monthWeeks = array_values(array_filter($weekSummaries, static fn (EmployeeRttWeekSummary $w): bool => $w->month === $month));
$totals = ['over' => 0, 'b25' => 0, 's25' => 0, 'b50' => 0, 's50' => 0, 'total' => 0];
foreach ($monthWeeks as $w) {
$lines[] = sprintf(
'| Semaine %d (%s → %s) | %s | %s | %s | %s | %s | %s | %s | %s |',
$w->weekNumber,
$w->weekStart,
$w->weekEnd,
$this->fmtMin($w->overtimeMinutes),
$this->fmtMin($w->base25Minutes),
$this->fmtMin($w->bonus25Minutes),
$this->fmtMin($w->base25Minutes + $w->bonus25Minutes),
$this->fmtMin($w->base50Minutes),
$this->fmtMin($w->bonus50Minutes),
$this->fmtMin($w->base50Minutes + $w->bonus50Minutes),
$this->fmtMin($w->totalMinutes),
);
$totals['over'] += $w->overtimeMinutes;
$totals['b25'] += $w->base25Minutes;
$totals['s25'] += $w->bonus25Minutes;
$totals['b50'] += $w->base50Minutes;
$totals['s50'] += $w->bonus50Minutes;
$totals['total'] += $w->totalMinutes;
}
if ([] === $monthWeeks && 6 !== $month) {
$lines[] = '| _aucune semaine_ | | | | | | | | |';
}
$lines[] = sprintf(
'| **Total** | %s | %s | %s | %s | %s | %s | %s | **%s** |',
$this->fmtMin($totals['over']),
$this->fmtMin($totals['b25']),
$this->fmtMin($totals['s25']),
$this->fmtMin($totals['b25'] + $totals['s25']),
$this->fmtMin($totals['b50']),
$this->fmtMin($totals['s50']),
$this->fmtMin($totals['b50'] + $totals['s50']),
$this->fmtMin($totals['total']),
);
$p = $paymentsByMonth[$month] ?? null;
$hasPayment = null !== $p;
if ($hasPayment) {
$lines[] = sprintf(
'| Payé | | -%s | -%s | -%s | -%s | -%s | -%s | -%s |',
$this->fmtMin($p['base25']),
$this->fmtMin($p['bonus25']),
$this->fmtMin($p['base25'] + $p['bonus25']),
$this->fmtMin($p['base50']),
$this->fmtMin($p['bonus50']),
$this->fmtMin($p['base50'] + $p['bonus50']),
$this->fmtMin($p['base25'] + $p['bonus25'] + $p['base50'] + $p['bonus50']),
);
} else {
$lines[] = '| Payé | | 0h | 0h | 0h | 0h | 0h | 0h | 0h |';
}
// Cumulative carry update — add month totals, subtract payments.
$cumulativeCarry['base25'] += $totals['b25'] - ($p['base25'] ?? 0);
$cumulativeCarry['bonus25'] += $totals['s25'] - ($p['bonus25'] ?? 0);
$cumulativeCarry['base50'] += $totals['b50'] - ($p['base50'] ?? 0);
$cumulativeCarry['bonus50'] += $totals['s50'] - ($p['bonus50'] ?? 0);
$cb25 = $cumulativeCarry['base25'];
$cs25 = $cumulativeCarry['bonus25'];
$cb50 = $cumulativeCarry['base50'];
$cs50 = $cumulativeCarry['bonus50'];
$cTotal = $cb25 + $cs25 + $cb50 + $cs50;
$lines[] = sprintf(
'| **Reste (cumul)** | | %s | %s | %s | %s | %s | %s | **%s** |',
$this->fmtMin($cb25),
$this->fmtMin($cs25),
$this->fmtMin($cb25 + $cs25),
$this->fmtMin($cb50),
$this->fmtMin($cs50),
$this->fmtMin($cb50 + $cs50),
$this->fmtMin($cTotal),
);
$lines[] = '';
}
// Final summary.
$currentYearRecovery = array_sum(array_map(static fn (EmployeeRttWeekSummary $w): int => $w->totalMinutes, $weekSummaries));
$totalPaid = 0;
foreach ($paymentsByMonth as $p) {
$totalPaid += $p['base25'] + $p['bonus25'] + $p['base50'] + $p['bonus50'];
}
$available = $carry->totalMinutes + $currentYearRecovery - $totalPaid;
$lines[] = '### Solde RTT total (fin de période calculée)';
$lines[] = '';
$lines[] = sprintf('- Report N-1 (opening) : **%s**', $this->fmtMin($carry->totalMinutes));
$lines[] = sprintf('- Cumul récupération exercice : **%s**', $this->fmtMin($currentYearRecovery));
$lines[] = sprintf('- Total payé : **%s**', $this->fmtMin($totalPaid));
$lines[] = sprintf('- **Disponible** : **%s**', $this->fmtMin($available));
return implode("\n", $lines);
}
/**
* Mirrors EmployeeRttSummaryProvider::buildWeekSummaries().
*
* @param list<array{weekNumber:int,start:DateTimeImmutable,end:DateTimeImmutable}> $weekRanges
* @param array<string, WeekRecoveryDetail> $recoveryByWeek
*
* @return list<EmployeeRttWeekSummary>
*/
private function buildWeekSummaries(array $weekRanges, array $recoveryByWeek, DateTimeImmutable $periodFrom, DateTimeImmutable $periodTo): array
{
$result = [];
foreach ($weekRanges as $week) {
$weekStart = $week['start'];
$weekEnd = $week['end'];
$weekKey = $weekStart->format('Y-m-d');
$detail = $recoveryByWeek[$weekKey] ?? new WeekRecoveryDetail();
$effectiveStart = $weekStart < $periodFrom ? $periodFrom : $weekStart;
$effectiveEnd = $weekEnd > $periodTo ? $periodTo : $weekEnd;
$startMonth = (int) $effectiveStart->format('n');
$endMonth = (int) $effectiveEnd->format('n');
if ($startMonth === $endMonth) {
$result[] = new EmployeeRttWeekSummary(
month: $startMonth,
weekNumber: (int) $week['weekNumber'],
weekStart: $weekStart->format('Y-m-d'),
weekEnd: $weekEnd->format('Y-m-d'),
overtimeMinutes: $detail->overtimeMinutes,
base25Minutes: $detail->base25Minutes,
bonus25Minutes: $detail->bonus25Minutes,
base50Minutes: $detail->base50Minutes,
bonus50Minutes: $detail->bonus50Minutes,
totalMinutes: $detail->totalMinutes,
);
continue;
}
$monthMinutes = [];
$monthWeekdays = [];
foreach ($detail->dailyMinutes as $date => $mins) {
$m = (int) new DateTimeImmutable($date)->format('n');
$monthMinutes[$m] = ($monthMinutes[$m] ?? 0) + $mins;
if ((int) new DateTimeImmutable($date)->format('N') < 6) {
$monthWeekdays[$m] = ($monthWeekdays[$m] ?? 0) + 1;
}
}
$totalWorked = array_sum($monthMinutes);
$totalWeekdays = array_sum($monthWeekdays);
foreach ([$startMonth, $endMonth] as $m) {
if ($totalWorked > 0) {
$ratio = ($monthMinutes[$m] ?? 0) / $totalWorked;
} elseif ($totalWeekdays > 0) {
$ratio = ($monthWeekdays[$m] ?? 0) / $totalWeekdays;
} else {
$ratio = 0.0;
}
$result[] = new EmployeeRttWeekSummary(
month: $m,
weekNumber: (int) $week['weekNumber'],
weekStart: $weekStart->format('Y-m-d'),
weekEnd: $weekEnd->format('Y-m-d'),
overtimeMinutes: (int) round($detail->overtimeMinutes * $ratio),
base25Minutes: (int) round($detail->base25Minutes * $ratio),
bonus25Minutes: (int) round($detail->bonus25Minutes * $ratio),
base50Minutes: (int) round($detail->base50Minutes * $ratio),
bonus50Minutes: (int) round($detail->bonus50Minutes * $ratio),
totalMinutes: (int) round($detail->totalMinutes * $ratio),
);
}
}
return $result;
}
/**
* Mirrors the deficit-distribution step in EmployeeRttSummaryProvider::provide().
*
* @param list<EmployeeRttWeekSummary> $weeks
*
* @return list<EmployeeRttWeekSummary>
*/
private function distributeDeficits(array $weeks, WeekRecoveryDetail $carry): array
{
$cumulative50 = $carry->base50Minutes + $carry->bonus50Minutes;
$cumulative25 = $carry->base25Minutes + $carry->bonus25Minutes;
foreach ($weeks as $i => $week) {
if ($week->totalMinutes >= 0) {
$cumulative50 += $week->base50Minutes + $week->bonus50Minutes;
$cumulative25 += $week->base25Minutes + $week->bonus25Minutes;
continue;
}
$deficit = -$week->totalMinutes;
$from50 = min($deficit, max(0, $cumulative50));
$from25 = $deficit - $from50;
$cumulative50 -= $from50;
$cumulative25 -= $from25;
$weeks[$i] = new EmployeeRttWeekSummary(
month: $week->month,
weekNumber: $week->weekNumber,
weekStart: $week->weekStart,
weekEnd: $week->weekEnd,
overtimeMinutes: $week->overtimeMinutes,
base25Minutes: $from25 > 0 ? -$from25 : 0,
bonus25Minutes: 0,
base50Minutes: $from50 > 0 ? -$from50 : 0,
bonus50Minutes: 0,
totalMinutes: $week->totalMinutes,
);
}
return $weeks;
}
/**
* @return array{WeekRecoveryDetail, int}
*/
private function resolveCarry(Employee $employee, int $year): array
{
$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(),
),
$balance->getMonth(),
];
}
return [$this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $year - 1), 5];
}
private function resolveWeekEndForEmployee(Employee $employee, DateTimeImmutable $weekStart, DateTimeImmutable $weekEnd, DateTimeImmutable $today): DateTimeImmutable
{
foreach ($employee->getContractPeriods() as $period) {
if ($period->getStartDate() > $today) {
continue;
}
$endDate = $period->getEndDate();
if (null === $endDate) {
continue;
}
if ($endDate >= $weekStart && $endDate <= $weekEnd) {
return $endDate;
}
}
return $weekEnd;
}
private function resolveCurrentRttExerciseYear(DateTimeImmutable $today): int
{
$y = (int) $today->format('Y');
$m = (int) $today->format('n');
return $m >= 6 ? $y + 1 : $y;
}
private function fmtMin(int $minutes): string
{
if (0 === $minutes) {
return '0h';
}
$sign = $minutes < 0 ? '-' : '';
$abs = abs($minutes);
$h = intdiv($abs, 60);
$m = $abs % 60;
return 0 === $m ? sprintf('%s%dh', $sign, $h) : sprintf('%s%dh%02d', $sign, $h, $m);
}
private function fmtDays(float $value): string
{
if (abs($value - round($value)) < 0.001) {
return (string) (int) round($value);
}
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
}
private function slugify(string $value): string
{
$value = trim($value);
$ascii = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value);
if (false === $ascii) {
$ascii = $value;
}
$ascii = strtolower($ascii);
$ascii = preg_replace('/[^a-z0-9]+/', '-', $ascii) ?? $ascii;
return trim($ascii, '-');
}
}

View File

@@ -29,5 +29,14 @@ final class ContractHistoryItem
public array $suspensions = [], public array $suspensions = [],
#[Groups(['employee:read'])] #[Groups(['employee:read'])]
public bool $isDriver = false, public bool $isDriver = false,
/**
* @var null|array<int, int> iso-day → minutes
*/
#[Groups(['employee:read'])]
public ?array $workDaysHours = null,
#[Groups(['employee:read'])]
public ?int $interimAgencyId = null,
#[Groups(['employee:read'])]
public ?string $interimAgencyName = null,
) {} ) {}
} }

View File

@@ -17,5 +17,6 @@ final class EmployeeRttWeekSummary
public int $base50Minutes = 0, public int $base50Minutes = 0,
public int $bonus50Minutes = 0, public int $bonus50Minutes = 0,
public int $totalMinutes = 0, public int $totalMinutes = 0,
public int $cumulativeBalanceMinutes = 0,
) {} ) {}
} }

View File

@@ -19,6 +19,8 @@ final class DayContextRow
public bool $isDriverContract = false, public bool $isDriverContract = false,
public bool $hasFormation = false, public bool $hasFormation = false,
public ?string $formationLabel = null, public ?string $formationLabel = null,
public int $virtualHolidayMinutes = 0,
public ?string $contractNature = null,
) {} ) {}
public function setFormation(string $label): void public function setFormation(string $label): void
@@ -75,7 +77,9 @@ final class DayContextRow
* creditedPresenceUnits:float, * creditedPresenceUnits:float,
* isDriverContract:bool, * isDriverContract:bool,
* hasFormation:bool, * hasFormation:bool,
* formationLabel:?string * formationLabel:?string,
* virtualHolidayMinutes:int,
* contractNature:?string
* } * }
*/ */
public function toArray(): array public function toArray(): array
@@ -93,6 +97,8 @@ final class DayContextRow
'isDriverContract' => $this->isDriverContract, 'isDriverContract' => $this->isDriverContract,
'hasFormation' => $this->hasFormation, 'hasFormation' => $this->hasFormation,
'formationLabel' => $this->formationLabel, 'formationLabel' => $this->formationLabel,
'virtualHolidayMinutes' => $this->virtualHolidayMinutes,
'contractNature' => $this->contractNature,
]; ];
} }

View File

@@ -21,5 +21,7 @@ final class WeeklyDaySummary
public bool $hasLunch = false, public bool $hasLunch = false,
public bool $hasDinner = false, public bool $hasDinner = false,
public bool $hasOvernight = false, public bool $hasOvernight = false,
public int $virtualHolidayMinutes = 0,
public ?string $holidayLabel = null,
) {} ) {}
} }

View File

@@ -34,5 +34,8 @@ final class WeeklySummaryRow
public int $weeklyDinnerCount = 0, public int $weeklyDinnerCount = 0,
public int $weeklyOvernightCount = 0, public int $weeklyOvernightCount = 0,
public bool $hasContractForWeek = true, public bool $hasContractForWeek = true,
public ?string $contractNature = null,
public ?string $comment = null,
public ?int $commentId = null,
) {} ) {}
} }

View File

@@ -92,6 +92,15 @@ class Employee
#[Groups(['employee:write'])] #[Groups(['employee:write'])]
private ?bool $isDriverInput = null; private ?bool $isDriverInput = null;
/**
* @var null|array<int, int> iso-day → minutes, write-only (propagated to EmployeeContractPeriod)
*/
#[Groups(['employee:write'])]
private ?array $workDaysHoursInput = null;
#[Groups(['employee:write'])]
private ?int $interimAgencyId = null;
public function __construct() public function __construct()
{ {
$this->createdAt = new DateTimeImmutable(); $this->createdAt = new DateTimeImmutable();
@@ -261,6 +270,58 @@ class Employee
return $this; return $this;
} }
/**
* @return null|array<int, int>
*/
public function getWorkDaysHoursInput(): ?array
{
return $this->workDaysHoursInput;
}
/**
* @param null|array<int|string, mixed> $workDaysHoursInput
*/
public function setWorkDaysHoursInput(?array $workDaysHoursInput): self
{
if (null === $workDaysHoursInput) {
$this->workDaysHoursInput = null;
return $this;
}
$normalized = [];
foreach ($workDaysHoursInput as $key => $value) {
$normalized[(int) $key] = (int) $value;
}
$this->workDaysHoursInput = $normalized;
return $this;
}
public function getInterimAgencyId(): ?int
{
return $this->interimAgencyId;
}
public function setInterimAgencyId(?int $interimAgencyId): self
{
$this->interimAgencyId = $interimAgencyId;
return $this;
}
#[Groups(['employee:read'])]
public function getCurrentInterimAgencyId(): ?int
{
return $this->resolveCurrentContractPeriod()?->getInterimAgency()?->getId();
}
#[Groups(['employee:read'])]
public function getCurrentInterimAgencyName(): ?string
{
return $this->resolveCurrentContractPeriod()?->getInterimAgency()?->getName();
}
#[Groups(['employee:read'])] #[Groups(['employee:read'])]
public function getHasActiveContract(): bool public function getHasActiveContract(): bool
{ {
@@ -358,6 +419,9 @@ class Employee
periodId: $period->getId(), periodId: $period->getId(),
suspensions: $suspensionData, suspensions: $suspensionData,
isDriver: $period->getIsDriver(), isDriver: $period->getIsDriver(),
workDaysHours: $period->getWorkDaysHours(),
interimAgencyId: $period->getInterimAgency()?->getId(),
interimAgencyName: $period->getInterimAgency()?->getName(),
); );
}, },
$periods $periods

View File

@@ -45,6 +45,20 @@ class EmployeeContractPeriod
#[ORM\Column(type: 'boolean', options: ['default' => false])] #[ORM\Column(type: 'boolean', options: ['default' => false])]
private bool $paidLeaveSettled = false; private bool $paidLeaveSettled = false;
/**
* Map ISO weekday (1=Mon..5=Fri) → minutes worked that day.
* Required for non-standard TIME contracts (weeklyHours ∉ {35, 39}, non-INTERIM)
* so that férié credit and absence credit respect the actual schedule.
*
* @var null|array<int, int>
*/
#[ORM\Column(type: 'json', nullable: true)]
private ?array $workDaysHours = null;
#[ORM\ManyToOne(targetEntity: InterimAgency::class)]
#[ORM\JoinColumn(nullable: true)]
private ?InterimAgency $interimAgency = null;
#[ORM\Column(type: 'text', nullable: true)] #[ORM\Column(type: 'text', nullable: true)]
private ?string $comment = null; private ?string $comment = null;
@@ -176,6 +190,36 @@ class EmployeeContractPeriod
return $this; return $this;
} }
/**
* @return null|array<int, int>
*/
public function getWorkDaysHours(): ?array
{
return $this->workDaysHours;
}
/**
* @param null|array<int, int> $workDaysHours
*/
public function setWorkDaysHours(?array $workDaysHours): self
{
$this->workDaysHours = $workDaysHours;
return $this;
}
public function getInterimAgency(): ?InterimAgency
{
return $this->interimAgency;
}
public function setInterimAgency(?InterimAgency $interimAgency): self
{
$this->interimAgency = $interimAgency;
return $this;
}
/** /**
* @return Collection<int, ContractSuspension> * @return Collection<int, ContractSuspension>
*/ */

View File

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\EmployeeWeekCommentRepository;
use App\State\EmployeeWeekCommentWriteProcessor;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
operations: [
new Get(security: "is_granted('ROLE_ADMIN')"),
new GetCollection(security: "is_granted('ROLE_ADMIN')"),
new Post(security: "is_granted('ROLE_ADMIN')", processor: EmployeeWeekCommentWriteProcessor::class),
new Patch(security: "is_granted('ROLE_ADMIN')", processor: EmployeeWeekCommentWriteProcessor::class),
new Delete(security: "is_granted('ROLE_ADMIN')", processor: EmployeeWeekCommentWriteProcessor::class),
],
normalizationContext: ['groups' => ['week_comment:read'], 'datetime_format' => 'Y-m-d'],
denormalizationContext: ['groups' => ['week_comment:write'], 'datetime_format' => 'Y-m-d'],
order: ['weekStartDate' => 'DESC'],
paginationEnabled: false,
)]
#[ApiFilter(DateFilter::class, properties: ['weekStartDate'])]
#[ApiFilter(SearchFilter::class, properties: ['employee' => 'exact'])]
#[ORM\Entity(repositoryClass: EmployeeWeekCommentRepository::class)]
#[ORM\Table(name: 'employee_week_comments')]
#[ORM\UniqueConstraint(name: 'uniq_employee_week_comment', columns: ['employee_id', 'week_start_date'])]
class EmployeeWeekComment
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
#[Groups(['week_comment:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Employee::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['week_comment:read', 'week_comment:write'])]
#[Assert\NotNull]
private ?Employee $employee = null;
#[ORM\Column(type: 'date_immutable')]
#[Groups(['week_comment:read', 'week_comment:write'])]
#[Assert\NotNull]
private ?DateTimeImmutable $weekStartDate = null;
#[ORM\Column(type: 'text')]
#[Groups(['week_comment:read', 'week_comment:write'])]
#[Assert\NotBlank]
#[Assert\Length(max: 5000)]
private string $content = '';
#[ORM\Column(type: 'datetime_immutable')]
#[Groups(['week_comment:read'])]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: 'datetime_immutable')]
#[Groups(['week_comment:read'])]
private DateTimeImmutable $updatedAt;
public function __construct()
{
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
}
public function getId(): ?int
{
return $this->id;
}
public function getEmployee(): ?Employee
{
return $this->employee;
}
public function setEmployee(?Employee $employee): self
{
$this->employee = $employee;
return $this;
}
public function getWeekStartDate(): ?DateTimeImmutable
{
return $this->weekStartDate;
}
public function setWeekStartDate(?DateTimeImmutable $weekStartDate): self
{
$this->weekStartDate = $weekStartDate;
return $this;
}
public function getContent(): string
{
return $this->content;
}
public function setContent(string $content): self
{
$this->content = $content;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): DateTimeImmutable
{
return $this->updatedAt;
}
public function touchUpdatedAt(): void
{
$this->updatedAt = new DateTimeImmutable();
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(),
],
normalizationContext: ['groups' => ['interim_agency:read']],
paginationEnabled: false,
security: "is_granted('ROLE_USER')",
order: ['name' => 'ASC'],
)]
#[ORM\Entity]
#[ORM\Table(name: 'interim_agencies')]
class InterimAgency
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
#[Groups(['interim_agency:read', 'employee:read'])]
private ?int $id = null;
#[ORM\Column(type: 'string', length: 150, unique: true)]
#[Groups(['interim_agency:read', 'employee:read'])]
private string $name = '';
public function getId(): ?int
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
}

View File

@@ -90,6 +90,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[SerializedName('isLocked')] #[SerializedName('isLocked')]
private bool $isLocked = false; private bool $isLocked = false;
#[ORM\Column(type: 'boolean', options: ['default' => false])]
#[Groups(['user:write'])]
#[SerializedName('hasLeaveRecapAccess')]
private bool $hasLeaveRecapAccess = false;
/** /**
* @var Collection<int, UserSiteRole> * @var Collection<int, UserSiteRole>
*/ */
@@ -224,6 +229,20 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this; return $this;
} }
#[Groups(['user:read'])]
#[SerializedName('hasLeaveRecapAccess')]
public function hasLeaveRecapAccess(): bool
{
return $this->hasLeaveRecapAccess;
}
public function setHasLeaveRecapAccess(bool $hasLeaveRecapAccess): self
{
$this->hasLeaveRecapAccess = $hasLeaveRecapAccess;
return $this;
}
#[Groups(['user:read'])] #[Groups(['user:read'])]
public function getIsDriver(): bool public function getIsDriver(): bool
{ {

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Employee;
use App\Entity\EmployeeWeekComment;
use DateTimeImmutable;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<EmployeeWeekComment>
*/
class EmployeeWeekCommentRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, EmployeeWeekComment::class);
}
public function findOneByEmployeeAndWeek(Employee $employee, DateTimeImmutable $weekStart): ?EmployeeWeekComment
{
return $this->findOneBy(['employee' => $employee, 'weekStartDate' => $weekStart]);
}
/**
* @param list<Employee> $employees
*
* @return array<int, EmployeeWeekComment> employee_id → comment
*/
public function findByWeekAndEmployees(DateTimeImmutable $weekStart, array $employees): array
{
if ([] === $employees) {
return [];
}
$rows = $this->createQueryBuilder('c')
->andWhere('c.weekStartDate = :weekStart')
->andWhere('c.employee IN (:employees)')
->setParameter('weekStart', $weekStart)
->setParameter('employees', $employees)
->innerJoin('c.employee', 'e')->addSelect('e')
->getQuery()->getResult()
;
$map = [];
foreach ($rows as $row) {
$eid = $row->getEmployee()?->getId();
if (null !== $eid) {
$map[$eid] = $row;
}
}
return $map;
}
}

View File

@@ -9,6 +9,9 @@ use DateTimeImmutable;
final readonly class EmployeeContractChangeRequest final readonly class EmployeeContractChangeRequest
{ {
/**
* @param null|array<int, int> $workDaysHours iso-day → minutes
*/
public function __construct( public function __construct(
public ?ContractNature $contractNature, public ?ContractNature $contractNature,
public ?DateTimeImmutable $contractStartDate, public ?DateTimeImmutable $contractStartDate,
@@ -16,6 +19,8 @@ final readonly class EmployeeContractChangeRequest
public ?bool $contractPaidLeaveSettled, public ?bool $contractPaidLeaveSettled,
public ?string $contractComment, public ?string $contractComment,
public ?bool $isDriver = null, public ?bool $isDriver = null,
public ?array $workDaysHours = null,
public ?int $interimAgencyId = null,
) {} ) {}
public function hasPeriodChangeRequest(): bool public function hasPeriodChangeRequest(): bool

View File

@@ -20,6 +20,8 @@ final class EmployeeContractChangeRequestFactory
contractPaidLeaveSettled: $employee->getContractPaidLeaveSettled(), contractPaidLeaveSettled: $employee->getContractPaidLeaveSettled(),
contractComment: $employee->getContractComment(), contractComment: $employee->getContractComment(),
isDriver: $employee->getIsDriverInput(), isDriver: $employee->getIsDriverInput(),
workDaysHours: $employee->getWorkDaysHoursInput(),
interimAgencyId: $employee->getInterimAgencyId(),
); );
} }

View File

@@ -7,11 +7,15 @@ namespace App\Service\Contracts;
use App\Entity\Contract; use App\Entity\Contract;
use App\Entity\Employee; use App\Entity\Employee;
use App\Entity\EmployeeContractPeriod; use App\Entity\EmployeeContractPeriod;
use App\Entity\InterimAgency;
use App\Enum\ContractNature; use App\Enum\ContractNature;
use DateTimeImmutable; use DateTimeImmutable;
final class EmployeeContractPeriodBuilder final class EmployeeContractPeriodBuilder
{ {
/**
* @param null|array<int, int> $workDaysHours iso-day → minutes
*/
public function build( public function build(
Employee $employee, Employee $employee,
Contract $contract, Contract $contract,
@@ -19,6 +23,8 @@ final class EmployeeContractPeriodBuilder
?DateTimeImmutable $endDate, ?DateTimeImmutable $endDate,
ContractNature $nature, ContractNature $nature,
bool $isDriver = false, bool $isDriver = false,
?array $workDaysHours = null,
?InterimAgency $interimAgency = null,
): EmployeeContractPeriod { ): EmployeeContractPeriod {
return new EmployeeContractPeriod() return new EmployeeContractPeriod()
->setEmployee($employee) ->setEmployee($employee)
@@ -27,6 +33,8 @@ final class EmployeeContractPeriodBuilder
->setEndDate($endDate) ->setEndDate($endDate)
->setContractNature($nature) ->setContractNature($nature)
->setIsDriver($isDriver) ->setIsDriver($isDriver)
->setWorkDaysHours($workDaysHours)
->setInterimAgency($interimAgency)
; ;
} }
} }

View File

@@ -7,6 +7,7 @@ namespace App\Service\Contracts;
use App\Entity\Contract; use App\Entity\Contract;
use App\Entity\Employee; use App\Entity\Employee;
use App\Entity\EmployeeContractPeriod; use App\Entity\EmployeeContractPeriod;
use App\Entity\InterimAgency;
use App\Enum\ContractNature; use App\Enum\ContractNature;
use App\Repository\EmployeeContractPeriodRepository; use App\Repository\EmployeeContractPeriodRepository;
use DateTimeImmutable; use DateTimeImmutable;
@@ -29,15 +30,19 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
?DateTimeImmutable $endDate, ?DateTimeImmutable $endDate,
ContractNature $nature, ContractNature $nature,
bool $isDriver = false, bool $isDriver = false,
?array $workDaysHours = null,
?int $interimAgencyId = null,
): void { ): void {
$this->periodValidator->assertPeriodDates($startDate, $endDate, $nature); $this->periodValidator->assertPeriodDates($startDate, $endDate, $nature);
$this->periodValidator->assertWorkDaysHours($contract, $nature, $workDaysHours);
$covered = $this->periodRepository->findOneCoveringDate($employee, $startDate); $covered = $this->periodRepository->findOneCoveringDate($employee, $startDate);
if (null !== $covered) { if (null !== $covered) {
return; return;
} }
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver); $interimAgency = $this->resolveInterimAgency($interimAgencyId);
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver, $workDaysHours, $interimAgency);
$this->entityManager->flush(); $this->entityManager->flush();
} }
@@ -75,8 +80,11 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
ContractNature $nature, ContractNature $nature,
?EmployeeContractPeriod $todayPeriod, ?EmployeeContractPeriod $todayPeriod,
bool $isDriver = false, bool $isDriver = false,
?array $workDaysHours = null,
?int $interimAgencyId = null,
): void { ): void {
$this->periodValidator->assertPeriodDates($startDate, $endDate, $nature); $this->periodValidator->assertPeriodDates($startDate, $endDate, $nature);
$this->periodValidator->assertWorkDaysHours($contract, $nature, $workDaysHours);
if (null !== $todayPeriod) { if (null !== $todayPeriod) {
$this->periodValidator->assertNextStartDateCompatible($startDate, $todayPeriod); $this->periodValidator->assertNextStartDateCompatible($startDate, $todayPeriod);
@@ -86,10 +94,14 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
} }
} }
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver); $interimAgency = $this->resolveInterimAgency($interimAgencyId);
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver, $workDaysHours, $interimAgency);
$this->entityManager->flush(); $this->entityManager->flush();
} }
/**
* @param null|array<int, int> $workDaysHours
*/
private function persistNewPeriod( private function persistNewPeriod(
Employee $employee, Employee $employee,
Contract $contract, Contract $contract,
@@ -97,8 +109,24 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
?DateTimeImmutable $endDate, ?DateTimeImmutable $endDate,
ContractNature $nature, ContractNature $nature,
bool $isDriver = false, bool $isDriver = false,
?array $workDaysHours = null,
?InterimAgency $interimAgency = null,
): void { ): void {
$period = $this->periodBuilder->build($employee, $contract, $startDate, $endDate, $nature, $isDriver); $period = $this->periodBuilder->build($employee, $contract, $startDate, $endDate, $nature, $isDriver, $workDaysHours, $interimAgency);
$this->entityManager->persist($period); $this->entityManager->persist($period);
} }
private function resolveInterimAgency(?int $id): ?InterimAgency
{
if (null === $id) {
return null;
}
$agency = $this->entityManager->find(InterimAgency::class, $id);
if (null === $agency) {
throw new UnprocessableEntityHttpException(sprintf('Interim agency with id %d not found.', $id));
}
return $agency;
}
} }

View File

@@ -12,6 +12,9 @@ use DateTimeImmutable;
interface EmployeeContractPeriodManagerInterface interface EmployeeContractPeriodManagerInterface
{ {
/**
* @param null|array<int, int> $workDaysHours iso-day → minutes
*/
public function ensureContractPeriodExists( public function ensureContractPeriodExists(
Employee $employee, Employee $employee,
Contract $contract, Contract $contract,
@@ -19,6 +22,8 @@ interface EmployeeContractPeriodManagerInterface
?DateTimeImmutable $endDate, ?DateTimeImmutable $endDate,
ContractNature $nature, ContractNature $nature,
bool $isDriver = false, bool $isDriver = false,
?array $workDaysHours = null,
?int $interimAgencyId = null,
): void; ): void;
public function closeCurrentPeriod( public function closeCurrentPeriod(
@@ -29,6 +34,9 @@ interface EmployeeContractPeriodManagerInterface
bool $isAlreadyEnded = false bool $isAlreadyEnded = false
): void; ): void;
/**
* @param null|array<int, int> $workDaysHours iso-day → minutes
*/
public function createNextPeriod( public function createNextPeriod(
Employee $employee, Employee $employee,
Contract $contract, Contract $contract,
@@ -37,5 +45,7 @@ interface EmployeeContractPeriodManagerInterface
ContractNature $nature, ContractNature $nature,
?EmployeeContractPeriod $todayPeriod, ?EmployeeContractPeriod $todayPeriod,
bool $isDriver = false, bool $isDriver = false,
?array $workDaysHours = null,
?int $interimAgencyId = null,
): void; ): void;
} }

View File

@@ -4,8 +4,10 @@ declare(strict_types=1);
namespace App\Service\Contracts; namespace App\Service\Contracts;
use App\Entity\Contract;
use App\Entity\EmployeeContractPeriod; use App\Entity\EmployeeContractPeriod;
use App\Enum\ContractNature; use App\Enum\ContractNature;
use App\Enum\TrackingMode;
use DateTimeImmutable; use DateTimeImmutable;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
@@ -60,4 +62,63 @@ final class EmployeeContractPeriodValidator
throw new UnprocessableEntityHttpException('contractStartDate must be after current contract end date.'); throw new UnprocessableEntityHttpException('contractStartDate must be after current contract end date.');
} }
} }
/**
* Validates the per-period work schedule (`workDaysHours`) against the contract.
*
* Mandatory for non-standard TIME contracts (weeklyHours ∉ {35, 39}, non-INTERIM,
* non-Forfait). Forbidden on standard/forfait/interim contracts (ambiguity).
* When provided, sum of minutes MUST equal weeklyHours × 60.
*
* @param null|array<int, int> $workDaysHours
*/
public function assertWorkDaysHours(?Contract $contract, ContractNature $nature, ?array $workDaysHours): void
{
if (null === $contract) {
return;
}
$trackingMode = $contract->getTrackingMode();
$weeklyHours = $contract->getWeeklyHours();
$isStandard = 35 === $weeklyHours || 39 === $weeklyHours;
$isForfait = TrackingMode::PRESENCE->value === $trackingMode;
$isInterim = ContractNature::INTERIM === $nature;
if ($isForfait || $isInterim || $isStandard) {
if (null !== $workDaysHours && [] !== $workDaysHours) {
throw new UnprocessableEntityHttpException('workDaysHours must not be provided for Forfait, Interim or 35h/39h contracts.');
}
return;
}
if (null === $workDaysHours || [] === $workDaysHours) {
throw new UnprocessableEntityHttpException('workDaysHours is required for non-standard contracts.');
}
$totalMinutes = 0;
foreach ($workDaysHours as $isoDay => $minutes) {
if (!is_int($isoDay) && !(is_string($isoDay) && ctype_digit($isoDay))) {
throw new UnprocessableEntityHttpException('workDaysHours keys must be iso weekdays 1-5 (Mon-Fri) as integers.');
}
$iso = (int) $isoDay;
if ($iso < 1 || $iso > 5) {
throw new UnprocessableEntityHttpException('workDaysHours keys must be iso weekdays 1-5 (Mon-Fri).');
}
if (!is_int($minutes) || $minutes < 0) {
throw new UnprocessableEntityHttpException('workDaysHours values must be non-negative integer minutes.');
}
$totalMinutes += $minutes;
}
$expectedMinutes = ($weeklyHours ?? 0) * 60;
if ($totalMinutes !== $expectedMinutes) {
throw new UnprocessableEntityHttpException(sprintf(
'workDaysHours total must equal contract weekly hours: got %d min, expected %d min.',
$totalMinutes,
$expectedMinutes
));
}
}
} }

View File

@@ -23,6 +23,20 @@ readonly class EmployeeContractResolver
return $period?->getContract(); return $period?->getContract();
} }
/**
* @return null|array<int, int> workDaysHours (iso day → minutes) for the contract period active on $date
*/
public function resolveWorkDaysMinutesForEmployeeAndDate(Employee $employee, DateTimeImmutable $date): ?array
{
$period = $this->periodRepository->findOneCoveringDate($employee, $date);
$raw = $period?->getWorkDaysHours();
if (null === $raw) {
return null;
}
return $this->normalizeWorkDaysMinutes($raw);
}
public function resolveIsDriverForEmployeeAndDate(Employee $employee, DateTimeImmutable $date): bool public function resolveIsDriverForEmployeeAndDate(Employee $employee, DateTimeImmutable $date): bool
{ {
$period = $this->periodRepository->findOneCoveringDate($employee, $date); $period = $this->periodRepository->findOneCoveringDate($employee, $date);
@@ -84,6 +98,57 @@ readonly class EmployeeContractResolver
return $period?->getContractNatureEnum() ?? ContractNature::CDI; return $period?->getContractNatureEnum() ?? ContractNature::CDI;
} }
/**
* @param list<Employee> $employees
* @param list<string> $days
*
* @return array<int, array<string, null|array<int, int>>>
*/
public function resolveWorkDaysMinutesForEmployeesAndDays(array $employees, array $days): array
{
$resolved = [];
if ([] === $employees || [] === $days) {
return $resolved;
}
foreach ($employees as $employee) {
$employeeId = $employee->getId();
if (!$employeeId) {
continue;
}
foreach ($days as $day) {
$resolved[$employeeId][$day] = null;
}
}
$from = new DateTimeImmutable(min($days));
$to = new DateTimeImmutable(max($days));
$periods = $this->periodRepository->findByEmployeesAndDateRange($employees, $from, $to);
foreach ($periods as $period) {
$employeeId = $period->getEmployee()?->getId();
if (!$employeeId) {
continue;
}
$raw = $period->getWorkDaysHours();
if (null === $raw) {
continue;
}
$normalized = $this->normalizeWorkDaysMinutes($raw);
$start = $period->getStartDate()->format('Y-m-d');
$end = $period->getEndDate()?->format('Y-m-d') ?? '9999-12-31';
foreach ($days as $day) {
if ($day < $start || $day > $end) {
continue;
}
$resolved[$employeeId][$day] = $normalized;
}
}
return $resolved;
}
/** /**
* @param list<Employee> $employees * @param list<Employee> $employees
* @param list<string> $days * @param list<string> $days
@@ -177,4 +242,23 @@ readonly class EmployeeContractResolver
return $resolved; return $resolved;
} }
/**
* @param array<int|string, mixed> $raw
*
* @return array<int, int>
*/
private function normalizeWorkDaysMinutes(array $raw): array
{
$result = [];
foreach ($raw as $key => $value) {
$iso = (int) $key;
if ($iso < 1 || $iso > 5) {
continue;
}
$result[$iso] = (int) $value;
}
return $result;
}
} }

View File

@@ -71,7 +71,10 @@ final readonly class LeaveBalanceComputationService
$fractionedDays = $this->resolveFractionedDays($employee, $ruleCode, $year); $fractionedDays = $this->resolveFractionedDays($employee, $ruleCode, $year);
if (LeaveRuleCode::FORFAIT_218 === $ruleCode) { if (LeaveRuleCode::FORFAIT_218 === $ruleCode) {
$totalBusinessDays = $this->countBusinessDays($from, $to); // Business days for forfait must use the RAW holiday list (excluded holidays
// like "Lundi de Pentecôte" / journée de solidarité still count as non-working
// days for the 218-day legal target).
$totalBusinessDays = $this->countBusinessDaysInRange($from, $to, $this->buildRawPublicHolidayMap($from, $to));
$baseAcquiredDays = (float) max(0, $totalBusinessDays - self::FORFAIT_TARGET_WORKED_DAYS); $baseAcquiredDays = (float) max(0, $totalBusinessDays - self::FORFAIT_TARGET_WORKED_DAYS);
$acquiredDays = $carryDays + $baseAcquiredDays + $fractionedDays; $acquiredDays = $carryDays + $baseAcquiredDays + $fractionedDays;
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to); $absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $effectiveFrom, $to);
@@ -406,6 +409,29 @@ final readonly class LeaveBalanceComputationService
return $map; return $map;
} }
/**
* @return array<string, string>
*/
private function buildRawPublicHolidayMap(DateTimeImmutable $from, DateTimeImmutable $to): array
{
$map = [];
$startYear = (int) $from->format('Y');
$endYear = (int) $to->format('Y');
try {
for ($year = $startYear; $year <= $endYear; ++$year) {
$holidays = $this->publicHolidayService->getRawHolidaysDayByYears('metropole', (string) $year);
foreach ($holidays as $date => $label) {
$map[(string) $date] = (string) $label;
}
}
} catch (Throwable) {
return [];
}
return $map;
}
/** /**
* @param list<Absence> $absences * @param list<Absence> $absences
* *

View File

@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace App\Service\Leave;
use App\Entity\Employee;
use App\Enum\ContractNature;
use App\Enum\ContractType;
use App\Enum\TrackingMode;
use App\Repository\EmployeeRttBalanceRepository;
use App\Repository\EmployeeRttPaymentRepository;
use App\Repository\WorkHourRepository;
use App\Service\Rtt\RttRecoveryComputationService;
use App\State\EmployeeLeaveSummaryProvider;
use DateTimeImmutable;
use Throwable;
final readonly class LeaveRecapRowBuilder
{
public function __construct(
private EmployeeLeaveSummaryProvider $leaveSummaryProvider,
private RttRecoveryComputationService $rttRecoveryService,
private EmployeeRttBalanceRepository $rttBalanceRepository,
private EmployeeRttPaymentRepository $rttPaymentRepository,
private WorkHourRepository $workHourRepository,
) {}
/**
* Builds a leave recap row for one employee.
*
* - $asOfDate = null → live behavior (identical to legacy PDF export): accrual capped at
* previous month end, ALL booked absences counted (incl. future ones), RTT uses today
* - $asOfDate = non-null → frozen snapshot at that date: accrual capped at the previous
* month end before asOfDate, absences after asOfDate excluded, RTT uses asOfDate
*
* @return array{
* lastName: string,
* firstName: string,
* contractName: ?string,
* cpN1Remaining: float|string,
* cpN: string,
* acquiredSaturdays: string,
* rtt: string
* }
*/
public function build(Employee $employee, ?DateTimeImmutable $asOfDate = null): array
{
$contract = $employee->getContract();
$contractName = $contract?->getName();
$isForfait = ContractType::FORFAIT === $contract?->getType();
$nature = ContractNature::tryFrom($employee->getCurrentContractNature());
$isInterim = ContractNature::INTERIM === $nature;
$rttReference = $asOfDate ?? new DateTimeImmutable('today');
$cpN1Remaining = 0.0;
$cpN = '-';
$acquiredSaturdays = '-';
$rtt = '-';
if (!$isInterim) {
$leaveYear = $this->leaveSummaryProvider->resolveLeaveYearForToday($employee);
$yearSummary = $this->leaveSummaryProvider->computeYearSummary($employee, $leaveYear, 0.0, $asOfDate);
if (null !== $yearSummary) {
if ($isForfait) {
$paidLeaveDays = $this->leaveSummaryProvider->resolvePaidLeaveDays($employee, $yearSummary['ruleCode'], $leaveYear);
if ($paidLeaveDays > 0.0) {
$recomputed = $this->leaveSummaryProvider->computeYearSummary($employee, $leaveYear, $paidLeaveDays, $asOfDate);
if (null !== $recomputed) {
$yearSummary = $recomputed;
}
}
$cpN1Remaining = round($yearSummary['previousYearRemainingDays'], 2);
$cpN = (string) round($yearSummary['remainingDays'], 2);
$acquiredSaturdays = '-';
} else {
$cpN1Remaining = round($yearSummary['remainingDays'], 2);
$cpN = (string) round($yearSummary['accruingDays'], 2);
$acquiredSaturdays = (string) round($yearSummary['remainingSaturdays'], 2);
}
}
if (!$isForfait && TrackingMode::PRESENCE->value !== $contract?->getTrackingMode()) {
try {
$rtt = $this->formatMinutes($this->computeAvailableRttMinutes($employee, $rttReference));
} catch (Throwable) {
$rtt = '-';
}
}
}
return [
'lastName' => $employee->getLastName(),
'firstName' => $employee->getFirstName(),
'contractName' => $contractName,
'cpN1Remaining' => $cpN1Remaining,
'cpN' => $cpN,
'acquiredSaturdays' => $acquiredSaturdays,
'rtt' => $rtt,
];
}
private function computeAvailableRttMinutes(Employee $employee, DateTimeImmutable $reference): int
{
$month = (int) $reference->format('n');
$year = (int) $reference->format('Y');
$exerciseYear = $month >= 6 ? $year + 1 : $year;
// Exclude incomplete current week: limit to last Sunday
$isoDay = (int) $reference->format('N');
$limitDate = 7 === $isoDay ? $reference : $reference->modify('last sunday');
// Include the current week if all existing days are admin-validated
if (7 !== $isoDay) {
$currentWeekStart = $reference->modify('monday this week');
$currentWeekEnd = $currentWeekStart->modify('+6 days');
$checkEnd = $this->resolveWeekEndForEmployee($employee, $currentWeekStart, $currentWeekEnd, $reference);
if ($this->workHourRepository->isWeekFullyValidated($employee, $currentWeekStart, $checkEnd)) {
$limitDate = $currentWeekEnd;
}
}
// Carry from previous exercise
$carry = 0;
$balance = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $exerciseYear);
if (null !== $balance) {
$carry = $balance->getTotalOpeningMinutes();
} else {
$previousTotal = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $exerciseYear - 1);
$carry = $previousTotal->totalMinutes;
}
// Current exercise (limited to completed weeks)
$current = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $exerciseYear, $limitDate);
// Paid RTT
$paid = 0;
$payments = $this->rttPaymentRepository->findByEmployeeAndYear($employee, $exerciseYear);
foreach ($payments as $payment) {
$paid += $payment->getBase25Minutes() + $payment->getBonus25Minutes()
+ $payment->getBase50Minutes() + $payment->getBonus50Minutes();
}
return $carry + $current->totalMinutes - $paid;
}
private function resolveWeekEndForEmployee(Employee $employee, DateTimeImmutable $weekStart, DateTimeImmutable $weekEnd, DateTimeImmutable $today): DateTimeImmutable
{
foreach ($employee->getContractPeriods() as $period) {
if ($period->getStartDate() > $today) {
continue;
}
$endDate = $period->getEndDate();
if (null === $endDate) {
continue;
}
if ($endDate >= $weekStart && $endDate <= $weekEnd) {
return $endDate;
}
}
return $weekEnd;
}
private function formatMinutes(int $minutes): string
{
if (0 === $minutes) {
return '0 h';
}
$sign = $minutes < 0 ? '- ' : '';
$abs = abs($minutes);
$h = intdiv($abs, 60);
$m = $abs % 60;
return 0 === $m ? "{$sign}{$h} h" : "{$sign}{$h} h {$m} m";
}
}

View File

@@ -17,11 +17,22 @@ use Throwable;
final readonly class PublicHolidayService implements PublicHolidayServiceInterface final readonly class PublicHolidayService implements PublicHolidayServiceInterface
{ {
/**
* @var list<string>
*/
private array $excludedLabels;
public function __construct( public function __construct(
private HttpClientInterface $client, private HttpClientInterface $client,
private string $holidayUrl, private string $holidayUrl,
private CacheInterface $cache, private CacheInterface $cache,
) {} string $excludedLabels = '',
) {
$this->excludedLabels = array_values(array_filter(
array_map('trim', explode(',', $excludedLabels)),
static fn (string $label): bool => '' !== $label,
));
}
/** /**
* @throws TransportExceptionInterface * @throws TransportExceptionInterface
@@ -35,7 +46,7 @@ final readonly class PublicHolidayService implements PublicHolidayServiceInterfa
$zone = strtolower(trim($zone)); $zone = strtolower(trim($zone));
$key = "public_holidays_{$zone}_all"; $key = "public_holidays_{$zone}_all";
return $this->cache->get($key, function (ItemInterface $item) use ($zone): array { $holidays = $this->cache->get($key, function (ItemInterface $item) use ($zone): array {
$item->expiresAfter(30 * 86400); $item->expiresAfter(30 * 86400);
$url = $this->holidayUrl."{$zone}.json"; $url = $this->holidayUrl."{$zone}.json";
@@ -56,6 +67,8 @@ final readonly class PublicHolidayService implements PublicHolidayServiceInterfa
return json_decode($response->getContent(), true); return json_decode($response->getContent(), true);
}); });
return $this->applyExclusions($holidays);
} }
/** /**
@@ -65,6 +78,19 @@ final readonly class PublicHolidayService implements PublicHolidayServiceInterfa
* @throws ClientExceptionInterface * @throws ClientExceptionInterface
*/ */
public function getHolidaysDayByYears(string $zone, string $years): array public function getHolidaysDayByYears(string $zone, string $years): array
{
return $this->applyExclusions($this->fetchHolidaysByYears($zone, $years));
}
public function getRawHolidaysDayByYears(string $zone, string $years): array
{
return $this->fetchHolidaysByYears($zone, $years);
}
/**
* @return array<string, string>
*/
private function fetchHolidaysByYears(string $zone, string $years): array
{ {
$zone = strtolower(trim($zone)); $zone = strtolower(trim($zone));
$years = trim($years); $years = trim($years);
@@ -89,4 +115,21 @@ final readonly class PublicHolidayService implements PublicHolidayServiceInterfa
return json_decode($response->getContent(), true); return json_decode($response->getContent(), true);
}); });
} }
/**
* @param array<string, string> $holidays
*
* @return array<string, string>
*/
private function applyExclusions(array $holidays): array
{
if ([] === $this->excludedLabels) {
return $holidays;
}
return array_filter(
$holidays,
fn (string $label): bool => !in_array($label, $this->excludedLabels, true),
);
}
} }

View File

@@ -9,4 +9,11 @@ interface PublicHolidayServiceInterface
public function getHolidaysDay(string $zone): array; public function getHolidaysDay(string $zone): array;
public function getHolidaysDayByYears(string $zone, string $years): array; public function getHolidaysDayByYears(string $zone, string $years): array;
/**
* Same as getHolidaysDayByYears but WITHOUT the configured exclusions applied.
* Used for legal/contractual computations (e.g. forfait 218 days) where excluded
* holidays (journée de solidarité) must still count as non-working days.
*/
public function getRawHolidaysDayByYears(string $zone, string $years): array;
} }

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