diff --git a/CLAUDE.md b/CLAUDE.md
index 8daf08f..cd33261 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -61,6 +61,7 @@
- INTERIM: no overtime bonuses, no recovery time
- 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 — 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é.
## 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.
diff --git a/doc/functional-rules.md b/doc/functional-rules.md
index 49db4b4..3be0d1d 100644
--- a/doc/functional-rules.md
+++ b/doc/functional-rules.md
@@ -335,7 +335,7 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
| Contrat | Contract.name |
| 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: `-` |
-| 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: `-` |
## 10bis) Écran Récap. congés (tableau)
diff --git a/frontend/composables/useEmployeeDetailPage.ts b/frontend/composables/useEmployeeDetailPage.ts
index 04e4309..bfdadce 100644
--- a/frontend/composables/useEmployeeDetailPage.ts
+++ b/frontend/composables/useEmployeeDetailPage.ts
@@ -10,10 +10,11 @@ export const useEmployeeDetailPage = () => {
const showLeaveTab = computed(() => employee.value?.currentContractNature !== 'INTERIM')
const showRttTab = computed(() => employee.value?.contract?.type !== CONTRACT_TYPES.FORFAIT)
+ const isForfait = computed(() => employee.value?.contract?.type === CONTRACT_TYPES.FORFAIT)
const employeeContractWorkLabel = computed(() => {
const contract = employee.value?.contract
if (!contract) return '-'
- if (contract.type === CONTRACT_TYPES.FORFAIT) return 'Forfait'
+ if (contract.type === CONTRACT_TYPES.FORFAIT) return 'Forfait - 218 jours'
if (contract.weeklyHours !== null && contract.weeklyHours !== undefined) return `${contract.weeklyHours} heures`
return contract.name || '-'
})
@@ -55,6 +56,9 @@ export const useEmployeeDetailPage = () => {
await bonus.loadBonusData()
} else if (activeTab.value === 'observation') {
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 {
isLoading.value = false
@@ -63,6 +67,13 @@ export const useEmployeeDetailPage = () => {
const contract = useEmployeeContract(employee, loadEmployee)
const leave = useEmployeeLeave(employee, loadEmployee)
+ const forfaitRemainingDaysLabel = computed(() => {
+ if (!isForfait.value) return ''
+ const presence = leave.leaveSummary.value?.presenceDaysToToday
+ if (presence === undefined || presence === null) return ''
+ const remaining = 218 - presence
+ return ` (${remaining} restants)`
+ })
const rtt = useEmployeeRtt(employee, loadEmployee)
const mileage = useEmployeeMileage(employee, loadEmployee)
const formation = useEmployeeFormation(employee, loadEmployee)
@@ -97,6 +108,7 @@ export const useEmployeeDetailPage = () => {
showLeaveTab,
showRttTab,
employeeContractWorkLabel,
+ forfaitRemainingDaysLabel,
...contract,
...leave,
...rtt,
diff --git a/frontend/pages/employees/[id].vue b/frontend/pages/employees/[id].vue
index 898f1a4..138cb2c 100644
--- a/frontend/pages/employees/[id].vue
+++ b/frontend/pages/employees/[id].vue
@@ -26,7 +26,7 @@
Date d'entrée : {{ employee.entryDate ? employee.entryDate.split('-').reverse().join('/') : '-' }}
-
{{ contractNatureLabel(employee.currentContractNature) }} {{ employeeContractWorkLabel }}
+
{{ contractNatureLabel(employee.currentContractNature) }} {{ employeeContractWorkLabel }}{{ forfaitRemainingDaysLabel }}
{{ employee.site?.name ?? '-' }}
@@ -257,6 +257,7 @@ const {
showRttTab,
contractHistory,
employeeContractWorkLabel,
+ forfaitRemainingDaysLabel,
contractForm,
createContractForm,
isContractDrawerOpen,
diff --git a/frontend/services/dto/employee-leave-summary.ts b/frontend/services/dto/employee-leave-summary.ts
index 20a85e0..7150778 100644
--- a/frontend/services/dto/employee-leave-summary.ts
+++ b/frontend/services/dto/employee-leave-summary.ts
@@ -15,5 +15,6 @@ export type EmployeeLeaveSummary = {
previousYearRemainingDays: number
previousYearPaidDays: number
presenceDaysByMonth: Record
+ presenceDaysToToday: number
}
diff --git a/src/ApiResource/EmployeeLeaveSummary.php b/src/ApiResource/EmployeeLeaveSummary.php
index 0b275bd..de27fac 100644
--- a/src/ApiResource/EmployeeLeaveSummary.php
+++ b/src/ApiResource/EmployeeLeaveSummary.php
@@ -38,4 +38,7 @@ final class EmployeeLeaveSummary
/** @var array YYYY-MM => count (0.5 for half-days) */
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;
}
diff --git a/src/Service/Leave/LeaveRecapRowBuilder.php b/src/Service/Leave/LeaveRecapRowBuilder.php
index 633a357..46a0d4e 100644
--- a/src/Service/Leave/LeaveRecapRowBuilder.php
+++ b/src/Service/Leave/LeaveRecapRowBuilder.php
@@ -73,7 +73,7 @@ final readonly class LeaveRecapRowBuilder
}
}
$cpN1Remaining = round($yearSummary['previousYearRemainingDays'], 2);
- $cpN = (string) round($yearSummary['acquiredDays'], 2);
+ $cpN = (string) round($yearSummary['remainingDays'], 2);
$acquiredSaturdays = '-';
} else {
$cpN1Remaining = round($yearSummary['remainingDays'], 2);
diff --git a/src/State/EmployeeLeaveSummaryProvider.php b/src/State/EmployeeLeaveSummaryProvider.php
index e1b7c28..24ed690 100644
--- a/src/State/EmployeeLeaveSummaryProvider.php
+++ b/src/State/EmployeeLeaveSummaryProvider.php
@@ -119,8 +119,29 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$summary->previousYearRemainingDays = $yearSummary['previousYearRemainingDays'];
$summary->previousYearPaidDays = $paidLeaveDays;
- [$periodFrom, $periodTo] = $this->resolvePeriodBounds($employee, $year);
- $summary->presenceDaysByMonth = $this->computePresenceDaysByMonth($employee, $periodFrom, $periodTo);
+ [$periodFrom, $periodTo] = $this->resolvePeriodBounds($employee, $year);
+ // 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;
}
@@ -686,8 +707,12 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
*
* @return array 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);
$weekendWorkedDays = $this->workHourRepository->countWeekendWorkedDaysByMonth($employee, $from, $to);
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to);
@@ -697,10 +722,20 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
? $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
// and properly distribute across months.
$absenceDaysByMonth = [];
- foreach ($absences as $absence) {
+ foreach ($sortedAbsences as $absence) {
$start = DateTimeImmutable::createFromInterface($absence->getStartDate())->setTime(0, 0);
$end = DateTimeImmutable::createFromInterface($absence->getEndDate())->setTime(0, 0);
@@ -718,6 +753,17 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
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;
}
}