Compare commits
56 Commits
v0.1.89
...
feat/contr
| Author | SHA1 | Date | |
|---|---|---|---|
| 750f2bffa8 | |||
| 653845655f | |||
| 54d9a30f71 | |||
| 2acee03e00 | |||
| bbde6ddcf3 | |||
| f48f1d2f3a | |||
| 3da1cab2c8 | |||
| fd71e2ab65 | |||
| f0dec14b29 | |||
| 9c82e4a0f2 | |||
| 835e758ed1 | |||
| a5c75a9129 | |||
| 9d4a12f6cb | |||
| fd9e551542 | |||
| 3209be8c33 | |||
| 2730e34f31 | |||
| ac53bbd5a1 | |||
| 03acaae135 | |||
| 5fc9dd1ec9 | |||
| 8f355e05ad | |||
| 613ac02e1d | |||
| 8684d240bc | |||
| 5a2a43bf51 | |||
| 9efe0e81a0 | |||
| e7035a7c30 | |||
| a56f797ed7 | |||
| 7ee2e91e71 | |||
| a2874b545a | |||
|
|
b541f9ded8 | ||
| 47f9bea57d | |||
| 7cadcfa362 | |||
|
|
3ec0d4b074 | ||
| eaf8a11e2b | |||
|
|
02fc94fbed | ||
| eb5910dffe | |||
| 78f73ed2e9 | |||
| eacf52425a | |||
|
|
6f43c3356f | ||
| 13eeeb9c86 | |||
|
|
973de2d094 | ||
| 74c109713c | |||
|
|
06173e7225 | ||
| cc868a1e82 | |||
|
|
90843dd997 | ||
| 8a449cf81b | |||
|
|
3926946a5f | ||
| b9c3a8a84f | |||
|
|
b2f6fdf222 | ||
| 0fe82c63c5 | |||
| 849d19f124 | |||
|
|
d230a252b6 | ||
| d46e7c04d5 | |||
|
|
fe0910a661 | ||
| ff7566d4cd | |||
|
|
2f25a3cd52 | ||
| 1fe7f2cdde |
@@ -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
|
||||||
|
|||||||
39
CLAUDE.md
39
CLAUDE.md
@@ -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,7 +31,10 @@
|
|||||||
- 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`.
|
- **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)
|
||||||
@@ -44,6 +48,12 @@
|
|||||||
- Saisie d'heures (ou de jours de présence) autorisée sur un 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`.
|
- **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
|
||||||
@@ -57,6 +67,35 @@
|
|||||||
- 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`.
|
||||||
|
|
||||||
|
## Vue contrat (sélecteur de phase)
|
||||||
|
- Picker `Vue contrat` en haut de la fiche employé (`pages/employees/[id].vue`). Caché si l'employé n'a qu'une phase.
|
||||||
|
- Phase = groupe d'`EmployeeContractPeriod` consécutifs partageant la signature `(contract.type, weeklyHours, isDriver)`. Résolu par `App\Service\Contracts\EmployeeContractPhaseResolver`.
|
||||||
|
- **Filtre `RTT_START_DATE`** : les phases dont `endDate < RTT_START_DATE` sont masquées (aucune donnée logiciel avant la mise en service). Le resolver reçoit la date via DI (`services.yaml`) ; `Employee::getContractPhases()` lit `$_SERVER['RTT_START_DATE']` pour instancier le resolver côté entité.
|
||||||
|
- Exposé via `Employee.contractPhases` (`employee:read`). Endpoints `GET /employees/{id}/leave-summary` et `GET /employees/{id}/rtt-summary` acceptent `?phaseId=N` ; défaut = phase courante.
|
||||||
|
- Sélectionner une phase passée :
|
||||||
|
- Onglet **Congés** : période et règles de la phase (Juin→Mai non-forfait, Jan→Déc FORFAIT). Exercice de transition capé sur `phase.endDate`. **Cap `from` au `phase.startDate` uniquement pour FORFAIT** (sémantique année civile). Pour le non-forfait, l'exercice CP reste annuel et continu à travers les changements d'heures (35h→39h, etc.) — seul `resolveEffectivePeriodStart` clampe sur la date d'entrée en contrat des nouveaux embauchés.
|
||||||
|
- Onglet **RTT** : visible ssi `phase.contractType !== FORFAIT`. Tableau hebdo affiché sur l'exercice complet (Juin→Mai) ; `periodFrom` non capé sur `phase.startDate` (les semaines avant embauche ou hors phase apparaissent à 0). `periodTo`/`limitDate` capés sur `phase.endDate` pour les phases clôturées. `+ Payer les RTT` actif uniquement sur l'exercice contenant `phase.endDate`.
|
||||||
|
- Bandeau jaune affiché en mode phase passée. Édition d'absences et des stocks de report (jours fractionnés, Année N-1 payés) désactivée.
|
||||||
|
- Sélection non persistée — chaque ouverture de fiche démarre sur la phase courante.
|
||||||
|
- CP : solder via `EmployeeContractPeriod.paidLeaveSettledClosureDate` (mécanisme existant). RTT : créer un `EmployeeRttPayment` sur le dernier exercice de la phase.
|
||||||
|
- "Exercise year for date" mutualisé dans `App\Service\Exercise\ExerciseYearResolver` (forfait = année civile, non-forfait = Juin N-1 → Mai N).
|
||||||
|
- Doc complète : `doc/contract-phase-view.md`.
|
||||||
|
|
||||||
## Récap. congés (écran)
|
## 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.
|
- 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.
|
||||||
|
|||||||
14
README.md
14
README.md
@@ -23,5 +23,17 @@ 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';
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Récupérer la bdd de prod en local
|
||||||
|
|
||||||
|
Sur le serveur de prod, créer le dump :
|
||||||
|
```shell
|
||||||
sudo -u postgres pg_dump --no-owner --no-privileges --clean --if-exists sirh_prod > /tmp/sirh_prod_$(date +%F).sql
|
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/
|
```
|
||||||
|
|
||||||
|
En local, récupérer le fichier et l'importer (remplace `YYYY-MM-DD` par la date du dump) :
|
||||||
|
```shell
|
||||||
|
scp user@<serveur>:/tmp/sirh_prod_YYYY-MM-DD.sql ~/workspace/SIRH/sirh.sql
|
||||||
|
docker compose exec -T db psql -U root -d sirh -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
|
||||||
|
docker compose exec -T db psql -U root -d sirh < ~/workspace/SIRH/sirh.sql
|
||||||
|
```
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ services:
|
|||||||
arguments:
|
arguments:
|
||||||
$rttStartDate: '%env(RTT_START_DATE)%'
|
$rttStartDate: '%env(RTT_START_DATE)%'
|
||||||
|
|
||||||
|
App\State\EmployeeLeaveSummaryProvider:
|
||||||
|
arguments:
|
||||||
|
$dataStartDate: '%env(RTT_START_DATE)%'
|
||||||
|
|
||||||
|
App\Service\Contracts\EmployeeContractPhaseResolver:
|
||||||
|
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'
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.89'
|
app.version: '0.1.101'
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
90
doc/contract-phase-view.md
Normal file
90
doc/contract-phase-view.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# Vue contrat — sélecteur de phase
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Permettre à la RH de consulter les onglets Congés et RTT d'un employé selon une phase de contrat passée (ex. un 39h CDI avant un switch FORFAIT, ou un CDD clôturé avec solde de tout compte) sans changer le comportement par défaut sur la phase courante.
|
||||||
|
|
||||||
|
## Concept de "phase"
|
||||||
|
|
||||||
|
Une **phase** = groupe d'`EmployeeContractPeriod` consécutifs (triés par `startDate`) partageant la signature `(contract.type, weeklyHours, isDriver)`. Le service `EmployeeContractPhaseResolver` (`src/Service/Contracts/EmployeeContractPhaseResolver.php`) calcule ces phases à la volée depuis `Employee::getContractPeriods()`.
|
||||||
|
|
||||||
|
Une transition de signature (35h → 39h, 39h → FORFAIT, driver false→true, weeklyHours 28→30, etc.) ouvre une nouvelle phase. Un type différent entre deux périodes de même signature empêche leur fusion (39h → INTERIM → 39h = 3 phases).
|
||||||
|
|
||||||
|
## Filtrage par `RTT_START_DATE`
|
||||||
|
|
||||||
|
Les phases dont la date de fin est strictement antérieure à `RTT_START_DATE` (env, date de mise en service du logiciel) sont **masquées** du picker. Aucune donnée logiciel (heures, absences) n'existe avant cette date, donc consulter une phase entièrement antérieure n'a pas de sens fonctionnel.
|
||||||
|
|
||||||
|
Exemple : un employé sous 35h CDI de 2014 à 2025-10-31 puis 39h CDI depuis 2025-11-01, avec `RTT_START_DATE=2026-02-23` :
|
||||||
|
- Phase 35h (2014 → 2025-10-31) : entièrement avant → masquée
|
||||||
|
- Phase 39h (depuis 2025-11-01) : chevauche → visible
|
||||||
|
- Résultat : 1 seule phase visible → picker caché (seuil ≥ 2 phases)
|
||||||
|
|
||||||
|
Une phase dont la date de fin est égale à `RTT_START_DATE` est conservée (inégalité `>=`, non stricte).
|
||||||
|
|
||||||
|
`EmployeeContractPhaseResolver` reçoit `RTT_START_DATE` via DI (`services.yaml`). L'entité `Employee::getContractPhases()` lit la valeur depuis `$_SERVER`/`$_ENV` directement (l'entité n'a pas d'injection) et passe la chaîne au constructeur du resolver.
|
||||||
|
|
||||||
|
## Picker UI
|
||||||
|
|
||||||
|
- Position : en haut de la fiche employé, sous le nom et au-dessus des onglets.
|
||||||
|
- Libellé : `Vue contrat`.
|
||||||
|
- Caché si l'employé n'a qu'une seule phase.
|
||||||
|
- Sélection non persistée — chaque ouverture de fiche démarre sur la phase courante.
|
||||||
|
|
||||||
|
## Bandeau d'information
|
||||||
|
|
||||||
|
Affiché quand le picker est sur une phase passée. Indique que le mode lecture est partiel — les paiements de solde restent possibles, l'édition d'absences et des stocks de report est désactivée.
|
||||||
|
|
||||||
|
## Effet sur les onglets
|
||||||
|
|
||||||
|
| Onglet | Effet |
|
||||||
|
|---|---|
|
||||||
|
| Congés | Recharge avec les règles de la phase. Période Juin→Mai pour non-forfait, Jan→Déc pour FORFAIT. Exercices bornés à la phase. |
|
||||||
|
| RTT | Visible ssi `phase.contractType !== FORFAIT`. Le tableau affiche **toujours toutes les semaines de l'exercice** (Juin→Mai). Pour une phase clôturée, `periodTo` et `limitDate` sont capés à `phase.endDate` (les semaines après la fin de phase contribuent 0 au cumul). `periodFrom` n'est pas capé : les semaines avant l'embauche ou avant le début d'une phase passée apparaissent à 0 (pas de contrat → pas de référence → 0 minute). |
|
||||||
|
| Heures, Frais, Formation, Contrat, Calendrier | Non impactés. |
|
||||||
|
|
||||||
|
## Paiements de solde sur phase passée
|
||||||
|
|
||||||
|
- **RTT** : `+ Payer les RTT` activé sur le **dernier exercice de la phase** uniquement (l'exercice contenant `phase.endDate`). Les exercices antérieurs restent en lecture seule.
|
||||||
|
- **CP** : utiliser le mécanisme existant `EmployeeContractPeriod.paidLeaveSettledClosureDate` pour solder. Pas de nouveau channel.
|
||||||
|
|
||||||
|
## Transition d'exercice
|
||||||
|
|
||||||
|
Quand un exercice chevauche deux phases, les bornes sont capées différemment selon le type de phase consultée :
|
||||||
|
|
||||||
|
### Phase FORFAIT (passée ou courante)
|
||||||
|
|
||||||
|
Le cumul 218 jours est **par année civile**. Toute consultation FORFAIT cape :
|
||||||
|
- `from` à `max(phase.startDate, 1er janvier de l'année)`
|
||||||
|
- `to` à `min(phase.endDate, 31 décembre de l'année)`
|
||||||
|
|
||||||
|
Ex. switch 39h → FORFAIT au 01/05/2026, vue FORFAIT année 2026 → période = [01/05/2026, 31/12/2026].
|
||||||
|
|
||||||
|
### Phase non-forfait (35h / 39h / CUSTOM / INTERIM)
|
||||||
|
|
||||||
|
L'exercice CP est **annuel** (Juin N-1 → Mai N) et continu à travers les changements d'heures contractuelles dans le même régime non-forfait. La cap **n'applique pas** sur `from` :
|
||||||
|
- `from` reste à 1er juin de l'année (le contrat-entry-date est géré par `resolveEffectivePeriodStart` pour les nouveaux embauchés)
|
||||||
|
- `to` est borné à `phase.endDate` uniquement quand on consulte une **phase passée**
|
||||||
|
|
||||||
|
Ex. employé 35h jusqu'au 31/10/2025 puis 39h depuis le 01/11/2025 :
|
||||||
|
- Vue 39h (courante) sur exercice 2026 → période = [01/06/2025, 31/05/2026]. Acquis CP = exercice complet (~27.5 jours à fin avril).
|
||||||
|
- Vue 35h (passée) sur exercice 2026 → période = [01/06/2025, 31/10/2025]. Acquis CP = 5 mois de l'exercice.
|
||||||
|
|
||||||
|
**Important** : c'est intentionnel que la vue courante 39h inclue les mois Juin-Octobre travaillés en 35h dans son cumul. Le stock CP est annuel, pas par phase ; un changement d'heures ne reset pas le compteur.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Les endpoints suivants acceptent `?phaseId=N` :
|
||||||
|
- `GET /employees/{id}/leave-summary`
|
||||||
|
- `GET /employees/{id}/rtt-summary`
|
||||||
|
|
||||||
|
Quand absent, ils utilisent la phase courante (comportement inchangé).
|
||||||
|
|
||||||
|
`Employee.contractPhases` (lecture, groupe `employee:read`) liste les phases au format `{id, contractType, weeklyHours, isDriver, contractNature, startDate, endDate, periodIds, isCurrent}`.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- `tests/Service/Contracts/EmployeeContractPhaseResolverTest.php` (unit)
|
||||||
|
- `tests/State/EmployeeLeaveSummaryProviderTest.php` (functional, phaseId)
|
||||||
|
- `tests/State/EmployeeRttSummaryProviderTest.php` (functional, phaseId)
|
||||||
|
- `tests/State/EmployeeRttPaymentProcessorTest.php` (functional, dernier exo phase clôturée)
|
||||||
|
- `tests/Service/Exercise/ExerciseYearResolverTest.php` (helper extrait pendant Task 5)
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -165,6 +173,7 @@ Documents complementaires:
|
|||||||
- 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.
|
- 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 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 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
|
- 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 ou de jours de présence autorisée — les heures saisies comptent normalement dans le total hebdo et le calcul RTT
|
- saisie d'heures ou de jours de présence autorisée — les heures saisies comptent normalement dans le total hebdo et le calcul RTT
|
||||||
@@ -305,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)
|
||||||
@@ -327,7 +337,7 @@ 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)
|
## 10bis) Écran Récap. congés (tableau)
|
||||||
@@ -370,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 |
|
||||||
@@ -434,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
|
||||||
|
|
||||||
@@ -452,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
|
||||||
|
|
||||||
|
|||||||
70
doc/leave-tab.md
Normal file
70
doc/leave-tab.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## Sélecteur de phase de contrat
|
||||||
|
|
||||||
|
Quand l'employé a plusieurs phases de contrat (`Employee.contractPhases.length > 1`), le picker `Vue contrat` en haut de la fiche permet de consulter une phase passée. L'onglet Congés bascule alors sur les règles de la phase choisie :
|
||||||
|
- Période Juin→Mai pour les phases non-forfait, Jan→Déc pour FORFAIT.
|
||||||
|
- Sélecteur d'année interne borné aux exercices intersectant la phase.
|
||||||
|
- Bornes d'exercice cappées sur `phase.endDate` côté backend (l'exercice de transition affiche les soldes acquis jusqu'à la date de fin de phase, pas au-delà).
|
||||||
|
- Boutons crayon `Jours fractionnés` / `Année N-1 payés` désactivés (lecture seule sur phase passée).
|
||||||
|
|
||||||
|
Cf. `doc/contract-phase-view.md` pour les détails complets.
|
||||||
|
|
||||||
|
## 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 2000–2100).
|
||||||
62
doc/rtt-tab.md
Normal file
62
doc/rtt-tab.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# 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 2000–2100) ;
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## Sélecteur de phase de contrat
|
||||||
|
|
||||||
|
L'onglet RTT est visible quand la **phase de contrat sélectionnée** n'est pas FORFAIT (et non pas le contrat courant). Concrètement, sur un employé passé en FORFAIT après une période 39h :
|
||||||
|
- En vue `FORFAIT` (défaut), l'onglet RTT est masqué.
|
||||||
|
- En vue `39h` (phase passée sélectionnée via le picker `Vue contrat`), l'onglet RTT redevient visible avec les exercices Juin→Mai bornés à la phase.
|
||||||
|
|
||||||
|
Le bouton `+ Payer les RTT` est activé uniquement sur le **dernier exercice de la phase passée** (l'exercice contenant `phase.endDate`). Les exercices antérieurs sont en lecture seule.
|
||||||
|
|
||||||
|
Cf. `doc/contract-phase-view.md` pour les détails complets.
|
||||||
|
|
||||||
|
## 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 2000–2100) et expose `rttStartDate`.
|
||||||
@@ -2,3 +2,6 @@
|
|||||||
; 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
|
||||||
|
|||||||
1607
docs/superpowers/plans/2026-05-19-contract-phase-view.md
Normal file
1607
docs/superpowers/plans/2026-05-19-contract-phase-view.md
Normal file
File diff suppressed because it is too large
Load Diff
230
docs/superpowers/specs/2026-05-19-contract-phase-view-design.md
Normal file
230
docs/superpowers/specs/2026-05-19-contract-phase-view-design.md
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
# Vue contrat (sélecteur de phase) — Design Spec
|
||||||
|
|
||||||
|
## Objectif
|
||||||
|
|
||||||
|
Permettre à la RH de consulter les onglets Congés et RTT d'un employé **selon le contrat actuel ET selon ses contrats passés**, sans changement de comportement par défaut.
|
||||||
|
|
||||||
|
Cas qui motive : un employé passe de 39h à FORFAIT. Tant que le contrat courant est FORFAIT, les soldes CP/RTT accumulés sous l'ancien contrat 39h sont invisibles ou faussés (l'onglet RTT est masqué, la période Congés passe de Juin→Mai à Jan→Déc, les règles d'acquisition appliquent du FORFAIT_218 à toute année consultée). Conséquence : la RH ne peut plus payer les soldes restants.
|
||||||
|
|
||||||
|
Le même verrou existe pour toute fin de contrat avec **solde de tout compte** (notamment fin de CDD) suivie d'un nouveau contrat de type différent.
|
||||||
|
|
||||||
|
## Principe directeur
|
||||||
|
|
||||||
|
> Une fois passé en contrat X, on utilise toutes les règles X par défaut. Le sélecteur permet de revenir sur les phases passées pour les consulter et solder leurs reliquats.
|
||||||
|
|
||||||
|
## Concept de "phase de contrat"
|
||||||
|
|
||||||
|
Une **phase** est un groupe d'`EmployeeContractPeriod` consécutifs (triés par `startDate`) partageant la même **signature contractuelle** = `(contract.type, weeklyHours, isDriver)`. La fusion s'arrête dès qu'une période diffère sur l'un de ces trois axes, même si une période identique apparaît plus tard.
|
||||||
|
|
||||||
|
La signature inclut `weeklyHours` et `isDriver` parce que :
|
||||||
|
- `weeklyHours` détermine les tranches d'heures supp (25%/50%), le rythme d'acquisition CP (cas 4h), la base contractuelle quotidienne.
|
||||||
|
- `isDriver` change l'écran (`/driver-hours` vs `/hours`) et les colonnes de WorkHour utilisées pour le calcul RTT.
|
||||||
|
|
||||||
|
Exemples :
|
||||||
|
- CDD 39h → CDI 39h → FORFAIT : 2 phases (`39h`, `FORFAIT`).
|
||||||
|
- CDD 35h → CDI 39h : 2 phases (`35h`, `39h`).
|
||||||
|
- 3 CDD 39h consécutifs sans interruption : 1 phase (`39h`).
|
||||||
|
- 39h → INTERIM 4 mois → 39h : 3 phases (les `39h` ne fusionnent pas à travers l'`INTERIM`).
|
||||||
|
- CUSTOM 28h → CUSTOM 30h : 2 phases (`weeklyHours` diffère).
|
||||||
|
- 35h non-driver → 35h driver : 2 phases (`isDriver` diffère).
|
||||||
|
|
||||||
|
La règle de groupement vit dans un service backend, pas dans le frontend.
|
||||||
|
|
||||||
|
## Backend
|
||||||
|
|
||||||
|
### Nouveau service `EmployeeContractPhaseResolver`
|
||||||
|
|
||||||
|
Localisation : `src/Service/Contracts/EmployeeContractPhaseResolver.php`.
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function resolvePhases(Employee $employee): array;
|
||||||
|
```
|
||||||
|
|
||||||
|
Retour : liste ordonnée (plus récente d'abord) de `ContractPhase` :
|
||||||
|
|
||||||
|
| Champ | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `id` | `int` | `EmployeeContractPeriod.id` de la première période (par date) du groupe — sert d'identifiant stable. |
|
||||||
|
| `contractType` | `ContractType` | Type partagé par les périodes du groupe. |
|
||||||
|
| `weeklyHours` | `int` | Heures hebdomadaires (partagées par construction). |
|
||||||
|
| `isDriver` | `bool` | Driver flag (partagé par construction). |
|
||||||
|
| `startDate` | `DateTimeImmutable` | `startDate` de la première période. |
|
||||||
|
| `endDate` | `?DateTimeImmutable` | `endDate` de la dernière période, ou `null` si en cours. |
|
||||||
|
| `periodIds` | `list<int>` | IDs des périodes composant la phase, par ordre chronologique. |
|
||||||
|
| `isCurrent` | `bool` | `true` si la phase couvre la date du jour (= `endDate === null` ou `endDate >= today`). |
|
||||||
|
|
||||||
|
### Exposition API
|
||||||
|
|
||||||
|
Nouveau computed field sur `Employee` (lecture seule, groupe `employee:read`) :
|
||||||
|
|
||||||
|
```json
|
||||||
|
"contractPhases": [
|
||||||
|
{ "id": 42, "contractType": "FORFAIT", "weeklyHours": 39, "isDriver": false, "startDate": "2026-05-01", "endDate": null, "isCurrent": true },
|
||||||
|
{ "id": 17, "contractType": "THIRTY_NINE_HOURS", "weeklyHours": 39, "isDriver": false, "startDate": "2020-06-01", "endDate": "2026-04-30", "isCurrent": false }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Le calcul se fait à la sérialisation via un getter virtuel `Employee::getContractPhases(): array` qui délègue au resolver.
|
||||||
|
|
||||||
|
### Endpoints impactés
|
||||||
|
|
||||||
|
Les endpoints `GET /employees/{id}/leave-summary` et `GET /employees/{id}/rtt-summary` acceptent un nouveau paramètre optionnel :
|
||||||
|
|
||||||
|
- `?phaseId=N` : id de la phase à consulter.
|
||||||
|
- Si absent → phase courante (= comportement actuel inchangé).
|
||||||
|
- Si invalide (phase n'appartient pas à l'employé) → 422.
|
||||||
|
- `?year=YYYY` reste accepté en parallèle et continue de cibler un exercice précis.
|
||||||
|
|
||||||
|
### Modifications des providers
|
||||||
|
|
||||||
|
**`EmployeeLeaveSummaryProvider`** :
|
||||||
|
- Nouvelle méthode `resolveTargetPhase(Employee $e, ?int $phaseId): ContractPhase` qui retourne la phase demandée ou la phase courante.
|
||||||
|
- `resolveLeavePolicy(...)` reçoit la phase au lieu de lire `$employee->getContract()`. Le `contract.type` et le `weeklyHours` viennent de la signature de la phase (homogène par construction).
|
||||||
|
- `resolvePeriodBounds(...)` : les bornes de l'exercice sont en plus contraintes à `[max(periodStart, phase.startDate), min(periodEnd, phase.endDate ?? periodEnd)]`.
|
||||||
|
- `resolveYear(...)` : si `phaseId` fourni et pas de `year` explicite, default = dernier exercice intersectant la phase (= année de `phase.endDate` ou année courante si phase en cours).
|
||||||
|
- Si `?year` est fourni hors de la plage des exercices intersectant la phase → **clamp silencieux** à l'exercice valide le plus proche, pas d'erreur 422 (cohérence avec l'expérience picker frontend).
|
||||||
|
- `resolveAccrualCalculationEndDate(...)` et `resolveTakenCalculationEndDate(...)` : caps additionnels sur `phase.endDate` quand la phase n'est pas la phase courante.
|
||||||
|
- `resolveFirstComputationYear(...)` : restreint aux exercices intersectant la phase.
|
||||||
|
|
||||||
|
**`EmployeeRttSummaryProvider`** :
|
||||||
|
- Mêmes principes : `?phaseId` côté API, `resolveTargetPhase`, bornes d'exercice cappées à la phase, `rttStartDate` exposé pour borner le sélecteur d'année frontend.
|
||||||
|
- Pour une phase FORFAIT, le tab RTT est masqué frontend, donc l'endpoint n'est pas appelé en pratique. Pas de garde spécifique côté backend, le comportement existant (retour d'un summary potentiellement vide/inutile) suffit.
|
||||||
|
|
||||||
|
### Paiements de solde
|
||||||
|
|
||||||
|
**RTT — `EmployeeRttPaymentProcessor`** :
|
||||||
|
- Garde actuelle "exercice courant uniquement" devient : "exercice courant OU dernier exercice d'une phase clôturée".
|
||||||
|
- Concrètement, autoriser la création d'un `EmployeeRttPayment` sur l'exercice contenant `phase.endDate` d'une phase non courante.
|
||||||
|
- Les exercices antérieurs au dernier de la phase restent verrouillés (lecture seule).
|
||||||
|
|
||||||
|
**CP — settlement period-level** :
|
||||||
|
- Le mécanisme existant `EmployeeContractPeriod.paidLeaveSettledClosureDate` reste le canal pour solder. Aucun changement de modèle.
|
||||||
|
- Le bouton "Année N-1 payés" (FORFAIT) et "Jours fractionnés" (non-FORFAIT) restent désactivés sur une phase passée — ce ne sont pas des paiements de solde mais des éditions de stock.
|
||||||
|
|
||||||
|
### Audit
|
||||||
|
|
||||||
|
- La création d'`EmployeeRttPayment` est déjà auditée (existant).
|
||||||
|
- La modification de `paidLeaveSettledClosureDate` est déjà auditée via `EmployeeContractPeriodManager` (existant).
|
||||||
|
- Aucun audit nouveau requis.
|
||||||
|
|
||||||
|
## Frontend
|
||||||
|
|
||||||
|
### Picker
|
||||||
|
|
||||||
|
- Composant `MalioSelect` placé dans `pages/employees/[id].vue`, dans le header de la fiche, sous le nom de l'employé et au-dessus de la barre d'onglets.
|
||||||
|
- Libellé : `Vue contrat`.
|
||||||
|
- Options formatées :
|
||||||
|
- `FORFAIT — depuis 01/05/2026 (actuel)`
|
||||||
|
- `39h CDI — 01/06/2020 → 30/04/2026`
|
||||||
|
- **Caché** si `contractPhases.length <= 1` (employé mono-phase, ~majorité des cas).
|
||||||
|
- Sélection en mémoire (état du composable), **non persistée** entre navigations ou rechargements. Chaque ouverture de fiche démarre sur la phase courante.
|
||||||
|
|
||||||
|
### Bandeau d'information
|
||||||
|
|
||||||
|
Affiché quand `selectedPhase.id !== currentPhase.id` :
|
||||||
|
|
||||||
|
> Vous consultez l'historique **{contractType} — jusqu'au {endDate}**.
|
||||||
|
> Les paiements de solde sont possibles ; l'édition d'absences et des stocks de report est désactivée.
|
||||||
|
|
||||||
|
Style : bandeau jaune doux (`bg-warning-100 border-warning-300`), sous le picker, au-dessus des onglets.
|
||||||
|
|
||||||
|
### Composables
|
||||||
|
|
||||||
|
**Nouveau `useEmployeeContractPhase()`** :
|
||||||
|
- État : `selectedPhase`, `currentPhase` (computed depuis `employee.contractPhases`).
|
||||||
|
- Computed : `availablePhases`, `isViewingPastPhase`.
|
||||||
|
- API : `setSelectedPhase(phaseId)`, `resetToCurrent()`.
|
||||||
|
- `resetToCurrent()` appelé au changement d'employé.
|
||||||
|
|
||||||
|
**`useEmployeeLeave`** :
|
||||||
|
- Reçoit `phaseId` en paramètre lors des appels à `getEmployeeLeaveSummary` / `listAbsences`.
|
||||||
|
- `availableLeaveYears` borné aux exercices intersectant la phase sélectionnée.
|
||||||
|
- `setSelectedPhase` côté parent → reset de `selectedLeaveYear` et reload.
|
||||||
|
|
||||||
|
**`useEmployeeRtt`** :
|
||||||
|
- Idem pour `getEmployeeRttSummary`, `availableRttYears`.
|
||||||
|
|
||||||
|
**`useEmployeeDetailPage`** :
|
||||||
|
- `showRttTab` devient : `selectedPhase.contractType !== FORFAIT`.
|
||||||
|
- La logique de fallback ("si sur l'onglet RTT et FORFAIT, basculer ailleurs") s'applique aussi quand on bascule de phase 39h vers phase FORFAIT.
|
||||||
|
|
||||||
|
### Onglets
|
||||||
|
|
||||||
|
- **Congés** : reçoit la phase via le composable. Bouton "Année N-1 payés" / "Jours fractionnés" reste désactivé sur phase passée (idem que sur exercice passé).
|
||||||
|
- **RTT** : visibilité driver par la phase. Bouton "+ Payer les RTT" activé **uniquement sur le dernier exercice de la phase passée**, désactivé sur les exercices antérieurs de la phase.
|
||||||
|
- **Heures, Frais, Formation, Contrat, Calendrier** : non impactés.
|
||||||
|
|
||||||
|
### Format des libellés du picker
|
||||||
|
|
||||||
|
Format de la phase : `{labelContractType} — {startDateFR} → {endDateFR}`, suffixé `(actuel)` si phase courante.
|
||||||
|
|
||||||
|
`labelContractType` mapping :
|
||||||
|
- `FORFAIT` → `FORFAIT`
|
||||||
|
- `THIRTY_FIVE_HOURS` → `35h`
|
||||||
|
- `THIRTY_NINE_HOURS` → `39h`
|
||||||
|
- `INTERIM` → `Intérim`
|
||||||
|
- `CUSTOM` → `CUSTOM ({weeklyHours}h)` (les heures hebdo sont homogènes par construction dans une phase)
|
||||||
|
|
||||||
|
Suffixe `(driver)` ajouté quand `isDriver=true`, ex. `35h CDI (driver) — ...`.
|
||||||
|
|
||||||
|
## Migration et impact sur l'existant
|
||||||
|
|
||||||
|
- Aucune migration de données. Le concept de phase est calculé à la volée depuis l'historique existant.
|
||||||
|
- Comportement inchangé pour tout employé avec une seule phase (cas standard).
|
||||||
|
- Comportement inchangé quand `phaseId` n'est pas fourni → phase courante.
|
||||||
|
- Pas de breaking change API : `contractPhases` est un champ additionnel ; `?phaseId` est un paramètre optionnel.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
### Unit
|
||||||
|
|
||||||
|
- `EmployeeContractPhaseResolverTest` :
|
||||||
|
- Employé mono-période → 1 phase, `isCurrent=true`.
|
||||||
|
- Trois périodes même signature consécutives → 1 phase.
|
||||||
|
- Switch 39h → FORFAIT → 2 phases avec `startDate`/`endDate` correctes.
|
||||||
|
- 39h → INTERIM 4 mois → 39h → 3 phases (pas de fusion).
|
||||||
|
- 35h → 39h → 2 phases (type différent).
|
||||||
|
- CUSTOM 28h → CUSTOM 30h → 2 phases (`weeklyHours` diffère).
|
||||||
|
- 35h non-driver → 35h driver → 2 phases (`isDriver` diffère).
|
||||||
|
|
||||||
|
### Functional
|
||||||
|
|
||||||
|
- `EmployeeLeaveSummaryProvider` avec `phaseId` :
|
||||||
|
- Phase 39h passée → `ruleCode = CDI_CDD_NON_FORFAIT`, période Juin→Mai, exercice de transition capé à `phase.endDate`.
|
||||||
|
- Phase FORFAIT passée → `ruleCode = FORFAIT_218`, période Jan→Déc.
|
||||||
|
- `phaseId` invalide pour l'employé → 422.
|
||||||
|
- `?year` hors de la plage de la phase → clamp silencieux à l'exercice intersectant le plus proche.
|
||||||
|
|
||||||
|
- `EmployeeRttSummaryProvider` avec `phaseId` :
|
||||||
|
- Phase 39h passée → données RTT renvoyées, bornes cappées sur `phase.endDate`.
|
||||||
|
- `?year` hors de la plage de la phase → clamp silencieux.
|
||||||
|
|
||||||
|
- `EmployeeRttPaymentProcessor` :
|
||||||
|
- Création autorisée sur exercice de fin d'une phase passée.
|
||||||
|
- Création refusée sur un exercice antérieur d'une phase passée.
|
||||||
|
|
||||||
|
### Documentation à mettre à jour
|
||||||
|
|
||||||
|
Obligatoire par CLAUDE.md :
|
||||||
|
|
||||||
|
- `doc/contract-phase-view.md` — nouveau fichier détaillant la fonctionnalité.
|
||||||
|
- `doc/leave-tab.md` — section "Sélecteur de phase" + interaction avec le sélecteur d'année.
|
||||||
|
- `doc/rtt-tab.md` — section "Sélecteur de phase" + règle de visibilité.
|
||||||
|
- `frontend/data/documentation-content.ts` — article niveau `admin`.
|
||||||
|
- `CLAUDE.md` — bloc "Vue contrat (sélecteur de phase)" sous Onglet Congés / Onglet RTT.
|
||||||
|
|
||||||
|
## Hors scope
|
||||||
|
|
||||||
|
- Surface d'alerte automatique sur les fiches employés ayant des soldes non payés sur des phases passées (potentiel follow-up).
|
||||||
|
- Persistance de la sélection du picker entre navigations.
|
||||||
|
- Picker exposé sur le calendrier global ou tout autre écran que la fiche employé.
|
||||||
|
- Modification de `WorkHourDayContext` (déjà date-driven, pas concerné).
|
||||||
|
- Évolution du mécanisme `paidLeaveSettledClosureDate` (canal existant suffisant).
|
||||||
|
- Cas exotiques : phases overlap (interdit par la modélisation actuelle), périodes avec dates incohérentes.
|
||||||
|
|
||||||
|
## Décisions confirmées avec l'utilisateur
|
||||||
|
|
||||||
|
- Picker global en haut de la fiche, **pas** par onglet.
|
||||||
|
- Phases groupées par `contract.type` consécutif.
|
||||||
|
- Sur une phase passée : exercices antérieurs visibles **en lecture seule**, seul le dernier exercice de la phase ouvre les actions de solde (RTT pay, CP settlement period-level).
|
||||||
|
- Comportement par défaut (phase courante) strictement inchangé.
|
||||||
1
frontend/.npmrc
Normal file
1
frontend/.npmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@malio:registry=https://gitea.malio.fr/api/packages/MALIO-DEV/npm/
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -9,8 +9,15 @@
|
|||||||
<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="min-h-0 flex-1 overflow-y-auto px-[20px] pb-4">
|
<div class="min-h-0 flex-1 overflow-y-auto px-[20px] pb-4 pt-1">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
108
frontend/components/BulkYearlyHoursDrawer.vue
Normal file
108
frontend/components/BulkYearlyHoursDrawer.vue
Normal 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>
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
</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>
|
<span>
|
||||||
{{ employee.site?.name ?? 'Sans site' }}<span v-if="employee.currentContractNature"> — {{ contractNatureLabel(employee.currentContractNature) }}</span>
|
{{ employee.site?.name ?? 'Sans site' }}<span v-if="getRowContractNature(employee.id)"> — {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
|
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
|
||||||
@@ -205,6 +205,7 @@ const props = defineProps<{
|
|||||||
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; workshopMinutes: number; totalMinutes: number; virtualHolidayMinutes: 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
|
||||||
|
|||||||
@@ -33,8 +33,11 @@
|
|||||||
{{ 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">
|
<p class="text-[11px] text-neutral-500 truncate inline-flex items-center gap-2">
|
||||||
{{ row.siteName ?? 'Sans site' }}<span v-if="row.contractNature"> — {{ contractNatureLabel(row.contractNature) }}</span>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -44,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>
|
||||||
@@ -93,19 +96,37 @@
|
|||||||
import type { WeeklyWorkHourSummary } from '~/services/dto/work-hour'
|
import type { WeeklyWorkHourSummary } from '~/services/dto/work-hour'
|
||||||
import { contractNatureLabel } from '~/utils/contract'
|
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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -221,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>
|
||||||
@@ -282,6 +298,7 @@
|
|||||||
<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'
|
import WorkDaysHoursInput from '~/components/employees/WorkDaysHoursInput.vue'
|
||||||
|
|
||||||
type SuspensionForm = {
|
type SuspensionForm = {
|
||||||
@@ -310,6 +327,7 @@ type CreateContractForm = {
|
|||||||
endDate: string
|
endDate: string
|
||||||
isDriver: boolean
|
isDriver: boolean
|
||||||
workDaysHours: Record<number, number> | null
|
workDaysHours: Record<number, number> | null
|
||||||
|
interimAgencyId: number | ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -351,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')
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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="isPayDisabled"
|
||||||
@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> </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"
|
||||||
/>
|
/>
|
||||||
@@ -248,16 +276,54 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { EmployeeRttSummary, EmployeeRttWeekSummary } from '~/services/dto/employee-rtt-summary'
|
import type { EmployeeRttSummary, EmployeeRttWeekSummary } from '~/services/dto/employee-rtt-summary'
|
||||||
|
import type { ContractPhase } from '~/services/dto/contract-phase'
|
||||||
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
|
||||||
|
selectedPhase: ContractPhase | 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 isLastExerciseOfPhase = computed(() => {
|
||||||
|
if (!props.selectedPhase || props.selectedPhase.isCurrent) return false
|
||||||
|
if (!props.selectedPhase.endDate) return false
|
||||||
|
const endDate = new Date(`${props.selectedPhase.endDate}T00:00:00`)
|
||||||
|
const endYear = endDate.getMonth() >= 5
|
||||||
|
? endDate.getFullYear() + 1
|
||||||
|
: endDate.getFullYear()
|
||||||
|
return props.selectedYear === endYear
|
||||||
|
})
|
||||||
|
|
||||||
|
const isPayDisabled = computed(() =>
|
||||||
|
isHistoricalYear.value && !isLastExerciseOfPhase.value
|
||||||
|
)
|
||||||
|
|
||||||
|
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 +379,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 +566,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 +582,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)
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -44,7 +212,7 @@
|
|||||||
</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>
|
<span>
|
||||||
{{ employee.site?.name ?? 'Sans site' }}<span v-if="employee.currentContractNature"> — {{ contractNatureLabel(employee.currentContractNature) }}</span>
|
{{ employee.site?.name ?? 'Sans site' }}<span v-if="getRowContractNature(employee.id)"> — {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
|
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
|
||||||
@@ -237,6 +405,7 @@ const props = defineProps<{
|
|||||||
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,8 +94,11 @@
|
|||||||
{{ 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">
|
<p class="text-[11px] text-neutral-500 truncate inline-flex items-center gap-2">
|
||||||
{{ row.siteName ?? 'Sans site' }}<span v-if="row.contractNature"> — {{ contractNatureLabel(row.contractNature) }}</span>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -40,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>
|
||||||
@@ -89,19 +157,37 @@ 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>
|
||||||
|
|||||||
81
frontend/components/hours/WeekCommentDrawer.vue
Normal file
81
frontend/components/hours/WeekCommentDrawer.vue
Normal 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>
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -417,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
|
||||||
@@ -922,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,
|
||||||
@@ -982,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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, formatWorkDaysHoursSummary, isContractNature, requiresContractEndDate, requiresWorkDaysHours, showsContractEndDate } from '~/utils/contract'
|
import { contractNatureLabel, formatWorkDaysHoursSummary, isContractNature, requiresContractEndDate, requiresWorkDaysHours, showsContractEndDate } from '~/utils/contract'
|
||||||
|
|
||||||
@@ -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)
|
||||||
@@ -46,7 +48,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
|
|||||||
startDate: '',
|
startDate: '',
|
||||||
endDate: '',
|
endDate: '',
|
||||||
isDriver: false,
|
isDriver: false,
|
||||||
workDaysHours: null as Record<number, number> | null
|
workDaysHours: null as Record<number, number> | null,
|
||||||
|
interimAgencyId: '' as number | ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const createValidationTouched = reactive({
|
const createValidationTouched = reactive({
|
||||||
@@ -207,6 +210,7 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
|
|||||||
createContractForm.endDate = ''
|
createContractForm.endDate = ''
|
||||||
createContractForm.isDriver = false
|
createContractForm.isDriver = false
|
||||||
createContractForm.workDaysHours = null
|
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()
|
||||||
@@ -283,7 +287,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
|
|||||||
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
|
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()
|
||||||
@@ -335,6 +340,16 @@ 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 = ''
|
||||||
@@ -386,6 +401,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
|
|||||||
submitSuspension,
|
submitSuspension,
|
||||||
addSuspensionForm,
|
addSuspensionForm,
|
||||||
currentActiveContractPeriodId,
|
currentActiveContractPeriodId,
|
||||||
loadContracts
|
interimAgencies,
|
||||||
|
loadContracts,
|
||||||
|
loadInterimAgencies
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
83
frontend/composables/useEmployeeContractPhase.ts
Normal file
83
frontend/composables/useEmployeeContractPhase.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import type { Ref } from 'vue'
|
||||||
|
import type { Employee } from '~/services/dto/employee'
|
||||||
|
import type { ContractPhase } from '~/services/dto/contract-phase'
|
||||||
|
import { CONTRACT_TYPES } from '~/services/dto/contract'
|
||||||
|
|
||||||
|
const formatDateFr = (iso: string | null): string => {
|
||||||
|
if (!iso) return ''
|
||||||
|
const [y, m, d] = iso.split('-')
|
||||||
|
return `${d}/${m}/${y}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatContractTypeLabel = (phase: ContractPhase): string => {
|
||||||
|
switch (phase.contractType) {
|
||||||
|
case CONTRACT_TYPES.FORFAIT:
|
||||||
|
return 'FORFAIT'
|
||||||
|
case CONTRACT_TYPES.H35:
|
||||||
|
return '35h'
|
||||||
|
case CONTRACT_TYPES.H39:
|
||||||
|
return '39h'
|
||||||
|
case CONTRACT_TYPES.INTERIM:
|
||||||
|
return 'Intérim'
|
||||||
|
case CONTRACT_TYPES.CUSTOM:
|
||||||
|
return `CUSTOM (${phase.weeklyHours ?? '?'}h)`
|
||||||
|
default:
|
||||||
|
return String(phase.contractType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatPhaseLabel = (phase: ContractPhase): string => {
|
||||||
|
const base = formatContractTypeLabel(phase)
|
||||||
|
const driver = phase.isDriver ? ' (driver)' : ''
|
||||||
|
const dates = phase.endDate
|
||||||
|
? `${formatDateFr(phase.startDate)} → ${formatDateFr(phase.endDate)}`
|
||||||
|
: `depuis ${formatDateFr(phase.startDate)}`
|
||||||
|
const suffix = phase.isCurrent ? ' (actuel)' : ''
|
||||||
|
return `${base}${driver} — ${dates}${suffix}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useEmployeeContractPhase = (employee: Ref<Employee | null>) => {
|
||||||
|
const selectedPhaseId = ref<number | null>(null)
|
||||||
|
|
||||||
|
const availablePhases = computed<ContractPhase[]>(() => employee.value?.contractPhases ?? [])
|
||||||
|
|
||||||
|
const currentPhase = computed<ContractPhase | null>(() => {
|
||||||
|
return availablePhases.value.find((p) => p.isCurrent) ?? availablePhases.value[0] ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedPhase = computed<ContractPhase | null>(() => {
|
||||||
|
if (selectedPhaseId.value === null) return currentPhase.value
|
||||||
|
return availablePhases.value.find((p) => p.id === selectedPhaseId.value) ?? currentPhase.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const isViewingPastPhase = computed<boolean>(() => {
|
||||||
|
if (!selectedPhase.value || !currentPhase.value) return false
|
||||||
|
return selectedPhase.value.id !== currentPhase.value.id
|
||||||
|
})
|
||||||
|
|
||||||
|
const phaseOptions = computed(() =>
|
||||||
|
availablePhases.value.map((p) => ({ value: p.id, label: formatPhaseLabel(p) }))
|
||||||
|
)
|
||||||
|
|
||||||
|
const showPicker = computed(() => availablePhases.value.length > 1)
|
||||||
|
|
||||||
|
const setSelectedPhase = (phaseId: number) => {
|
||||||
|
selectedPhaseId.value = phaseId
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetToCurrent = () => {
|
||||||
|
selectedPhaseId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedPhaseId,
|
||||||
|
selectedPhase,
|
||||||
|
currentPhase,
|
||||||
|
availablePhases,
|
||||||
|
phaseOptions,
|
||||||
|
showPicker,
|
||||||
|
isViewingPastPhase,
|
||||||
|
setSelectedPhase,
|
||||||
|
resetToCurrent,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Employee } from '~/services/dto/employee'
|
import type { Employee } from '~/services/dto/employee'
|
||||||
import { CONTRACT_TYPES } from '~/services/dto/contract'
|
import { CONTRACT_TYPES } from '~/services/dto/contract'
|
||||||
import { getEmployee } from '~/services/employees'
|
import { getEmployee } from '~/services/employees'
|
||||||
|
import { useEmployeeContractPhase } from '~/composables/useEmployeeContractPhase'
|
||||||
|
|
||||||
export const useEmployeeDetailPage = () => {
|
export const useEmployeeDetailPage = () => {
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -8,12 +9,15 @@ export const useEmployeeDetailPage = () => {
|
|||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const activeTab = ref<'contract' | 'leave' | 'rtt' | 'mileage' | 'formation' | 'bonus' | 'observation'>('contract')
|
const activeTab = ref<'contract' | 'leave' | 'rtt' | 'mileage' | 'formation' | 'bonus' | 'observation'>('contract')
|
||||||
|
|
||||||
|
const phase = useEmployeeContractPhase(employee)
|
||||||
|
|
||||||
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(() => phase.selectedPhase.value?.contractType !== CONTRACT_TYPES.FORFAIT)
|
||||||
|
const isForfait = computed(() => phase.selectedPhase.value?.contractType === 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 || '-'
|
||||||
})
|
})
|
||||||
@@ -28,6 +32,7 @@ export const useEmployeeDetailPage = () => {
|
|||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
employee.value = await getEmployee(employeeId)
|
employee.value = await getEmployee(employeeId)
|
||||||
|
phase.resetToCurrent()
|
||||||
|
|
||||||
if (!showLeaveTab.value && activeTab.value === 'leave') {
|
if (!showLeaveTab.value && activeTab.value === 'leave') {
|
||||||
activeTab.value = 'contract'
|
activeTab.value = 'contract'
|
||||||
@@ -55,6 +60,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
|
||||||
@@ -62,13 +70,34 @@ export const useEmployeeDetailPage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const contract = useEmployeeContract(employee, loadEmployee)
|
const contract = useEmployeeContract(employee, loadEmployee)
|
||||||
const leave = useEmployeeLeave(employee, loadEmployee)
|
const leave = useEmployeeLeave(employee, loadEmployee, phase.selectedPhase)
|
||||||
const rtt = useEmployeeRtt(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, phase.selectedPhase)
|
||||||
const mileage = useEmployeeMileage(employee, loadEmployee)
|
const mileage = useEmployeeMileage(employee, loadEmployee)
|
||||||
const formation = useEmployeeFormation(employee, loadEmployee)
|
const formation = useEmployeeFormation(employee, loadEmployee)
|
||||||
const bonus = useEmployeeBonus(employee, loadEmployee)
|
const bonus = useEmployeeBonus(employee, loadEmployee)
|
||||||
const observation = useEmployeeObservation(employee, loadEmployee)
|
const observation = useEmployeeObservation(employee, loadEmployee)
|
||||||
|
|
||||||
|
watch(() => phase.selectedPhase.value?.id, (newId, oldId) => {
|
||||||
|
if (newId === oldId || oldId === undefined) return
|
||||||
|
// Bascule onglet si on entre dans une phase qui ne supporte plus le tab actuel
|
||||||
|
if (!showRttTab.value && activeTab.value === 'rtt') {
|
||||||
|
activeTab.value = 'leave'
|
||||||
|
}
|
||||||
|
// Recharger l'onglet courant
|
||||||
|
if (activeTab.value === 'leave' && showLeaveTab.value) {
|
||||||
|
leave.loadLeaveData()
|
||||||
|
} else if (activeTab.value === 'rtt' && showRttTab.value) {
|
||||||
|
rtt.loadRttData()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
watch(activeTab, (tab) => {
|
watch(activeTab, (tab) => {
|
||||||
if (tab === 'leave' && !leave.leaveDataLoaded.value && showLeaveTab.value) {
|
if (tab === 'leave' && !leave.leaveDataLoaded.value && showLeaveTab.value) {
|
||||||
leave.loadLeaveData()
|
leave.loadLeaveData()
|
||||||
@@ -86,7 +115,7 @@ export const useEmployeeDetailPage = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await contract.loadContracts()
|
await Promise.all([contract.loadContracts(), contract.loadInterimAgencies()])
|
||||||
await loadEmployee()
|
await loadEmployee()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -97,6 +126,8 @@ export const useEmployeeDetailPage = () => {
|
|||||||
showLeaveTab,
|
showLeaveTab,
|
||||||
showRttTab,
|
showRttTab,
|
||||||
employeeContractWorkLabel,
|
employeeContractWorkLabel,
|
||||||
|
forfaitRemainingDaysLabel,
|
||||||
|
...phase,
|
||||||
...contract,
|
...contract,
|
||||||
...leave,
|
...leave,
|
||||||
...rtt,
|
...rtt,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Ref } from 'vue'
|
import type { Ref } from 'vue'
|
||||||
import type { Absence } from '~/services/dto/absence'
|
import type { Absence } from '~/services/dto/absence'
|
||||||
|
import type { ContractPhase } from '~/services/dto/contract-phase'
|
||||||
import type { EmployeeLeaveSummary } from '~/services/dto/employee-leave-summary'
|
import type { EmployeeLeaveSummary } from '~/services/dto/employee-leave-summary'
|
||||||
import type { Employee } from '~/services/dto/employee'
|
import type { Employee } from '~/services/dto/employee'
|
||||||
import { CONTRACT_TYPES } from '~/services/dto/contract'
|
import { CONTRACT_TYPES } from '~/services/dto/contract'
|
||||||
@@ -7,34 +8,99 @@ 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 const useEmployeeLeave = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
|
export type LeaveYearOption = {
|
||||||
|
value: number
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useEmployeeLeave = (
|
||||||
|
employee: Ref<Employee | null>,
|
||||||
|
reloadEmployee: () => Promise<void>,
|
||||||
|
selectedPhase: Ref<ContractPhase | null>,
|
||||||
|
) => {
|
||||||
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 isForfaitOnPhase = computed(() =>
|
||||||
const now = new Date()
|
selectedPhase.value?.contractType === CONTRACT_TYPES.FORFAIT
|
||||||
const isForfait = employee.value?.contract?.type === CONTRACT_TYPES.FORFAIT
|
)
|
||||||
return isForfait
|
|
||||||
? now.getFullYear()
|
const computeLeaveYearForDate = (date: Date): number => {
|
||||||
: (now.getMonth() >= 5 ? now.getFullYear() + 1 : now.getFullYear())
|
if (isForfaitOnPhase.value) return date.getFullYear()
|
||||||
|
return date.getMonth() >= 5 ? date.getFullYear() + 1 : date.getFullYear()
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentLeaveYear = computed<number | null>(() => {
|
||||||
|
if (!employee.value) return null
|
||||||
|
return computeLeaveYearForDate(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 || !selectedPhase.value || currentLeaveYear.value === null) return []
|
||||||
|
const isForfait = isForfaitOnPhase.value
|
||||||
|
const phase = selectedPhase.value
|
||||||
|
|
||||||
|
// Plage = exercices intersectant la phase.
|
||||||
|
const phaseStartYear = computeLeaveYearForDate(new Date(`${phase.startDate}T00:00:00`))
|
||||||
|
const phaseEndYear = phase.endDate
|
||||||
|
? computeLeaveYearForDate(new Date(`${phase.endDate}T00:00:00`))
|
||||||
|
: currentLeaveYear.value
|
||||||
|
|
||||||
|
// 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(dataStartDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const minYear = dataFloor !== null ? Math.max(phaseStartYear, dataFloor) : phaseStartYear
|
||||||
|
const maxYear = phaseEndYear
|
||||||
|
|
||||||
|
const years: LeaveYearOption[] = []
|
||||||
|
for (let y = maxYear; 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 = isForfaitOnPhase.value
|
||||||
const leaveYear = getLeaveYear()
|
const leaveYear = selectedLeaveYear.value
|
||||||
const from = isForfait ? `${leaveYear}-01-01` : `${leaveYear - 1}-06-01`
|
let from = isForfait ? `${leaveYear}-01-01` : `${leaveYear - 1}-06-01`
|
||||||
const to = isForfait ? `${leaveYear}-12-31` : `${leaveYear}-05-31`
|
let to = isForfait ? `${leaveYear}-12-31` : `${leaveYear}-05-31`
|
||||||
|
const phase = selectedPhase.value
|
||||||
|
if (phase?.startDate && phase.startDate > from) from = phase.startDate
|
||||||
|
if (phase?.endDate && phase.endDate < to) to = phase.endDate
|
||||||
const holidayYears = isForfait ? [leaveYear] : [leaveYear - 1, leaveYear]
|
const holidayYears = isForfait ? [leaveYear] : [leaveYear - 1, leaveYear]
|
||||||
|
|
||||||
const [absences, summary, ...holidayResults] = await Promise.all([
|
const [absences, summary, ...holidayResults] = await Promise.all([
|
||||||
listAbsences({ from, to, employeeId: employee.value.id }),
|
listAbsences({ from, to, employeeId: employee.value.id }),
|
||||||
getEmployeeLeaveSummary(employee.value.id, leaveYear),
|
getEmployeeLeaveSummary(employee.value.id, leaveYear, selectedPhase.value?.id),
|
||||||
...holidayYears.map((y) => listPublicHolidays('metropole', y))
|
...holidayYears.map((y) => listPublicHolidays('metropole', y))
|
||||||
])
|
])
|
||||||
employeeAbsences.value = absences
|
employeeAbsences.value = absences
|
||||||
@@ -46,10 +112,25 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(() => selectedPhase.value?.id, () => {
|
||||||
|
// Reset l'année car la plage a peut-être changé.
|
||||||
|
selectedLeaveYear.value = null
|
||||||
|
leaveDataLoaded.value = false
|
||||||
|
// Le rechargement effectif est piloté par useEmployeeDetailPage.
|
||||||
|
})
|
||||||
|
|
||||||
const submitFractionedDays = async (days: number) => {
|
const submitFractionedDays = async (days: number) => {
|
||||||
if (!employee.value) return
|
if (!employee.value) return
|
||||||
const year = leaveSummary.value?.year ?? undefined
|
const year = leaveSummary.value?.year ?? undefined
|
||||||
@@ -70,6 +151,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,
|
||||||
|
|||||||
@@ -1,29 +1,107 @@
|
|||||||
import type { Ref } from 'vue'
|
import type { Ref } from 'vue'
|
||||||
|
import type { ContractPhase } from '~/services/dto/contract-phase'
|
||||||
import type { EmployeeRttSummary } from '~/services/dto/employee-rtt-summary'
|
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 const useEmployeeRtt = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
|
export type RttYearOption = {
|
||||||
|
value: number
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useEmployeeRtt = (
|
||||||
|
employee: Ref<Employee | null>,
|
||||||
|
reloadEmployee: () => Promise<void>,
|
||||||
|
selectedPhase: Ref<ContractPhase | null>,
|
||||||
|
) => {
|
||||||
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 || !selectedPhase.value || currentRttYear.value === null) return []
|
||||||
|
const phase = selectedPhase.value
|
||||||
|
|
||||||
|
// Plage = exercices intersectant la phase.
|
||||||
|
const phaseStartYear = computeRttYearForDate(new Date(`${phase.startDate}T00:00:00`))
|
||||||
|
const phaseEndYear = phase.endDate
|
||||||
|
? computeRttYearForDate(new Date(`${phase.endDate}T00:00:00`))
|
||||||
|
: currentRttYear.value
|
||||||
|
|
||||||
|
// 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(phaseStartYear, dataFloor) : phaseStartYear
|
||||||
|
const maxYear = phaseEndYear
|
||||||
|
|
||||||
|
const years: RttYearOption[] = []
|
||||||
|
for (let y = maxYear; 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(
|
||||||
rttSummary.value = await getEmployeeRttSummary(employee.value.id, rttYear)
|
employee.value.id,
|
||||||
|
selectedRttYear.value,
|
||||||
|
selectedPhase.value?.id,
|
||||||
|
)
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(() => selectedPhase.value?.id, () => {
|
||||||
|
// Reset l'année car la plage a peut-être changé.
|
||||||
|
selectedRttYear.value = null
|
||||||
|
rttDataLoaded.value = false
|
||||||
|
// Le rechargement effectif est piloté par useEmployeeDetailPage.
|
||||||
|
})
|
||||||
|
|
||||||
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) => {
|
||||||
if (!employee.value) return
|
if (!employee.value) return
|
||||||
const year = rttSummary.value?.year ?? undefined
|
const year = rttSummary.value?.year ?? undefined
|
||||||
@@ -35,6 +113,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
|
||||||
|
|||||||
@@ -494,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 ''
|
||||||
@@ -1108,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,
|
||||||
@@ -1174,6 +1187,7 @@ export const useHoursPage = () => {
|
|||||||
getRowAbsenceStyle,
|
getRowAbsenceStyle,
|
||||||
hasRowFormation,
|
hasRowFormation,
|
||||||
getRowFormationLabel,
|
getRowFormationLabel,
|
||||||
|
getRowContractNature,
|
||||||
getRowUpdatedAt,
|
getRowUpdatedAt,
|
||||||
getPresenceDayValue,
|
getPresenceDayValue,
|
||||||
openAbsenceDrawer,
|
openAbsenceDrawer,
|
||||||
@@ -1181,6 +1195,10 @@ export const useHoursPage = () => {
|
|||||||
deleteAbsenceFromDrawer,
|
deleteAbsenceFromDrawer,
|
||||||
closeAbsenceDrawer,
|
closeAbsenceDrawer,
|
||||||
formatMinutes,
|
formatMinutes,
|
||||||
handleSave
|
handleSave,
|
||||||
|
isWeekCommentDrawerOpen,
|
||||||
|
weekCommentContext,
|
||||||
|
openWeekCommentDrawer,
|
||||||
|
reloadWeeklySummary
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -79,6 +80,16 @@ export const documentationSections: DocSection[] = [
|
|||||||
{ type: 'list', content: 'Jour : total des heures dans la plage 06:00–21:00\nNuit : total des heures dans les plages 00:00–06:00 et 21:00–24:00\nTotal : somme des heures de jour et de nuit' },
|
{ type: 'list', content: 'Jour : total des heures dans la plage 06:00–21:00\nNuit : total des heures dans les plages 00:00–06:00 et 21:00–24: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.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -207,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é.' },
|
||||||
],
|
],
|
||||||
@@ -258,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)' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -290,6 +301,18 @@ export const documentationSections: DocSection[] = [
|
|||||||
{ type: 'paragraph', content: 'Un employé conducteur apparaît uniquement sur l\'écran "Heures Conducteurs" et non sur l\'écran "Heures" classique.' },
|
{ type: 'paragraph', content: 'Un employé conducteur apparaît uniquement sur l\'écran "Heures Conducteurs" et non sur l\'écran "Heures" classique.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'contract-phase-view',
|
||||||
|
title: 'Vue contrat — sélecteur de phase',
|
||||||
|
requiredLevel: 'admin',
|
||||||
|
blocks: [
|
||||||
|
{ type: 'paragraph', content: 'Quand un employé change de type de contrat (ex. 39h → FORFAIT) ou enchaîne plusieurs CDD avec solde de tout compte, ses anciennes phases de contrat restent consultables via le sélecteur "Vue contrat" en haut de la fiche.' },
|
||||||
|
{ type: 'paragraph', content: 'Choisir une phase passée fait basculer les onglets Congés et RTT sur les règles de cette phase. L\'onglet RTT réapparaît si la phase n\'est pas un FORFAIT. Un bandeau jaune indique que vous êtes en mode historique.' },
|
||||||
|
{ type: 'paragraph', content: 'Sur une phase passée, vous pouvez :' },
|
||||||
|
{ type: 'list', content: 'Solder les RTT restants — bouton "+ Payer les RTT" actif uniquement sur le dernier exercice de la phase (celui contenant la date de fin)\nSolder les CP restants via le champ "Solde de tout compte" sur la période de contrat correspondante (onglet Contrat)' },
|
||||||
|
{ type: 'note', content: 'L\'édition d\'absences et des stocks de report (jours fractionnés, Année N-1) est désactivée en mode phase passée.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -331,7 +354,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' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -388,6 +411,7 @@ export const documentationSections: DocSection[] = [
|
|||||||
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 (cliquable pour créer une absence)\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.' },
|
{ 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.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -445,6 +469,17 @@ 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',
|
id: 'ecran-recap-conges',
|
||||||
title: 'Écran Récap. congés',
|
title: 'Écran Récap. congés',
|
||||||
@@ -480,6 +515,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).' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -491,6 +527,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',
|
||||||
@@ -568,7 +613,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)' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -586,7 +631,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)' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -59,6 +59,10 @@
|
|||||||
},
|
},
|
||||||
"leaveRecap": {
|
"leaveRecap": {
|
||||||
"load": "Impossible de charger le récap des congés."
|
"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": {
|
||||||
@@ -110,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é."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +81,7 @@
|
|||||||
: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>
|
||||||
@@ -60,6 +93,7 @@
|
|||||||
:class="route.path.startsWith('/leave-recap')
|
:class="route.path.startsWith('/leave-recap')
|
||||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||||
: ''"
|
: ''"
|
||||||
|
@click="closeSidebarOnMobile"
|
||||||
>
|
>
|
||||||
<Icon name="mdi:beach" size="24"/>
|
<Icon name="mdi:beach" size="24"/>
|
||||||
<p>Récap. congés</p>
|
<p>Récap. congés</p>
|
||||||
@@ -70,6 +104,7 @@
|
|||||||
: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>
|
||||||
@@ -80,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"
|
||||||
@@ -90,6 +126,7 @@
|
|||||||
: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>
|
||||||
@@ -100,6 +137,7 @@
|
|||||||
to="/leave-recap"
|
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="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' : ''"
|
:class="route.path.startsWith('/leave-recap') ? 'bg-tertiary-500 text-primary-500 font-bold' : ''"
|
||||||
|
@click="closeSidebarOnMobile"
|
||||||
>
|
>
|
||||||
<Icon name="mdi:beach" size="24"/>
|
<Icon name="mdi:beach" size="24"/>
|
||||||
<p>Récap. congés</p>
|
<p>Récap. congés</p>
|
||||||
@@ -111,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>
|
||||||
@@ -121,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>
|
||||||
@@ -132,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>
|
||||||
@@ -150,4 +190,9 @@ const isSuperAdmin = computed(() => auth.user?.roles?.includes('ROLE_SUPER_ADMIN
|
|||||||
const isDriver = computed(() => auth.user?.isDriver ?? false)
|
const isDriver = computed(() => auth.user?.isDriver ?? false)
|
||||||
const hasLeaveRecapAccess = computed(() => auth.user?.hasLeaveRecapAccess ?? 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>
|
||||||
|
|||||||
@@ -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 || '/')
|
||||||
|
|||||||
260
frontend/package-lock.json
generated
260
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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() ?? ''
|
||||||
|
|||||||
@@ -64,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"
|
||||||
@@ -73,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>
|
||||||
|
|
||||||
@@ -109,6 +112,17 @@
|
|||||||
@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>
|
||||||
|
|
||||||
@@ -169,6 +183,7 @@ const {
|
|||||||
getRowMetrics,
|
getRowMetrics,
|
||||||
getRowAbsenceLabel,
|
getRowAbsenceLabel,
|
||||||
getRowAbsenceStyle,
|
getRowAbsenceStyle,
|
||||||
|
getRowContractNature,
|
||||||
getRowUpdatedAt,
|
getRowUpdatedAt,
|
||||||
openAbsenceDrawer,
|
openAbsenceDrawer,
|
||||||
submitAbsence,
|
submitAbsence,
|
||||||
@@ -177,7 +192,11 @@ const {
|
|||||||
formatMinutes,
|
formatMinutes,
|
||||||
isSelectedDateHoliday,
|
isSelectedDateHoliday,
|
||||||
selectedHolidayLabel,
|
selectedHolidayLabel,
|
||||||
handleSave
|
handleSave,
|
||||||
|
isWeekCommentDrawerOpen,
|
||||||
|
weekCommentContext,
|
||||||
|
openWeekCommentDrawer,
|
||||||
|
reloadWeeklySummary
|
||||||
} = useDriverHoursPage()
|
} = useDriverHoursPage()
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
|
|||||||
@@ -26,10 +26,28 @@
|
|||||||
<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>
|
||||||
|
<div v-if="showPicker" class="mt-3 flex items-center gap-3">
|
||||||
|
<MalioSelect
|
||||||
|
label="Contrat"
|
||||||
|
:model-value="selectedPhase?.id ?? null"
|
||||||
|
:options="phaseOptions"
|
||||||
|
group-class="w-[420px]"
|
||||||
|
min-width=""
|
||||||
|
@update:model-value="(v) => { if (v !== null) setSelectedPhase(Number(v)) }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="isViewingPastPhase && selectedPhase"
|
||||||
|
class="mt-3 rounded-md border border-amber-300 bg-amber-100 px-4 py-2 text-sm text-amber-900"
|
||||||
|
>
|
||||||
|
Vous consultez l'historique
|
||||||
|
<strong>{{ formatPhaseLabel(selectedPhase) }}</strong>.
|
||||||
|
Les paiements de solde sont possibles ; l'édition d'absences et des stocks de report est désactivée.
|
||||||
|
</div>
|
||||||
<div class="mt-[44px] border-b border-primary-500">
|
<div class="mt-[44px] border-b border-primary-500">
|
||||||
<div class="flex justify-center gap-16 text-2xl font-bold">
|
<div class="flex justify-center gap-16 text-2xl font-bold">
|
||||||
<button
|
<button
|
||||||
@@ -148,6 +166,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">
|
||||||
@@ -159,15 +178,29 @@
|
|||||||
: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"
|
||||||
|
:selected-phase="selectedPhase"
|
||||||
|
@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">
|
||||||
@@ -239,6 +272,7 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import EmployeeYearlyHoursDrawer from '~/components/EmployeeYearlyHoursDrawer.vue'
|
import EmployeeYearlyHoursDrawer from '~/components/EmployeeYearlyHoursDrawer.vue'
|
||||||
import { usePdfPrinter } from '~/composables/usePdfPrinter'
|
import { usePdfPrinter } from '~/composables/usePdfPrinter'
|
||||||
|
import { formatPhaseLabel } from '~/composables/useEmployeeContractPhase'
|
||||||
|
|
||||||
const { printPdf } = usePdfPrinter()
|
const { printPdf } = usePdfPrinter()
|
||||||
const isYearlyHoursDrawerOpen = ref(false)
|
const isYearlyHoursDrawerOpen = ref(false)
|
||||||
@@ -252,10 +286,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,
|
||||||
@@ -295,6 +338,7 @@ const {
|
|||||||
submitSuspension,
|
submitSuspension,
|
||||||
addSuspensionForm,
|
addSuspensionForm,
|
||||||
currentActiveContractPeriodId,
|
currentActiveContractPeriodId,
|
||||||
|
interimAgencies,
|
||||||
isLeaveLoading,
|
isLeaveLoading,
|
||||||
isRttLoading,
|
isRttLoading,
|
||||||
mileageAllowances,
|
mileageAllowances,
|
||||||
@@ -318,7 +362,12 @@ const {
|
|||||||
isObservationLoading,
|
isObservationLoading,
|
||||||
submitCreateObservation,
|
submitCreateObservation,
|
||||||
submitUpdateObservation,
|
submitUpdateObservation,
|
||||||
submitDeleteObservation
|
submitDeleteObservation,
|
||||||
|
selectedPhase,
|
||||||
|
showPicker,
|
||||||
|
phaseOptions,
|
||||||
|
setSelectedPhase,
|
||||||
|
isViewingPastPhase,
|
||||||
} = useEmployeeDetailPage()
|
} = useEmployeeDetailPage()
|
||||||
|
|
||||||
const handleYearlyHoursPrint = async (payload: { year: number; month: number | null }) => {
|
const handleYearlyHoursPrint = async (payload: { year: number; month: number | null }) => {
|
||||||
|
|||||||
@@ -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,22 +154,18 @@
|
|||||||
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
|
<WorkDaysHoursInput
|
||||||
v-if="requiresSchedule"
|
v-if="requiresSchedule"
|
||||||
@@ -212,28 +174,72 @@
|
|||||||
/>
|
/>
|
||||||
</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>
|
||||||
|
|
||||||
@@ -246,8 +252,7 @@ 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'
|
||||||
|
|
||||||
@@ -258,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)
|
||||||
@@ -269,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 []
|
||||||
@@ -300,7 +355,8 @@ const form = reactive({
|
|||||||
contractStartDate: '',
|
contractStartDate: '',
|
||||||
contractEndDate: '',
|
contractEndDate: '',
|
||||||
isDriver: false,
|
isDriver: false,
|
||||||
workDaysHours: null as Record<number, number> | null
|
workDaysHours: null as Record<number, number> | null,
|
||||||
|
interimAgencyId: '' as number | ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const validationTouched = reactive({
|
const validationTouched = reactive({
|
||||||
@@ -376,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
|
||||||
@@ -451,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)
|
||||||
}
|
}
|
||||||
@@ -503,7 +523,8 @@ const handleSubmit = async () => {
|
|||||||
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
|
workDaysHoursInput: requiresSchedule.value ? form.workDaysHours : null,
|
||||||
|
interimAgencyId: form.contractNature === 'INTERIM' && form.interimAgencyId !== '' ? Number(form.interimAgencyId) : null
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,6 +537,7 @@ const handleSubmit = async () => {
|
|||||||
form.contractEndDate = ''
|
form.contractEndDate = ''
|
||||||
form.isDriver = false
|
form.isDriver = false
|
||||||
form.workDaysHours = null
|
form.workDaysHours = null
|
||||||
|
form.interimAgencyId = ''
|
||||||
editingEmployee.value = null
|
editingEmployee.value = null
|
||||||
isDrawerOpen.value = false
|
isDrawerOpen.value = false
|
||||||
await loadEmployees()
|
await loadEmployees()
|
||||||
@@ -542,6 +564,12 @@ watch(showsContractEndDateComputed, (shows) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(() => form.contractNature, (nature) => {
|
||||||
|
if (nature !== 'INTERIM') {
|
||||||
|
form.interimAgencyId = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
watch(requiresSchedule, (required) => {
|
watch(requiresSchedule, (required) => {
|
||||||
if (!required) {
|
if (!required) {
|
||||||
form.workDaysHours = null
|
form.workDaysHours = null
|
||||||
@@ -567,18 +595,33 @@ const openCreate = () => {
|
|||||||
form.contractEndDate = ''
|
form.contractEndDate = ''
|
||||||
form.isDriver = false
|
form.isDriver = false
|
||||||
form.workDaysHours = null
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -70,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"
|
||||||
@@ -80,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>
|
||||||
|
|
||||||
@@ -116,6 +119,17 @@
|
|||||||
@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>
|
||||||
|
|
||||||
@@ -184,6 +198,7 @@ const {
|
|||||||
getRowAbsenceStyle,
|
getRowAbsenceStyle,
|
||||||
hasRowFormation,
|
hasRowFormation,
|
||||||
getRowFormationLabel,
|
getRowFormationLabel,
|
||||||
|
getRowContractNature,
|
||||||
getRowUpdatedAt,
|
getRowUpdatedAt,
|
||||||
getPresenceDayValue,
|
getPresenceDayValue,
|
||||||
openAbsenceDrawer,
|
openAbsenceDrawer,
|
||||||
@@ -191,7 +206,11 @@ const {
|
|||||||
deleteAbsenceFromDrawer,
|
deleteAbsenceFromDrawer,
|
||||||
closeAbsenceDrawer,
|
closeAbsenceDrawer,
|
||||||
formatMinutes,
|
formatMinutes,
|
||||||
handleSave
|
handleSave,
|
||||||
|
isWeekCommentDrawerOpen,
|
||||||
|
weekCommentContext,
|
||||||
|
openWeekCommentDrawer,
|
||||||
|
reloadWeeklySummary
|
||||||
} = useHoursPage()
|
} = useHoursPage()
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-full flex flex-col overflow-hidden">
|
<div class="h-full flex flex-col overflow-hidden">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-4 pb-8">
|
<div class="flex flex-wrap items-center justify-between gap-4 pb-8">
|
||||||
<h1 class="text-4xl font-bold text-primary-500">Récap. congés</h1>
|
<h1 class="text-2xl font-bold text-primary-500 lg:text-4xl">Récap. congés</h1>
|
||||||
<span
|
<span
|
||||||
v-if="cutoffLabel"
|
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"
|
class="inline-flex items-center gap-2 rounded-full bg-tertiary-500 px-4 py-1 text-sm font-semibold text-primary-500"
|
||||||
@@ -25,7 +25,8 @@
|
|||||||
Aucun employé à afficher.
|
Aucun employé à afficher.
|
||||||
</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
|
<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`"
|
: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`"
|
||||||
>
|
>
|
||||||
@@ -64,6 +65,47 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,44 +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"
|
/>
|
||||||
/>
|
|
||||||
<span class="text-md font-semibold text-neutral-700">Verrouiller le compte</span>
|
|
||||||
</label>
|
|
||||||
<p class="mt-1 text-sm text-neutral-500">
|
|
||||||
Un compte verrouillé ne peut plus se connecter.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<MalioCheckbox
|
||||||
<input
|
v-model="form.hasLeaveRecapAccess"
|
||||||
v-model="form.hasLeaveRecapAccess"
|
label="Accès à l'écran Récap. congés"
|
||||||
type="checkbox"
|
hint="Affiche l'onglet dans la sidebar et donne accès au tableau récap."
|
||||||
class="cursor-pointer"
|
/>
|
||||||
/>
|
|
||||||
<span class="text-md font-semibold text-neutral-700">Accès à l'écran Récap. congés</span>
|
|
||||||
</label>
|
|
||||||
<p class="mt-1 text-sm text-neutral-500">
|
|
||||||
Affiche l'onglet dans la sidebar et donne accès au tableau récap.
|
|
||||||
</p>
|
|
||||||
</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>
|
||||||
|
|
||||||
@@ -311,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
|
||||||
|
|||||||
13
frontend/services/dto/contract-phase.ts
Normal file
13
frontend/services/dto/contract-phase.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { ContractType } from './contract'
|
||||||
|
|
||||||
|
export type ContractPhase = {
|
||||||
|
id: number
|
||||||
|
contractType: ContractType
|
||||||
|
weeklyHours: number | null
|
||||||
|
isDriver: boolean
|
||||||
|
startDate: string
|
||||||
|
endDate: string | null
|
||||||
|
periodIds: number[]
|
||||||
|
isCurrent: boolean
|
||||||
|
contractNature: 'CDI' | 'CDD' | 'INTERIM'
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Site } from './site'
|
import type { Site } from './site'
|
||||||
import type { Contract } from './contract'
|
import type { Contract } from './contract'
|
||||||
|
import type { ContractPhase } from './contract-phase'
|
||||||
|
|
||||||
export type ContractSuspension = {
|
export type ContractSuspension = {
|
||||||
id: number
|
id: number
|
||||||
@@ -20,6 +21,8 @@ export type ContractHistoryItem = {
|
|||||||
suspensions?: ContractSuspension[]
|
suspensions?: ContractSuspension[]
|
||||||
isDriver?: boolean
|
isDriver?: boolean
|
||||||
workDaysHours?: Record<number, number> | null
|
workDaysHours?: Record<number, number> | null
|
||||||
|
interimAgencyId?: number | null
|
||||||
|
interimAgencyName?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Employee = {
|
export type Employee = {
|
||||||
@@ -37,4 +40,7 @@ export type Employee = {
|
|||||||
displayOrder?: number
|
displayOrder?: number
|
||||||
entryDate?: string | null
|
entryDate?: string | null
|
||||||
currentSuspensions?: ContractSuspension[]
|
currentSuspensions?: ContractSuspension[]
|
||||||
|
currentInterimAgencyId?: number | null
|
||||||
|
currentInterimAgencyName?: string | null
|
||||||
|
contractPhases?: ContractPhase[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export type WeeklyWorkHourDailySummary = {
|
|||||||
hasDinner?: boolean
|
hasDinner?: boolean
|
||||||
hasOvernight?: boolean
|
hasOvernight?: boolean
|
||||||
virtualHolidayMinutes?: number
|
virtualHolidayMinutes?: number
|
||||||
|
holidayLabel?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WeeklyWorkHourRowSummary = {
|
export type WeeklyWorkHourRowSummary = {
|
||||||
@@ -88,6 +89,8 @@ export type WeeklyWorkHourRowSummary = {
|
|||||||
weeklyOvernightCount?: number
|
weeklyOvernightCount?: number
|
||||||
hasContractForWeek?: boolean
|
hasContractForWeek?: boolean
|
||||||
contractNature?: 'CDI' | 'CDD' | 'INTERIM' | null
|
contractNature?: 'CDI' | 'CDD' | 'INTERIM' | null
|
||||||
|
comment?: string | null
|
||||||
|
commentId?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WeeklyWorkHourSummary = {
|
export type WeeklyWorkHourSummary = {
|
||||||
@@ -111,6 +114,7 @@ export type WorkHourDayContextRow = {
|
|||||||
hasFormation?: boolean
|
hasFormation?: boolean
|
||||||
formationLabel?: string | null
|
formationLabel?: string | null
|
||||||
virtualHolidayMinutes?: number
|
virtualHolidayMinutes?: number
|
||||||
|
contractNature?: 'CDI' | 'CDD' | 'INTERIM' | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorkHourDayContext = {
|
export type WorkHourDayContext = {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { EmployeeLeaveSummary } from './dto/employee-leave-summary'
|
import type { EmployeeLeaveSummary } from './dto/employee-leave-summary'
|
||||||
|
|
||||||
export const getEmployeeLeaveSummary = async (employeeId: number, year?: number) => {
|
export const getEmployeeLeaveSummary = async (employeeId: number, year?: number, phaseId?: number) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const query: Record<string, string> = {}
|
const query: Record<string, string> = {}
|
||||||
if (year) query.year = String(year)
|
if (year) query.year = String(year)
|
||||||
|
if (phaseId !== undefined) query.phaseId = String(phaseId)
|
||||||
|
|
||||||
return api.get<EmployeeLeaveSummary>(`/employees/${employeeId}/leave-summary`, query, { toast: false })
|
return api.get<EmployeeLeaveSummary>(`/employees/${employeeId}/leave-summary`, query, { toast: false })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { EmployeeRttSummary } from './dto/employee-rtt-summary'
|
import type { EmployeeRttSummary } from './dto/employee-rtt-summary'
|
||||||
|
|
||||||
export const getEmployeeRttSummary = async (employeeId: number, year?: number) => {
|
export const getEmployeeRttSummary = async (employeeId: number, year?: number, phaseId?: number) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const query = year ? { year } : {}
|
const query: Record<string, number> = {}
|
||||||
|
if (year) query.year = year
|
||||||
|
if (phaseId !== undefined) query.phaseId = phaseId
|
||||||
return api.get<EmployeeRttSummary>(`/employees/${employeeId}/rtt-summary`, query, { toast: false })
|
return api.get<EmployeeRttSummary>(`/employees/${employeeId}/rtt-summary`, query, { toast: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
24
frontend/services/employee-week-comments.ts
Normal file
24
frontend/services/employee-week-comments.ts
Normal 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' })
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ export const createEmployee = async (payload: {
|
|||||||
contractEndDate?: string | null
|
contractEndDate?: string | null
|
||||||
isDriverInput?: boolean
|
isDriverInput?: boolean
|
||||||
workDaysHoursInput?: Record<number, number> | null
|
workDaysHoursInput?: Record<number, number> | null
|
||||||
|
interimAgencyId?: number | null
|
||||||
}) => {
|
}) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
return api.post<Employee>('/employees', {
|
return api.post<Employee>('/employees', {
|
||||||
@@ -47,7 +48,8 @@ export const createEmployee = async (payload: {
|
|||||||
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
|
workDaysHoursInput: payload.workDaysHoursInput ?? null,
|
||||||
|
interimAgencyId: payload.interimAgencyId ?? null
|
||||||
}, {
|
}, {
|
||||||
toastSuccessKey: 'success.employee.create',
|
toastSuccessKey: 'success.employee.create',
|
||||||
toastErrorKey: 'errors.employee.create'
|
toastErrorKey: 'errors.employee.create'
|
||||||
@@ -69,6 +71,7 @@ export const updateEmployee = async (
|
|||||||
displayOrder?: number
|
displayOrder?: number
|
||||||
isDriverInput?: boolean
|
isDriverInput?: boolean
|
||||||
workDaysHoursInput?: Record<number, number> | null
|
workDaysHoursInput?: Record<number, number> | null
|
||||||
|
interimAgencyId?: number | null
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
@@ -103,6 +106,9 @@ export const updateEmployee = async (
|
|||||||
if (payload.workDaysHoursInput !== undefined) {
|
if (payload.workDaysHoursInput !== undefined) {
|
||||||
body.workDaysHoursInput = payload.workDaysHoursInput
|
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',
|
||||||
|
|||||||
16
frontend/services/interim-agencies.ts
Normal file
16
frontend/services/interim-agencies.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ 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 => {
|
||||||
|
|||||||
29
migrations/Version20260417100000.php
Normal file
29
migrations/Version20260417100000.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
32
migrations/Version20260417120000.php
Normal file
32
migrations/Version20260417120000.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
24
src/ApiResource/EmployeeYearlyHoursBulkPrint.php
Normal file
24
src/ApiResource/EmployeeYearlyHoursBulkPrint.php
Normal 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 {}
|
||||||
27
src/Dto/Contracts/ContractPhase.php
Normal file
27
src/Dto/Contracts/ContractPhase.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Dto\Contracts;
|
||||||
|
|
||||||
|
use App\Enum\ContractNature;
|
||||||
|
use App\Enum\ContractType;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
final readonly class ContractPhase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<int> $periodIds
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public int $id,
|
||||||
|
public ContractType $contractType,
|
||||||
|
public ?int $weeklyHours,
|
||||||
|
public bool $isDriver,
|
||||||
|
public DateTimeImmutable $startDate,
|
||||||
|
public ?DateTimeImmutable $endDate,
|
||||||
|
public array $periodIds,
|
||||||
|
public bool $isCurrent,
|
||||||
|
public ContractNature $contractNature,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -34,5 +34,9 @@ final class ContractHistoryItem
|
|||||||
*/
|
*/
|
||||||
#[Groups(['employee:read'])]
|
#[Groups(['employee:read'])]
|
||||||
public ?array $workDaysHours = null,
|
public ?array $workDaysHours = null,
|
||||||
|
#[Groups(['employee:read'])]
|
||||||
|
public ?int $interimAgencyId = null,
|
||||||
|
#[Groups(['employee:read'])]
|
||||||
|
public ?string $interimAgencyName = null,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ final class DayContextRow
|
|||||||
public bool $hasFormation = false,
|
public bool $hasFormation = false,
|
||||||
public ?string $formationLabel = null,
|
public ?string $formationLabel = null,
|
||||||
public int $virtualHolidayMinutes = 0,
|
public int $virtualHolidayMinutes = 0,
|
||||||
|
public ?string $contractNature = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function setFormation(string $label): void
|
public function setFormation(string $label): void
|
||||||
@@ -77,7 +78,8 @@ final class DayContextRow
|
|||||||
* isDriverContract:bool,
|
* isDriverContract:bool,
|
||||||
* hasFormation:bool,
|
* hasFormation:bool,
|
||||||
* formationLabel:?string,
|
* formationLabel:?string,
|
||||||
* virtualHolidayMinutes:int
|
* virtualHolidayMinutes:int,
|
||||||
|
* contractNature:?string
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public function toArray(): array
|
public function toArray(): array
|
||||||
@@ -96,6 +98,7 @@ final class DayContextRow
|
|||||||
'hasFormation' => $this->hasFormation,
|
'hasFormation' => $this->hasFormation,
|
||||||
'formationLabel' => $this->formationLabel,
|
'formationLabel' => $this->formationLabel,
|
||||||
'virtualHolidayMinutes' => $this->virtualHolidayMinutes,
|
'virtualHolidayMinutes' => $this->virtualHolidayMinutes,
|
||||||
|
'contractNature' => $this->contractNature,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,5 +22,6 @@ final class WeeklyDaySummary
|
|||||||
public bool $hasDinner = false,
|
public bool $hasDinner = false,
|
||||||
public bool $hasOvernight = false,
|
public bool $hasOvernight = false,
|
||||||
public int $virtualHolidayMinutes = 0,
|
public int $virtualHolidayMinutes = 0,
|
||||||
|
public ?string $holidayLabel = null,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,5 +35,7 @@ final class WeeklySummaryRow
|
|||||||
public int $weeklyOvernightCount = 0,
|
public int $weeklyOvernightCount = 0,
|
||||||
public bool $hasContractForWeek = true,
|
public bool $hasContractForWeek = true,
|
||||||
public ?string $contractNature = null,
|
public ?string $contractNature = null,
|
||||||
|
public ?string $comment = null,
|
||||||
|
public ?int $commentId = null,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ namespace App\Entity;
|
|||||||
|
|
||||||
use ApiPlatform\Metadata\ApiProperty;
|
use ApiPlatform\Metadata\ApiProperty;
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use App\Dto\Contracts\ContractPhase;
|
||||||
use App\Dto\Employees\ContractHistoryItem;
|
use App\Dto\Employees\ContractHistoryItem;
|
||||||
use App\Enum\ContractNature;
|
use App\Enum\ContractNature;
|
||||||
use App\Repository\EmployeeRepository;
|
use App\Repository\EmployeeRepository;
|
||||||
|
use App\Service\Contracts\EmployeeContractPhaseResolver;
|
||||||
use App\State\EmployeeWriteProcessor;
|
use App\State\EmployeeWriteProcessor;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
@@ -98,6 +100,9 @@ class Employee
|
|||||||
#[Groups(['employee:write'])]
|
#[Groups(['employee:write'])]
|
||||||
private ?array $workDaysHoursInput = null;
|
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();
|
||||||
@@ -295,6 +300,30 @@ class Employee
|
|||||||
return $this;
|
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
|
||||||
{
|
{
|
||||||
@@ -393,12 +422,51 @@ class Employee
|
|||||||
suspensions: $suspensionData,
|
suspensions: $suspensionData,
|
||||||
isDriver: $period->getIsDriver(),
|
isDriver: $period->getIsDriver(),
|
||||||
workDaysHours: $period->getWorkDaysHours(),
|
workDaysHours: $period->getWorkDaysHours(),
|
||||||
|
interimAgencyId: $period->getInterimAgency()?->getId(),
|
||||||
|
interimAgencyName: $period->getInterimAgency()?->getName(),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
$periods
|
$periods
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{
|
||||||
|
* id: int,
|
||||||
|
* contractType: string,
|
||||||
|
* weeklyHours: ?int,
|
||||||
|
* isDriver: bool,
|
||||||
|
* startDate: string,
|
||||||
|
* endDate: ?string,
|
||||||
|
* periodIds: list<int>,
|
||||||
|
* isCurrent: bool,
|
||||||
|
* contractNature: string
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
#[Groups(['employee:read'])]
|
||||||
|
public function getContractPhases(): array
|
||||||
|
{
|
||||||
|
// Read RTT_START_DATE directly here: the entity has no DI but must filter
|
||||||
|
// out contract phases that ended before the application's data start.
|
||||||
|
$rawDate = $_SERVER['RTT_START_DATE'] ?? $_ENV['RTT_START_DATE'] ?? '';
|
||||||
|
$resolver = new EmployeeContractPhaseResolver(is_string($rawDate) ? $rawDate : '');
|
||||||
|
|
||||||
|
return array_map(
|
||||||
|
static fn (ContractPhase $phase): array => [
|
||||||
|
'id' => $phase->id,
|
||||||
|
'contractType' => $phase->contractType->value,
|
||||||
|
'weeklyHours' => $phase->weeklyHours,
|
||||||
|
'isDriver' => $phase->isDriver,
|
||||||
|
'startDate' => $phase->startDate->format('Y-m-d'),
|
||||||
|
'endDate' => $phase->endDate?->format('Y-m-d'),
|
||||||
|
'periodIds' => $phase->periodIds,
|
||||||
|
'isCurrent' => $phase->isCurrent,
|
||||||
|
'contractNature' => $phase->contractNature->value,
|
||||||
|
],
|
||||||
|
$resolver->resolvePhases($this),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private function resolveCurrentContractPeriod(): ?EmployeeContractPeriod
|
private function resolveCurrentContractPeriod(): ?EmployeeContractPeriod
|
||||||
{
|
{
|
||||||
$today = new DateTimeImmutable('today');
|
$today = new DateTimeImmutable('today');
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ class EmployeeContractPeriod
|
|||||||
#[ORM\Column(type: 'json', nullable: true)]
|
#[ORM\Column(type: 'json', nullable: true)]
|
||||||
private ?array $workDaysHours = null;
|
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;
|
||||||
|
|
||||||
@@ -204,6 +208,18 @@ class EmployeeContractPeriod
|
|||||||
return $this;
|
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>
|
||||||
*/
|
*/
|
||||||
|
|||||||
136
src/Entity/EmployeeWeekComment.php
Normal file
136
src/Entity/EmployeeWeekComment.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/Entity/InterimAgency.php
Normal file
51
src/Entity/InterimAgency.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/Repository/EmployeeWeekCommentRepository.php
Normal file
58
src/Repository/EmployeeWeekCommentRepository.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ final readonly class EmployeeContractChangeRequest
|
|||||||
public ?string $contractComment,
|
public ?string $contractComment,
|
||||||
public ?bool $isDriver = null,
|
public ?bool $isDriver = null,
|
||||||
public ?array $workDaysHours = null,
|
public ?array $workDaysHours = null,
|
||||||
|
public ?int $interimAgencyId = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function hasPeriodChangeRequest(): bool
|
public function hasPeriodChangeRequest(): bool
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ final class EmployeeContractChangeRequestFactory
|
|||||||
contractComment: $employee->getContractComment(),
|
contractComment: $employee->getContractComment(),
|
||||||
isDriver: $employee->getIsDriverInput(),
|
isDriver: $employee->getIsDriverInput(),
|
||||||
workDaysHours: $employee->getWorkDaysHoursInput(),
|
workDaysHours: $employee->getWorkDaysHoursInput(),
|
||||||
|
interimAgencyId: $employee->getInterimAgencyId(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ final class EmployeeContractPeriodBuilder
|
|||||||
ContractNature $nature,
|
ContractNature $nature,
|
||||||
bool $isDriver = false,
|
bool $isDriver = false,
|
||||||
?array $workDaysHours = null,
|
?array $workDaysHours = null,
|
||||||
|
?InterimAgency $interimAgency = null,
|
||||||
): EmployeeContractPeriod {
|
): EmployeeContractPeriod {
|
||||||
return new EmployeeContractPeriod()
|
return new EmployeeContractPeriod()
|
||||||
->setEmployee($employee)
|
->setEmployee($employee)
|
||||||
@@ -32,6 +34,7 @@ final class EmployeeContractPeriodBuilder
|
|||||||
->setContractNature($nature)
|
->setContractNature($nature)
|
||||||
->setIsDriver($isDriver)
|
->setIsDriver($isDriver)
|
||||||
->setWorkDaysHours($workDaysHours)
|
->setWorkDaysHours($workDaysHours)
|
||||||
|
->setInterimAgency($interimAgency)
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -30,6 +31,7 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
|
|||||||
ContractNature $nature,
|
ContractNature $nature,
|
||||||
bool $isDriver = false,
|
bool $isDriver = false,
|
||||||
?array $workDaysHours = null,
|
?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);
|
$this->periodValidator->assertWorkDaysHours($contract, $nature, $workDaysHours);
|
||||||
@@ -39,7 +41,8 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver, $workDaysHours);
|
$interimAgency = $this->resolveInterimAgency($interimAgencyId);
|
||||||
|
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver, $workDaysHours, $interimAgency);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +81,7 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
|
|||||||
?EmployeeContractPeriod $todayPeriod,
|
?EmployeeContractPeriod $todayPeriod,
|
||||||
bool $isDriver = false,
|
bool $isDriver = false,
|
||||||
?array $workDaysHours = null,
|
?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);
|
$this->periodValidator->assertWorkDaysHours($contract, $nature, $workDaysHours);
|
||||||
@@ -90,7 +94,8 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver, $workDaysHours);
|
$interimAgency = $this->resolveInterimAgency($interimAgencyId);
|
||||||
|
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver, $workDaysHours, $interimAgency);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,8 +110,23 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
|
|||||||
ContractNature $nature,
|
ContractNature $nature,
|
||||||
bool $isDriver = false,
|
bool $isDriver = false,
|
||||||
?array $workDaysHours = null,
|
?array $workDaysHours = null,
|
||||||
|
?InterimAgency $interimAgency = null,
|
||||||
): void {
|
): void {
|
||||||
$period = $this->periodBuilder->build($employee, $contract, $startDate, $endDate, $nature, $isDriver, $workDaysHours);
|
$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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface EmployeeContractPeriodManagerInterface
|
|||||||
ContractNature $nature,
|
ContractNature $nature,
|
||||||
bool $isDriver = false,
|
bool $isDriver = false,
|
||||||
?array $workDaysHours = null,
|
?array $workDaysHours = null,
|
||||||
|
?int $interimAgencyId = null,
|
||||||
): void;
|
): void;
|
||||||
|
|
||||||
public function closeCurrentPeriod(
|
public function closeCurrentPeriod(
|
||||||
@@ -45,5 +46,6 @@ interface EmployeeContractPeriodManagerInterface
|
|||||||
?EmployeeContractPeriod $todayPeriod,
|
?EmployeeContractPeriod $todayPeriod,
|
||||||
bool $isDriver = false,
|
bool $isDriver = false,
|
||||||
?array $workDaysHours = null,
|
?array $workDaysHours = null,
|
||||||
|
?int $interimAgencyId = null,
|
||||||
): void;
|
): void;
|
||||||
}
|
}
|
||||||
|
|||||||
110
src/Service/Contracts/EmployeeContractPhaseResolver.php
Normal file
110
src/Service/Contracts/EmployeeContractPhaseResolver.php
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Contracts;
|
||||||
|
|
||||||
|
use App\Dto\Contracts\ContractPhase;
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\EmployeeContractPeriod;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use LogicException;
|
||||||
|
|
||||||
|
final readonly class EmployeeContractPhaseResolver
|
||||||
|
{
|
||||||
|
private ?DateTimeImmutable $dataStartDate;
|
||||||
|
|
||||||
|
public function __construct(string $dataStartDate = '')
|
||||||
|
{
|
||||||
|
$trimmed = trim($dataStartDate);
|
||||||
|
if ('' === $trimmed) {
|
||||||
|
$this->dataStartDate = null;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parsed = DateTimeImmutable::createFromFormat('!Y-m-d', $trimmed);
|
||||||
|
$this->dataStartDate = $parsed instanceof DateTimeImmutable ? $parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<ContractPhase>
|
||||||
|
*/
|
||||||
|
public function resolvePhases(Employee $employee): array
|
||||||
|
{
|
||||||
|
$periods = $employee->getContractPeriods()->toArray();
|
||||||
|
usort(
|
||||||
|
$periods,
|
||||||
|
static fn (EmployeeContractPeriod $a, EmployeeContractPeriod $b): int => $a->getStartDate() <=> $b->getStartDate()
|
||||||
|
);
|
||||||
|
|
||||||
|
$today = new DateTimeImmutable('today');
|
||||||
|
$phases = [];
|
||||||
|
$group = [];
|
||||||
|
$signature = null;
|
||||||
|
|
||||||
|
foreach ($periods as $period) {
|
||||||
|
$currentSignature = $this->signature($period);
|
||||||
|
if (null !== $signature && $currentSignature !== $signature) {
|
||||||
|
$phases[] = $this->buildPhase($group, $today);
|
||||||
|
$group = [];
|
||||||
|
}
|
||||||
|
$group[] = $period;
|
||||||
|
$signature = $currentSignature;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([] !== $group) {
|
||||||
|
$phases[] = $this->buildPhase($group, $today);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide phases entirely before the application's data start date: no usable
|
||||||
|
// work-hour or absence data exists before that date, so exposing them would
|
||||||
|
// confuse HR (e.g. legacy contract periods predating the software launch).
|
||||||
|
if (null !== $this->dataStartDate) {
|
||||||
|
$dataStart = $this->dataStartDate;
|
||||||
|
$phases = array_values(array_filter(
|
||||||
|
$phases,
|
||||||
|
static fn (ContractPhase $phase): bool => null === $phase->endDate || $phase->endDate >= $dataStart,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Most recent first.
|
||||||
|
return array_reverse($phases);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function signature(EmployeeContractPeriod $period): string
|
||||||
|
{
|
||||||
|
$contract = $period->getContract();
|
||||||
|
$type = $contract?->getType()->value ?? '';
|
||||||
|
$hours = $contract?->getWeeklyHours() ?? -1;
|
||||||
|
$driver = $period->getIsDriver() ? '1' : '0';
|
||||||
|
|
||||||
|
return sprintf('%s|%d|%s', $type, $hours, $driver);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param non-empty-list<EmployeeContractPeriod> $group
|
||||||
|
*/
|
||||||
|
private function buildPhase(array $group, DateTimeImmutable $today): ContractPhase
|
||||||
|
{
|
||||||
|
$first = $group[0];
|
||||||
|
$last = end($group);
|
||||||
|
|
||||||
|
$endDate = $last->getEndDate();
|
||||||
|
$isCurrent = null === $endDate || $endDate >= $today;
|
||||||
|
|
||||||
|
$contract = $first->getContract();
|
||||||
|
|
||||||
|
return new ContractPhase(
|
||||||
|
id: (int) $first->getId(),
|
||||||
|
contractType: $contract?->getType() ?? throw new LogicException('Phase requires a contract type'),
|
||||||
|
weeklyHours: $contract?->getWeeklyHours(),
|
||||||
|
isDriver: $first->getIsDriver(),
|
||||||
|
startDate: $first->getStartDate(),
|
||||||
|
endDate: $endDate,
|
||||||
|
periodIds: array_map(static fn (EmployeeContractPeriod $p): int => (int) $p->getId(), $group),
|
||||||
|
isCurrent: $isCurrent,
|
||||||
|
contractNature: $first->getContractNatureEnum(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/Service/Exercise/ExerciseYearResolver.php
Normal file
27
src/Service/Exercise/ExerciseYearResolver.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Exercise;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
final readonly class ExerciseYearResolver
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Convert a date to its leave/RTT exercise year.
|
||||||
|
*
|
||||||
|
* - Forfait: calendar year (Jan→Dec) — returns $date.Y.
|
||||||
|
* - Non-forfait: leave year (Juin N-1 → Mai N) — returns $date.Y+1 if month >= 6, else $date.Y.
|
||||||
|
*/
|
||||||
|
public function forDate(DateTimeImmutable $date, bool $isForfait = false): int
|
||||||
|
{
|
||||||
|
if ($isForfait) {
|
||||||
|
return (int) $date->format('Y');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $date->format('n') >= 6
|
||||||
|
? (int) $date->format('Y') + 1
|
||||||
|
: (int) $date->format('Y');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,7 +73,7 @@ final readonly class LeaveRecapRowBuilder
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
$cpN1Remaining = round($yearSummary['previousYearRemainingDays'], 2);
|
$cpN1Remaining = round($yearSummary['previousYearRemainingDays'], 2);
|
||||||
$cpN = (string) round($yearSummary['acquiredDays'], 2);
|
$cpN = (string) round($yearSummary['remainingDays'], 2);
|
||||||
$acquiredSaturdays = '-';
|
$acquiredSaturdays = '-';
|
||||||
} else {
|
} else {
|
||||||
$cpN1Remaining = round($yearSummary['remainingDays'], 2);
|
$cpN1Remaining = round($yearSummary['remainingDays'], 2);
|
||||||
|
|||||||
@@ -413,10 +413,17 @@ final readonly class RttRecoveryComputationService
|
|||||||
{
|
{
|
||||||
$total = 0;
|
$total = 0;
|
||||||
foreach ($days as $date) {
|
foreach ($days as $date) {
|
||||||
$isoDay = (int) new DateTimeImmutable($date)->format('N');
|
$isoDay = (int) new DateTimeImmutable($date)->format('N');
|
||||||
$contract = $contractsByDate[$date] ?? null;
|
$contract = $contractsByDate[$date] ?? null;
|
||||||
$hours = $contract?->getWeeklyHours();
|
$hours = $contract?->getWeeklyHours();
|
||||||
$startHours = (null !== $hours && $hours >= 39) ? 39 : 35;
|
// Days without an active contract (pre-hire, post-termination, contract
|
||||||
|
// gaps) must NOT contribute to the weekly 25% overtime threshold —
|
||||||
|
// otherwise hiring mid-week artificially inflates the threshold and
|
||||||
|
// erases legitimate overtime.
|
||||||
|
if (null === $hours || $hours <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$startHours = $hours >= 39 ? 39 : 35;
|
||||||
$total += $this->resolveDailyReferenceMinutes($startHours, $isoDay);
|
$total += $this->resolveDailyReferenceMinutes($startHours, $isoDay);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
503
src/Service/WorkHours/YearlyHoursExportBuilder.php
Normal file
503
src/Service/WorkHours/YearlyHoursExportBuilder.php
Normal file
@@ -0,0 +1,503 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\WorkHours;
|
||||||
|
|
||||||
|
use App\Dto\WorkHours\WorkMetrics;
|
||||||
|
use App\Entity\Absence;
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\WorkHour;
|
||||||
|
use App\Enum\ContractNature;
|
||||||
|
use App\Enum\ContractType;
|
||||||
|
use App\Enum\TrackingMode;
|
||||||
|
use App\Repository\AbsenceRepository;
|
||||||
|
use App\Repository\WorkHourRepository;
|
||||||
|
use App\Service\Contracts\EmployeeContractResolver;
|
||||||
|
use App\Service\PublicHolidayServiceInterface;
|
||||||
|
use DateInterval;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class YearlyHoursExportBuilder
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private WorkHourRepository $workHourRepository,
|
||||||
|
private AbsenceRepository $absenceRepository,
|
||||||
|
private EmployeeContractResolver $contractResolver,
|
||||||
|
private AbsenceSegmentsResolver $absenceSegmentsResolver,
|
||||||
|
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
|
||||||
|
private PublicHolidayServiceInterface $publicHolidayService,
|
||||||
|
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function buildDays(DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||||
|
{
|
||||||
|
$days = [];
|
||||||
|
$current = $from;
|
||||||
|
|
||||||
|
while ($current <= $to) {
|
||||||
|
$days[] = $current->format('Y-m-d');
|
||||||
|
$current = $current->add(new DateInterval('P1D'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $days;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<Employee> $employees
|
||||||
|
*
|
||||||
|
* @return list<array{employeeName: string, contractLabel: ?string, segments: list<array>}>
|
||||||
|
*/
|
||||||
|
public function buildForEmployees(array $employees, DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||||
|
{
|
||||||
|
$days = $this->buildDays($from, $to);
|
||||||
|
|
||||||
|
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($from, $to, $employees);
|
||||||
|
$absences = $this->absenceRepository->findForPrint($from, $to, $employees);
|
||||||
|
$contractMap = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
|
||||||
|
$driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days);
|
||||||
|
$workDaysMap = $this->contractResolver->resolveWorkDaysMinutesForEmployeesAndDays($employees, $days);
|
||||||
|
$holidayMap = $this->buildHolidayMap($from, $to);
|
||||||
|
|
||||||
|
$workHourMap = $this->buildWorkHourMap($workHours);
|
||||||
|
$absenceMap = $this->buildAbsenceMap($absences, $days);
|
||||||
|
|
||||||
|
$results = [];
|
||||||
|
foreach ($employees as $employee) {
|
||||||
|
$employeeId = $employee->getId();
|
||||||
|
$absenceData = $this->resolveAbsenceDataForEmployee($absenceMap[$employeeId] ?? [], $days, $employee);
|
||||||
|
|
||||||
|
$segments = $this->buildSegments(
|
||||||
|
$days,
|
||||||
|
$contractMap[$employeeId] ?? [],
|
||||||
|
$driverMap[$employeeId] ?? [],
|
||||||
|
$workHourMap[$employeeId] ?? [],
|
||||||
|
$absenceData,
|
||||||
|
$workDaysMap[$employeeId] ?? [],
|
||||||
|
$holidayMap,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ([] === $segments) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$results[] = [
|
||||||
|
'employeeName' => trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? '')),
|
||||||
|
'contractLabel' => $this->buildContractLabel($employee),
|
||||||
|
'segments' => $segments,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{employeeName: string, contractLabel: ?string, segments: list<array>}>
|
||||||
|
*/
|
||||||
|
public function buildForEmployee(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||||
|
{
|
||||||
|
return $this->buildForEmployees([$employee], $from, $to);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function buildContractLabel(Employee $employee): ?string
|
||||||
|
{
|
||||||
|
$contract = $employee->getContract();
|
||||||
|
if (null === $contract) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$natureRaw = $employee->getCurrentContractNature();
|
||||||
|
$nature = ContractNature::tryFrom($natureRaw) ?? ContractNature::CDI;
|
||||||
|
$natureLabel = match ($nature) {
|
||||||
|
ContractNature::CDI => 'CDI',
|
||||||
|
ContractNature::CDD => 'CDD',
|
||||||
|
ContractNature::INTERIM => 'Intérim',
|
||||||
|
};
|
||||||
|
|
||||||
|
$contractType = $contract->getType();
|
||||||
|
if (ContractType::FORFAIT === $contractType) {
|
||||||
|
return $natureLabel.' Forfait';
|
||||||
|
}
|
||||||
|
|
||||||
|
$weeklyHours = $contract->getWeeklyHours();
|
||||||
|
if (null !== $weeklyHours && $weeklyHours > 0) {
|
||||||
|
return sprintf('%s %d heures', $natureLabel, $weeklyHours);
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = $contract->getName();
|
||||||
|
|
||||||
|
return null !== $name && '' !== $name ? $natureLabel.' '.$name : $natureLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, WorkHour>>
|
||||||
|
*/
|
||||||
|
private function buildWorkHourMap(array $workHours): array
|
||||||
|
{
|
||||||
|
$map = [];
|
||||||
|
foreach ($workHours as $wh) {
|
||||||
|
$employeeId = $wh->getEmployee()?->getId();
|
||||||
|
if (!$employeeId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$date = $wh->getWorkDate()->format('Y-m-d');
|
||||||
|
$map[$employeeId][$date] = $wh;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, list<Absence>>
|
||||||
|
*/
|
||||||
|
private function buildAbsenceMap(array $absences, array $days): array
|
||||||
|
{
|
||||||
|
$map = [];
|
||||||
|
foreach ($absences as $absence) {
|
||||||
|
$employeeId = $absence->getEmployee()?->getId();
|
||||||
|
if (!$employeeId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$map[$employeeId][] = $absence;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{credited: array<string, int>, labels: array<string, string>, absentMorning: array<string, bool>, absentAfternoon: array<string, bool>, hasDayAbsence: array<string, bool>}
|
||||||
|
*/
|
||||||
|
private function resolveAbsenceDataForEmployee(array $absences, array $days, Employee $employee): array
|
||||||
|
{
|
||||||
|
$credited = [];
|
||||||
|
$labels = [];
|
||||||
|
$absentMorning = [];
|
||||||
|
$absentAfternoon = [];
|
||||||
|
$hasDayAbsence = [];
|
||||||
|
|
||||||
|
foreach ($absences as $absence) {
|
||||||
|
$start = $absence->getStartDate()->format('Y-m-d');
|
||||||
|
$end = $absence->getEndDate()->format('Y-m-d');
|
||||||
|
|
||||||
|
foreach ($days as $date) {
|
||||||
|
if ($date < $start || $date > $end) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$isMorning, $isAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date);
|
||||||
|
if ($isMorning || $isAfternoon) {
|
||||||
|
$hasDayAbsence[$date] = true;
|
||||||
|
$absentMorning[$date] = ($absentMorning[$date] ?? false) || $isMorning;
|
||||||
|
$absentAfternoon[$date] = ($absentAfternoon[$date] ?? false) || $isAfternoon;
|
||||||
|
if (!isset($labels[$date])) {
|
||||||
|
$labels[$date] = $absence->getType()?->getLabel() ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$credited[$date] = ($credited[$date] ?? 0)
|
||||||
|
+ $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $isMorning, $isAfternoon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'credited' => $credited,
|
||||||
|
'labels' => $labels,
|
||||||
|
'absentMorning' => $absentMorning,
|
||||||
|
'absentAfternoon' => $absentAfternoon,
|
||||||
|
'hasDayAbsence' => $hasDayAbsence,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, ?array<int, int>> $workDaysMinutesByDate
|
||||||
|
* @param array<string, string> $holidayMap
|
||||||
|
*
|
||||||
|
* @return list<array{mode: string, contractName: ?string, rows: list<array>}>
|
||||||
|
*/
|
||||||
|
private function buildSegments(
|
||||||
|
array $days,
|
||||||
|
array $contractsByDate,
|
||||||
|
array $driverByDate,
|
||||||
|
array $workHoursByDate,
|
||||||
|
array $absenceData,
|
||||||
|
array $workDaysMinutesByDate,
|
||||||
|
array $holidayMap,
|
||||||
|
): array {
|
||||||
|
$segments = [];
|
||||||
|
$currentMode = null;
|
||||||
|
$currentRows = [];
|
||||||
|
$currentName = null;
|
||||||
|
|
||||||
|
$firstDataDate = null;
|
||||||
|
foreach ($days as $date) {
|
||||||
|
$hasRow = null !== ($workHoursByDate[$date] ?? null)
|
||||||
|
|| ($absenceData['hasDayAbsence'][$date] ?? false)
|
||||||
|
|| isset($holidayMap[$date]);
|
||||||
|
if ($hasRow) {
|
||||||
|
$firstDataDate = $date;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $firstDataDate) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$todayYmd = new DateTimeImmutable('today')->format('Y-m-d');
|
||||||
|
|
||||||
|
foreach ($days as $date) {
|
||||||
|
if ($date < $firstDataDate || $date > $todayYmd) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$contract = $contractsByDate[$date] ?? null;
|
||||||
|
$isDriver = $driverByDate[$date] ?? false;
|
||||||
|
$wh = $workHoursByDate[$date] ?? null;
|
||||||
|
$hasData = null !== $wh || ($absenceData['hasDayAbsence'][$date] ?? false);
|
||||||
|
$holidayLabel = $holidayMap[$date] ?? null;
|
||||||
|
$isHoliday = null !== $holidayLabel;
|
||||||
|
$isoDay = (int) new DateTimeImmutable($date)->format('N');
|
||||||
|
$isWeekend = $isoDay >= 6;
|
||||||
|
|
||||||
|
if (!$hasData && !$isWeekend && !$isHoliday) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$hasData && null === $contract) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$trackingMode = $contract?->getTrackingMode() ?? TrackingMode::TIME->value;
|
||||||
|
$mode = $this->resolveSegmentMode($trackingMode, $isDriver);
|
||||||
|
$contractName = $contract?->getName();
|
||||||
|
|
||||||
|
if ($mode !== $currentMode) {
|
||||||
|
if (null !== $currentMode && [] !== $currentRows) {
|
||||||
|
$segments[] = [
|
||||||
|
'mode' => $currentMode,
|
||||||
|
'contractName' => $currentName,
|
||||||
|
'rows' => $currentRows,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$currentMode = $mode;
|
||||||
|
$currentRows = [];
|
||||||
|
$currentName = $contractName;
|
||||||
|
}
|
||||||
|
|
||||||
|
$creditedMinutes = $absenceData['credited'][$date] ?? 0;
|
||||||
|
$absenceLabel = $absenceData['labels'][$date] ?? null;
|
||||||
|
$hasAbsence = $absenceData['hasDayAbsence'][$date] ?? false;
|
||||||
|
$virtualMinutes = $this->holidayVirtualHoursResolver->resolveVirtualCredit(
|
||||||
|
$contract,
|
||||||
|
new DateTimeImmutable($date),
|
||||||
|
$hasAbsence,
|
||||||
|
$workDaysMinutesByDate[$date] ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$row = [
|
||||||
|
'date' => new DateTimeImmutable($date)->format('d/m/Y'),
|
||||||
|
'absenceLabel' => $absenceLabel,
|
||||||
|
'holidayLabel' => $holidayLabel,
|
||||||
|
'isWeekend' => $isWeekend,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ('presence' === $mode) {
|
||||||
|
$absentMorning = $absenceData['absentMorning'][$date] ?? false;
|
||||||
|
$absentAfternoon = $absenceData['absentAfternoon'][$date] ?? false;
|
||||||
|
$morning = (($wh?->getIsPresentMorning() ?? false) && !$absentMorning) ? 0.5 : 0.0;
|
||||||
|
$afternoon = (($wh?->getIsPresentAfternoon() ?? false) && !$absentAfternoon) ? 0.5 : 0.0;
|
||||||
|
$total = $morning + $afternoon;
|
||||||
|
|
||||||
|
$row['presentMorning'] = $morning > 0;
|
||||||
|
$row['presentAfternoon'] = $afternoon > 0;
|
||||||
|
$row['total'] = $total > 0 ? (string) $total : '';
|
||||||
|
} elseif ('driver' === $mode) {
|
||||||
|
$dayMin = $wh?->getDayHoursMinutes() ?? 0;
|
||||||
|
$nightMin = $wh?->getNightHoursMinutes() ?? 0;
|
||||||
|
$workshopMin = $wh?->getWorkshopHoursMinutes() ?? 0;
|
||||||
|
$totalMin = $dayMin + $nightMin + $workshopMin + $creditedMinutes;
|
||||||
|
if ($virtualMinutes > $totalMin) {
|
||||||
|
$totalMin = $virtualMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row['dayHours'] = $this->formatMinutes($dayMin);
|
||||||
|
$row['nightHours'] = $this->formatMinutes($nightMin);
|
||||||
|
$row['workshopHours'] = $this->formatMinutes($workshopMin);
|
||||||
|
$row['total'] = $this->formatMinutes($totalMin);
|
||||||
|
} else {
|
||||||
|
$metrics = null !== $wh ? $this->computeMetrics($wh) : new WorkMetrics();
|
||||||
|
$metrics->addCreditedMinutes($creditedMinutes);
|
||||||
|
$totalMin = $metrics->totalMinutes;
|
||||||
|
if ($virtualMinutes > $totalMin) {
|
||||||
|
$totalMin = $virtualMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row['morningFrom'] = $wh?->getMorningFrom() ?? '';
|
||||||
|
$row['morningTo'] = $wh?->getMorningTo() ?? '';
|
||||||
|
$row['afternoonFrom'] = $wh?->getAfternoonFrom() ?? '';
|
||||||
|
$row['afternoonTo'] = $wh?->getAfternoonTo() ?? '';
|
||||||
|
$row['eveningFrom'] = $wh?->getEveningFrom() ?? '';
|
||||||
|
$row['eveningTo'] = $wh?->getEveningTo() ?? '';
|
||||||
|
$row['total'] = $this->formatMinutes($totalMin);
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentRows[] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $currentMode && [] !== $currentRows) {
|
||||||
|
$segments[] = [
|
||||||
|
'mode' => $currentMode,
|
||||||
|
'contractName' => $currentName,
|
||||||
|
'rows' => $currentRows,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string> Y-m-d => label
|
||||||
|
*/
|
||||||
|
private function buildHolidayMap(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->getHolidaysDayByYears('metropole', (string) $year);
|
||||||
|
foreach ($holidays as $date => $label) {
|
||||||
|
$map[(string) $date] = (string) $label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Throwable) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveSegmentMode(string $trackingMode, bool $isDriver): string
|
||||||
|
{
|
||||||
|
if ($isDriver) {
|
||||||
|
return 'driver';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TrackingMode::PRESENCE->value === $trackingMode) {
|
||||||
|
return 'presence';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'time';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function computeMetrics(WorkHour $workHour): WorkMetrics
|
||||||
|
{
|
||||||
|
$ranges = [
|
||||||
|
[$workHour->getMorningFrom(), $workHour->getMorningTo()],
|
||||||
|
[$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()],
|
||||||
|
[$workHour->getEveningFrom(), $workHour->getEveningTo()],
|
||||||
|
];
|
||||||
|
|
||||||
|
$totalMinutes = 0;
|
||||||
|
$nightMinutes = 0;
|
||||||
|
|
||||||
|
foreach ($ranges as [$from, $to]) {
|
||||||
|
$totalMinutes += $this->intervalMinutes($from, $to);
|
||||||
|
$nightMinutes += $this->nightIntervalMinutes($from, $to);
|
||||||
|
}
|
||||||
|
|
||||||
|
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
|
||||||
|
|
||||||
|
return new WorkMetrics(
|
||||||
|
dayMinutes: $dayMinutes,
|
||||||
|
nightMinutes: $nightMinutes,
|
||||||
|
totalMinutes: $totalMinutes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return null|array{int, int}
|
||||||
|
*/
|
||||||
|
private function resolveInterval(?string $from, ?string $to): ?array
|
||||||
|
{
|
||||||
|
$fromMinutes = $this->toMinutes($from);
|
||||||
|
$toMinutes = $this->toMinutes($to);
|
||||||
|
if (null === $fromMinutes || null === $toMinutes) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$end = $toMinutes <= $fromMinutes ? $toMinutes + 1440 : $toMinutes;
|
||||||
|
|
||||||
|
return [$fromMinutes, $end];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function toMinutes(?string $time): ?int
|
||||||
|
{
|
||||||
|
if (null === $time || '' === $time) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$hours, $minutes] = array_map('intval', explode(':', $time));
|
||||||
|
|
||||||
|
return ($hours * 60) + $minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function intervalMinutes(?string $from, ?string $to): int
|
||||||
|
{
|
||||||
|
$interval = $this->resolveInterval($from, $to);
|
||||||
|
if (null === $interval) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$start, $end] = $interval;
|
||||||
|
|
||||||
|
return max(0, $end - $start);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nightIntervalMinutes(?string $from, ?string $to): int
|
||||||
|
{
|
||||||
|
$interval = $this->resolveInterval($from, $to);
|
||||||
|
if (null === $interval) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$start, $end] = $interval;
|
||||||
|
$windows = [[0, 360], [1260, 1440]];
|
||||||
|
$total = 0;
|
||||||
|
|
||||||
|
for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) {
|
||||||
|
$shift = $dayOffset * 1440;
|
||||||
|
foreach ($windows as [$windowStart, $windowEnd]) {
|
||||||
|
$total += $this->overlap($start, $end, $windowStart + $shift, $windowEnd + $shift);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $total;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function overlap(int $startA, int $endA, int $startB, int $endB): int
|
||||||
|
{
|
||||||
|
$start = max($startA, $startB);
|
||||||
|
$end = min($endA, $endB);
|
||||||
|
|
||||||
|
return max(0, $end - $start);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatMinutes(int $minutes): string
|
||||||
|
{
|
||||||
|
if (0 === $minutes) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$h = intdiv($minutes, 60);
|
||||||
|
$m = $minutes % 60;
|
||||||
|
|
||||||
|
return 0 === $m ? "{$h}h" : "{$h}h{$m}m";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ namespace App\State;
|
|||||||
use ApiPlatform\Metadata\Operation;
|
use ApiPlatform\Metadata\Operation;
|
||||||
use ApiPlatform\State\ProviderInterface;
|
use ApiPlatform\State\ProviderInterface;
|
||||||
use App\ApiResource\EmployeeLeaveSummary;
|
use App\ApiResource\EmployeeLeaveSummary;
|
||||||
|
use App\Dto\Contracts\ContractPhase;
|
||||||
use App\Entity\Absence;
|
use App\Entity\Absence;
|
||||||
use App\Entity\ContractSuspension;
|
use App\Entity\ContractSuspension;
|
||||||
use App\Entity\Employee;
|
use App\Entity\Employee;
|
||||||
@@ -20,6 +21,8 @@ use App\Repository\EmployeeLeaveBalanceRepository;
|
|||||||
use App\Repository\EmployeeRepository;
|
use App\Repository\EmployeeRepository;
|
||||||
use App\Repository\WorkHourRepository;
|
use App\Repository\WorkHourRepository;
|
||||||
use App\Security\EmployeeScopeService;
|
use App\Security\EmployeeScopeService;
|
||||||
|
use App\Service\Contracts\EmployeeContractPhaseResolver;
|
||||||
|
use App\Service\Exercise\ExerciseYearResolver;
|
||||||
use App\Service\Leave\LeaveBalanceComputationService;
|
use App\Service\Leave\LeaveBalanceComputationService;
|
||||||
use App\Service\Leave\LongMaladieService;
|
use App\Service\Leave\LongMaladieService;
|
||||||
use App\Service\Leave\SuspensionDaysCalculator;
|
use App\Service\Leave\SuspensionDaysCalculator;
|
||||||
@@ -45,6 +48,8 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
private const float CDI_NON_FORFAIT_4H_SATURDAY_ACCRUAL_PER_MONTH = 0.0;
|
private const float CDI_NON_FORFAIT_4H_SATURDAY_ACCRUAL_PER_MONTH = 0.0;
|
||||||
private const float LONG_MALADIE_MONTHLY_ACCRUAL = 2.0;
|
private const float LONG_MALADIE_MONTHLY_ACCRUAL = 2.0;
|
||||||
|
|
||||||
|
private ?string $dataStartDate;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private Security $security,
|
private Security $security,
|
||||||
private RequestStack $requestStack,
|
private RequestStack $requestStack,
|
||||||
@@ -58,7 +63,12 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
private PublicHolidayServiceInterface $publicHolidayService,
|
private PublicHolidayServiceInterface $publicHolidayService,
|
||||||
private SuspensionDaysCalculator $suspensionDaysCalculator,
|
private SuspensionDaysCalculator $suspensionDaysCalculator,
|
||||||
private WorkHourRepository $workHourRepository,
|
private WorkHourRepository $workHourRepository,
|
||||||
) {}
|
private EmployeeContractPhaseResolver $phaseResolver,
|
||||||
|
private ExerciseYearResolver $exerciseYearResolver,
|
||||||
|
string $dataStartDate = '',
|
||||||
|
) {
|
||||||
|
$this->dataStartDate = '' !== $dataStartDate ? $dataStartDate : null;
|
||||||
|
}
|
||||||
|
|
||||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): EmployeeLeaveSummary
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): EmployeeLeaveSummary
|
||||||
{
|
{
|
||||||
@@ -81,13 +91,15 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
throw new AccessDeniedHttpException('Employee outside your scope.');
|
throw new AccessDeniedHttpException('Employee outside your scope.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$year = $this->resolveYear($employee);
|
$phase = $this->resolveTargetPhase($employee);
|
||||||
|
$year = $this->resolveYear($employee, $phase);
|
||||||
|
|
||||||
$summary = new EmployeeLeaveSummary();
|
$summary = new EmployeeLeaveSummary();
|
||||||
$summary->year = $year;
|
$summary->year = $year;
|
||||||
$summary->ruleCode = LeaveRuleCode::UNSUPPORTED->value;
|
$summary->ruleCode = LeaveRuleCode::UNSUPPORTED->value;
|
||||||
|
$summary->dataStartDate = $this->dataStartDate;
|
||||||
|
|
||||||
$yearSummary = $this->computeYearSummary($employee, $year);
|
$yearSummary = $this->computeYearSummary($employee, $year, 0.0, null, $phase);
|
||||||
if (null === $yearSummary) {
|
if (null === $yearSummary) {
|
||||||
return $summary;
|
return $summary;
|
||||||
}
|
}
|
||||||
@@ -98,7 +110,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
// For forfait contracts, paid days reduce N-1 stock before taken-day attribution.
|
// For forfait contracts, paid days reduce N-1 stock before taken-day attribution.
|
||||||
// Recompute with paidLeaveDays so taken days shift from N-1 to N when N-1 is consumed by payment.
|
// Recompute with paidLeaveDays so taken days shift from N-1 to N when N-1 is consumed by payment.
|
||||||
if ($paidLeaveDays > 0.0) {
|
if ($paidLeaveDays > 0.0) {
|
||||||
$yearSummary = $this->computeYearSummary($employee, $year, $paidLeaveDays);
|
$yearSummary = $this->computeYearSummary($employee, $year, $paidLeaveDays, null, $phase);
|
||||||
if (null === $yearSummary) {
|
if (null === $yearSummary) {
|
||||||
return $summary;
|
return $summary;
|
||||||
}
|
}
|
||||||
@@ -119,8 +131,29 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
$summary->previousYearRemainingDays = $yearSummary['previousYearRemainingDays'];
|
$summary->previousYearRemainingDays = $yearSummary['previousYearRemainingDays'];
|
||||||
$summary->previousYearPaidDays = $paidLeaveDays;
|
$summary->previousYearPaidDays = $paidLeaveDays;
|
||||||
|
|
||||||
[$periodFrom, $periodTo] = $this->resolvePeriodBounds($employee, $year);
|
[$periodFrom, $periodTo] = $this->resolvePeriodBounds($employee, $year, $phase);
|
||||||
$summary->presenceDaysByMonth = $this->computePresenceDaysByMonth($employee, $periodFrom, $periodTo);
|
// Forfait-only: leaves taken from N-1 stock do NOT decrement presence days.
|
||||||
|
// For non-forfait, previousYearTakenDays is always 0, so the budget has no effect.
|
||||||
|
$n1AbsencesBudget = $yearSummary['previousYearTakenDays'];
|
||||||
|
$summary->presenceDaysByMonth = $this->computePresenceDaysByMonth(
|
||||||
|
$employee,
|
||||||
|
$periodFrom,
|
||||||
|
$periodTo,
|
||||||
|
$n1AbsencesBudget
|
||||||
|
);
|
||||||
|
|
||||||
|
// Same logic as presenceDaysByMonth but bounded at today: number of presence days
|
||||||
|
// accumulated from leave year start up to today (inclusive).
|
||||||
|
$today = new DateTimeImmutable('today');
|
||||||
|
$cappedTo = $today < $periodTo ? $today : $periodTo;
|
||||||
|
$summary->presenceDaysToToday = $today < $periodFrom
|
||||||
|
? 0.0
|
||||||
|
: array_sum($this->computePresenceDaysByMonth(
|
||||||
|
$employee,
|
||||||
|
$periodFrom,
|
||||||
|
$cappedTo,
|
||||||
|
$n1AbsencesBudget
|
||||||
|
));
|
||||||
|
|
||||||
return $summary;
|
return $summary;
|
||||||
}
|
}
|
||||||
@@ -140,9 +173,20 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
* previousYearRemainingDays: float
|
* previousYearRemainingDays: float
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public function computeYearSummary(Employee $employee, int $targetYear, float $paidLeaveDays = 0.0, ?DateTimeImmutable $asOfDate = null): ?array
|
public function computeYearSummary(Employee $employee, int $targetYear, float $paidLeaveDays = 0.0, ?DateTimeImmutable $asOfDate = null, ?ContractPhase $phase = null): ?array
|
||||||
{
|
{
|
||||||
$firstYear = max($this->resolveFirstComputationYear($employee), $targetYear - 1);
|
// Track whether a phase was provided explicitly. When the caller supplies $phase,
|
||||||
|
// we apply the phase-end cap on period bounds. When we fall back to resolveCurrentPhase
|
||||||
|
// (legacy callers without phase awareness, e.g. LeaveRecapRowBuilder), we preserve
|
||||||
|
// the pre-phase-cap behavior to avoid changing observable results for terminated
|
||||||
|
// employees (the resolved fallback phase would otherwise unduly cap `to`).
|
||||||
|
$applyPhaseEndCap = null !== $phase;
|
||||||
|
$phase ??= $this->resolveCurrentPhase($employee);
|
||||||
|
if (null === $phase) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$firstYear = max($this->resolveFirstComputationYear($employee, $phase), $targetYear - 1);
|
||||||
if ($targetYear < $firstYear) {
|
if ($targetYear < $firstYear) {
|
||||||
$targetYear = $firstYear;
|
$targetYear = $firstYear;
|
||||||
}
|
}
|
||||||
@@ -152,8 +196,8 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
$targetSummary = null;
|
$targetSummary = null;
|
||||||
|
|
||||||
for ($year = $firstYear; $year <= $targetYear; ++$year) {
|
for ($year = $firstYear; $year <= $targetYear; ++$year) {
|
||||||
[$from, $to] = $this->resolvePeriodBounds($employee, $year);
|
[$from, $to] = $this->resolvePeriodBounds($employee, $year, $phase, $applyPhaseEndCap);
|
||||||
$leavePolicy = $this->resolveLeavePolicy($employee, $from, $to);
|
$leavePolicy = $this->resolveLeavePolicy($employee, $phase, $from, $to);
|
||||||
if (null === $leavePolicy) {
|
if (null === $leavePolicy) {
|
||||||
if ($year === $targetYear) {
|
if ($year === $targetYear) {
|
||||||
return null;
|
return null;
|
||||||
@@ -197,8 +241,8 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
$effectiveAsOfDate = ($year === $targetYear) ? $asOfDate : null;
|
$effectiveAsOfDate = ($year === $targetYear) ? $asOfDate : null;
|
||||||
$accrualCalculationEnd = $this->resolveAccrualCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee, $effectiveAsOfDate);
|
$accrualCalculationEnd = $this->resolveAccrualCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee, $phase, $effectiveAsOfDate, $applyPhaseEndCap);
|
||||||
$takenCalculationEnd = $this->resolveTakenCalculationEndDate($to, $employee, $effectiveAsOfDate);
|
$takenCalculationEnd = $this->resolveTakenCalculationEndDate($to, $employee, $phase, $effectiveAsOfDate, $applyPhaseEndCap);
|
||||||
$suspensions = $this->suspensionDaysCalculator->applyFirstMonthGrace(
|
$suspensions = $this->suspensionDaysCalculator->applyFirstMonthGrace(
|
||||||
$this->resolveSuspensionsForPeriod($employee, $effectiveFrom, $to)
|
$this->resolveSuspensionsForPeriod($employee, $effectiveFrom, $to)
|
||||||
);
|
);
|
||||||
@@ -206,7 +250,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
$longMaladiePeriods = [];
|
$longMaladiePeriods = [];
|
||||||
$longMaladieReductionFactor = 1.0;
|
$longMaladieReductionFactor = 1.0;
|
||||||
if (LeaveRuleCode::CDI_CDD_NON_FORFAIT->value === $leavePolicy['ruleCode']
|
if (LeaveRuleCode::CDI_CDD_NON_FORFAIT->value === $leavePolicy['ruleCode']
|
||||||
&& 4 !== $employee->getContract()?->getWeeklyHours()
|
&& 4 !== $phase->weeklyHours
|
||||||
&& null !== $accrualCalculationEnd
|
&& null !== $accrualCalculationEnd
|
||||||
) {
|
) {
|
||||||
$longMaladiePeriods = $this->longMaladieService->findReducedRatePeriods($employee, $effectiveFrom, $accrualCalculationEnd);
|
$longMaladiePeriods = $this->longMaladieService->findReducedRatePeriods($employee, $effectiveFrom, $accrualCalculationEnd);
|
||||||
@@ -393,16 +437,29 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
return $earliest;
|
return $earliest;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveYear(Employee $employee): int
|
private function resolveYear(Employee $employee, ContractPhase $phase): int
|
||||||
{
|
{
|
||||||
$raw = (string) ($this->requestStack->getCurrentRequest()?->query->get('year') ?? '');
|
$isForfait = ContractType::FORFAIT === $phase->contractType;
|
||||||
|
$raw = (string) ($this->requestStack->getCurrentRequest()?->query->get('year') ?? '');
|
||||||
|
$phaseIdRaw = $this->requestStack->getCurrentRequest()?->query->get('phaseId');
|
||||||
|
$phaseIdProvided = null !== $phaseIdRaw && '' !== (string) $phaseIdRaw;
|
||||||
|
|
||||||
if ('' === $raw) {
|
if ('' === $raw) {
|
||||||
$today = new DateTimeImmutable('today');
|
// When a phaseId is explicitly provided, default to the year derived from
|
||||||
if (ContractType::FORFAIT === $employee->getContract()?->getType()) {
|
// the phase's end date (or today if the phase is still current).
|
||||||
return (int) $today->format('Y');
|
if ($phaseIdProvided) {
|
||||||
|
$reference = $phase->endDate ?? new DateTimeImmutable('today');
|
||||||
|
|
||||||
|
return $isForfait
|
||||||
|
? (int) $reference->format('Y')
|
||||||
|
: $this->resolveCurrentLeaveYear($reference);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->resolveCurrentLeaveYear($today);
|
$today = new DateTimeImmutable('today');
|
||||||
|
|
||||||
|
return $isForfait
|
||||||
|
? (int) $today->format('Y')
|
||||||
|
: $this->resolveCurrentLeaveYear($today);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!preg_match('/^\d{4}$/', $raw)) {
|
if (!preg_match('/^\d{4}$/', $raw)) {
|
||||||
@@ -414,9 +471,79 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
throw new UnprocessableEntityHttpException('year must be between 2000 and 2100.');
|
throw new UnprocessableEntityHttpException('year must be between 2000 and 2100.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When a phaseId is explicit, silently clamp the requested year to the
|
||||||
|
// first/last exercise covered by the phase.
|
||||||
|
if ($phaseIdProvided) {
|
||||||
|
$year = $this->clampYearToPhase($year, $phase, $isForfait);
|
||||||
|
}
|
||||||
|
|
||||||
return $year;
|
return $year;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function clampYearToPhase(int $year, ContractPhase $phase, bool $isForfait): int
|
||||||
|
{
|
||||||
|
$firstYear = $this->exerciseYearResolver->forDate($phase->startDate, $isForfait);
|
||||||
|
$lastYear = $phase->endDate instanceof DateTimeImmutable
|
||||||
|
? $this->exerciseYearResolver->forDate($phase->endDate, $isForfait)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if ($year < $firstYear) {
|
||||||
|
return $firstYear;
|
||||||
|
}
|
||||||
|
if (null !== $lastYear && $year > $lastYear) {
|
||||||
|
return $lastYear;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $year;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveTargetPhase(Employee $employee): ContractPhase
|
||||||
|
{
|
||||||
|
$raw = $this->requestStack->getCurrentRequest()?->query->get('phaseId');
|
||||||
|
$phases = $this->phaseResolver->resolvePhases($employee);
|
||||||
|
if ([] === $phases) {
|
||||||
|
throw new UnprocessableEntityHttpException('Employee has no contract phase.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $raw || '' === (string) $raw) {
|
||||||
|
// Phase courante par défaut = celle marquée isCurrent ou, à défaut, la plus récente.
|
||||||
|
foreach ($phases as $phase) {
|
||||||
|
if ($phase->isCurrent) {
|
||||||
|
return $phase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $phases[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!preg_match('/^\d+$/', (string) $raw)) {
|
||||||
|
throw new UnprocessableEntityHttpException('phaseId must be a positive integer.');
|
||||||
|
}
|
||||||
|
$phaseId = (int) $raw;
|
||||||
|
foreach ($phases as $phase) {
|
||||||
|
if ($phase->id === $phaseId) {
|
||||||
|
return $phase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new UnprocessableEntityHttpException('phaseId does not match any phase of this employee.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveCurrentPhase(Employee $employee): ?ContractPhase
|
||||||
|
{
|
||||||
|
$phases = $this->phaseResolver->resolvePhases($employee);
|
||||||
|
if ([] === $phases) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
foreach ($phases as $phase) {
|
||||||
|
if ($phase->isCurrent) {
|
||||||
|
return $phase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $phases[0];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<ContractSuspension> $suspensions
|
* @param list<ContractSuspension> $suspensions
|
||||||
* @param list<array{start: DateTimeImmutable, end: DateTimeImmutable}> $longMaladiePeriods
|
* @param list<array{start: DateTimeImmutable, end: DateTimeImmutable}> $longMaladiePeriods
|
||||||
@@ -491,14 +618,21 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
int $year,
|
int $year,
|
||||||
DateTimeImmutable $periodEnd,
|
DateTimeImmutable $periodEnd,
|
||||||
Employee $employee,
|
Employee $employee,
|
||||||
?DateTimeImmutable $asOfDate = null
|
ContractPhase $phase,
|
||||||
|
?DateTimeImmutable $asOfDate = null,
|
||||||
|
bool $applyPhaseEndCap = true
|
||||||
): ?DateTimeImmutable {
|
): ?DateTimeImmutable {
|
||||||
$reference = $asOfDate ?? new DateTimeImmutable('today');
|
$reference = $asOfDate ?? new DateTimeImmutable('today');
|
||||||
$currentYear = LeaveRuleCode::FORFAIT_218->value === $ruleCode
|
$currentYear = LeaveRuleCode::FORFAIT_218->value === $ruleCode
|
||||||
? (int) $reference->format('Y')
|
? (int) $reference->format('Y')
|
||||||
: $this->resolveCurrentLeaveYear($reference);
|
: $this->resolveCurrentLeaveYear($reference);
|
||||||
|
|
||||||
if ($year < $currentYear) {
|
// When viewing a closed phase explicitly, treat its end date as the reference cutoff:
|
||||||
|
// accrual is bounded to the phase end, never running to "today".
|
||||||
|
// Legacy callers (no explicit phase) skip this cap to preserve pre-phase behavior.
|
||||||
|
if ($applyPhaseEndCap && !$phase->isCurrent && null !== $phase->endDate) {
|
||||||
|
$end = $phase->endDate < $periodEnd ? $phase->endDate : $periodEnd;
|
||||||
|
} elseif ($year < $currentYear) {
|
||||||
$end = $periodEnd;
|
$end = $periodEnd;
|
||||||
} elseif ($year > $currentYear) {
|
} elseif ($year > $currentYear) {
|
||||||
$end = null;
|
$end = null;
|
||||||
@@ -511,12 +645,17 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
$end = $lastDayPreviousMonth < $periodEnd ? $lastDayPreviousMonth : $periodEnd;
|
$end = $lastDayPreviousMonth < $periodEnd ? $lastDayPreviousMonth : $periodEnd;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cap at contract end date if the employee has left.
|
// Cap at contract end date if the employee has left (only meaningful when
|
||||||
$contractEndRaw = $employee->getCurrentContractEndDate();
|
// viewing the current phase; closed phases are already capped above).
|
||||||
if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
|
// Legacy callers (no explicit phase) always evaluate this branch to mimic
|
||||||
$contractEnd = $this->parseYmdDate($contractEndRaw);
|
// the pre-phase behavior, which relied on getCurrentContractEndDate().
|
||||||
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) {
|
if (!$applyPhaseEndCap || $phase->isCurrent) {
|
||||||
$end = $contractEnd;
|
$contractEndRaw = $employee->getCurrentContractEndDate();
|
||||||
|
if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
|
||||||
|
$contractEnd = $this->parseYmdDate($contractEndRaw);
|
||||||
|
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) {
|
||||||
|
$end = $contractEnd;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,7 +665,9 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
private function resolveTakenCalculationEndDate(
|
private function resolveTakenCalculationEndDate(
|
||||||
DateTimeImmutable $periodEnd,
|
DateTimeImmutable $periodEnd,
|
||||||
Employee $employee,
|
Employee $employee,
|
||||||
?DateTimeImmutable $asOfDate = null
|
ContractPhase $phase,
|
||||||
|
?DateTimeImmutable $asOfDate = null,
|
||||||
|
bool $applyPhaseEndCap = true
|
||||||
): ?DateTimeImmutable {
|
): ?DateTimeImmutable {
|
||||||
$end = $periodEnd;
|
$end = $periodEnd;
|
||||||
|
|
||||||
@@ -534,12 +675,21 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
$end = $asOfDate;
|
$end = $asOfDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cap at contract end date if the employee has left.
|
// Closed phase: cap taken-absence accounting at the phase end.
|
||||||
$contractEndRaw = $employee->getCurrentContractEndDate();
|
// Skip for legacy callers (no explicit phase) to preserve pre-phase behavior.
|
||||||
if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
|
if ($applyPhaseEndCap && !$phase->isCurrent && null !== $phase->endDate && $phase->endDate < $end) {
|
||||||
$contractEnd = $this->parseYmdDate($contractEndRaw);
|
$end = $phase->endDate;
|
||||||
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) {
|
}
|
||||||
$end = $contractEnd;
|
|
||||||
|
// Legacy callers (no explicit phase) always use the live contract end date,
|
||||||
|
// mirroring the pre-phase implementation.
|
||||||
|
if (!$applyPhaseEndCap || $phase->isCurrent) {
|
||||||
|
$contractEndRaw = $employee->getCurrentContractEndDate();
|
||||||
|
if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
|
||||||
|
$contractEnd = $this->parseYmdDate($contractEndRaw);
|
||||||
|
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) {
|
||||||
|
$end = $contractEnd;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -557,9 +707,9 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
* splitSaturdays: bool
|
* splitSaturdays: bool
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
private function resolveLeavePolicy(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): ?array
|
private function resolveLeavePolicy(Employee $employee, ContractPhase $phase, DateTimeImmutable $from, DateTimeImmutable $to): ?array
|
||||||
{
|
{
|
||||||
$type = $employee->getContract()?->getType();
|
$type = $phase->contractType;
|
||||||
if (ContractType::FORFAIT === $type) {
|
if (ContractType::FORFAIT === $type) {
|
||||||
// Business days for forfait must use the RAW holiday list (excluded holidays like
|
// 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
|
// "Lundi de Pentecôte" / journée de solidarité still count as non-working days for
|
||||||
@@ -588,12 +738,13 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$nature = ContractNature::tryFrom($employee->getCurrentContractNature());
|
// Resolve nature directly from the phase DTO (populated by EmployeeContractPhaseResolver).
|
||||||
|
$nature = $phase->contractNature;
|
||||||
if (ContractNature::CDI !== $nature && ContractNature::CDD !== $nature) {
|
if (ContractNature::CDI !== $nature && ContractNature::CDD !== $nature) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$weeklyHours = $employee->getContract()?->getWeeklyHours();
|
$weeklyHours = $phase->weeklyHours;
|
||||||
if (4 === $weeklyHours) {
|
if (4 === $weeklyHours) {
|
||||||
return [
|
return [
|
||||||
'ruleCode' => LeaveRuleCode::CDI_CDD_NON_FORFAIT->value,
|
'ruleCode' => LeaveRuleCode::CDI_CDD_NON_FORFAIT->value,
|
||||||
@@ -686,8 +837,12 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
*
|
*
|
||||||
* @return array<string, float> YYYY-MM => presence day count
|
* @return array<string, float> YYYY-MM => presence day count
|
||||||
*/
|
*/
|
||||||
private function computePresenceDaysByMonth(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): array
|
private function computePresenceDaysByMonth(
|
||||||
{
|
Employee $employee,
|
||||||
|
DateTimeImmutable $from,
|
||||||
|
DateTimeImmutable $to,
|
||||||
|
float $n1AbsencesBudget = 0.0
|
||||||
|
): array {
|
||||||
$publicHolidays = $this->buildPublicHolidayMap($from, $to);
|
$publicHolidays = $this->buildPublicHolidayMap($from, $to);
|
||||||
$weekendWorkedDays = $this->workHourRepository->countWeekendWorkedDaysByMonth($employee, $from, $to);
|
$weekendWorkedDays = $this->workHourRepository->countWeekendWorkedDaysByMonth($employee, $from, $to);
|
||||||
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to);
|
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to);
|
||||||
@@ -697,10 +852,20 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
? $this->workHourRepository->findWorkedDatesAmong($employee, array_keys($publicHolidays))
|
? $this->workHourRepository->findWorkedDatesAmong($employee, array_keys($publicHolidays))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
// Sort absences chronologically so N-1 budget (forfait only) is consumed in date order:
|
||||||
|
// earliest absences attribute to N-1 first, later ones overflow to N and reduce presence.
|
||||||
|
$sortedAbsences = $absences;
|
||||||
|
usort(
|
||||||
|
$sortedAbsences,
|
||||||
|
static fn ($a, $b): int => $a->getStartDate() <=> $b->getStartDate()
|
||||||
|
);
|
||||||
|
|
||||||
|
$remainingN1Budget = $n1AbsencesBudget;
|
||||||
|
|
||||||
// Count absence days per month, iterating day by day to handle multi-day absences
|
// Count absence days per month, iterating day by day to handle multi-day absences
|
||||||
// and properly distribute across months.
|
// and properly distribute across months.
|
||||||
$absenceDaysByMonth = [];
|
$absenceDaysByMonth = [];
|
||||||
foreach ($absences as $absence) {
|
foreach ($sortedAbsences as $absence) {
|
||||||
$start = DateTimeImmutable::createFromInterface($absence->getStartDate())->setTime(0, 0);
|
$start = DateTimeImmutable::createFromInterface($absence->getStartDate())->setTime(0, 0);
|
||||||
$end = DateTimeImmutable::createFromInterface($absence->getEndDate())->setTime(0, 0);
|
$end = DateTimeImmutable::createFromInterface($absence->getEndDate())->setTime(0, 0);
|
||||||
|
|
||||||
@@ -718,6 +883,17 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Forfait: leaves taken from N-1 stock do NOT decrement presence days.
|
||||||
|
// We chronologically consume the N-1 budget before counting any absence.
|
||||||
|
if ($remainingN1Budget > 0.0) {
|
||||||
|
$consumed = min($remainingN1Budget, $dayAmount);
|
||||||
|
$remainingN1Budget -= $consumed;
|
||||||
|
$dayAmount -= $consumed;
|
||||||
|
if ($dayAmount <= 0.0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$absenceDaysByMonth[$monthKey] = ($absenceDaysByMonth[$monthKey] ?? 0.0) + $dayAmount;
|
$absenceDaysByMonth[$monthKey] = ($absenceDaysByMonth[$monthKey] ?? 0.0) + $dayAmount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -763,13 +939,33 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
/**
|
/**
|
||||||
* @return array{DateTimeImmutable, DateTimeImmutable}
|
* @return array{DateTimeImmutable, DateTimeImmutable}
|
||||||
*/
|
*/
|
||||||
private function resolvePeriodBounds(Employee $employee, int $year): array
|
private function resolvePeriodBounds(Employee $employee, int $year, ContractPhase $phase, bool $applyPhaseEndCap = true): array
|
||||||
{
|
{
|
||||||
if (ContractType::FORFAIT === $employee->getContract()?->getType()) {
|
if (ContractType::FORFAIT === $phase->contractType) {
|
||||||
return $this->resolveForfaitYearBounds($employee, $year);
|
[$from, $to] = $this->resolveForfaitYearBounds($employee, $year, $phase);
|
||||||
|
|
||||||
|
// For FORFAIT, cap from at phase.startDate: the 218-day FORFAIT accrual
|
||||||
|
// is calendar-year scoped and only counts the FORFAIT portion of the year.
|
||||||
|
if ($phase->startDate > $from) {
|
||||||
|
$from = $phase->startDate;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
[$from, $to] = $this->resolveLeavePeriodBounds($year);
|
||||||
|
|
||||||
|
// For non-forfait, do NOT cap from at phase.startDate: CP accrual is
|
||||||
|
// annual (Juin→Mai) and continuous across signature changes within the
|
||||||
|
// same leave rule (e.g. 35h → 39h, driver flag flip, weeklyHours bump).
|
||||||
|
// The contract-entry-date cap is handled by resolveEffectivePeriodStart().
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->resolveLeavePeriodBounds($year);
|
// End cap applies to both modes. Skipped when the phase was not explicitly
|
||||||
|
// provided (legacy callers) to preserve pre-phase-cap behavior for
|
||||||
|
// terminated employees.
|
||||||
|
if ($applyPhaseEndCap && null !== $phase->endDate && $phase->endDate < $to) {
|
||||||
|
$to = $phase->endDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$from, $to];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -787,24 +983,29 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
/**
|
/**
|
||||||
* @return array{DateTimeImmutable, DateTimeImmutable}
|
* @return array{DateTimeImmutable, DateTimeImmutable}
|
||||||
*/
|
*/
|
||||||
private function resolveForfaitYearBounds(Employee $employee, int $year): array
|
private function resolveForfaitYearBounds(Employee $employee, int $year, ContractPhase $phase): array
|
||||||
{
|
{
|
||||||
$from = new DateTimeImmutable(sprintf('%d-01-01 00:00:00', $year));
|
$from = new DateTimeImmutable(sprintf('%d-01-01 00:00:00', $year));
|
||||||
$to = new DateTimeImmutable(sprintf('%d-12-31 00:00:00', $year));
|
$to = new DateTimeImmutable(sprintf('%d-12-31 00:00:00', $year));
|
||||||
|
|
||||||
$contractStartRaw = $employee->getCurrentContractStartDate();
|
// When viewing the current phase, prefer the live "current contract" dates
|
||||||
if (null !== $contractStartRaw && '' !== trim($contractStartRaw)) {
|
// for backward compat with existing tests/usage. Closed phases rely on the
|
||||||
$contractStart = $this->parseYmdDate($contractStartRaw);
|
// generic cap applied in resolvePeriodBounds().
|
||||||
if ($contractStart instanceof DateTimeImmutable && $contractStart > $from) {
|
if ($phase->isCurrent) {
|
||||||
$from = $contractStart;
|
$contractStartRaw = $employee->getCurrentContractStartDate();
|
||||||
|
if (null !== $contractStartRaw && '' !== trim($contractStartRaw)) {
|
||||||
|
$contractStart = $this->parseYmdDate($contractStartRaw);
|
||||||
|
if ($contractStart instanceof DateTimeImmutable && $contractStart > $from) {
|
||||||
|
$from = $contractStart;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
$contractEndRaw = $employee->getCurrentContractEndDate();
|
$contractEndRaw = $employee->getCurrentContractEndDate();
|
||||||
if (null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
|
if (null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
|
||||||
$contractEnd = $this->parseYmdDate($contractEndRaw);
|
$contractEnd = $this->parseYmdDate($contractEndRaw);
|
||||||
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $to) {
|
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $to) {
|
||||||
$to = $contractEnd;
|
$to = $contractEnd;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -826,16 +1027,19 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
return $month >= 6 ? $year + 1 : $year;
|
return $month >= 6 ? $year + 1 : $year;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveFirstComputationYear(Employee $employee): int
|
private function resolveFirstComputationYear(Employee $employee, ContractPhase $phase): int
|
||||||
{
|
{
|
||||||
$isForfait = ContractType::FORFAIT === $employee->getContract()?->getType();
|
$isForfait = ContractType::FORFAIT === $phase->contractType;
|
||||||
$fallbackYear = $isForfait
|
$fallbackYear = $isForfait
|
||||||
? (int) new DateTimeImmutable('today')->format('Y')
|
? (int) new DateTimeImmutable('today')->format('Y')
|
||||||
: $this->resolveCurrentLeaveYear(new DateTimeImmutable('today'));
|
: $this->resolveCurrentLeaveYear(new DateTimeImmutable('today'));
|
||||||
|
|
||||||
|
// Do not go before the exercice containing $phase->startDate.
|
||||||
|
$phaseFirstYear = $this->exerciseYearResolver->forDate($phase->startDate, $isForfait);
|
||||||
|
|
||||||
$history = $employee->getContractHistory();
|
$history = $employee->getContractHistory();
|
||||||
if ([] === $history) {
|
if ([] === $history) {
|
||||||
return $fallbackYear;
|
return max($phaseFirstYear, $fallbackYear);
|
||||||
}
|
}
|
||||||
|
|
||||||
$oldestStartDate = null;
|
$oldestStartDate = null;
|
||||||
@@ -851,22 +1055,19 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
|
|
||||||
if (null === $oldestStartDate) {
|
if (null === $oldestStartDate) {
|
||||||
$oldestBalanceYear = $this->leaveBalanceRepository->findEarliestYearForEmployee($employee);
|
$oldestBalanceYear = $this->leaveBalanceRepository->findEarliestYearForEmployee($employee);
|
||||||
|
$candidate = null === $oldestBalanceYear ? $fallbackYear : min($fallbackYear, $oldestBalanceYear);
|
||||||
|
|
||||||
return null === $oldestBalanceYear ? $fallbackYear : min($fallbackYear, $oldestBalanceYear);
|
return max($phaseFirstYear, $candidate);
|
||||||
}
|
}
|
||||||
|
|
||||||
$firstYear = $isForfait
|
$firstYear = $this->exerciseYearResolver->forDate($oldestStartDate, $isForfait);
|
||||||
? (int) $oldestStartDate->format('Y')
|
|
||||||
: ((int) $oldestStartDate->format('n') >= 6
|
|
||||||
? (int) $oldestStartDate->format('Y') + 1
|
|
||||||
: (int) $oldestStartDate->format('Y'));
|
|
||||||
|
|
||||||
$oldestBalanceYear = $this->leaveBalanceRepository->findEarliestYearForEmployee($employee);
|
$oldestBalanceYear = $this->leaveBalanceRepository->findEarliestYearForEmployee($employee);
|
||||||
if (null !== $oldestBalanceYear && $oldestBalanceYear < $firstYear) {
|
if (null !== $oldestBalanceYear && $oldestBalanceYear < $firstYear) {
|
||||||
return $oldestBalanceYear;
|
$firstYear = $oldestBalanceYear;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $firstYear;
|
return max($phaseFirstYear, $firstYear);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function parseYmdDate(string $value): ?DateTimeImmutable
|
private function parseYmdDate(string $value): ?DateTimeImmutable
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ use App\Entity\EmployeeRttPayment;
|
|||||||
use App\Repository\EmployeeRepository;
|
use App\Repository\EmployeeRepository;
|
||||||
use App\Repository\EmployeeRttPaymentRepository;
|
use App\Repository\EmployeeRttPaymentRepository;
|
||||||
use App\Service\AuditLogger;
|
use App\Service\AuditLogger;
|
||||||
use DateTimeImmutable;
|
use App\Service\Contracts\EmployeeContractPhaseResolver;
|
||||||
|
use App\Service\Exercise\ExerciseYearResolver;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Psr\Clock\ClockInterface;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||||
|
|
||||||
@@ -24,6 +26,9 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
|
|||||||
private EmployeeRttPaymentRepository $rttPaymentRepository,
|
private EmployeeRttPaymentRepository $rttPaymentRepository,
|
||||||
private EntityManagerInterface $entityManager,
|
private EntityManagerInterface $entityManager,
|
||||||
private AuditLogger $auditLogger,
|
private AuditLogger $auditLogger,
|
||||||
|
private EmployeeContractPhaseResolver $phaseResolver,
|
||||||
|
private ClockInterface $clock,
|
||||||
|
private ExerciseYearResolver $exerciseYearResolver,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): EmployeeRttPaymentInput
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): EmployeeRttPaymentInput
|
||||||
@@ -48,6 +53,8 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
|
|||||||
|
|
||||||
$year = $data->year ?? $this->resolveCurrentExerciseYear();
|
$year = $data->year ?? $this->resolveCurrentExerciseYear();
|
||||||
|
|
||||||
|
$this->assertYearAllowedForPayment($employee, $year);
|
||||||
|
|
||||||
$payment = $this->rttPaymentRepository->findOneByEmployeeYearMonth($employee, $year, $data->month);
|
$payment = $this->rttPaymentRepository->findOneByEmployeeYearMonth($employee, $year, $data->month);
|
||||||
|
|
||||||
if (null === $payment) {
|
if (null === $payment) {
|
||||||
@@ -83,10 +90,33 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
|
|||||||
|
|
||||||
private function resolveCurrentExerciseYear(): int
|
private function resolveCurrentExerciseYear(): int
|
||||||
{
|
{
|
||||||
$today = new DateTimeImmutable('today');
|
return $this->exerciseYearResolver->forDate($this->clock->now());
|
||||||
$year = (int) $today->format('Y');
|
}
|
||||||
$month = (int) $today->format('n');
|
|
||||||
|
|
||||||
return $month >= 6 ? $year + 1 : $year;
|
/**
|
||||||
|
* Allow payment when the requested exercise is either the current one
|
||||||
|
* or the last exercise of a closed contract phase (the one containing
|
||||||
|
* the phase end date). Reject any other exercise (past or future).
|
||||||
|
*/
|
||||||
|
private function assertYearAllowedForPayment(Employee $employee, int $year): void
|
||||||
|
{
|
||||||
|
$currentExerciseYear = $this->resolveCurrentExerciseYear();
|
||||||
|
if ($year === $currentExerciseYear) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$phases = $this->phaseResolver->resolvePhases($employee);
|
||||||
|
foreach ($phases as $phase) {
|
||||||
|
if ($phase->isCurrent || null === $phase->endDate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($year === $this->exerciseYearResolver->forDate($phase->endDate)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new UnprocessableEntityHttpException(
|
||||||
|
'RTT payment is only allowed on the current exercise or the last exercise of a closed contract phase.'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\State;
|
|||||||
use ApiPlatform\Metadata\Operation;
|
use ApiPlatform\Metadata\Operation;
|
||||||
use ApiPlatform\State\ProviderInterface;
|
use ApiPlatform\State\ProviderInterface;
|
||||||
use App\ApiResource\EmployeeRttSummary;
|
use App\ApiResource\EmployeeRttSummary;
|
||||||
|
use App\Dto\Contracts\ContractPhase;
|
||||||
use App\Dto\Rtt\EmployeeRttWeekSummary;
|
use App\Dto\Rtt\EmployeeRttWeekSummary;
|
||||||
use App\Dto\Rtt\RttMonthPayment;
|
use App\Dto\Rtt\RttMonthPayment;
|
||||||
use App\Dto\Rtt\WeekRecoveryDetail;
|
use App\Dto\Rtt\WeekRecoveryDetail;
|
||||||
@@ -17,6 +18,8 @@ use App\Repository\EmployeeRttBalanceRepository;
|
|||||||
use App\Repository\EmployeeRttPaymentRepository;
|
use App\Repository\EmployeeRttPaymentRepository;
|
||||||
use App\Repository\WorkHourRepository;
|
use App\Repository\WorkHourRepository;
|
||||||
use App\Security\EmployeeScopeService;
|
use App\Security\EmployeeScopeService;
|
||||||
|
use App\Service\Contracts\EmployeeContractPhaseResolver;
|
||||||
|
use App\Service\Exercise\ExerciseYearResolver;
|
||||||
use App\Service\Rtt\RttRecoveryComputationService;
|
use App\Service\Rtt\RttRecoveryComputationService;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
@@ -38,6 +41,8 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
|||||||
private EmployeeRttPaymentRepository $rttPaymentRepository,
|
private EmployeeRttPaymentRepository $rttPaymentRepository,
|
||||||
private RttRecoveryComputationService $rttRecoveryService,
|
private RttRecoveryComputationService $rttRecoveryService,
|
||||||
private WorkHourRepository $workHourRepository,
|
private WorkHourRepository $workHourRepository,
|
||||||
|
private EmployeeContractPhaseResolver $phaseResolver,
|
||||||
|
private ExerciseYearResolver $exerciseYearResolver,
|
||||||
string $rttStartDate = '',
|
string $rttStartDate = '',
|
||||||
) {
|
) {
|
||||||
$this->rttStartDate = '' !== $rttStartDate ? $rttStartDate : null;
|
$this->rttStartDate = '' !== $rttStartDate ? $rttStartDate : null;
|
||||||
@@ -64,12 +69,25 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
|||||||
throw new AccessDeniedHttpException('Employee outside your scope.');
|
throw new AccessDeniedHttpException('Employee outside your scope.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$year = $this->resolveYear();
|
$phase = $this->resolveTargetPhase($employee);
|
||||||
|
$year = $this->resolveYear($phase);
|
||||||
$today = new DateTimeImmutable('today');
|
$today = new DateTimeImmutable('today');
|
||||||
$currentExerciseYear = $this->resolveCurrentExerciseYear($today);
|
$currentExerciseYear = $this->resolveCurrentExerciseYear($today);
|
||||||
[$periodFrom, $periodTo] = $this->rttRecoveryService->resolveExerciseBounds($year);
|
[$periodFrom, $periodTo] = $this->rttRecoveryService->resolveExerciseBounds($year);
|
||||||
$weeks = $this->rttRecoveryService->buildWeeksForExercise($periodFrom, $periodTo);
|
|
||||||
$weekRanges = array_map(
|
// Cap periodTo at the phase endDate for closed phases so the RTT table does
|
||||||
|
// not extend past the date the phase ended.
|
||||||
|
// Do NOT cap periodFrom at phase.startDate: keep the full exercise
|
||||||
|
// displayed so weeks before the employee's hire (or before a past phase
|
||||||
|
// started) appear at 0, matching the previous behavior. Weeks outside the
|
||||||
|
// contract range contribute 0 minutes to the cumul naturally (no contract
|
||||||
|
// ⇒ no reference, no worked hours).
|
||||||
|
if (!$phase->isCurrent && null !== $phase->endDate && $phase->endDate < $periodTo) {
|
||||||
|
$periodTo = $phase->endDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
$weeks = $this->rttRecoveryService->buildWeeksForExercise($periodFrom, $periodTo);
|
||||||
|
$weekRanges = array_map(
|
||||||
static fn (array $week): array => [
|
static fn (array $week): array => [
|
||||||
'weekNumber' => (int) $week['weekNumber'],
|
'weekNumber' => (int) $week['weekNumber'],
|
||||||
'start' => $week['start'],
|
'start' => $week['start'],
|
||||||
@@ -96,6 +114,12 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For a closed phase: cap the week-computation limit at the phase end date,
|
||||||
|
// so weeks beyond the phase are not counted.
|
||||||
|
if (!$phase->isCurrent && null !== $phase->endDate && $phase->endDate < $limitDate) {
|
||||||
|
$limitDate = $phase->endDate;
|
||||||
|
}
|
||||||
|
|
||||||
$currentByWeekStart = $this->rttRecoveryService->computeRecoveryByWeek($employee, $weekRanges, $periodFrom, $periodTo, $limitDate);
|
$currentByWeekStart = $this->rttRecoveryService->computeRecoveryByWeek($employee, $weekRanges, $periodFrom, $periodTo, $limitDate);
|
||||||
[$carry, $carryMonth] = $this->resolveCarry($employee, $year);
|
[$carry, $carryMonth] = $this->resolveCarry($employee, $year);
|
||||||
|
|
||||||
@@ -110,14 +134,11 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
|||||||
$summary->currentYearRecoveryMinutes = array_sum(array_map(static fn ($d) => $d->totalMinutes, $currentByWeekStart));
|
$summary->currentYearRecoveryMinutes = array_sum(array_map(static fn ($d) => $d->totalMinutes, $currentByWeekStart));
|
||||||
$summary->availableMinutes = $summary->carryFromPreviousYearMinutes + $summary->currentYearRecoveryMinutes;
|
$summary->availableMinutes = $summary->carryFromPreviousYearMinutes + $summary->currentYearRecoveryMinutes;
|
||||||
|
|
||||||
// Pass rttStartDate only if it falls within this exercise
|
// Always expose rttStartDate so the frontend can use it as a hard floor
|
||||||
if (null !== $this->rttStartDate) {
|
// for the year selector. Frontend already uses month-level comparison
|
||||||
$startDate = new DateTimeImmutable($this->rttStartDate);
|
// to hide carry/report rows when the date is outside the exercise.
|
||||||
if ($startDate >= $periodFrom && $startDate <= $periodTo) {
|
$summary->rttStartDate = $this->rttStartDate;
|
||||||
$summary->rttStartDate = $this->rttStartDate;
|
$summary->weeks = $this->buildWeekSummaries($weekRanges, $currentByWeekStart, $periodFrom, $periodTo);
|
||||||
}
|
|
||||||
}
|
|
||||||
$summary->weeks = $this->buildWeekSummaries($weekRanges, $currentByWeekStart, $periodFrom, $periodTo);
|
|
||||||
|
|
||||||
// Post-process: distribute deficit weeks across cumulative balance (50% first, then 25%)
|
// Post-process: distribute deficit weeks across cumulative balance (50% first, then 25%)
|
||||||
$cumulative50 = $carry->base50Minutes + $carry->bonus50Minutes;
|
$cumulative50 = $carry->base50Minutes + $carry->bonus50Minutes;
|
||||||
@@ -164,6 +185,18 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
|||||||
$monthBuckets[$m]['bonus50'] += $payment->getBonus50Minutes();
|
$monthBuckets[$m]['bonus50'] += $payment->getBonus50Minutes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$runningCumul = $summary->carryFromPreviousYearMinutes;
|
||||||
|
$prevMonth = null;
|
||||||
|
foreach ($summary->weeks as $week) {
|
||||||
|
if (null !== $prevMonth && $week->month !== $prevMonth && isset($monthBuckets[$prevMonth])) {
|
||||||
|
$b = $monthBuckets[$prevMonth];
|
||||||
|
$runningCumul -= $b['base25'] + $b['bonus25'] + $b['base50'] + $b['bonus50'];
|
||||||
|
}
|
||||||
|
$runningCumul += $week->totalMinutes;
|
||||||
|
$week->cumulativeBalanceMinutes = $runningCumul;
|
||||||
|
$prevMonth = $week->month;
|
||||||
|
}
|
||||||
|
|
||||||
$monthPayments = [];
|
$monthPayments = [];
|
||||||
$totalPaidMinutes = 0;
|
$totalPaidMinutes = 0;
|
||||||
|
|
||||||
@@ -204,10 +237,21 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveYear(): int
|
private function resolveYear(ContractPhase $phase): int
|
||||||
{
|
{
|
||||||
$raw = (string) ($this->requestStack->getCurrentRequest()?->query->get('year') ?? '');
|
$raw = (string) ($this->requestStack->getCurrentRequest()?->query->get('year') ?? '');
|
||||||
|
$phaseIdRaw = $this->requestStack->getCurrentRequest()?->query->get('phaseId');
|
||||||
|
$phaseIdProvided = null !== $phaseIdRaw && '' !== (string) $phaseIdRaw;
|
||||||
|
|
||||||
if ('' === $raw) {
|
if ('' === $raw) {
|
||||||
|
// When a phaseId is explicitly provided, default to the exercise year derived from
|
||||||
|
// the phase's end date (or today if the phase is still current).
|
||||||
|
if ($phaseIdProvided) {
|
||||||
|
$reference = $phase->endDate ?? new DateTimeImmutable('today');
|
||||||
|
|
||||||
|
return $this->resolveCurrentExerciseYear($reference);
|
||||||
|
}
|
||||||
|
|
||||||
return $this->resolveCurrentExerciseYear(new DateTimeImmutable('today'));
|
return $this->resolveCurrentExerciseYear(new DateTimeImmutable('today'));
|
||||||
}
|
}
|
||||||
if (!preg_match('/^\d{4}$/', $raw)) {
|
if (!preg_match('/^\d{4}$/', $raw)) {
|
||||||
@@ -219,9 +263,64 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
|||||||
throw new UnprocessableEntityHttpException('year must be between 2000 and 2100.');
|
throw new UnprocessableEntityHttpException('year must be between 2000 and 2100.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When a phaseId is explicit, silently clamp the requested year to the
|
||||||
|
// first/last exercise covered by the phase.
|
||||||
|
if ($phaseIdProvided) {
|
||||||
|
$year = $this->clampYearToPhase($year, $phase);
|
||||||
|
}
|
||||||
|
|
||||||
return $year;
|
return $year;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function clampYearToPhase(int $year, ContractPhase $phase): int
|
||||||
|
{
|
||||||
|
$firstYear = $this->exerciseYearResolver->forDate($phase->startDate);
|
||||||
|
$lastYear = $phase->endDate instanceof DateTimeImmutable
|
||||||
|
? $this->exerciseYearResolver->forDate($phase->endDate)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if ($year < $firstYear) {
|
||||||
|
return $firstYear;
|
||||||
|
}
|
||||||
|
if (null !== $lastYear && $year > $lastYear) {
|
||||||
|
return $lastYear;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $year;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveTargetPhase(Employee $employee): ContractPhase
|
||||||
|
{
|
||||||
|
$raw = $this->requestStack->getCurrentRequest()?->query->get('phaseId');
|
||||||
|
$phases = $this->phaseResolver->resolvePhases($employee);
|
||||||
|
if ([] === $phases) {
|
||||||
|
throw new UnprocessableEntityHttpException('Employee has no contract phase.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $raw || '' === (string) $raw) {
|
||||||
|
// Phase courante par défaut = celle marquée isCurrent ou, à défaut, la plus récente.
|
||||||
|
foreach ($phases as $phase) {
|
||||||
|
if ($phase->isCurrent) {
|
||||||
|
return $phase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $phases[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!preg_match('/^\d+$/', (string) $raw)) {
|
||||||
|
throw new UnprocessableEntityHttpException('phaseId must be a positive integer.');
|
||||||
|
}
|
||||||
|
$phaseId = (int) $raw;
|
||||||
|
foreach ($phases as $phase) {
|
||||||
|
if ($phase->id === $phaseId) {
|
||||||
|
return $phase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new UnprocessableEntityHttpException('phaseId does not match any phase of this employee.');
|
||||||
|
}
|
||||||
|
|
||||||
private function resolveCurrentExerciseYear(DateTimeImmutable $today): int
|
private function resolveCurrentExerciseYear(DateTimeImmutable $today): int
|
||||||
{
|
{
|
||||||
$year = (int) $today->format('Y');
|
$year = (int) $today->format('Y');
|
||||||
|
|||||||
80
src/State/EmployeeWeekCommentWriteProcessor.php
Normal file
80
src/State/EmployeeWeekCommentWriteProcessor.php
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\State;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\DeleteOperationInterface;
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\EmployeeWeekComment;
|
||||||
|
use App\Service\AuditLogger;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||||
|
|
||||||
|
final readonly class EmployeeWeekCommentWriteProcessor implements ProcessorInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||||
|
private ProcessorInterface $persistProcessor,
|
||||||
|
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
|
||||||
|
private ProcessorInterface $removeProcessor,
|
||||||
|
private EntityManagerInterface $entityManager,
|
||||||
|
private AuditLogger $auditLogger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||||
|
{
|
||||||
|
if (!$data instanceof EmployeeWeekComment) {
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
$employee = $data->getEmployee();
|
||||||
|
|
||||||
|
if ($operation instanceof DeleteOperationInterface) {
|
||||||
|
$this->auditLogger->log(
|
||||||
|
$employee,
|
||||||
|
'delete',
|
||||||
|
'week_comment',
|
||||||
|
$data->getId(),
|
||||||
|
sprintf('Commentaire semaine supprimé pour %s (semaine du %s)', $this->label($employee), $data->getWeekStartDate()?->format('d/m/Y') ?? '?'),
|
||||||
|
['old' => ['content' => $data->getContent()]],
|
||||||
|
$data->getWeekStartDate(),
|
||||||
|
);
|
||||||
|
$result = $this->removeProcessor->process($data, $operation, $uriVariables, $context);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
$weekStart = $data->getWeekStartDate();
|
||||||
|
if (null === $weekStart || '1' !== $weekStart->format('N')) {
|
||||||
|
throw new UnprocessableEntityHttpException('weekStartDate must be a Monday (ISO weekday 1).');
|
||||||
|
}
|
||||||
|
|
||||||
|
$prev = null;
|
||||||
|
if (null !== $data->getId()) {
|
||||||
|
$prev = $this->entityManager->getUnitOfWork()->getOriginalEntityData($data)['content'] ?? null;
|
||||||
|
$data->touchUpdatedAt();
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||||
|
|
||||||
|
if (null === $prev) {
|
||||||
|
$this->auditLogger->log($employee, 'create', 'week_comment', $data->getId(), sprintf('Commentaire semaine créé pour %s (semaine du %s)', $this->label($employee), $weekStart->format('d/m/Y')), ['new' => ['content' => $data->getContent()]], $weekStart);
|
||||||
|
} elseif ($prev !== $data->getContent()) {
|
||||||
|
$this->auditLogger->log($employee, 'update', 'week_comment', $data->getId(), sprintf('Commentaire semaine modifié pour %s (semaine du %s)', $this->label($employee), $weekStart->format('d/m/Y')), ['old' => ['content' => $prev], 'new' => ['content' => $data->getContent()]], $weekStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function label(mixed $e): string
|
||||||
|
{
|
||||||
|
return $e instanceof Employee ? trim(($e->getLastName() ?? '').' '.($e->getFirstName() ?? '')) : '?';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,6 +70,7 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
|
|||||||
nature: $nature,
|
nature: $nature,
|
||||||
isDriver: $changeRequest->isDriver ?? false,
|
isDriver: $changeRequest->isDriver ?? false,
|
||||||
workDaysHours: $changeRequest->workDaysHours,
|
workDaysHours: $changeRequest->workDaysHours,
|
||||||
|
interimAgencyId: $changeRequest->interimAgencyId,
|
||||||
);
|
);
|
||||||
|
|
||||||
$data->setEntryDate($startDate);
|
$data->setEntryDate($startDate);
|
||||||
@@ -140,6 +141,7 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
|
|||||||
todayPeriod: $effectivePeriod,
|
todayPeriod: $effectivePeriod,
|
||||||
isDriver: $changeRequest->isDriver ?? false,
|
isDriver: $changeRequest->isDriver ?? false,
|
||||||
workDaysHours: $changeRequest->workDaysHours,
|
workDaysHours: $changeRequest->workDaysHours,
|
||||||
|
interimAgencyId: $changeRequest->interimAgencyId,
|
||||||
);
|
);
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
|
|||||||
86
src/State/EmployeeYearlyHoursBulkPrintProvider.php
Normal file
86
src/State/EmployeeYearlyHoursBulkPrintProvider.php
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\State;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\Repository\EmployeeRepository;
|
||||||
|
use App\Service\WorkHours\YearlyHoursExportBuilder;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Dompdf\Dompdf;
|
||||||
|
use Dompdf\Options;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||||
|
use Twig\Environment;
|
||||||
|
|
||||||
|
class EmployeeYearlyHoursBulkPrintProvider implements ProviderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Environment $twig,
|
||||||
|
private readonly RequestStack $requestStack,
|
||||||
|
private EmployeeRepository $employeeRepository,
|
||||||
|
private YearlyHoursExportBuilder $exportBuilder,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
|
||||||
|
{
|
||||||
|
$request = $this->requestStack->getCurrentRequest();
|
||||||
|
if (!$request) {
|
||||||
|
return new Response('Missing request.', Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
$yearRaw = (string) $request->query->get('year');
|
||||||
|
if (!preg_match('/^\d{4}$/', $yearRaw)) {
|
||||||
|
throw new UnprocessableEntityHttpException('year must use YYYY format.');
|
||||||
|
}
|
||||||
|
$year = (int) $yearRaw;
|
||||||
|
|
||||||
|
$monthRaw = (string) $request->query->get('month', '');
|
||||||
|
$month = null;
|
||||||
|
if ('' !== $monthRaw) {
|
||||||
|
if (!preg_match('/^(?:0?[1-9]|1[0-2])$/', $monthRaw)) {
|
||||||
|
throw new UnprocessableEntityHttpException('month must be between 1 and 12.');
|
||||||
|
}
|
||||||
|
$month = (int) $monthRaw;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $month) {
|
||||||
|
$from = new DateTimeImmutable(sprintf('%d-%02d-01', $year, $month));
|
||||||
|
$to = $from->modify('last day of this month');
|
||||||
|
} else {
|
||||||
|
$from = new DateTimeImmutable("{$year}-01-01");
|
||||||
|
$to = new DateTimeImmutable("{$year}-12-31");
|
||||||
|
}
|
||||||
|
|
||||||
|
$employees = $this->employeeRepository->findAll();
|
||||||
|
usort($employees, fn ($a, $b) => ($a->getLastName() ?? '') <=> ($b->getLastName() ?? ''));
|
||||||
|
|
||||||
|
$entries = $this->exportBuilder->buildForEmployees($employees, $from, $to);
|
||||||
|
|
||||||
|
$options = new Options();
|
||||||
|
$options->set('isRemoteEnabled', true);
|
||||||
|
|
||||||
|
$dompdf = new Dompdf($options);
|
||||||
|
$html = $this->twig->render('employee-yearly-hours/print-all.html.twig', [
|
||||||
|
'entries' => $entries,
|
||||||
|
'year' => $year,
|
||||||
|
'month' => $month,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$dompdf->loadHtml($html);
|
||||||
|
$dompdf->setPaper('A4', 'portrait');
|
||||||
|
$dompdf->render();
|
||||||
|
|
||||||
|
$filename = null !== $month
|
||||||
|
? sprintf('heures_tous_%d-%02d.pdf', $year, $month)
|
||||||
|
: sprintf('heures_tous_%d.pdf', $year);
|
||||||
|
|
||||||
|
return new Response($dompdf->output(), Response::HTTP_OK, [
|
||||||
|
'Content-Type' => 'application/pdf',
|
||||||
|
'Content-Disposition' => 'inline; filename="'.$filename.'"',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,19 +6,9 @@ namespace App\State;
|
|||||||
|
|
||||||
use ApiPlatform\Metadata\Operation;
|
use ApiPlatform\Metadata\Operation;
|
||||||
use ApiPlatform\State\ProviderInterface;
|
use ApiPlatform\State\ProviderInterface;
|
||||||
use App\Dto\WorkHours\WorkMetrics;
|
|
||||||
use App\Entity\Employee;
|
use App\Entity\Employee;
|
||||||
use App\Entity\WorkHour;
|
|
||||||
use App\Enum\ContractNature;
|
|
||||||
use App\Enum\ContractType;
|
|
||||||
use App\Enum\TrackingMode;
|
|
||||||
use App\Repository\AbsenceRepository;
|
|
||||||
use App\Repository\EmployeeRepository;
|
use App\Repository\EmployeeRepository;
|
||||||
use App\Repository\WorkHourRepository;
|
use App\Service\WorkHours\YearlyHoursExportBuilder;
|
||||||
use App\Service\Contracts\EmployeeContractResolver;
|
|
||||||
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
|
||||||
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
|
||||||
use DateInterval;
|
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Dompdf\Dompdf;
|
use Dompdf\Dompdf;
|
||||||
use Dompdf\Options;
|
use Dompdf\Options;
|
||||||
@@ -34,11 +24,7 @@ class EmployeeYearlyHoursPrintProvider implements ProviderInterface
|
|||||||
private Environment $twig,
|
private Environment $twig,
|
||||||
private readonly RequestStack $requestStack,
|
private readonly RequestStack $requestStack,
|
||||||
private EmployeeRepository $employeeRepository,
|
private EmployeeRepository $employeeRepository,
|
||||||
private WorkHourRepository $workHourRepository,
|
private YearlyHoursExportBuilder $exportBuilder,
|
||||||
private AbsenceRepository $absenceRepository,
|
|
||||||
private EmployeeContractResolver $contractResolver,
|
|
||||||
private AbsenceSegmentsResolver $absenceSegmentsResolver,
|
|
||||||
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
|
||||||
@@ -80,27 +66,11 @@ class EmployeeYearlyHoursPrintProvider implements ProviderInterface
|
|||||||
$from = new DateTimeImmutable("{$year}-01-01");
|
$from = new DateTimeImmutable("{$year}-01-01");
|
||||||
$to = new DateTimeImmutable("{$year}-12-31");
|
$to = new DateTimeImmutable("{$year}-12-31");
|
||||||
}
|
}
|
||||||
$days = $this->buildDays($from, $to);
|
|
||||||
|
|
||||||
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($from, $to, [$employee]);
|
$entries = $this->exportBuilder->buildForEmployee($employee, $from, $to);
|
||||||
$absences = $this->absenceRepository->findForPrint($from, $to, [$employee]);
|
|
||||||
$contractMap = $this->contractResolver->resolveForEmployeesAndDays([$employee], $days);
|
|
||||||
$driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays([$employee], $days);
|
|
||||||
|
|
||||||
$workHourMap = $this->buildWorkHourMap($workHours);
|
|
||||||
$absenceData = $this->buildAbsenceData($absences, $days, $employee);
|
|
||||||
|
|
||||||
$segments = $this->buildSegments(
|
|
||||||
$employee,
|
|
||||||
$days,
|
|
||||||
$contractMap[$employee->getId()] ?? [],
|
|
||||||
$driverMap[$employee->getId()] ?? [],
|
|
||||||
$workHourMap[$employee->getId()] ?? [],
|
|
||||||
$absenceData,
|
|
||||||
);
|
|
||||||
|
|
||||||
$employeeName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? ''));
|
$employeeName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? ''));
|
||||||
$contractLabel = $this->buildContractLabel($employee);
|
$contractLabel = $this->exportBuilder->buildContractLabel($employee);
|
||||||
|
|
||||||
$options = new Options();
|
$options = new Options();
|
||||||
$options->set('isRemoteEnabled', true);
|
$options->set('isRemoteEnabled', true);
|
||||||
@@ -111,7 +81,7 @@ class EmployeeYearlyHoursPrintProvider implements ProviderInterface
|
|||||||
'contractLabel' => $contractLabel,
|
'contractLabel' => $contractLabel,
|
||||||
'year' => $year,
|
'year' => $year,
|
||||||
'month' => $month,
|
'month' => $month,
|
||||||
'segments' => $segments,
|
'segments' => $entries[0]['segments'] ?? [],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$dompdf->loadHtml($html);
|
$dompdf->loadHtml($html);
|
||||||
@@ -139,367 +109,6 @@ class EmployeeYearlyHoursPrintProvider implements ProviderInterface
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function buildContractLabel(Employee $employee): ?string
|
|
||||||
{
|
|
||||||
$contract = $employee->getContract();
|
|
||||||
if (null === $contract) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$natureRaw = $employee->getCurrentContractNature();
|
|
||||||
$nature = ContractNature::tryFrom($natureRaw) ?? ContractNature::CDI;
|
|
||||||
$natureLabel = match ($nature) {
|
|
||||||
ContractNature::CDI => 'CDI',
|
|
||||||
ContractNature::CDD => 'CDD',
|
|
||||||
ContractNature::INTERIM => 'Intérim',
|
|
||||||
};
|
|
||||||
|
|
||||||
$contractType = $contract->getType();
|
|
||||||
if (ContractType::FORFAIT === $contractType) {
|
|
||||||
return $natureLabel.' Forfait';
|
|
||||||
}
|
|
||||||
|
|
||||||
$weeklyHours = $contract->getWeeklyHours();
|
|
||||||
if (null !== $weeklyHours && $weeklyHours > 0) {
|
|
||||||
return sprintf('%s %d heures', $natureLabel, $weeklyHours);
|
|
||||||
}
|
|
||||||
|
|
||||||
$name = $contract->getName();
|
|
||||||
|
|
||||||
return null !== $name && '' !== $name ? $natureLabel.' '.$name : $natureLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
private function buildDays(DateTimeImmutable $from, DateTimeImmutable $to): array
|
|
||||||
{
|
|
||||||
$days = [];
|
|
||||||
$current = $from;
|
|
||||||
|
|
||||||
while ($current <= $to) {
|
|
||||||
$days[] = $current->format('Y-m-d');
|
|
||||||
$current = $current->add(new DateInterval('P1D'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $days;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, array<string, WorkHour>>
|
|
||||||
*/
|
|
||||||
private function buildWorkHourMap(array $workHours): array
|
|
||||||
{
|
|
||||||
$map = [];
|
|
||||||
foreach ($workHours as $wh) {
|
|
||||||
$employeeId = $wh->getEmployee()?->getId();
|
|
||||||
if (!$employeeId) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$date = $wh->getWorkDate()->format('Y-m-d');
|
|
||||||
$map[$employeeId][$date] = $wh;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $map;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{credited: array<string, int>, labels: array<string, string>, absentMorning: array<string, bool>, absentAfternoon: array<string, bool>, hasDayAbsence: array<string, bool>}
|
|
||||||
*/
|
|
||||||
private function buildAbsenceData(array $absences, array $days, Employee $employee): array
|
|
||||||
{
|
|
||||||
$credited = [];
|
|
||||||
$labels = [];
|
|
||||||
$absentMorning = [];
|
|
||||||
$absentAfternoon = [];
|
|
||||||
$hasDayAbsence = [];
|
|
||||||
|
|
||||||
foreach ($absences as $absence) {
|
|
||||||
$absEmployeeId = $absence->getEmployee()?->getId();
|
|
||||||
if ($absEmployeeId !== $employee->getId()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$start = $absence->getStartDate()->format('Y-m-d');
|
|
||||||
$end = $absence->getEndDate()->format('Y-m-d');
|
|
||||||
|
|
||||||
foreach ($days as $date) {
|
|
||||||
if ($date < $start || $date > $end) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
[$isMorning, $isAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date);
|
|
||||||
if ($isMorning || $isAfternoon) {
|
|
||||||
$hasDayAbsence[$date] = true;
|
|
||||||
$absentMorning[$date] = ($absentMorning[$date] ?? false) || $isMorning;
|
|
||||||
$absentAfternoon[$date] = ($absentAfternoon[$date] ?? false) || $isAfternoon;
|
|
||||||
if (!isset($labels[$date])) {
|
|
||||||
$labels[$date] = $absence->getType()?->getLabel() ?? '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$credited[$date] = ($credited[$date] ?? 0)
|
|
||||||
+ $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $isMorning, $isAfternoon);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'credited' => $credited,
|
|
||||||
'labels' => $labels,
|
|
||||||
'absentMorning' => $absentMorning,
|
|
||||||
'absentAfternoon' => $absentAfternoon,
|
|
||||||
'hasDayAbsence' => $hasDayAbsence,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<array{mode: string, contractName: ?string, rows: list<array>}>
|
|
||||||
*/
|
|
||||||
private function buildSegments(
|
|
||||||
Employee $employee,
|
|
||||||
array $days,
|
|
||||||
array $contractsByDate,
|
|
||||||
array $driverByDate,
|
|
||||||
array $workHoursByDate,
|
|
||||||
array $absenceData,
|
|
||||||
): array {
|
|
||||||
$segments = [];
|
|
||||||
$currentMode = null;
|
|
||||||
$currentRows = [];
|
|
||||||
$currentName = null;
|
|
||||||
|
|
||||||
// Crop the output window to [first data day, today] to avoid padding the
|
|
||||||
// export with empty rows (notably weekends before the first saisie or after today).
|
|
||||||
$firstDataDate = null;
|
|
||||||
foreach ($days as $date) {
|
|
||||||
$hasRow = null !== ($workHoursByDate[$date] ?? null)
|
|
||||||
|| ($absenceData['hasDayAbsence'][$date] ?? false);
|
|
||||||
if ($hasRow) {
|
|
||||||
$firstDataDate = $date;
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (null === $firstDataDate) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$todayYmd = new DateTimeImmutable('today')->format('Y-m-d');
|
|
||||||
|
|
||||||
foreach ($days as $date) {
|
|
||||||
if ($date < $firstDataDate || $date > $todayYmd) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$contract = $contractsByDate[$date] ?? null;
|
|
||||||
$isDriver = $driverByDate[$date] ?? false;
|
|
||||||
$wh = $workHoursByDate[$date] ?? null;
|
|
||||||
$hasData = null !== $wh || ($absenceData['hasDayAbsence'][$date] ?? false);
|
|
||||||
$isoDay = (int) new DateTimeImmutable($date)->format('N');
|
|
||||||
$isWeekend = $isoDay >= 6;
|
|
||||||
|
|
||||||
// Keep weekend rows even when empty so the reader can distinguish
|
|
||||||
// worked vs non-worked Saturdays/Sundays at a glance.
|
|
||||||
if (!$hasData && !$isWeekend) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$hasData && null === $contract) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$trackingMode = $contract?->getTrackingMode() ?? TrackingMode::TIME->value;
|
|
||||||
$mode = $this->resolveSegmentMode($trackingMode, $isDriver);
|
|
||||||
$contractName = $contract?->getName();
|
|
||||||
|
|
||||||
if ($mode !== $currentMode) {
|
|
||||||
if (null !== $currentMode && [] !== $currentRows) {
|
|
||||||
$segments[] = [
|
|
||||||
'mode' => $currentMode,
|
|
||||||
'contractName' => $currentName,
|
|
||||||
'rows' => $currentRows,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
$currentMode = $mode;
|
|
||||||
$currentRows = [];
|
|
||||||
$currentName = $contractName;
|
|
||||||
}
|
|
||||||
|
|
||||||
$creditedMinutes = $absenceData['credited'][$date] ?? 0;
|
|
||||||
$absenceLabel = $absenceData['labels'][$date] ?? null;
|
|
||||||
|
|
||||||
$row = [
|
|
||||||
'date' => new DateTimeImmutable($date)->format('d/m/Y'),
|
|
||||||
'absenceLabel' => $absenceLabel,
|
|
||||||
'isWeekend' => $isWeekend,
|
|
||||||
];
|
|
||||||
|
|
||||||
if ('presence' === $mode) {
|
|
||||||
$absentMorning = $absenceData['absentMorning'][$date] ?? false;
|
|
||||||
$absentAfternoon = $absenceData['absentAfternoon'][$date] ?? false;
|
|
||||||
$morning = (($wh?->getIsPresentMorning() ?? false) && !$absentMorning) ? 0.5 : 0.0;
|
|
||||||
$afternoon = (($wh?->getIsPresentAfternoon() ?? false) && !$absentAfternoon) ? 0.5 : 0.0;
|
|
||||||
$total = $morning + $afternoon;
|
|
||||||
|
|
||||||
$row['presentMorning'] = $morning > 0;
|
|
||||||
$row['presentAfternoon'] = $afternoon > 0;
|
|
||||||
$row['total'] = $total > 0 ? (string) $total : '';
|
|
||||||
} elseif ('driver' === $mode) {
|
|
||||||
$dayMin = $wh?->getDayHoursMinutes() ?? 0;
|
|
||||||
$nightMin = $wh?->getNightHoursMinutes() ?? 0;
|
|
||||||
$workshopMin = $wh?->getWorkshopHoursMinutes() ?? 0;
|
|
||||||
$totalMin = $dayMin + $nightMin + $workshopMin + $creditedMinutes;
|
|
||||||
|
|
||||||
$row['dayHours'] = $this->formatMinutes($dayMin);
|
|
||||||
$row['nightHours'] = $this->formatMinutes($nightMin);
|
|
||||||
$row['workshopHours'] = $this->formatMinutes($workshopMin);
|
|
||||||
$row['total'] = $this->formatMinutes($totalMin);
|
|
||||||
} else {
|
|
||||||
$metrics = null !== $wh ? $this->computeMetrics($wh) : new WorkMetrics();
|
|
||||||
$metrics->addCreditedMinutes($creditedMinutes);
|
|
||||||
|
|
||||||
$row['morningFrom'] = $wh?->getMorningFrom() ?? '';
|
|
||||||
$row['morningTo'] = $wh?->getMorningTo() ?? '';
|
|
||||||
$row['afternoonFrom'] = $wh?->getAfternoonFrom() ?? '';
|
|
||||||
$row['afternoonTo'] = $wh?->getAfternoonTo() ?? '';
|
|
||||||
$row['eveningFrom'] = $wh?->getEveningFrom() ?? '';
|
|
||||||
$row['eveningTo'] = $wh?->getEveningTo() ?? '';
|
|
||||||
$row['total'] = $this->formatMinutes($metrics->totalMinutes);
|
|
||||||
}
|
|
||||||
|
|
||||||
$currentRows[] = $row;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (null !== $currentMode && [] !== $currentRows) {
|
|
||||||
$segments[] = [
|
|
||||||
'mode' => $currentMode,
|
|
||||||
'contractName' => $currentName,
|
|
||||||
'rows' => $currentRows,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $segments;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveSegmentMode(string $trackingMode, bool $isDriver): string
|
|
||||||
{
|
|
||||||
if ($isDriver) {
|
|
||||||
return 'driver';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (TrackingMode::PRESENCE->value === $trackingMode) {
|
|
||||||
return 'presence';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'time';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function computeMetrics(WorkHour $workHour): WorkMetrics
|
|
||||||
{
|
|
||||||
$ranges = [
|
|
||||||
[$workHour->getMorningFrom(), $workHour->getMorningTo()],
|
|
||||||
[$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()],
|
|
||||||
[$workHour->getEveningFrom(), $workHour->getEveningTo()],
|
|
||||||
];
|
|
||||||
|
|
||||||
$totalMinutes = 0;
|
|
||||||
$nightMinutes = 0;
|
|
||||||
|
|
||||||
foreach ($ranges as [$from, $to]) {
|
|
||||||
$totalMinutes += $this->intervalMinutes($from, $to);
|
|
||||||
$nightMinutes += $this->nightIntervalMinutes($from, $to);
|
|
||||||
}
|
|
||||||
|
|
||||||
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
|
|
||||||
|
|
||||||
return new WorkMetrics(
|
|
||||||
dayMinutes: $dayMinutes,
|
|
||||||
nightMinutes: $nightMinutes,
|
|
||||||
totalMinutes: $totalMinutes,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return null|array{int, int}
|
|
||||||
*/
|
|
||||||
private function resolveInterval(?string $from, ?string $to): ?array
|
|
||||||
{
|
|
||||||
$fromMinutes = $this->toMinutes($from);
|
|
||||||
$toMinutes = $this->toMinutes($to);
|
|
||||||
if (null === $fromMinutes || null === $toMinutes) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$end = $toMinutes <= $fromMinutes ? $toMinutes + 1440 : $toMinutes;
|
|
||||||
|
|
||||||
return [$fromMinutes, $end];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function toMinutes(?string $time): ?int
|
|
||||||
{
|
|
||||||
if (null === $time || '' === $time) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
[$hours, $minutes] = array_map('intval', explode(':', $time));
|
|
||||||
|
|
||||||
return ($hours * 60) + $minutes;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function intervalMinutes(?string $from, ?string $to): int
|
|
||||||
{
|
|
||||||
$interval = $this->resolveInterval($from, $to);
|
|
||||||
if (null === $interval) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
[$start, $end] = $interval;
|
|
||||||
|
|
||||||
return max(0, $end - $start);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function nightIntervalMinutes(?string $from, ?string $to): int
|
|
||||||
{
|
|
||||||
$interval = $this->resolveInterval($from, $to);
|
|
||||||
if (null === $interval) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
[$start, $end] = $interval;
|
|
||||||
$windows = [[0, 360], [1260, 1440]];
|
|
||||||
$total = 0;
|
|
||||||
|
|
||||||
for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) {
|
|
||||||
$shift = $dayOffset * 1440;
|
|
||||||
foreach ($windows as [$windowStart, $windowEnd]) {
|
|
||||||
$total += $this->overlap($start, $end, $windowStart + $shift, $windowEnd + $shift);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $total;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function overlap(int $startA, int $endA, int $startB, int $endB): int
|
|
||||||
{
|
|
||||||
$start = max($startA, $startB);
|
|
||||||
$end = min($endA, $endB);
|
|
||||||
|
|
||||||
return max(0, $end - $start);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function formatMinutes(int $minutes): string
|
|
||||||
{
|
|
||||||
if (0 === $minutes) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$h = intdiv($minutes, 60);
|
|
||||||
$m = $minutes % 60;
|
|
||||||
|
|
||||||
return 0 === $m ? "{$h}h" : "{$h}h{$m}m";
|
|
||||||
}
|
|
||||||
|
|
||||||
private function sanitizeFilename(string $name): string
|
private function sanitizeFilename(string $name): string
|
||||||
{
|
{
|
||||||
$name = str_replace(' ', '_', $name);
|
$name = str_replace(' ', '_', $name);
|
||||||
|
|||||||
@@ -363,7 +363,10 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
|||||||
if ($wh->getHasBreakfast()) {
|
if ($wh->getHasBreakfast()) {
|
||||||
++$driverBreakfast;
|
++$driverBreakfast;
|
||||||
}
|
}
|
||||||
if ($wh->getHasLunch() || $wh->getHasDinner()) {
|
if ($wh->getHasLunch()) {
|
||||||
|
++$driverMeals;
|
||||||
|
}
|
||||||
|
if ($wh->getHasDinner()) {
|
||||||
++$driverMeals;
|
++$driverMeals;
|
||||||
}
|
}
|
||||||
if ($wh->getHasOvernight()) {
|
if ($wh->getHasOvernight()) {
|
||||||
|
|||||||
@@ -57,13 +57,17 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
// On initialise toutes les lignes, même sans absence ce jour-là.
|
// On initialise toutes les lignes, même sans absence ce jour-là.
|
||||||
$contract = $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate);
|
$contract = $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate);
|
||||||
$workDaysMinutes = $this->contractResolver->resolveWorkDaysMinutesForEmployeeAndDate($employee, $workDate);
|
$workDaysMinutes = $this->contractResolver->resolveWorkDaysMinutesForEmployeeAndDate($employee, $workDate);
|
||||||
|
$contractNature = null !== $contract
|
||||||
|
? $this->contractResolver->resolveNatureForEmployeeAndDate($employee, $workDate)->value
|
||||||
|
: null;
|
||||||
$rowsByEmployeeId[$employeeId] = new DayContextRow(
|
$rowsByEmployeeId[$employeeId] = new DayContextRow(
|
||||||
employeeId: $employeeId,
|
employeeId: $employeeId,
|
||||||
hasContractAtDate: null !== $contract,
|
hasContractAtDate: null !== $contract,
|
||||||
isDriverContract: $this->contractResolver->resolveIsDriverForEmployeeAndDate($employee, $workDate),
|
isDriverContract: $this->contractResolver->resolveIsDriverForEmployeeAndDate($employee, $workDate),
|
||||||
virtualHolidayMinutes: $this->holidayVirtualHoursResolver->resolveVirtualCredit($contract, $workDate, false, $workDaysMinutes),
|
virtualHolidayMinutes: $this->holidayVirtualHoursResolver->resolveVirtualCredit($contract, $workDate, false, $workDaysMinutes),
|
||||||
|
contractNature: $contractNature,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use App\Dto\WorkHours\WorkMetrics;
|
|||||||
use App\Entity\Absence;
|
use App\Entity\Absence;
|
||||||
use App\Entity\Contract;
|
use App\Entity\Contract;
|
||||||
use App\Entity\Employee;
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\EmployeeWeekComment;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Entity\WorkHour;
|
use App\Entity\WorkHour;
|
||||||
use App\Enum\ContractNature;
|
use App\Enum\ContractNature;
|
||||||
@@ -21,7 +22,9 @@ use App\Enum\TrackingMode;
|
|||||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
||||||
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
|
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
|
||||||
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
||||||
|
use App\Repository\EmployeeWeekCommentRepository;
|
||||||
use App\Service\Contracts\EmployeeContractResolver;
|
use App\Service\Contracts\EmployeeContractResolver;
|
||||||
|
use App\Service\PublicHolidayServiceInterface;
|
||||||
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
||||||
use App\Service\WorkHours\DailyReferenceMinutesResolver;
|
use App\Service\WorkHours\DailyReferenceMinutesResolver;
|
||||||
use App\Service\WorkHours\HolidayVirtualHoursResolver;
|
use App\Service\WorkHours\HolidayVirtualHoursResolver;
|
||||||
@@ -31,6 +34,7 @@ use Symfony\Bundle\SecurityBundle\Security;
|
|||||||
use Symfony\Component\HttpFoundation\RequestStack;
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
||||||
{
|
{
|
||||||
@@ -45,6 +49,8 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
private EmployeeContractResolver $contractResolver,
|
private EmployeeContractResolver $contractResolver,
|
||||||
private DailyReferenceMinutesResolver $dailyReferenceResolver,
|
private DailyReferenceMinutesResolver $dailyReferenceResolver,
|
||||||
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
|
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
|
||||||
|
private PublicHolidayServiceInterface $publicHolidayService,
|
||||||
|
private EmployeeWeekCommentRepository $weekCommentRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourWeeklySummary
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourWeeklySummary
|
||||||
@@ -62,11 +68,13 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($weekStart, $weekEnd, $employees);
|
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($weekStart, $weekEnd, $employees);
|
||||||
$absences = $this->absenceRepository->findForPrint($weekStart, $weekEnd, $employees);
|
$absences = $this->absenceRepository->findForPrint($weekStart, $weekEnd, $employees);
|
||||||
|
|
||||||
|
$weekComments = $this->weekCommentRepository->findByWeekAndEmployees($weekStart, $employees);
|
||||||
|
|
||||||
$summary = new WorkHourWeeklySummary();
|
$summary = new WorkHourWeeklySummary();
|
||||||
$summary->weekStart = $weekStart->format('Y-m-d');
|
$summary->weekStart = $weekStart->format('Y-m-d');
|
||||||
$summary->weekEnd = $weekEnd->format('Y-m-d');
|
$summary->weekEnd = $weekEnd->format('Y-m-d');
|
||||||
$summary->days = $days;
|
$summary->days = $days;
|
||||||
$summary->rows = $this->buildRows($employees, $workHours, $absences, $days, $anchorDate->format('Y-m-d'));
|
$summary->rows = $this->buildRows($employees, $workHours, $absences, $days, $anchorDate->format('Y-m-d'), $weekComments);
|
||||||
|
|
||||||
return $summary;
|
return $summary;
|
||||||
}
|
}
|
||||||
@@ -109,19 +117,21 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<Employee> $employees
|
* @param list<Employee> $employees
|
||||||
* @param list<WorkHour> $workHours
|
* @param list<WorkHour> $workHours
|
||||||
* @param list<Absence> $absences
|
* @param list<Absence> $absences
|
||||||
* @param list<string> $days
|
* @param list<string> $days
|
||||||
|
* @param array<int, EmployeeWeekComment> $weekComments
|
||||||
*
|
*
|
||||||
* @return list<WeeklySummaryRow>
|
* @return list<WeeklySummaryRow>
|
||||||
*/
|
*/
|
||||||
private function buildRows(array $employees, array $workHours, array $absences, array $days, string $anchorDateYmd): array
|
private function buildRows(array $employees, array $workHours, array $absences, array $days, string $anchorDateYmd, array $weekComments = []): array
|
||||||
{
|
{
|
||||||
$contractsByEmployeeDate = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
|
$contractsByEmployeeDate = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
|
||||||
$contractNaturesByEmployeeDate = $this->contractResolver->resolveNaturesForEmployeesAndDays($employees, $days);
|
$contractNaturesByEmployeeDate = $this->contractResolver->resolveNaturesForEmployeesAndDays($employees, $days);
|
||||||
$isDriverByEmployeeDate = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days);
|
$isDriverByEmployeeDate = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days);
|
||||||
$workDaysByEmployeeDate = $this->contractResolver->resolveWorkDaysMinutesForEmployeesAndDays($employees, $days);
|
$workDaysByEmployeeDate = $this->contractResolver->resolveWorkDaysMinutesForEmployeesAndDays($employees, $days);
|
||||||
|
$holidayLabelsByDate = $this->buildHolidayLabelsForDays($days);
|
||||||
$metricsByEmployeeDate = [];
|
$metricsByEmployeeDate = [];
|
||||||
foreach ($workHours as $workHour) {
|
foreach ($workHours as $workHour) {
|
||||||
$employeeId = $workHour->getEmployee()?->getId();
|
$employeeId = $workHour->getEmployee()?->getId();
|
||||||
@@ -324,6 +334,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
hasDinner: $hasDinner,
|
hasDinner: $hasDinner,
|
||||||
hasOvernight: $hasOvernight,
|
hasOvernight: $hasOvernight,
|
||||||
virtualHolidayMinutes: $virtualHolidayMinutes,
|
virtualHolidayMinutes: $virtualHolidayMinutes,
|
||||||
|
holidayLabel: $holidayLabelsByDate[$date] ?? null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,12 +381,46 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
weeklyOvernightCount: $weeklyOvernightCount,
|
weeklyOvernightCount: $weeklyOvernightCount,
|
||||||
hasContractForWeek: $hasContractForWeek,
|
hasContractForWeek: $hasContractForWeek,
|
||||||
contractNature: $weekAnchorContractNature->value,
|
contractNature: $weekAnchorContractNature->value,
|
||||||
|
comment: ($weekComments[$employeeId] ?? null)?->getContent(),
|
||||||
|
commentId: ($weekComments[$employeeId] ?? null)?->getId(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $rows;
|
return $rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $days
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function buildHolidayLabelsForDays(array $days): array
|
||||||
|
{
|
||||||
|
if ([] === $days) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$years = [];
|
||||||
|
foreach ($days as $day) {
|
||||||
|
$years[substr($day, 0, 4)] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$map = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
foreach (array_keys($years) as $year) {
|
||||||
|
$holidays = $this->publicHolidayService->getHolidaysDayByYears('metropole', (string) $year);
|
||||||
|
foreach ($holidays as $date => $label) {
|
||||||
|
$map[(string) $date] = (string) $label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Throwable) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
private function computeMetrics(WorkHour $workHour): WorkMetrics
|
private function computeMetrics(WorkHour $workHour): WorkMetrics
|
||||||
{
|
{
|
||||||
$ranges = [
|
$ranges = [
|
||||||
|
|||||||
283
templates/employee-yearly-hours/print-all.html.twig
Normal file
283
templates/employee-yearly-hours/print-all.html.twig
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Export heures - {% set months = {
|
||||||
|
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'
|
||||||
|
} %}{% if month %}{{ months[month] }} {{ year }}{% else %}{{ year }}{% endif %}</title>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@page { size: A4 portrait; margin: 4mm; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 2mm;
|
||||||
|
font-family: Helvetica, sans-serif;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-section {
|
||||||
|
page-break-before: always;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-section:first-child {
|
||||||
|
page-break-before: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-bar {
|
||||||
|
position: relative;
|
||||||
|
margin: 0 0 4mm 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
font-size: 9px;
|
||||||
|
color: #333;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 4mm 0 2mm 0;
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: #e8e8e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: auto;
|
||||||
|
border: 2px solid #0a0a0a;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
border: 1px solid #0a0a0a;
|
||||||
|
padding: 2px 4px;
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead th {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 9px;
|
||||||
|
background: #d9e2f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
td { font-size: 9px; }
|
||||||
|
td.date { text-align: left; font-weight: bold; }
|
||||||
|
td.absence { text-align: left; color: #c00; }
|
||||||
|
td.absence .holiday { color: #0277bd; font-weight: 600; }
|
||||||
|
td.absence .holiday.with-absence { display: block; }
|
||||||
|
td.time { text-align: center; }
|
||||||
|
td.presence { text-align: center; }
|
||||||
|
td.total { text-align: center; font-weight: bold; }
|
||||||
|
tr.weekend td { background: #f3f3f3; color: #555; }
|
||||||
|
tr.weekend td.date { color: #333; }
|
||||||
|
tr.holiday td { background: #e1f5fe; }
|
||||||
|
|
||||||
|
.signature-footer {
|
||||||
|
page-break-inside: avoid;
|
||||||
|
margin-top: 6mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-intro {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 6mm;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-blocks {
|
||||||
|
display: table;
|
||||||
|
width: 100%;
|
||||||
|
table-layout: fixed;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 4mm 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-block {
|
||||||
|
display: table-cell;
|
||||||
|
border: 1px solid #0a0a0a;
|
||||||
|
padding: 3mm;
|
||||||
|
vertical-align: top;
|
||||||
|
width: 33.33%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-block .title {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 11px;
|
||||||
|
margin-bottom: 7mm;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-block .line {
|
||||||
|
margin-bottom: 2mm;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-block .signature-line {
|
||||||
|
margin-top: 6mm;
|
||||||
|
margin-bottom: 18mm;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
{% set months = {
|
||||||
|
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'
|
||||||
|
} %}
|
||||||
|
|
||||||
|
{% for entry in entries %}
|
||||||
|
<div class="employee-section">
|
||||||
|
<div class="title-bar">
|
||||||
|
<h1>
|
||||||
|
{{ entry.employeeName }}{% if entry.contractLabel %} - {{ entry.contractLabel }}{% endif %}<br>
|
||||||
|
{% if month %}{{ months[month] }} {{ year }}{% else %}{{ year }}{% endif %}
|
||||||
|
</h1>
|
||||||
|
<div class="export-date">Exporté le {{ "now"|date('d/m/Y') }} à {{ "now"|date('H:i:s') }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for segment in entry.segments %}
|
||||||
|
{% if entry.segments|length > 1 %}
|
||||||
|
<h2>{{ segment.contractName ?? 'Contrat inconnu' }}{% if segment.mode == 'driver' %} (Chauffeur){% endif %}</h2>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if segment.mode == 'presence' %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Absence</th>
|
||||||
|
<th>Présence matin</th>
|
||||||
|
<th>Présence après-midi</th>
|
||||||
|
<th>Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in segment.rows %}
|
||||||
|
<tr class="{{ row.isWeekend ? 'weekend' : (row.holidayLabel ? 'holiday' : '') }}">
|
||||||
|
<td class="date">{{ row.date }}</td>
|
||||||
|
<td class="absence">
|
||||||
|
{{ row.absenceLabel ?? '' }}
|
||||||
|
{% if row.holidayLabel %}<span class="holiday {{ row.absenceLabel ? 'with-absence' : '' }}">Férié : {{ row.holidayLabel }}</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="presence">{{ row.presentMorning ? 'X' : '' }}</td>
|
||||||
|
<td class="presence">{{ row.presentAfternoon ? 'X' : '' }}</td>
|
||||||
|
<td class="total">{{ row.total }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% elseif segment.mode == 'driver' %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Absence</th>
|
||||||
|
<th>Heures jour</th>
|
||||||
|
<th>Heures nuit</th>
|
||||||
|
<th>Heures atelier</th>
|
||||||
|
<th>Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in segment.rows %}
|
||||||
|
<tr class="{{ row.isWeekend ? 'weekend' : (row.holidayLabel ? 'holiday' : '') }}">
|
||||||
|
<td class="date">{{ row.date }}</td>
|
||||||
|
<td class="absence">
|
||||||
|
{{ row.absenceLabel ?? '' }}
|
||||||
|
{% if row.holidayLabel %}<span class="holiday {{ row.absenceLabel ? 'with-absence' : '' }}">Férié : {{ row.holidayLabel }}</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="time">{{ row.dayHours }}</td>
|
||||||
|
<td class="time">{{ row.nightHours }}</td>
|
||||||
|
<td class="time">{{ row.workshopHours }}</td>
|
||||||
|
<td class="total">{{ row.total }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Absence</th>
|
||||||
|
<th>Début matin</th>
|
||||||
|
<th>Fin matin</th>
|
||||||
|
<th>Début après-midi</th>
|
||||||
|
<th>Fin après-midi</th>
|
||||||
|
<th>Début soir</th>
|
||||||
|
<th>Fin soir</th>
|
||||||
|
<th>Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in segment.rows %}
|
||||||
|
<tr class="{{ row.isWeekend ? 'weekend' : (row.holidayLabel ? 'holiday' : '') }}">
|
||||||
|
<td class="date">{{ row.date }}</td>
|
||||||
|
<td class="absence">
|
||||||
|
{{ row.absenceLabel ?? '' }}
|
||||||
|
{% if row.holidayLabel %}<span class="holiday {{ row.absenceLabel ? 'with-absence' : '' }}">Férié : {{ row.holidayLabel }}</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="time">{{ row.morningFrom }}</td>
|
||||||
|
<td class="time">{{ row.morningTo }}</td>
|
||||||
|
<td class="time">{{ row.afternoonFrom }}</td>
|
||||||
|
<td class="time">{{ row.afternoonTo }}</td>
|
||||||
|
<td class="time">{{ row.eveningFrom }}</td>
|
||||||
|
<td class="time">{{ row.eveningTo }}</td>
|
||||||
|
<td class="total">{{ row.total }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="signature-footer">
|
||||||
|
<div class="signature-intro">
|
||||||
|
Nom + Prénom<br>
|
||||||
|
Signature avec mention « bon pour accord »
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="signature-blocks">
|
||||||
|
<div class="signature-block">
|
||||||
|
<p class="title">Direction</p>
|
||||||
|
<p class="line">Nom : ...............</p>
|
||||||
|
<p class="line">Prénom : ...............</p>
|
||||||
|
<p class="line">Mention : ........................................</p>
|
||||||
|
<p class="signature-line">Signature :</p>
|
||||||
|
</div>
|
||||||
|
<div class="signature-block">
|
||||||
|
<p class="title">Responsable usine</p>
|
||||||
|
<p class="line">Nom : ...............</p>
|
||||||
|
<p class="line">Prénom : ...............</p>
|
||||||
|
<p class="line">Mention : ........................................</p>
|
||||||
|
<p class="signature-line">Signature :</p>
|
||||||
|
</div>
|
||||||
|
<div class="signature-block">
|
||||||
|
<p class="title">Salarié</p>
|
||||||
|
<p class="line">Nom : ...............</p>
|
||||||
|
<p class="line">Prénom : ...............</p>
|
||||||
|
<p class="line">Mention : ........................................</p>
|
||||||
|
<p class="signature-line">Signature :</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user