Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
187a634cc8 | ||
| 0897154460 | |||
|
|
11331da6a1 | ||
| 399fd7335e | |||
|
|
46cb7f1a16 | ||
| b934f4d81f | |||
| 77c1cdcbbd | |||
|
|
de302d9ded | ||
| ef18210bf7 |
3
.env
3
.env
@@ -38,6 +38,9 @@ DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&ch
|
|||||||
|
|
||||||
###> app ###
|
###> app ###
|
||||||
RTT_START_DATE=2026-02-23
|
RTT_START_DATE=2026-02-23
|
||||||
|
# Comma-separated list of public holiday labels to exclude from the government API response
|
||||||
|
# (typically the "journée de solidarité" worked in many companies)
|
||||||
|
EXCLUDED_PUBLIC_HOLIDAYS="Lundi de Pentecôte"
|
||||||
###< app ###
|
###< app ###
|
||||||
|
|
||||||
###> nelmio/cors-bundle ###
|
###> nelmio/cors-bundle ###
|
||||||
|
|||||||
18
CLAUDE.md
18
CLAUDE.md
@@ -35,6 +35,13 @@
|
|||||||
- Absences with `countAsWorkedHours=true`: credit minutes (TIME) or nothing (PRESENCE)
|
- Absences with `countAsWorkedHours=true`: credit minutes (TIME) or nothing (PRESENCE)
|
||||||
- Driver periods (`isDriver=true` on `EmployeeContractPeriod`): separate screen `/driver-hours`, uses `dayHoursMinutes`/`nightHoursMinutes` + meal/overnight flags on `WorkHour`
|
- Driver periods (`isDriver=true` on `EmployeeContractPeriod`): separate screen `/driver-hours`, uses `dayHoursMinutes`/`nightHoursMinutes` + meal/overnight flags on `WorkHour`
|
||||||
|
|
||||||
|
## Fériés
|
||||||
|
- Source : API gouv via `PublicHolidayService` (cache 30j)
|
||||||
|
- Exclusions : env `EXCLUDED_PUBLIC_HOLIDAYS` (CSV de libellés), défaut `"Lundi de Pentecôte"`. Le filtre s'applique après le cache, côté service, donc frontend et calculs backend voient la même liste.
|
||||||
|
- Écrans Heures / Heures Conducteurs (vue jour) : le nom du férié est affiché en badge `#b3e5fc` avec icône `mdi:calendar-star` dans la colonne Absence (distinct du pill absence). Bouton "Modifier" absence masqué sur férié (comme pour les formations).
|
||||||
|
- Création/édition d'absence bloquée sur un férié
|
||||||
|
- Saisie d'heures (ou de jours de présence) autorisée sur un férié — nécessaire pour éviter un déficit hebdomadaire (la référence hebdo n'est pas réduite par les fériés)
|
||||||
|
|
||||||
## 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
|
||||||
@@ -49,6 +56,17 @@
|
|||||||
- 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.
|
||||||
|
|
||||||
|
## Récap. congés (écran)
|
||||||
|
- Accès via sidebar `Récap. congés`, conditionné au flag `User.hasLeaveRecapAccess` (défaut `false`) — activé au create/edit user. Le flag s'applique à tous les profils, y compris admin.
|
||||||
|
- Scope : `ROLE_ADMIN` → tous les employés, `ROLE_USER` (chef de site) → employés de ses sites, `ROLE_SELF` → sa ligne
|
||||||
|
- Cutoff temporel : fin de la semaine S-2 (dimanche 23:59:59). Formule : `dimanche(lundi_semaine_courante − 14j)`. Pas de gate `isValid`.
|
||||||
|
- Helper : `App\Util\LeaveRecapCutoff::resolveCutoff()`
|
||||||
|
- Colonnes : Nom, Prénom, Contrat, CP N-1 restant, CP N, Samedis, RTT — identiques au PDF
|
||||||
|
- Service partagé : `LeaveRecapRowBuilder` consommé par `LeaveRecapPrintProvider` (as-of today) et `EmployeeLeaveRecapProvider` (as-of cutoff)
|
||||||
|
- `EmployeeLeaveSummaryProvider::computeYearSummary()` accepte un `?DateTimeImmutable $asOfDate` qui cappe l'accrual et les absences sur l'année cible (`null` = comportement live inchangé)
|
||||||
|
- Pas d'export PDF depuis cet écran
|
||||||
|
- Doc détaillée : `doc/leave-recap-screen.md`
|
||||||
|
|
||||||
## Frais (MileageAllowance)
|
## Frais (MileageAllowance)
|
||||||
- Onglet "Frais" (anciennement "Frais Kms") sur la fiche employé
|
- Onglet "Frais" (anciennement "Frais Kms") sur la fiche employé
|
||||||
- Validation: mois obligatoire + au moins `kilometers > 0` ou `amount > 0`
|
- Validation: mois obligatoire + au moins `kilometers > 0` ou `amount > 0`
|
||||||
|
|||||||
@@ -23,3 +23,5 @@ docker compose exec -T db psql -U root -d sirh < sirh.sql
|
|||||||
```sql
|
```sql
|
||||||
UPDATE users SET roles = '["ROLE_ADMIN","ROLE_SUPER_ADMIN"]' WHERE username = 'emilie';
|
UPDATE users SET roles = '["ROLE_ADMIN","ROLE_SUPER_ADMIN"]' WHERE username = 'emilie';
|
||||||
```
|
```
|
||||||
|
sudo -u postgres pg_dump --no-owner --no-privileges --clean --if-exists sirh_prod > /tmp/sirh_prod_$(date +%F).sql
|
||||||
|
scp user@<serveur>:/tmp/sirh_prod_2026-04-14.dump ~/workspace/
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ services:
|
|||||||
App\Service\PublicHolidayService:
|
App\Service\PublicHolidayService:
|
||||||
arguments:
|
arguments:
|
||||||
$holidayUrl: '%env(HOLIDAY_URL)%'
|
$holidayUrl: '%env(HOLIDAY_URL)%'
|
||||||
|
$excludedLabels: '%env(default::EXCLUDED_PUBLIC_HOLIDAYS)%'
|
||||||
|
|
||||||
App\Service\Rtt\RttRecoveryComputationService:
|
App\Service\Rtt\RttRecoveryComputationService:
|
||||||
arguments:
|
arguments:
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.81'
|
app.version: '0.1.85'
|
||||||
|
|||||||
@@ -23,3 +23,4 @@ DEFAULT_URI=https://sirh.malio-dev.fr
|
|||||||
APP_SHARE_DIR=var/share
|
APP_SHARE_DIR=var/share
|
||||||
RTT_START_DATE=2026-02-23
|
RTT_START_DATE=2026-02-23
|
||||||
HOLIDAY_URL="https://calendrier.api.gouv.fr/jours-feries/"
|
HOLIDAY_URL="https://calendrier.api.gouv.fr/jours-feries/"
|
||||||
|
EXCLUDED_PUBLIC_HOLIDAYS="Lundi de Pentecôte"
|
||||||
|
|||||||
@@ -161,10 +161,14 @@ Documents complementaires:
|
|||||||
## 7) Fériés
|
## 7) Fériés
|
||||||
|
|
||||||
- Les jours fériés sont identifiés et affichés
|
- Les jours fériés sont identifiés et affichés
|
||||||
|
- Source: API `calendrier.api.gouv.fr/jours-feries/` via `PublicHolidayService` (cache 30j)
|
||||||
|
- Exclusions configurables: variable d'env `EXCLUDED_PUBLIC_HOLIDAYS` (liste de libellés séparés par virgules). Par défaut `"Lundi de Pentecôte"` — journée de solidarité généralement travaillée. Le filtre s'applique à tous les consommateurs (frontend + calculs backend) en amont du retour du service.
|
||||||
- Onglet congés: jours fériés affichés sur le calendrier avec fond `rgb(179, 229, 252)` et nom au survol
|
- Onglet congés: jours fériés affichés sur le calendrier avec fond `rgb(179, 229, 252)` et nom au survol
|
||||||
|
- Écran Heures et Heures Conducteurs (vue jour): le nom du férié est affiché dans la colonne Absence sous forme de pill (fond `#b3e5fc`, icône `mdi:calendar-star`), distinct du pill absence
|
||||||
- Règle courante:
|
- Règle courante:
|
||||||
- absences bloquées sur jour férié
|
- absences bloquées sur jour férié (création/édition) — bouton "Modifier" masqué comme pour les formations
|
||||||
- saisie d'heures autorisée
|
- saisie d'heures ou de jours de présence autorisée — les heures saisies comptent normalement dans le total hebdo et le calcul RTT
|
||||||
|
- la référence hebdomadaire n'est pas réduite par un férié: un salarié qui ne saisit rien sur un férié est en déficit de la journée correspondante
|
||||||
|
|
||||||
## 8) Impression absences (PDF)
|
## 8) Impression absences (PDF)
|
||||||
|
|
||||||
@@ -326,6 +330,24 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
|
|||||||
| CP N | Forfait: jours acquis année civile. Non-forfait: en cours d'acquisition |
|
| CP N | Forfait: jours acquis année civile. Non-forfait: en cours d'acquisition |
|
||||||
| RTT | Minutes disponibles (report N-1 + acquis N - payés). Format `X h Y m`. Forfait et INTERIM: `-` |
|
| RTT | Minutes disponibles (report N-1 + acquis N - payés). Format `X h Y m`. Forfait et INTERIM: `-` |
|
||||||
|
|
||||||
|
## 10bis) Écran Récap. congés (tableau)
|
||||||
|
|
||||||
|
- Complément de l'export PDF : même logique de calcul, mais accessible aux employés et chefs de site
|
||||||
|
- Endpoint: `GET /api/leave-recap`
|
||||||
|
- Accès conditionné au flag `User.hasLeaveRecapAccess` (défaut `false`, activé au create/edit user)
|
||||||
|
- Le flag s'applique à tous les profils, y compris admin (pas de bypass)
|
||||||
|
- Scoping :
|
||||||
|
- `ROLE_ADMIN` : tous les employés
|
||||||
|
- `ROLE_USER` (chef de site) : employés des sites autorisés (`UserSiteRole`)
|
||||||
|
- `ROLE_SELF` : uniquement son employé lié
|
||||||
|
- **Cutoff temporel** : le récap est figé à la fin de la semaine S-2 (dimanche 23:59:59)
|
||||||
|
- Formule : `cutoffDate = dimanche(lundi_semaine_courante − 14 jours)`
|
||||||
|
- Exemple : mardi 14/04/2026 (S16) → dimanche 05/04/2026 (fin S14)
|
||||||
|
- `isValid` n'entre PAS en compte : cutoff purement temporel
|
||||||
|
- Les heures et absences postérieures au cutoff sont ignorées dans les calculs
|
||||||
|
- Colonnes identiques au PDF (voir §10)
|
||||||
|
- Détails techniques : voir `doc/leave-recap-screen.md`
|
||||||
|
|
||||||
## 11) Récapitulatif Salaire (PDF mensuel)
|
## 11) Récapitulatif Salaire (PDF mensuel)
|
||||||
|
|
||||||
- Accessible depuis la page Employés via le bouton "Récap. Salaire" (réservé `ROLE_ADMIN`)
|
- Accessible depuis la page Employés via le bouton "Récap. Salaire" (réservé `ROLE_ADMIN`)
|
||||||
|
|||||||
73
doc/leave-recap-screen.md
Normal file
73
doc/leave-recap-screen.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Écran Récap. congés
|
||||||
|
|
||||||
|
## Objet
|
||||||
|
|
||||||
|
Vue tableau des soldes de congés par employé, figée à un cutoff temporel (fin de semaine S-2).
|
||||||
|
Complémentaire à l'export PDF admin : mêmes colonnes, accès étendu aux employés et chefs de site.
|
||||||
|
|
||||||
|
## Cutoff
|
||||||
|
|
||||||
|
La formule est : `cutoffDate = dimanche de (lundi de la semaine courante − 14 jours)`.
|
||||||
|
|
||||||
|
Exemple : mardi 14/04/2026 (S16) → **dimanche 05/04/2026 23:59:59** (fin S14).
|
||||||
|
|
||||||
|
Le cutoff est purement temporel : l'état `isValid` des heures n'entre pas en compte. Les heures
|
||||||
|
et absences postérieures au cutoff sont ignorées dans le calcul des soldes.
|
||||||
|
|
||||||
|
Implémentation : `App\Util\LeaveRecapCutoff::resolveCutoff()` côté backend, helper `parseYmd` +
|
||||||
|
`getIsoWeekNumber` côté frontend pour l'affichage du badge.
|
||||||
|
|
||||||
|
## Colonnes
|
||||||
|
|
||||||
|
Identiques au PDF :
|
||||||
|
|
||||||
|
- Nom
|
||||||
|
- Prénom
|
||||||
|
- Contrat
|
||||||
|
- CP N-1 restant
|
||||||
|
- CP N
|
||||||
|
- Samedis acquis
|
||||||
|
- RTT
|
||||||
|
|
||||||
|
Pour les admins et chefs de site, une colonne **Site** est ajoutée à gauche.
|
||||||
|
|
||||||
|
## Scoping
|
||||||
|
|
||||||
|
| Profil | Données visibles |
|
||||||
|
|---------------|-----------------------------------------|
|
||||||
|
| `ROLE_ADMIN` | Tous les employés actifs, tous sites |
|
||||||
|
| `ROLE_USER` (chef de site) | Employés actifs des sites autorisés via `UserSiteRole` |
|
||||||
|
| `ROLE_SELF` | Uniquement l'employé lié à son compte |
|
||||||
|
|
||||||
|
## Flag d'accès
|
||||||
|
|
||||||
|
Le champ `User.hasLeaveRecapAccess` (boolean, défaut `false`) conditionne :
|
||||||
|
|
||||||
|
- L'affichage de l'entrée "Récap. congés" dans la sidebar
|
||||||
|
- L'accès à la route `/leave-recap` (middleware `leave-recap-access.ts`)
|
||||||
|
- L'endpoint API `GET /api/leave-recap` (le provider renvoie `403` si le flag est faux)
|
||||||
|
|
||||||
|
Le flag s'applique même aux admins : un admin sans le flag ne voit pas l'écran. Il se configure
|
||||||
|
dans le drawer de création/édition d'un utilisateur.
|
||||||
|
|
||||||
|
## Service partagé
|
||||||
|
|
||||||
|
`App\Service\Leave\LeaveRecapRowBuilder::build(Employee $employee, DateTimeImmutable $asOfDate)`
|
||||||
|
construit une ligne de récap. Il est utilisé par :
|
||||||
|
|
||||||
|
- `LeaveRecapPrintProvider` (PDF admin) avec `$asOfDate = today`
|
||||||
|
- `EmployeeLeaveRecapProvider` (écran) avec `$asOfDate = cutoff`
|
||||||
|
|
||||||
|
## Propagation du cutoff dans les calculs
|
||||||
|
|
||||||
|
`EmployeeLeaveSummaryProvider::computeYearSummary()` accepte un `?DateTimeImmutable $asOfDate`.
|
||||||
|
Lorsqu'il est fourni et appliqué à l'année cible, il remplace "today" dans :
|
||||||
|
|
||||||
|
- `resolveAccrualCalculationEndDate()` — la borne d'accrual devient le dernier jour du mois
|
||||||
|
précédant `asOfDate` (au lieu du mois précédent today).
|
||||||
|
- `resolveTakenCalculationEndDate()` — les absences postérieures à `asOfDate` sont ignorées.
|
||||||
|
|
||||||
|
Pour les années antérieures (carry forward), le comportement reste inchangé (pas de cap).
|
||||||
|
|
||||||
|
Le RTT est capé via `RttRecoveryComputationService::computeTotalRecoveryForExercise(..., $limitDate)`
|
||||||
|
qui existait déjà, en passant `cutoff` comme date de référence.
|
||||||
@@ -25,19 +25,7 @@
|
|||||||
@change="onBulkValidationChange"
|
@change="onBulkValidationChange"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="isSiteManager" class="inline-flex items-center gap-2">
|
<span v-else-if="!isSiteManager">Site <Icon name="mdi:check-bold" class="ml-1"/></span>
|
||||||
<span>Site</span>
|
|
||||||
<input
|
|
||||||
ref="bulkSiteValidationInput"
|
|
||||||
:checked="isBulkSiteValidationChecked"
|
|
||||||
type="checkbox"
|
|
||||||
class="h-4 w-4"
|
|
||||||
:class="canBulkToggleSiteValidation ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'"
|
|
||||||
:disabled="!canBulkToggleSiteValidation"
|
|
||||||
@change="onBulkSiteValidationChange"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span v-else>Site <Icon name="mdi:check-bold" class="ml-1"/></span>
|
|
||||||
<span v-if="!isAdmin">RH <Icon name="mdi:check-bold" class="ml-1"/></span>
|
<span v-if="!isAdmin">RH <Icon name="mdi:check-bold" class="ml-1"/></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -68,19 +56,31 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="pl-2 min-w-0 self-stretch flex flex-col gap-1 justify-between py-0.5">
|
<div class="pl-2 min-w-0 self-stretch flex flex-col gap-1 justify-between py-0.5">
|
||||||
<p
|
<div class="flex flex-col gap-1 min-w-0">
|
||||||
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate"
|
<p
|
||||||
:class="getRowAbsenceLabel(employee.id) ? 'text-white' : 'invisible'"
|
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate"
|
||||||
:title="getRowAbsenceLabel(employee.id) || ''"
|
:class="getRowAbsenceLabel(employee.id) ? 'text-white' : 'invisible'"
|
||||||
:style="getRowAbsenceStyle(employee.id)"
|
:title="getRowAbsenceLabel(employee.id) || ''"
|
||||||
>
|
:style="getRowAbsenceStyle(employee.id)"
|
||||||
{{ getRowAbsenceLabel(employee.id) || '—' }}
|
>
|
||||||
</p>
|
{{ getRowAbsenceLabel(employee.id) || '—' }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="isHoliday"
|
||||||
|
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate text-sky-900 inline-flex items-center gap-1"
|
||||||
|
style="background-color: #b3e5fc"
|
||||||
|
:title="holidayLabel || 'Férié'"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:calendar-star" size="14" class="shrink-0"/>
|
||||||
|
<span class="truncate">{{ holidayLabel || 'Férié' }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
|
v-if="!isHoliday"
|
||||||
type="button"
|
type="button"
|
||||||
class="self-start text-left text-xs font-semibold underline"
|
class="self-start text-left text-xs font-semibold underline"
|
||||||
:class="isHoliday || isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
|
:class="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
|
||||||
:disabled="isHoliday || isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
|
:disabled="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
|
||||||
@click="onAbsenceClick(employee.id)"
|
@click="onAbsenceClick(employee.id)"
|
||||||
>
|
>
|
||||||
Modifier
|
Modifier
|
||||||
@@ -147,16 +147,8 @@
|
|||||||
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
|
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-right p-5">
|
<div v-else-if="!isSiteManager" class="text-right p-5">
|
||||||
<input
|
<span v-if="rows[employee.id]?.isSiteValid" class="text-xs font-semibold text-neutral-700">Validé</span>
|
||||||
v-if="isSiteManager"
|
|
||||||
:checked="rows[employee.id]?.isSiteValid ?? false"
|
|
||||||
type="checkbox"
|
|
||||||
class="h-4 w-4 cursor-pointer"
|
|
||||||
:disabled="(!canToggleSiteValidation(employee.id) && !canCreateSiteValidationRowFromAbsence(employee.id)) || isSiteValidationPending(employee.id)"
|
|
||||||
@change="onToggleSiteValidation(employee.id, ($event.target as HTMLInputElement).checked)"
|
|
||||||
/>
|
|
||||||
<span v-else-if="rows[employee.id]?.isSiteValid" class="text-xs font-semibold text-neutral-700">Validé</span>
|
|
||||||
<span v-else class="text-xs text-neutral-500">-</span>
|
<span v-else class="text-xs text-neutral-500">-</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!isAdmin">
|
<div v-if="!isAdmin">
|
||||||
@@ -184,6 +176,7 @@ const props = defineProps<{
|
|||||||
isSiteManager: boolean
|
isSiteManager: boolean
|
||||||
dayGridCols: string
|
dayGridCols: string
|
||||||
isHoliday: boolean
|
isHoliday: boolean
|
||||||
|
holidayLabel: string
|
||||||
contractLabel: (employee: Employee) => string
|
contractLabel: (employee: Employee) => string
|
||||||
isRowLocked: (employeeId: number) => boolean
|
isRowLocked: (employeeId: number) => boolean
|
||||||
hasContractAtSelectedDate: (employeeId: number) => boolean
|
hasContractAtSelectedDate: (employeeId: number) => boolean
|
||||||
|
|||||||
@@ -26,19 +26,7 @@
|
|||||||
@change="onBulkValidationChange"
|
@change="onBulkValidationChange"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="isSiteManager" class="inline-flex items-center gap-2">
|
<span v-else-if="!isSiteManager">Site <Icon name="mdi:check-bold" class="ml-1"/></span>
|
||||||
<span>Site</span>
|
|
||||||
<input
|
|
||||||
ref="bulkSiteValidationInput"
|
|
||||||
:checked="isBulkSiteValidationChecked"
|
|
||||||
type="checkbox"
|
|
||||||
class="h-4 w-4"
|
|
||||||
:class="canBulkToggleSiteValidation ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'"
|
|
||||||
:disabled="!canBulkToggleSiteValidation"
|
|
||||||
@change="onBulkSiteValidationChange"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span v-else>Site <Icon name="mdi:check-bold" class="ml-1"/></span>
|
|
||||||
<span v-if="!isAdmin">RH <Icon name="mdi:check-bold" class="ml-1"/></span>
|
<span v-if="!isAdmin">RH <Icon name="mdi:check-bold" class="ml-1"/></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -78,6 +66,15 @@
|
|||||||
>
|
>
|
||||||
{{ getRowAbsenceLabel(employee.id) || '—' }}
|
{{ getRowAbsenceLabel(employee.id) || '—' }}
|
||||||
</p>
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="isHoliday"
|
||||||
|
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate text-sky-900 inline-flex items-center gap-1"
|
||||||
|
style="background-color: #b3e5fc"
|
||||||
|
:title="holidayLabel || 'Férié'"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:calendar-star" size="14" class="shrink-0"/>
|
||||||
|
<span class="truncate">{{ holidayLabel || 'Férié' }}</span>
|
||||||
|
</p>
|
||||||
<p
|
<p
|
||||||
v-if="hasRowFormation(employee.id)"
|
v-if="hasRowFormation(employee.id)"
|
||||||
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate text-white bg-indigo-500 inline-flex items-center gap-1"
|
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate text-white bg-indigo-500 inline-flex items-center gap-1"
|
||||||
@@ -88,11 +85,11 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
v-if="!hasRowFormation(employee.id)"
|
v-if="!hasRowFormation(employee.id) && !isHoliday"
|
||||||
type="button"
|
type="button"
|
||||||
class="self-start text-left text-xs font-semibold underline"
|
class="self-start text-left text-xs font-semibold underline"
|
||||||
:class="isHoliday || isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
|
:class="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
|
||||||
:disabled="isHoliday || isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
|
:disabled="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
|
||||||
@click="onAbsenceClick(employee.id)"
|
@click="onAbsenceClick(employee.id)"
|
||||||
>
|
>
|
||||||
Modifier
|
Modifier
|
||||||
@@ -181,16 +178,8 @@
|
|||||||
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
|
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="text-right p-5">
|
<div v-else-if="!isSiteManager" class="text-right p-5">
|
||||||
<input
|
<span v-if="rows[employee.id]?.isSiteValid" class="text-xs font-semibold text-neutral-700">Validé</span>
|
||||||
v-if="isSiteManager"
|
|
||||||
:checked="rows[employee.id]?.isSiteValid ?? false"
|
|
||||||
type="checkbox"
|
|
||||||
class="h-4 w-4 cursor-pointer"
|
|
||||||
:disabled="(!canToggleSiteValidation(employee.id) && !canCreateSiteValidationRowFromAbsence(employee.id)) || isSiteValidationPending(employee.id)"
|
|
||||||
@change="onToggleSiteValidation(employee.id, ($event.target as HTMLInputElement).checked)"
|
|
||||||
/>
|
|
||||||
<span v-else-if="rows[employee.id]?.isSiteValid" class="text-xs font-semibold text-neutral-700">Validé</span>
|
|
||||||
<span v-else class="text-xs text-neutral-500">-</span>
|
<span v-else class="text-xs text-neutral-500">-</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!isAdmin">
|
<div v-if="!isAdmin">
|
||||||
@@ -218,6 +207,7 @@ const props = defineProps<{
|
|||||||
isSiteManager: boolean
|
isSiteManager: boolean
|
||||||
dayGridCols: string
|
dayGridCols: string
|
||||||
isHoliday: boolean
|
isHoliday: boolean
|
||||||
|
holidayLabel: string
|
||||||
contractLabel: (employee: Employee) => string
|
contractLabel: (employee: Employee) => string
|
||||||
isTimeTracking: (employee: Employee) => boolean
|
isTimeTracking: (employee: Employee) => boolean
|
||||||
isPresenceTracking: (employee: Employee) => boolean
|
isPresenceTracking: (employee: Employee) => boolean
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export const useDriverHoursPage = () => {
|
|||||||
|
|
||||||
const dayGridCols = computed(() => {
|
const dayGridCols = computed(() => {
|
||||||
const metricCol = '0.4fr'
|
const metricCol = '0.4fr'
|
||||||
const validationCols = isAdmin.value ? `${metricCol}` : `${metricCol} ${metricCol}`
|
const validationCols = isAdmin.value || isSiteManager.value ? `${metricCol}` : `${metricCol} ${metricCol}`
|
||||||
return `1.2fr 0.6fr 0.8fr 0.8fr 0.8fr ${metricCol} ${metricCol} ${metricCol} ${metricCol} ${metricCol} ${validationCols}`
|
return `1.2fr 0.6fr 0.8fr 0.8fr 0.8fr ${metricCol} ${metricCol} ${metricCol} ${metricCol} ${metricCol} ${validationCols}`
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -381,7 +381,6 @@ export const useDriverHoursPage = () => {
|
|||||||
if (dayRow && dayRow.hasContractAtDate === false) {
|
if (dayRow && dayRow.hasContractAtDate === false) {
|
||||||
return 'Contrat non démarré'
|
return 'Contrat non démarré'
|
||||||
}
|
}
|
||||||
if (isSelectedDateHoliday.value) return 'Férié'
|
|
||||||
if (!dayRow?.absenceLabel) return ''
|
if (!dayRow?.absenceLabel) return ''
|
||||||
if (dayRow.absenceHalf === 'AM' || dayRow.absenceHalf === 'PM') {
|
if (dayRow.absenceHalf === 'AM' || dayRow.absenceHalf === 'PM') {
|
||||||
const halfLabel = dayRow.absenceHalf === 'AM' ? 'Matin' : 'ap.-m.'
|
const halfLabel = dayRow.absenceHalf === 'AM' ? 'Matin' : 'ap.-m.'
|
||||||
@@ -941,6 +940,7 @@ export const useDriverHoursPage = () => {
|
|||||||
saveButtonClass,
|
saveButtonClass,
|
||||||
formattedSelectedDate,
|
formattedSelectedDate,
|
||||||
isSelectedDateHoliday,
|
isSelectedDateHoliday,
|
||||||
|
selectedHolidayLabel,
|
||||||
weekDayHeaders,
|
weekDayHeaders,
|
||||||
shortcutButtonClass,
|
shortcutButtonClass,
|
||||||
weekShortcutButtonClass,
|
weekShortcutButtonClass,
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export const useHoursPage = () => {
|
|||||||
|
|
||||||
const dayGridCols = computed(() => {
|
const dayGridCols = computed(() => {
|
||||||
const metricCol = '0.4fr'
|
const metricCol = '0.4fr'
|
||||||
const validationCols = isAdmin.value ? `${metricCol}` : `${metricCol} ${metricCol}`
|
const validationCols = isAdmin.value || isSiteManager.value ? `${metricCol}` : `${metricCol} ${metricCol}`
|
||||||
return `1.2fr 0.6fr repeat(6, 0.8fr) ${metricCol} ${metricCol} ${metricCol} ${validationCols}`
|
return `1.2fr 0.6fr repeat(6, 0.8fr) ${metricCol} ${metricCol} ${metricCol} ${validationCols}`
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -458,7 +458,6 @@ export const useHoursPage = () => {
|
|||||||
if (dayRow && dayRow.hasContractAtDate === false) {
|
if (dayRow && dayRow.hasContractAtDate === false) {
|
||||||
return 'Contrat non démarré'
|
return 'Contrat non démarré'
|
||||||
}
|
}
|
||||||
if (isSelectedDateHoliday.value) return 'Férié'
|
|
||||||
if (!dayRow?.absenceLabel) return ''
|
if (!dayRow?.absenceLabel) return ''
|
||||||
if (dayRow.absenceHalf === 'AM' || dayRow.absenceHalf === 'PM') {
|
if (dayRow.absenceHalf === 'AM' || dayRow.absenceHalf === 'PM') {
|
||||||
const halfLabel = dayRow.absenceHalf === 'AM' ? 'Matin' : 'ap.-m.'
|
const halfLabel = dayRow.absenceHalf === 'AM' ? 'Matin' : 'ap.-m.'
|
||||||
@@ -1127,6 +1126,7 @@ export const useHoursPage = () => {
|
|||||||
saveButtonClass,
|
saveButtonClass,
|
||||||
formattedSelectedDate,
|
formattedSelectedDate,
|
||||||
isSelectedDateHoliday,
|
isSelectedDateHoliday,
|
||||||
|
selectedHolidayLabel,
|
||||||
weekDayHeaders,
|
weekDayHeaders,
|
||||||
shortcutButtonClass,
|
shortcutButtonClass,
|
||||||
weekShortcutButtonClass,
|
weekShortcutButtonClass,
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export const documentationSections: DocSection[] = [
|
|||||||
{ type: 'list', content: 'Matin : heure de début et heure de fin\nAprès-midi : heure de début et heure de fin\nSoir : heure de début et heure de fin' },
|
{ type: 'list', content: 'Matin : heure de début et heure de fin\nAprès-midi : heure de début et heure de fin\nSoir : heure de début et heure de fin' },
|
||||||
{ type: 'paragraph', content: 'Le sélecteur de temps fonctionne par tranches de 15 minutes (00, 15, 30, 45). La saisie libre est possible mais sera corrigée automatiquement.' },
|
{ type: 'paragraph', content: 'Le sélecteur de temps fonctionne par tranches de 15 minutes (00, 15, 30, 45). La saisie libre est possible mais sera corrigée automatiquement.' },
|
||||||
{ type: 'note', content: 'Les calculs sont mis à jour automatiquement : heures de jour (06:00–21:00), heures de nuit (00:00–06:00 et 21:00–24:00) et total.' },
|
{ type: 'note', content: 'Les calculs sont mis à jour automatiquement : heures de jour (06:00–21:00), heures de nuit (00:00–06:00 et 21:00–24:00) et total.' },
|
||||||
|
{ type: 'note', content: 'Jours fériés : le nom du férié apparaît en badge bleu dans la colonne Absence. La saisie d\'heures (ou de jours de présence) reste autorisée — elle est même nécessaire pour ne pas être en déficit sur la semaine concernée. La création d\'une absence sur un férié reste bloquée.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -441,6 +442,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: 'ecran-recap-conges',
|
||||||
|
title: 'Écran Récap. congés',
|
||||||
|
requiredLevel: 'employee',
|
||||||
|
blocks: [
|
||||||
|
{ type: 'paragraph', content: 'L\'écran "Récap. congés" affiche un tableau figé des soldes de congés et RTT par employé. Il est accessible via la sidebar lorsque l\'accès a été activé sur le compte utilisateur.' },
|
||||||
|
{ type: 'list', content: 'Employé : voit uniquement sa propre ligne\nChef de site : voit les employés des sites qui lui sont rattachés\nAdmin : voit tous les employés, groupés par site' },
|
||||||
|
{ type: 'note', content: 'Le récap est arrêté à la fin de la semaine S-2 (dimanche). Exemple : un mardi en S16, les soldes sont calculés jusqu\'au dimanche de la S14 inclus. Les heures et absences postérieures ne sont pas comptées.' },
|
||||||
|
{ type: 'paragraph', content: 'Les colonnes affichées sont identiques à l\'export PDF admin (Nom, Prénom, Contrat, CP N-1 restant, CP N, Samedis, RTT). L\'accès à cet écran est géré par un flag sur l\'utilisateur, activé depuis le drawer de création/édition d\'un utilisateur par un admin.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -56,6 +56,9 @@
|
|||||||
"create": "Impossible de créer l'observation.",
|
"create": "Impossible de créer l'observation.",
|
||||||
"update": "Impossible de mettre à jour l'observation.",
|
"update": "Impossible de mettre à jour l'observation.",
|
||||||
"delete": "Impossible de supprimer l'observation."
|
"delete": "Impossible de supprimer l'observation."
|
||||||
|
},
|
||||||
|
"leaveRecap": {
|
||||||
|
"load": "Impossible de charger le récap des congés."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"success": {
|
"success": {
|
||||||
|
|||||||
@@ -53,6 +53,17 @@
|
|||||||
<Icon name="mdi:account-group-outline" size="24"/>
|
<Icon name="mdi:account-group-outline" size="24"/>
|
||||||
<p>Employés</p>
|
<p>Employés</p>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
v-if="hasLeaveRecapAccess"
|
||||||
|
to="/leave-recap"
|
||||||
|
class="flex items-center gap-2 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
|
||||||
|
:class="route.path.startsWith('/leave-recap')
|
||||||
|
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||||
|
: ''"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:beach" size="24"/>
|
||||||
|
<p>Récap. congés</p>
|
||||||
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/sites"
|
to="/sites"
|
||||||
class="flex items-center gap-2 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
|
class="flex items-center gap-2 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
|
||||||
@@ -84,6 +95,15 @@
|
|||||||
<p>Utilisateurs</p>
|
<p>Utilisateurs</p>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
|
<NuxtLink
|
||||||
|
v-if="hasLeaveRecapAccess && !isAdmin"
|
||||||
|
to="/leave-recap"
|
||||||
|
class="flex items-center gap-2 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500 pt-3"
|
||||||
|
:class="route.path.startsWith('/leave-recap') ? 'bg-tertiary-500 text-primary-500 font-bold' : ''"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:beach" size="24"/>
|
||||||
|
<p>Récap. congés</p>
|
||||||
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-if="isSuperAdmin"
|
v-if="isSuperAdmin"
|
||||||
to="/audit-logs"
|
to="/audit-logs"
|
||||||
@@ -128,5 +148,6 @@ const {version} = useAppVersion()
|
|||||||
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||||
const isSuperAdmin = computed(() => auth.user?.roles?.includes('ROLE_SUPER_ADMIN') ?? false)
|
const isSuperAdmin = computed(() => auth.user?.roles?.includes('ROLE_SUPER_ADMIN') ?? false)
|
||||||
const isDriver = computed(() => auth.user?.isDriver ?? false)
|
const isDriver = computed(() => auth.user?.isDriver ?? false)
|
||||||
|
const hasLeaveRecapAccess = computed(() => auth.user?.hasLeaveRecapAccess ?? false)
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
11
frontend/middleware/leave-recap-access.ts
Normal file
11
frontend/middleware/leave-recap-access.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export default defineNuxtRouteMiddleware(async () => {
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
if (!auth.checked) {
|
||||||
|
await auth.ensureSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!auth.user?.hasLeaveRecapAccess) {
|
||||||
|
return navigateTo('/')
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -43,6 +43,7 @@
|
|||||||
:is-site-manager="isSiteManager"
|
:is-site-manager="isSiteManager"
|
||||||
:day-grid-cols="dayGridCols"
|
:day-grid-cols="dayGridCols"
|
||||||
:is-holiday="isSelectedDateHoliday"
|
:is-holiday="isSelectedDateHoliday"
|
||||||
|
:holiday-label="selectedHolidayLabel"
|
||||||
:contract-label="contractLabel"
|
:contract-label="contractLabel"
|
||||||
:is-row-locked="isRowLocked"
|
:is-row-locked="isRowLocked"
|
||||||
:has-contract-at-selected-date="hasContractAtSelectedDate"
|
:has-contract-at-selected-date="hasContractAtSelectedDate"
|
||||||
@@ -174,6 +175,7 @@ const {
|
|||||||
closeAbsenceDrawer,
|
closeAbsenceDrawer,
|
||||||
formatMinutes,
|
formatMinutes,
|
||||||
isSelectedDateHoliday,
|
isSelectedDateHoliday,
|
||||||
|
selectedHolidayLabel,
|
||||||
handleSave
|
handleSave
|
||||||
} = useDriverHoursPage()
|
} = useDriverHoursPage()
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
:is-site-manager="isSiteManager"
|
:is-site-manager="isSiteManager"
|
||||||
:day-grid-cols="dayGridCols"
|
:day-grid-cols="dayGridCols"
|
||||||
:is-holiday="isSelectedDateHoliday"
|
:is-holiday="isSelectedDateHoliday"
|
||||||
|
:holiday-label="selectedHolidayLabel"
|
||||||
:contract-label="contractLabel"
|
:contract-label="contractLabel"
|
||||||
:is-time-tracking="isTimeTracking"
|
:is-time-tracking="isTimeTracking"
|
||||||
:is-presence-tracking="isPresenceTracking"
|
:is-presence-tracking="isPresenceTracking"
|
||||||
@@ -141,6 +142,7 @@ const {
|
|||||||
isSubmitting,
|
isSubmitting,
|
||||||
dayGridCols,
|
dayGridCols,
|
||||||
isSelectedDateHoliday,
|
isSelectedDateHoliday,
|
||||||
|
selectedHolidayLabel,
|
||||||
weekGridCols,
|
weekGridCols,
|
||||||
saveButtonClass,
|
saveButtonClass,
|
||||||
formattedSelectedDate,
|
formattedSelectedDate,
|
||||||
|
|||||||
121
frontend/pages/leave-recap.vue
Normal file
121
frontend/pages/leave-recap.vue
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<template>
|
||||||
|
<div class="h-full flex flex-col overflow-hidden">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-4 pb-8">
|
||||||
|
<h1 class="text-4xl font-bold text-primary-500">Récap. congés</h1>
|
||||||
|
<span
|
||||||
|
v-if="cutoffLabel"
|
||||||
|
class="inline-flex items-center gap-2 rounded-full bg-tertiary-500 px-4 py-1 text-sm font-semibold text-primary-500"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:calendar-check-outline" size="18"/>
|
||||||
|
{{ cutoffLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isLoading"
|
||||||
|
class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600"
|
||||||
|
>
|
||||||
|
Chargement...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="rows.length === 0"
|
||||||
|
class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600"
|
||||||
|
>
|
||||||
|
Aucun employé à afficher.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="min-h-0 overflow-auto rounded-md bg-white">
|
||||||
|
<div
|
||||||
|
:class="`grid ${gridColsClass} gap-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10`"
|
||||||
|
>
|
||||||
|
<span v-if="showSiteColumn" class="text-left">Site</span>
|
||||||
|
<span class="text-left">Nom</span>
|
||||||
|
<span class="text-left">Prénom</span>
|
||||||
|
<span class="text-left">Contrat</span>
|
||||||
|
<span class="text-right">CP N-1 restant</span>
|
||||||
|
<span class="text-right">CP N</span>
|
||||||
|
<span class="text-right">Samedis</span>
|
||||||
|
<span class="text-right">RTT</span>
|
||||||
|
</div>
|
||||||
|
<div class="border-x border-b border-primary-500 rounded-b-md">
|
||||||
|
<div
|
||||||
|
v-for="row in rows"
|
||||||
|
:key="row.employeeId"
|
||||||
|
:class="`grid ${gridColsClass} items-center gap-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0`"
|
||||||
|
>
|
||||||
|
<span v-if="showSiteColumn" class="truncate">
|
||||||
|
<span
|
||||||
|
v-if="row.siteName"
|
||||||
|
class="inline-block rounded-full px-3 py-1 text-sm"
|
||||||
|
:style="{ backgroundColor: row.siteColor || '#ffd7d7', color: '#1a1a1a' }"
|
||||||
|
>
|
||||||
|
{{ row.siteName }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-neutral-500">-</span>
|
||||||
|
</span>
|
||||||
|
<span class="truncate">{{ row.lastName }}</span>
|
||||||
|
<span class="truncate">{{ row.firstName }}</span>
|
||||||
|
<span class="truncate">{{ row.contractName ?? '-' }}</span>
|
||||||
|
<span class="text-right tabular-nums">{{ formatNumber(row.cpN1Remaining) }}</span>
|
||||||
|
<span class="text-right tabular-nums">{{ row.cpN }}</span>
|
||||||
|
<span class="text-right tabular-nums">{{ row.acquiredSaturdays }}</span>
|
||||||
|
<span class="text-right tabular-nums">{{ row.rtt }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { LeaveRecapRow } from '~/services/dto/leave-recap'
|
||||||
|
import { fetchLeaveRecap } from '~/services/leave-recap'
|
||||||
|
import { formatYmdToFr, getIsoWeekNumber, parseYmd } from '~/utils/date'
|
||||||
|
|
||||||
|
definePageMeta({ middleware: ['leave-recap-access'] })
|
||||||
|
useHead({ title: 'Récap. congés' })
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
|
const rows = ref<LeaveRecapRow[]>([])
|
||||||
|
const isLoading = ref(false)
|
||||||
|
|
||||||
|
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||||
|
const isSelfOnly = computed(() => {
|
||||||
|
const roles = auth.user?.roles ?? []
|
||||||
|
return roles.includes('ROLE_SELF') && !roles.includes('ROLE_ADMIN')
|
||||||
|
})
|
||||||
|
const showSiteColumn = computed(() => !isSelfOnly.value)
|
||||||
|
const gridColsClass = computed(() =>
|
||||||
|
showSiteColumn.value
|
||||||
|
? 'grid-cols-[1.2fr_1fr_1fr_1.2fr_140px_100px_100px_120px]'
|
||||||
|
: 'grid-cols-[1fr_1fr_1.2fr_140px_100px_100px_120px]'
|
||||||
|
)
|
||||||
|
|
||||||
|
const cutoffLabel = computed(() => {
|
||||||
|
const ymd = rows.value[0]?.cutoffDate
|
||||||
|
if (!ymd) return ''
|
||||||
|
const parsed = parseYmd(ymd)
|
||||||
|
if (!parsed) return ''
|
||||||
|
const week = getIsoWeekNumber(parsed)
|
||||||
|
return `Arrêté au ${formatYmdToFr(ymd)} (fin S${week})`
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatNumber = (value: number) => {
|
||||||
|
if (!Number.isFinite(value)) return '-'
|
||||||
|
return value.toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 2 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
rows.value = await fetchLeaveRecap()
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
|
||||||
|
// Silence unused linter warning for isAdmin (kept for future site grouping)
|
||||||
|
void isAdmin
|
||||||
|
</script>
|
||||||
@@ -189,6 +189,20 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
v-model="form.hasLeaveRecapAccess"
|
||||||
|
type="checkbox"
|
||||||
|
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 class="flex justify-center pt-2">
|
<div class="flex justify-center pt-2">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -233,7 +247,8 @@ const form = reactive({
|
|||||||
accessMode: 'admin' as 'admin' | 'self' | 'sites',
|
accessMode: 'admin' as 'admin' | 'self' | 'sites',
|
||||||
employeeId: '' as number | '',
|
employeeId: '' as number | '',
|
||||||
siteIds: [] as number[],
|
siteIds: [] as number[],
|
||||||
isLocked: false
|
isLocked: false,
|
||||||
|
hasLeaveRecapAccess: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const validationTouched = reactive({
|
const validationTouched = reactive({
|
||||||
@@ -345,6 +360,7 @@ const resetForm = () => {
|
|||||||
form.accessMode = 'admin'
|
form.accessMode = 'admin'
|
||||||
form.siteIds = []
|
form.siteIds = []
|
||||||
form.isLocked = false
|
form.isLocked = false
|
||||||
|
form.hasLeaveRecapAccess = false
|
||||||
editingUser.value = null
|
editingUser.value = null
|
||||||
validationTouched.username = false
|
validationTouched.username = false
|
||||||
validationTouched.password = false
|
validationTouched.password = false
|
||||||
@@ -373,6 +389,7 @@ const openEdit = (user: User) => {
|
|||||||
|
|
||||||
form.employeeId = user.employee?.id ?? ''
|
form.employeeId = user.employee?.id ?? ''
|
||||||
form.isLocked = user.isLocked
|
form.isLocked = user.isLocked
|
||||||
|
form.hasLeaveRecapAccess = user.hasLeaveRecapAccess ?? false
|
||||||
|
|
||||||
const siteRoles = userAccessById.value.get(user.id) ?? []
|
const siteRoles = userAccessById.value.get(user.id) ?? []
|
||||||
form.siteIds = siteRoles.map((role) => role.site?.id).filter((id): id is number => typeof id === 'number')
|
form.siteIds = siteRoles.map((role) => role.site?.id).filter((id): id is number => typeof id === 'number')
|
||||||
@@ -427,7 +444,8 @@ const handleSubmit = async () => {
|
|||||||
plainPassword: form.password.trim() ? form.password : undefined,
|
plainPassword: form.password.trim() ? form.password : undefined,
|
||||||
roles,
|
roles,
|
||||||
employeeId,
|
employeeId,
|
||||||
isLocked: form.isLocked
|
isLocked: form.isLocked,
|
||||||
|
hasLeaveRecapAccess: form.hasLeaveRecapAccess
|
||||||
})
|
})
|
||||||
|
|
||||||
const existingSiteRoles = userAccessById.value.get(editingUser.value.id) ?? []
|
const existingSiteRoles = userAccessById.value.get(editingUser.value.id) ?? []
|
||||||
@@ -452,7 +470,8 @@ const handleSubmit = async () => {
|
|||||||
plainPassword: form.password,
|
plainPassword: form.password,
|
||||||
roles,
|
roles,
|
||||||
employeeId,
|
employeeId,
|
||||||
isLocked: form.isLocked
|
isLocked: form.isLocked,
|
||||||
|
hasLeaveRecapAccess: form.hasLeaveRecapAccess
|
||||||
})
|
})
|
||||||
|
|
||||||
if (form.accessMode === 'sites' && form.siteIds.length > 0) {
|
if (form.accessMode === 'sites' && form.siteIds.length > 0) {
|
||||||
|
|||||||
14
frontend/services/dto/leave-recap.ts
Normal file
14
frontend/services/dto/leave-recap.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export type LeaveRecapRow = {
|
||||||
|
employeeId: number
|
||||||
|
lastName: string
|
||||||
|
firstName: string
|
||||||
|
siteId: number | null
|
||||||
|
siteName: string | null
|
||||||
|
siteColor: string | null
|
||||||
|
contractName: string | null
|
||||||
|
cpN1Remaining: number
|
||||||
|
cpN: string
|
||||||
|
acquiredSaturdays: string
|
||||||
|
rtt: string
|
||||||
|
cutoffDate: string
|
||||||
|
}
|
||||||
@@ -3,4 +3,5 @@ export type UserData = {
|
|||||||
username: string
|
username: string
|
||||||
roles: string[]
|
roles: string[]
|
||||||
isDriver: boolean
|
isDriver: boolean
|
||||||
|
hasLeaveRecapAccess: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,6 @@ export type User = {
|
|||||||
username: string
|
username: string
|
||||||
roles: string[]
|
roles: string[]
|
||||||
isLocked: boolean
|
isLocked: boolean
|
||||||
|
hasLeaveRecapAccess: boolean
|
||||||
employee?: Employee | null
|
employee?: Employee | null
|
||||||
}
|
}
|
||||||
|
|||||||
12
frontend/services/leave-recap.ts
Normal file
12
frontend/services/leave-recap.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { LeaveRecapRow } from './dto/leave-recap'
|
||||||
|
import { extractItems } from '~/utils/api'
|
||||||
|
|
||||||
|
export const fetchLeaveRecap = async (): Promise<LeaveRecapRow[]> => {
|
||||||
|
const api = useApi()
|
||||||
|
const data = await api.get<LeaveRecapRow[] | { 'hydra:member'?: LeaveRecapRow[] }>(
|
||||||
|
'/leave-recap',
|
||||||
|
{},
|
||||||
|
{ toastErrorKey: 'errors.leaveRecap.load' }
|
||||||
|
)
|
||||||
|
return extractItems<LeaveRecapRow>(data)
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ export const createUser = async (payload: {
|
|||||||
roles: string[]
|
roles: string[]
|
||||||
employeeId?: number | null
|
employeeId?: number | null
|
||||||
isLocked?: boolean
|
isLocked?: boolean
|
||||||
|
hasLeaveRecapAccess?: boolean
|
||||||
}) => {
|
}) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
return api.post<User>(
|
return api.post<User>(
|
||||||
@@ -26,7 +27,8 @@ export const createUser = async (payload: {
|
|||||||
plainPassword: payload.plainPassword,
|
plainPassword: payload.plainPassword,
|
||||||
roles: payload.roles,
|
roles: payload.roles,
|
||||||
employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null,
|
employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null,
|
||||||
isLocked: payload.isLocked ?? false
|
isLocked: payload.isLocked ?? false,
|
||||||
|
hasLeaveRecapAccess: payload.hasLeaveRecapAccess ?? false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
toastSuccessKey: 'success.user.create',
|
toastSuccessKey: 'success.user.create',
|
||||||
@@ -41,13 +43,15 @@ export const updateUser = async (id: number, payload: {
|
|||||||
roles: string[]
|
roles: string[]
|
||||||
employeeId?: number | null
|
employeeId?: number | null
|
||||||
isLocked?: boolean
|
isLocked?: boolean
|
||||||
|
hasLeaveRecapAccess?: boolean
|
||||||
}) => {
|
}) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const body: Record<string, unknown> = {
|
const body: Record<string, unknown> = {
|
||||||
username: payload.username,
|
username: payload.username,
|
||||||
roles: payload.roles,
|
roles: payload.roles,
|
||||||
employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null,
|
employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null,
|
||||||
isLocked: payload.isLocked ?? false
|
isLocked: payload.isLocked ?? false,
|
||||||
|
hasLeaveRecapAccess: payload.hasLeaveRecapAccess ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.plainPassword) {
|
if (payload.plainPassword) {
|
||||||
|
|||||||
26
migrations/Version20260414100000.php
Normal file
26
migrations/Version20260414100000.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260414100000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add has_leave_recap_access flag on users';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE users ADD has_leave_recap_access BOOLEAN DEFAULT FALSE NOT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE users DROP has_leave_recap_access');
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/ApiResource/EmployeeLeaveRecap.php
Normal file
35
src/ApiResource/EmployeeLeaveRecap.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ApiResource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
|
use App\State\EmployeeLeaveRecapProvider;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new GetCollection(
|
||||||
|
uriTemplate: '/leave-recap',
|
||||||
|
paginationEnabled: false,
|
||||||
|
security: "is_granted('ROLE_USER')",
|
||||||
|
provider: EmployeeLeaveRecapProvider::class,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
final class EmployeeLeaveRecap
|
||||||
|
{
|
||||||
|
public int $employeeId = 0;
|
||||||
|
public string $lastName = '';
|
||||||
|
public string $firstName = '';
|
||||||
|
public ?int $siteId = null;
|
||||||
|
public ?string $siteName = null;
|
||||||
|
public ?string $siteColor = null;
|
||||||
|
public ?string $contractName = null;
|
||||||
|
public float $cpN1Remaining = 0.0;
|
||||||
|
public string $cpN = '-';
|
||||||
|
public string $acquiredSaturdays = '-';
|
||||||
|
public string $rtt = '-';
|
||||||
|
public string $cutoffDate = '';
|
||||||
|
}
|
||||||
@@ -90,6 +90,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
#[SerializedName('isLocked')]
|
#[SerializedName('isLocked')]
|
||||||
private bool $isLocked = false;
|
private bool $isLocked = false;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
||||||
|
#[Groups(['user:write'])]
|
||||||
|
#[SerializedName('hasLeaveRecapAccess')]
|
||||||
|
private bool $hasLeaveRecapAccess = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @var Collection<int, UserSiteRole>
|
* @var Collection<int, UserSiteRole>
|
||||||
*/
|
*/
|
||||||
@@ -224,6 +229,20 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Groups(['user:read'])]
|
||||||
|
#[SerializedName('hasLeaveRecapAccess')]
|
||||||
|
public function hasLeaveRecapAccess(): bool
|
||||||
|
{
|
||||||
|
return $this->hasLeaveRecapAccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setHasLeaveRecapAccess(bool $hasLeaveRecapAccess): self
|
||||||
|
{
|
||||||
|
$this->hasLeaveRecapAccess = $hasLeaveRecapAccess;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
#[Groups(['user:read'])]
|
#[Groups(['user:read'])]
|
||||||
public function getIsDriver(): bool
|
public function getIsDriver(): bool
|
||||||
{
|
{
|
||||||
|
|||||||
180
src/Service/Leave/LeaveRecapRowBuilder.php
Normal file
180
src/Service/Leave/LeaveRecapRowBuilder.php
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\Leave;
|
||||||
|
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Enum\ContractNature;
|
||||||
|
use App\Enum\ContractType;
|
||||||
|
use App\Enum\TrackingMode;
|
||||||
|
use App\Repository\EmployeeRttBalanceRepository;
|
||||||
|
use App\Repository\EmployeeRttPaymentRepository;
|
||||||
|
use App\Repository\WorkHourRepository;
|
||||||
|
use App\Service\Rtt\RttRecoveryComputationService;
|
||||||
|
use App\State\EmployeeLeaveSummaryProvider;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final readonly class LeaveRecapRowBuilder
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private EmployeeLeaveSummaryProvider $leaveSummaryProvider,
|
||||||
|
private RttRecoveryComputationService $rttRecoveryService,
|
||||||
|
private EmployeeRttBalanceRepository $rttBalanceRepository,
|
||||||
|
private EmployeeRttPaymentRepository $rttPaymentRepository,
|
||||||
|
private WorkHourRepository $workHourRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a leave recap row for one employee.
|
||||||
|
*
|
||||||
|
* - $asOfDate = null → live behavior (identical to legacy PDF export): accrual capped at
|
||||||
|
* previous month end, ALL booked absences counted (incl. future ones), RTT uses today
|
||||||
|
* - $asOfDate = non-null → frozen snapshot at that date: accrual capped at the previous
|
||||||
|
* month end before asOfDate, absences after asOfDate excluded, RTT uses asOfDate
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* lastName: string,
|
||||||
|
* firstName: string,
|
||||||
|
* contractName: ?string,
|
||||||
|
* cpN1Remaining: float|string,
|
||||||
|
* cpN: string,
|
||||||
|
* acquiredSaturdays: string,
|
||||||
|
* rtt: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function build(Employee $employee, ?DateTimeImmutable $asOfDate = null): array
|
||||||
|
{
|
||||||
|
$contract = $employee->getContract();
|
||||||
|
$contractName = $contract?->getName();
|
||||||
|
$isForfait = ContractType::FORFAIT === $contract?->getType();
|
||||||
|
$nature = ContractNature::tryFrom($employee->getCurrentContractNature());
|
||||||
|
$isInterim = ContractNature::INTERIM === $nature;
|
||||||
|
|
||||||
|
$rttReference = $asOfDate ?? new DateTimeImmutable('today');
|
||||||
|
|
||||||
|
$cpN1Remaining = 0.0;
|
||||||
|
$cpN = '-';
|
||||||
|
$acquiredSaturdays = '-';
|
||||||
|
$rtt = '-';
|
||||||
|
|
||||||
|
if (!$isInterim) {
|
||||||
|
$leaveYear = $this->leaveSummaryProvider->resolveLeaveYearForToday($employee);
|
||||||
|
$yearSummary = $this->leaveSummaryProvider->computeYearSummary($employee, $leaveYear, 0.0, $asOfDate);
|
||||||
|
|
||||||
|
if (null !== $yearSummary) {
|
||||||
|
if ($isForfait) {
|
||||||
|
$paidLeaveDays = $this->leaveSummaryProvider->resolvePaidLeaveDays($employee, $yearSummary['ruleCode'], $leaveYear);
|
||||||
|
if ($paidLeaveDays > 0.0) {
|
||||||
|
$recomputed = $this->leaveSummaryProvider->computeYearSummary($employee, $leaveYear, $paidLeaveDays, $asOfDate);
|
||||||
|
if (null !== $recomputed) {
|
||||||
|
$yearSummary = $recomputed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$cpN1Remaining = round($yearSummary['previousYearRemainingDays'], 2);
|
||||||
|
$cpN = (string) round($yearSummary['acquiredDays'], 2);
|
||||||
|
$acquiredSaturdays = '-';
|
||||||
|
} else {
|
||||||
|
$cpN1Remaining = round($yearSummary['remainingDays'], 2);
|
||||||
|
$cpN = (string) round($yearSummary['accruingDays'], 2);
|
||||||
|
$acquiredSaturdays = (string) round($yearSummary['remainingSaturdays'], 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$isForfait && TrackingMode::PRESENCE->value !== $contract?->getTrackingMode()) {
|
||||||
|
try {
|
||||||
|
$rtt = $this->formatMinutes($this->computeAvailableRttMinutes($employee, $rttReference));
|
||||||
|
} catch (Throwable) {
|
||||||
|
$rtt = '-';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'lastName' => $employee->getLastName(),
|
||||||
|
'firstName' => $employee->getFirstName(),
|
||||||
|
'contractName' => $contractName,
|
||||||
|
'cpN1Remaining' => $cpN1Remaining,
|
||||||
|
'cpN' => $cpN,
|
||||||
|
'acquiredSaturdays' => $acquiredSaturdays,
|
||||||
|
'rtt' => $rtt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function computeAvailableRttMinutes(Employee $employee, DateTimeImmutable $reference): int
|
||||||
|
{
|
||||||
|
$month = (int) $reference->format('n');
|
||||||
|
$year = (int) $reference->format('Y');
|
||||||
|
$exerciseYear = $month >= 6 ? $year + 1 : $year;
|
||||||
|
|
||||||
|
// Exclude incomplete current week: limit to last Sunday
|
||||||
|
$isoDay = (int) $reference->format('N');
|
||||||
|
$limitDate = 7 === $isoDay ? $reference : $reference->modify('last sunday');
|
||||||
|
|
||||||
|
// Include the current week if all existing days are admin-validated
|
||||||
|
if (7 !== $isoDay) {
|
||||||
|
$currentWeekStart = $reference->modify('monday this week');
|
||||||
|
$currentWeekEnd = $currentWeekStart->modify('+6 days');
|
||||||
|
$checkEnd = $this->resolveWeekEndForEmployee($employee, $currentWeekStart, $currentWeekEnd, $reference);
|
||||||
|
if ($this->workHourRepository->isWeekFullyValidated($employee, $currentWeekStart, $checkEnd)) {
|
||||||
|
$limitDate = $currentWeekEnd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Carry from previous exercise
|
||||||
|
$carry = 0;
|
||||||
|
$balance = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $exerciseYear);
|
||||||
|
if (null !== $balance) {
|
||||||
|
$carry = $balance->getTotalOpeningMinutes();
|
||||||
|
} else {
|
||||||
|
$previousTotal = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $exerciseYear - 1);
|
||||||
|
$carry = $previousTotal->totalMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current exercise (limited to completed weeks)
|
||||||
|
$current = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $exerciseYear, $limitDate);
|
||||||
|
|
||||||
|
// Paid RTT
|
||||||
|
$paid = 0;
|
||||||
|
$payments = $this->rttPaymentRepository->findByEmployeeAndYear($employee, $exerciseYear);
|
||||||
|
foreach ($payments as $payment) {
|
||||||
|
$paid += $payment->getBase25Minutes() + $payment->getBonus25Minutes()
|
||||||
|
+ $payment->getBase50Minutes() + $payment->getBonus50Minutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $carry + $current->totalMinutes - $paid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveWeekEndForEmployee(Employee $employee, DateTimeImmutable $weekStart, DateTimeImmutable $weekEnd, DateTimeImmutable $today): DateTimeImmutable
|
||||||
|
{
|
||||||
|
foreach ($employee->getContractPeriods() as $period) {
|
||||||
|
if ($period->getStartDate() > $today) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$endDate = $period->getEndDate();
|
||||||
|
if (null === $endDate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($endDate >= $weekStart && $endDate <= $weekEnd) {
|
||||||
|
return $endDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $weekEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatMinutes(int $minutes): string
|
||||||
|
{
|
||||||
|
if (0 === $minutes) {
|
||||||
|
return '0 h';
|
||||||
|
}
|
||||||
|
|
||||||
|
$sign = $minutes < 0 ? '- ' : '';
|
||||||
|
$abs = abs($minutes);
|
||||||
|
$h = intdiv($abs, 60);
|
||||||
|
$m = $abs % 60;
|
||||||
|
|
||||||
|
return 0 === $m ? "{$sign}{$h} h" : "{$sign}{$h} h {$m} m";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,11 +17,22 @@ use Throwable;
|
|||||||
|
|
||||||
final readonly class PublicHolidayService implements PublicHolidayServiceInterface
|
final readonly class PublicHolidayService implements PublicHolidayServiceInterface
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private array $excludedLabels;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private HttpClientInterface $client,
|
private HttpClientInterface $client,
|
||||||
private string $holidayUrl,
|
private string $holidayUrl,
|
||||||
private CacheInterface $cache,
|
private CacheInterface $cache,
|
||||||
) {}
|
string $excludedLabels = '',
|
||||||
|
) {
|
||||||
|
$this->excludedLabels = array_values(array_filter(
|
||||||
|
array_map('trim', explode(',', $excludedLabels)),
|
||||||
|
static fn (string $label): bool => '' !== $label,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws TransportExceptionInterface
|
* @throws TransportExceptionInterface
|
||||||
@@ -35,7 +46,7 @@ final readonly class PublicHolidayService implements PublicHolidayServiceInterfa
|
|||||||
$zone = strtolower(trim($zone));
|
$zone = strtolower(trim($zone));
|
||||||
$key = "public_holidays_{$zone}_all";
|
$key = "public_holidays_{$zone}_all";
|
||||||
|
|
||||||
return $this->cache->get($key, function (ItemInterface $item) use ($zone): array {
|
$holidays = $this->cache->get($key, function (ItemInterface $item) use ($zone): array {
|
||||||
$item->expiresAfter(30 * 86400);
|
$item->expiresAfter(30 * 86400);
|
||||||
$url = $this->holidayUrl."{$zone}.json";
|
$url = $this->holidayUrl."{$zone}.json";
|
||||||
|
|
||||||
@@ -56,6 +67,8 @@ final readonly class PublicHolidayService implements PublicHolidayServiceInterfa
|
|||||||
|
|
||||||
return json_decode($response->getContent(), true);
|
return json_decode($response->getContent(), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return $this->applyExclusions($holidays);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,7 +83,7 @@ final readonly class PublicHolidayService implements PublicHolidayServiceInterfa
|
|||||||
$years = trim($years);
|
$years = trim($years);
|
||||||
$key = "public_holidays_{$zone}_{$years}";
|
$key = "public_holidays_{$zone}_{$years}";
|
||||||
|
|
||||||
return $this->cache->get($key, function (ItemInterface $item) use ($zone, $years): array {
|
$holidays = $this->cache->get($key, function (ItemInterface $item) use ($zone, $years): array {
|
||||||
$item->expiresAfter(30 * 86400);
|
$item->expiresAfter(30 * 86400);
|
||||||
$url = $this->holidayUrl."{$zone}/{$years}.json";
|
$url = $this->holidayUrl."{$zone}/{$years}.json";
|
||||||
|
|
||||||
@@ -88,5 +101,24 @@ final readonly class PublicHolidayService implements PublicHolidayServiceInterfa
|
|||||||
|
|
||||||
return json_decode($response->getContent(), true);
|
return json_decode($response->getContent(), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return $this->applyExclusions($holidays);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $holidays
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
private function applyExclusions(array $holidays): array
|
||||||
|
{
|
||||||
|
if ([] === $this->excludedLabels) {
|
||||||
|
return $holidays;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_filter(
|
||||||
|
$holidays,
|
||||||
|
fn (string $label): bool => !in_array($label, $this->excludedLabels, true),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
114
src/State/EmployeeLeaveRecapProvider.php
Normal file
114
src/State/EmployeeLeaveRecapProvider.php
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\State;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\ApiResource\EmployeeLeaveRecap;
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\User;
|
||||||
|
use App\Repository\EmployeeRepository;
|
||||||
|
use App\Security\EmployeeScopeService;
|
||||||
|
use App\Service\Leave\LeaveRecapRowBuilder;
|
||||||
|
use App\Util\LeaveRecapCutoff;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
|
|
||||||
|
final readonly class EmployeeLeaveRecapProvider implements ProviderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Security $security,
|
||||||
|
private EmployeeRepository $employeeRepository,
|
||||||
|
private EmployeeScopeService $employeeScopeService,
|
||||||
|
private LeaveRecapRowBuilder $rowBuilder,
|
||||||
|
private EntityManagerInterface $entityManager,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<EmployeeLeaveRecap>
|
||||||
|
*/
|
||||||
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
|
||||||
|
{
|
||||||
|
$user = $this->security->getUser();
|
||||||
|
if (!$user instanceof User) {
|
||||||
|
throw new AccessDeniedHttpException('Authentication required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$user->hasLeaveRecapAccess()) {
|
||||||
|
throw new AccessDeniedHttpException('Leave recap access not granted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$cutoff = LeaveRecapCutoff::resolveCutoff(new DateTimeImmutable('today'));
|
||||||
|
$cutoffYmd = $cutoff->format('Y-m-d');
|
||||||
|
$employees = $this->resolveScopedEmployees($user);
|
||||||
|
$rows = [];
|
||||||
|
|
||||||
|
foreach ($employees as $employee) {
|
||||||
|
if (!$employee->getHasActiveContract()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $this->rowBuilder->build($employee, $cutoff);
|
||||||
|
|
||||||
|
$resource = new EmployeeLeaveRecap();
|
||||||
|
$resource->employeeId = (int) $employee->getId();
|
||||||
|
$resource->lastName = $row['lastName'] ?? '';
|
||||||
|
$resource->firstName = $row['firstName'] ?? '';
|
||||||
|
$site = $employee->getSite();
|
||||||
|
$resource->siteId = $site?->getId();
|
||||||
|
$resource->siteName = $site?->getName();
|
||||||
|
$resource->siteColor = $site?->getColor();
|
||||||
|
$resource->contractName = $row['contractName'] ?? null;
|
||||||
|
$resource->cpN1Remaining = is_numeric($row['cpN1Remaining']) ? (float) $row['cpN1Remaining'] : 0.0;
|
||||||
|
$resource->cpN = (string) $row['cpN'];
|
||||||
|
$resource->acquiredSaturdays = (string) $row['acquiredSaturdays'];
|
||||||
|
$resource->rtt = (string) $row['rtt'];
|
||||||
|
$resource->cutoffDate = $cutoffYmd;
|
||||||
|
|
||||||
|
$rows[] = $resource;
|
||||||
|
$this->entityManager->clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($rows, static function (EmployeeLeaveRecap $a, EmployeeLeaveRecap $b): int {
|
||||||
|
$siteCmp = strcmp((string) ($a->siteName ?? 'zzz'), (string) ($b->siteName ?? 'zzz'));
|
||||||
|
if (0 !== $siteCmp) {
|
||||||
|
return $siteCmp;
|
||||||
|
}
|
||||||
|
$lastCmp = strcmp($a->lastName, $b->lastName);
|
||||||
|
if (0 !== $lastCmp) {
|
||||||
|
return $lastCmp;
|
||||||
|
}
|
||||||
|
|
||||||
|
return strcmp($a->firstName, $b->firstName);
|
||||||
|
});
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<Employee>
|
||||||
|
*/
|
||||||
|
private function resolveScopedEmployees(User $user): array
|
||||||
|
{
|
||||||
|
if (in_array('ROLE_ADMIN', $user->getRoles(), true)) {
|
||||||
|
return $this->employeeRepository->findForPrintBySiteIds([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array('ROLE_SELF', $user->getRoles(), true)) {
|
||||||
|
$employee = $user->getEmployee();
|
||||||
|
|
||||||
|
return $employee instanceof Employee ? [$employee] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$siteIds = $this->employeeScopeService->getAllowedSiteIds($user);
|
||||||
|
if ([] === $siteIds) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->employeeRepository->findForPrintBySiteIds($siteIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -140,7 +140,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
* previousYearRemainingDays: float
|
* previousYearRemainingDays: float
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public function computeYearSummary(Employee $employee, int $targetYear, float $paidLeaveDays = 0.0): ?array
|
public function computeYearSummary(Employee $employee, int $targetYear, float $paidLeaveDays = 0.0, ?DateTimeImmutable $asOfDate = null): ?array
|
||||||
{
|
{
|
||||||
$firstYear = max($this->resolveFirstComputationYear($employee), $targetYear - 1);
|
$firstYear = max($this->resolveFirstComputationYear($employee), $targetYear - 1);
|
||||||
if ($targetYear < $firstYear) {
|
if ($targetYear < $firstYear) {
|
||||||
@@ -196,8 +196,9 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
$carrySaturdays = 0.0;
|
$carrySaturdays = 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$accrualCalculationEnd = $this->resolveAccrualCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee);
|
$effectiveAsOfDate = ($year === $targetYear) ? $asOfDate : null;
|
||||||
$takenCalculationEnd = $this->resolveTakenCalculationEndDate($to, $employee);
|
$accrualCalculationEnd = $this->resolveAccrualCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee, $effectiveAsOfDate);
|
||||||
|
$takenCalculationEnd = $this->resolveTakenCalculationEndDate($to, $employee, $effectiveAsOfDate);
|
||||||
$suspensions = $this->suspensionDaysCalculator->applyFirstMonthGrace(
|
$suspensions = $this->suspensionDaysCalculator->applyFirstMonthGrace(
|
||||||
$this->resolveSuspensionsForPeriod($employee, $effectiveFrom, $to)
|
$this->resolveSuspensionsForPeriod($employee, $effectiveFrom, $to)
|
||||||
);
|
);
|
||||||
@@ -330,6 +331,13 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
return $this->resolveCurrentLeaveYear($today);
|
return $this->resolveCurrentLeaveYear($today);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function resolvePaidLeaveDays(Employee $employee, string $ruleCode, int $year): float
|
||||||
|
{
|
||||||
|
$balance = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $year);
|
||||||
|
|
||||||
|
return null !== $balance ? $balance->getPaidLeaveDays() : 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
private function resolveEffectivePeriodStart(
|
private function resolveEffectivePeriodStart(
|
||||||
Employee $employee,
|
Employee $employee,
|
||||||
DateTimeImmutable $from,
|
DateTimeImmutable $from,
|
||||||
@@ -482,19 +490,20 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
string $ruleCode,
|
string $ruleCode,
|
||||||
int $year,
|
int $year,
|
||||||
DateTimeImmutable $periodEnd,
|
DateTimeImmutable $periodEnd,
|
||||||
Employee $employee
|
Employee $employee,
|
||||||
|
?DateTimeImmutable $asOfDate = null
|
||||||
): ?DateTimeImmutable {
|
): ?DateTimeImmutable {
|
||||||
$today = new DateTimeImmutable('today');
|
$reference = $asOfDate ?? new DateTimeImmutable('today');
|
||||||
$currentYear = LeaveRuleCode::FORFAIT_218->value === $ruleCode
|
$currentYear = LeaveRuleCode::FORFAIT_218->value === $ruleCode
|
||||||
? (int) $today->format('Y')
|
? (int) $reference->format('Y')
|
||||||
: $this->resolveCurrentLeaveYear($today);
|
: $this->resolveCurrentLeaveYear($reference);
|
||||||
|
|
||||||
if ($year < $currentYear) {
|
if ($year < $currentYear) {
|
||||||
$end = $periodEnd;
|
$end = $periodEnd;
|
||||||
} elseif ($year > $currentYear) {
|
} elseif ($year > $currentYear) {
|
||||||
$end = null;
|
$end = null;
|
||||||
} else {
|
} else {
|
||||||
$lastDayPreviousMonth = $today
|
$lastDayPreviousMonth = $reference
|
||||||
->modify('first day of this month')
|
->modify('first day of this month')
|
||||||
->modify('-1 day')
|
->modify('-1 day')
|
||||||
->setTime(0, 0)
|
->setTime(0, 0)
|
||||||
@@ -516,10 +525,15 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
|
|
||||||
private function resolveTakenCalculationEndDate(
|
private function resolveTakenCalculationEndDate(
|
||||||
DateTimeImmutable $periodEnd,
|
DateTimeImmutable $periodEnd,
|
||||||
Employee $employee
|
Employee $employee,
|
||||||
|
?DateTimeImmutable $asOfDate = null
|
||||||
): ?DateTimeImmutable {
|
): ?DateTimeImmutable {
|
||||||
$end = $periodEnd;
|
$end = $periodEnd;
|
||||||
|
|
||||||
|
if ($asOfDate instanceof DateTimeImmutable && $asOfDate < $end) {
|
||||||
|
$end = $asOfDate;
|
||||||
|
}
|
||||||
|
|
||||||
// Cap at contract end date if the employee has left.
|
// Cap at contract end date if the employee has left.
|
||||||
$contractEndRaw = $employee->getCurrentContractEndDate();
|
$contractEndRaw = $employee->getCurrentContractEndDate();
|
||||||
if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
|
if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
|
||||||
@@ -778,13 +792,6 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
return null !== $balance ? $balance->getFractionedDays() : 0.0;
|
return null !== $balance ? $balance->getFractionedDays() : 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolvePaidLeaveDays(Employee $employee, string $ruleCode, int $year): float
|
|
||||||
{
|
|
||||||
$balance = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $year);
|
|
||||||
|
|
||||||
return null !== $balance ? $balance->getPaidLeaveDays() : 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveCurrentLeaveYear(DateTimeImmutable $today): int
|
private function resolveCurrentLeaveYear(DateTimeImmutable $today): int
|
||||||
{
|
{
|
||||||
$year = (int) $today->format('Y');
|
$year = (int) $today->format('Y');
|
||||||
|
|||||||
@@ -6,21 +6,13 @@ namespace App\State;
|
|||||||
|
|
||||||
use ApiPlatform\Metadata\Operation;
|
use ApiPlatform\Metadata\Operation;
|
||||||
use ApiPlatform\State\ProviderInterface;
|
use ApiPlatform\State\ProviderInterface;
|
||||||
use App\Entity\Employee;
|
|
||||||
use App\Enum\ContractNature;
|
|
||||||
use App\Enum\ContractType;
|
|
||||||
use App\Enum\TrackingMode;
|
|
||||||
use App\Repository\EmployeeRepository;
|
use App\Repository\EmployeeRepository;
|
||||||
use App\Repository\EmployeeRttBalanceRepository;
|
use App\Service\Leave\LeaveRecapRowBuilder;
|
||||||
use App\Repository\EmployeeRttPaymentRepository;
|
|
||||||
use App\Repository\WorkHourRepository;
|
|
||||||
use App\Service\Rtt\RttRecoveryComputationService;
|
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
use Dompdf\Dompdf;
|
use Dompdf\Dompdf;
|
||||||
use Dompdf\Options;
|
use Dompdf\Options;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Throwable;
|
|
||||||
use Twig\Environment;
|
use Twig\Environment;
|
||||||
|
|
||||||
class LeaveRecapPrintProvider implements ProviderInterface
|
class LeaveRecapPrintProvider implements ProviderInterface
|
||||||
@@ -28,12 +20,8 @@ class LeaveRecapPrintProvider implements ProviderInterface
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private Environment $twig,
|
private Environment $twig,
|
||||||
private EmployeeRepository $employeeRepository,
|
private EmployeeRepository $employeeRepository,
|
||||||
private EmployeeLeaveSummaryProvider $leaveSummaryProvider,
|
private LeaveRecapRowBuilder $rowBuilder,
|
||||||
private RttRecoveryComputationService $rttRecoveryService,
|
|
||||||
private EmployeeRttBalanceRepository $rttBalanceRepository,
|
|
||||||
private EmployeeRttPaymentRepository $rttPaymentRepository,
|
|
||||||
private EntityManagerInterface $entityManager,
|
private EntityManagerInterface $entityManager,
|
||||||
private WorkHourRepository $workHourRepository,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
|
||||||
@@ -59,7 +47,7 @@ class LeaveRecapPrintProvider implements ProviderInterface
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$siteGroups[$siteId]['employees'][] = $this->buildEmployeeRow($employee, $today);
|
$siteGroups[$siteId]['employees'][] = $this->rowBuilder->build($employee);
|
||||||
$this->entityManager->clear();
|
$this->entityManager->clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,129 +72,4 @@ class LeaveRecapPrintProvider implements ProviderInterface
|
|||||||
'Content-Disposition' => 'inline; filename="'.$filename.'"',
|
'Content-Disposition' => 'inline; filename="'.$filename.'"',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function buildEmployeeRow(Employee $employee, DateTimeImmutable $today): array
|
|
||||||
{
|
|
||||||
$contract = $employee->getContract();
|
|
||||||
$contractName = $contract?->getName();
|
|
||||||
$isForfait = ContractType::FORFAIT === $contract?->getType();
|
|
||||||
$nature = ContractNature::tryFrom($employee->getCurrentContractNature());
|
|
||||||
$isInterim = ContractNature::INTERIM === $nature;
|
|
||||||
|
|
||||||
$cpN1Remaining = 0.0;
|
|
||||||
$cpN = '-';
|
|
||||||
$acquiredSaturdays = '-';
|
|
||||||
$rtt = '-';
|
|
||||||
|
|
||||||
if (!$isInterim) {
|
|
||||||
$leaveYear = $this->leaveSummaryProvider->resolveLeaveYearForToday($employee);
|
|
||||||
$yearSummary = $this->leaveSummaryProvider->computeYearSummary($employee, $leaveYear);
|
|
||||||
|
|
||||||
if (null !== $yearSummary) {
|
|
||||||
if ($isForfait) {
|
|
||||||
$cpN1Remaining = round($yearSummary['previousYearRemainingDays'], 2);
|
|
||||||
$cpN = (string) round($yearSummary['acquiredDays'], 2);
|
|
||||||
$acquiredSaturdays = '-';
|
|
||||||
} else {
|
|
||||||
$cpN1Remaining = round($yearSummary['remainingDays'], 2);
|
|
||||||
$cpN = (string) round($yearSummary['accruingDays'], 2);
|
|
||||||
$acquiredSaturdays = (string) round($yearSummary['remainingSaturdays'], 2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$isForfait && TrackingMode::PRESENCE->value !== $contract?->getTrackingMode()) {
|
|
||||||
try {
|
|
||||||
$rtt = $this->formatMinutes($this->computeAvailableRttMinutes($employee, $today));
|
|
||||||
} catch (Throwable) {
|
|
||||||
$rtt = '-';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'lastName' => $employee->getLastName(),
|
|
||||||
'firstName' => $employee->getFirstName(),
|
|
||||||
'contractName' => $contractName,
|
|
||||||
'cpN1Remaining' => $cpN1Remaining,
|
|
||||||
'cpN' => $cpN,
|
|
||||||
'acquiredSaturdays' => $acquiredSaturdays,
|
|
||||||
'rtt' => $rtt,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function computeAvailableRttMinutes(Employee $employee, DateTimeImmutable $today): int
|
|
||||||
{
|
|
||||||
$month = (int) $today->format('n');
|
|
||||||
$year = (int) $today->format('Y');
|
|
||||||
$exerciseYear = $month >= 6 ? $year + 1 : $year;
|
|
||||||
|
|
||||||
// Exclude incomplete current week: limit to last Sunday
|
|
||||||
$isoDay = (int) $today->format('N');
|
|
||||||
$limitDate = 7 === $isoDay ? $today : $today->modify('last sunday');
|
|
||||||
|
|
||||||
// Include the current week if all existing days are admin-validated
|
|
||||||
if (7 !== $isoDay) {
|
|
||||||
$currentWeekStart = $today->modify('monday this week');
|
|
||||||
$currentWeekEnd = $currentWeekStart->modify('+6 days');
|
|
||||||
$checkEnd = $this->resolveWeekEndForEmployee($employee, $currentWeekStart, $currentWeekEnd, $today);
|
|
||||||
if ($this->workHourRepository->isWeekFullyValidated($employee, $currentWeekStart, $checkEnd)) {
|
|
||||||
$limitDate = $currentWeekEnd;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Carry from previous exercise
|
|
||||||
$carry = 0;
|
|
||||||
$balance = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $exerciseYear);
|
|
||||||
if (null !== $balance) {
|
|
||||||
$carry = $balance->getTotalOpeningMinutes();
|
|
||||||
} else {
|
|
||||||
$previousTotal = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $exerciseYear - 1);
|
|
||||||
$carry = $previousTotal->totalMinutes;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Current exercise (limited to completed weeks)
|
|
||||||
$current = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $exerciseYear, $limitDate);
|
|
||||||
|
|
||||||
// Paid RTT
|
|
||||||
$paid = 0;
|
|
||||||
$payments = $this->rttPaymentRepository->findByEmployeeAndYear($employee, $exerciseYear);
|
|
||||||
foreach ($payments as $payment) {
|
|
||||||
$paid += $payment->getBase25Minutes() + $payment->getBonus25Minutes()
|
|
||||||
+ $payment->getBase50Minutes() + $payment->getBonus50Minutes();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $carry + $current->totalMinutes - $paid;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveWeekEndForEmployee(Employee $employee, DateTimeImmutable $weekStart, DateTimeImmutable $weekEnd, DateTimeImmutable $today): DateTimeImmutable
|
|
||||||
{
|
|
||||||
foreach ($employee->getContractPeriods() as $period) {
|
|
||||||
if ($period->getStartDate() > $today) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$endDate = $period->getEndDate();
|
|
||||||
if (null === $endDate) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if ($endDate >= $weekStart && $endDate <= $weekEnd) {
|
|
||||||
return $endDate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $weekEnd;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function formatMinutes(int $minutes): string
|
|
||||||
{
|
|
||||||
if (0 === $minutes) {
|
|
||||||
return '0 h';
|
|
||||||
}
|
|
||||||
|
|
||||||
$sign = $minutes < 0 ? '- ' : '';
|
|
||||||
$abs = abs($minutes);
|
|
||||||
$h = intdiv($abs, 60);
|
|
||||||
$m = $abs % 60;
|
|
||||||
|
|
||||||
return 0 === $m ? "{$sign}{$h} h" : "{$sign}{$h} h {$m} m";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
23
src/Util/LeaveRecapCutoff.php
Normal file
23
src/Util/LeaveRecapCutoff.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Util;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leave recap cutoff rule: as-of end of ISO week S-2 (Sunday 23:59:59).
|
||||||
|
*
|
||||||
|
* Example: Tuesday 2026-04-14 (S16) → Sunday 2026-04-05 23:59:59 (end of S14).
|
||||||
|
*/
|
||||||
|
final class LeaveRecapCutoff
|
||||||
|
{
|
||||||
|
public static function resolveCutoff(DateTimeImmutable $today): DateTimeImmutable
|
||||||
|
{
|
||||||
|
$currentWeekMonday = $today->modify('monday this week')->setTime(0, 0);
|
||||||
|
$cutoffWeekMonday = $currentWeekMonday->modify('-14 days');
|
||||||
|
|
||||||
|
return $cutoffWeekMonday->modify('+6 days')->setTime(23, 59, 59);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user