feat : sélecteur d'année sur l'onglet Congés de la fiche employé
Permet de consulter les exercices passés (calendrier + compteurs) sur l'onglet Congés. La plage proposée est bornée par max(début historique contrat, RTT_START_DATE) pour ne pas remonter avant la mise en service du logiciel. Édition des stocks N-1 et fractionnés verrouillée sur exercices clos. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -69,6 +69,13 @@
|
||||
- FORFAIT weekend/holiday bonus: each weekend or public holiday day worked gives bonus leave (full day if morning+afternoon, 0.5 if only one). Added to acquired days, no cap. PRESENCE mode only.
|
||||
- **FORFAIT — jours de présence et N-1** : les congés posés et imputés sur le stock N-1 ne décrémentent **pas** les jours de présence affichés (`presenceDaysByMonth` et `presenceDaysToToday`). Implémenté dans `EmployeeLeaveSummaryProvider::computePresenceDaysByMonth` via un budget N-1 (= `previousYearTakenDays`) consommé chronologiquement avant comptage des absences. Pour les non-forfait, ce budget vaut toujours 0 → comportement inchangé.
|
||||
|
||||
## Onglet Congés (fiche employé)
|
||||
- Calendrier annuel des congés (`frontend/components/employees/LeaveTab.vue`) — période = Janvier→Décembre pour FORFAIT, Juin(N-1)→Mai(N) pour les autres contrats. Règle pilotée par le **contrat courant** (cf. `EmployeeLeaveSummaryProvider::resolveYear`), même quand on consulte une année passée.
|
||||
- **Sélecteur d'année** en pied de calendrier (zone scrollable, à gauche). Plage : de l'exercice courant jusqu'à `max(floor_contrat, floor_data_start_date)` — `floor_contrat` = premier exercice avec contrat ouvert (`employee.contractHistory[].startDate`) ; `floor_data_start_date` = exercice contenant `RTT_START_DATE` (env, ex. `2026-02-23` → exercice 2026). Le double plancher empêche de remonter avant la mise en service du logiciel. Format : `2026` pour FORFAIT, `Juin 2025 → Mai 2026` sinon.
|
||||
- Changement d'année → recharge complète de l'onglet via `useEmployeeLeave.setSelectedLeaveYear(year)` (reload de `getEmployeeLeaveSummary?year=YYYY` + `listAbsences` + `listPublicHolidays`). Backend : filtre `?year=YYYY` validé 2000-2100, et `EmployeeLeaveSummary` expose `dataStartDate` (env `RTT_START_DATE`, injecté via `services.yaml`).
|
||||
- Sur un exercice passé (`selectedYear !== currentYear`), les boutons crayon **Jours fractionnés** et **Année N-1 payés** sont **désactivés** : pas d'édition rétroactive des stocks de report.
|
||||
- Doc : `doc/leave-tab.md`.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -35,6 +35,10 @@ services:
|
||||
arguments:
|
||||
$rttStartDate: '%env(RTT_START_DATE)%'
|
||||
|
||||
App\State\EmployeeLeaveSummaryProvider:
|
||||
arguments:
|
||||
$dataStartDate: '%env(RTT_START_DATE)%'
|
||||
|
||||
App\Repository\Contract\AbsenceReadRepositoryInterface: '@App\Repository\AbsenceRepository'
|
||||
App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface: '@App\Repository\EmployeeContractPeriodRepository'
|
||||
App\Repository\Contract\EmployeeScopedRepositoryInterface: '@App\Repository\EmployeeRepository'
|
||||
|
||||
60
doc/leave-tab.md
Normal file
60
doc/leave-tab.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Onglet "Congés" — fiche employé
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
L'onglet **Congés** de la fiche employé (`frontend/components/employees/LeaveTab.vue`) affiche :
|
||||
- un bandeau de compteurs (acquis, pris, reste, en cours d'acquisition, N-1 ou samedis selon le contrat) ;
|
||||
- un calendrier annuel coloré des congés posés (12 mois en grille 4×3) ;
|
||||
- pour chaque mois, le nombre de jours de présence (`presenceDaysByMonth`) ;
|
||||
- un sélecteur d'année en pied de calendrier.
|
||||
|
||||
## Période affichée
|
||||
|
||||
La période dépend du **type de contrat actuel** de l'employé :
|
||||
|
||||
| Type de contrat | Période affichée |
|
||||
|-------------------|--------------------------------|
|
||||
| FORFAIT | Janvier → Décembre (année civile) |
|
||||
| Autres | Juin (Y-1) → Mai (Y) (exercice CP) |
|
||||
|
||||
Cette règle suit `EmployeeLeaveSummaryProvider::resolveYear()` côté backend : la sélection FORFAIT vs non-FORFAIT se fait toujours sur le contrat **courant**, pas sur celui qui était en vigueur à l'année consultée.
|
||||
|
||||
## Sélecteur d'année
|
||||
|
||||
Position : **en bas du calendrier**, à gauche, à l'intérieur de la zone scrollable. Il scrolle donc avec les mois et apparaît sous la grille.
|
||||
|
||||
Plage proposée :
|
||||
- du plus récent (= année courante) au plus ancien ;
|
||||
- **double plancher** : l'année minimum est `max(floor_historique_contrat, floor_data_start_date)`
|
||||
- **floor_historique_contrat** : dérivé de `employee.contractHistory[].startDate` — premier exercice où l'employé avait un contrat ouvert
|
||||
- **floor_data_start_date** : dérivé de l'env `RTT_START_DATE` (date de mise en service du logiciel, ex. `2026-02-23` → exercice 2026 / année forfait 2026). Aucune donnée historique n'existe avant cette date, donc on ne propose pas d'années antérieures même si le contrat de l'employé est plus ancien.
|
||||
- la valeur est exposée par l'API `GET /employees/{id}/leave-summary` via le champ `dataStartDate` (peuplé depuis l'env serveur).
|
||||
- en cas d'historique manquant **et** d'env absente, la plage se réduit à l'année courante.
|
||||
|
||||
Format des libellés :
|
||||
- FORFAIT : `2026`, `2025`, `2024`…
|
||||
- Autres : `Juin 2025 → Mai 2026`, `Juin 2024 → Mai 2025`…
|
||||
|
||||
Comportement :
|
||||
- changer d'année recharge l'intégralité de l'onglet (`getEmployeeLeaveSummary?year=YYYY` + `listAbsences` + `listPublicHolidays`) ;
|
||||
- les compteurs du bandeau reflètent l'année sélectionnée.
|
||||
|
||||
## Verrouillage des éditions sur années passées
|
||||
|
||||
Quand `selectedYear !== currentYear` (consultation d'une année antérieure) :
|
||||
- le bouton crayon **Jours fractionnés** (non-FORFAIT) est désactivé ;
|
||||
- le bouton crayon **Année N-1 payés** (FORFAIT) est désactivé.
|
||||
|
||||
Justification : modifier rétroactivement les stocks de report ou les jours fractionnés d'un exercice clos décalerait silencieusement les soldes de toutes les années postérieures. La consultation reste possible, l'édition non.
|
||||
|
||||
## Implémentation
|
||||
|
||||
- Composable : `frontend/composables/useEmployeeLeave.ts`
|
||||
- État : `selectedLeaveYear`, computed `currentLeaveYear`, `availableLeaveYears`
|
||||
- API : `setSelectedLeaveYear(year)`, `loadLeaveData()`, `resetLoaded()`
|
||||
- `resetLoaded()` (appelé au changement d'employé) remet `selectedLeaveYear = null` pour que la valeur par défaut soit recalculée à partir du nouveau contrat.
|
||||
- Composant : `frontend/components/employees/LeaveTab.vue`
|
||||
- Props : `selectedYear`, `availableYears`, `currentYear`
|
||||
- Event : `update-selected-year`
|
||||
- Page : `frontend/pages/employees/[id].vue` (câble le composable au composant)
|
||||
- Backend : `EmployeeLeaveSummaryProvider` reçoit `RTT_START_DATE` via `services.yaml` (argument `$dataStartDate`) et l'expose dans la réponse `EmployeeLeaveSummary.dataStartDate`. Le filtrage `?year=YYYY` était déjà accepté (validation 2000–2100).
|
||||
@@ -39,6 +39,8 @@
|
||||
</div>
|
||||
<button
|
||||
class="flex items-center"
|
||||
:class="isHistoricalYear ? 'opacity-40 cursor-not-allowed' : ''"
|
||||
:disabled="isHistoricalYear"
|
||||
@click="openPaidLeaveDrawer"
|
||||
>
|
||||
<Icon name="mdi:edit-box" size="24"/>
|
||||
@@ -51,6 +53,8 @@
|
||||
</div>
|
||||
<button
|
||||
class="flex items-center"
|
||||
:class="isHistoricalYear ? 'opacity-40 cursor-not-allowed' : ''"
|
||||
:disabled="isHistoricalYear"
|
||||
@click="openFractionedDrawer"
|
||||
>
|
||||
<Icon name="mdi:edit-box" size="24"/>
|
||||
@@ -90,6 +94,22 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex items-center gap-3">
|
||||
<label for="leave-year-select" class="text-md font-semibold text-primary-500 uppercase">
|
||||
{{ isForfaitRule ? 'Année :' : 'Exercice :' }}
|
||||
</label>
|
||||
<select
|
||||
id="leave-year-select"
|
||||
:value="selectedYear ?? ''"
|
||||
:disabled="!availableYears.length"
|
||||
class="border border-primary-500 rounded-md px-3 py-1 text-md font-semibold text-primary-500 bg-white focus:outline-none focus:ring-2 focus:ring-secondary-500/20 disabled:opacity-50"
|
||||
@change="handleYearChange"
|
||||
>
|
||||
<option v-for="option in availableYears" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<AppDrawer v-model="isFractionedDrawerOpen" title="Jours fractionnés">
|
||||
<form class="space-y-4" @submit.prevent="handleSubmitFractioned">
|
||||
@@ -173,17 +193,39 @@ type DayLeaveState = {
|
||||
colors: string[]
|
||||
}
|
||||
|
||||
type LeaveYearOption = {
|
||||
value: number
|
||||
label: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
absences: Absence[]
|
||||
summary: EmployeeLeaveSummary | null
|
||||
publicHolidays: Record<string, string>
|
||||
selectedYear: number | null
|
||||
availableYears: LeaveYearOption[]
|
||||
currentYear: number | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update-fractioned-days', days: number): void
|
||||
(event: 'update-paid-leave-days', days: number): void
|
||||
(event: 'update-selected-year', year: number): void
|
||||
}>()
|
||||
|
||||
const isHistoricalYear = computed(() =>
|
||||
props.selectedYear !== null
|
||||
&& props.currentYear !== null
|
||||
&& props.selectedYear !== props.currentYear
|
||||
)
|
||||
|
||||
const handleYearChange = (event: Event) => {
|
||||
const target = event.target as HTMLSelectElement
|
||||
const value = Number(target.value)
|
||||
if (Number.isNaN(value)) return
|
||||
emit('update-selected-year', value)
|
||||
}
|
||||
|
||||
const isFractionedDrawerOpen = ref(false)
|
||||
const fractionedForm = reactive({days: 0})
|
||||
|
||||
@@ -239,6 +281,7 @@ const currentYearTakenDays = computed(() => {
|
||||
})
|
||||
|
||||
const displayedYear = computed(() => {
|
||||
if (props.selectedYear) return props.selectedYear
|
||||
if (props.summary?.year) return props.summary.year
|
||||
const today = new Date()
|
||||
const year = today.getFullYear()
|
||||
|
||||
@@ -7,27 +7,91 @@ import { listAbsences } from '~/services/absences'
|
||||
import { getEmployeeLeaveSummary, updateFractionedDays, updatePaidLeaveDays } from '~/services/employee-leave-summary'
|
||||
import { listPublicHolidays } from '~/services/public-holidays'
|
||||
|
||||
export type LeaveYearOption = {
|
||||
value: number
|
||||
label: string
|
||||
}
|
||||
|
||||
export const useEmployeeLeave = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
|
||||
const employeeAbsences = ref<Absence[]>([])
|
||||
const leaveSummary = ref<EmployeeLeaveSummary | null>(null)
|
||||
const publicHolidays = ref<Record<string, string>>({})
|
||||
const isLeaveLoading = ref(false)
|
||||
const leaveDataLoaded = ref(false)
|
||||
const selectedLeaveYear = ref<number | null>(null)
|
||||
|
||||
const getLeaveYear = () => {
|
||||
const now = new Date()
|
||||
const isForfait = employee.value?.contract?.type === CONTRACT_TYPES.FORFAIT
|
||||
return isForfait
|
||||
? now.getFullYear()
|
||||
: (now.getMonth() >= 5 ? now.getFullYear() + 1 : now.getFullYear())
|
||||
const isForfaitContract = (emp: Employee | null) =>
|
||||
emp?.contract?.type === CONTRACT_TYPES.FORFAIT
|
||||
|
||||
const computeLeaveYearForDate = (emp: Employee | null, date: Date): number => {
|
||||
if (isForfaitContract(emp)) return date.getFullYear()
|
||||
return date.getMonth() >= 5 ? date.getFullYear() + 1 : date.getFullYear()
|
||||
}
|
||||
|
||||
const currentLeaveYear = computed<number | null>(() => {
|
||||
if (!employee.value) return null
|
||||
return computeLeaveYearForDate(employee.value, new Date())
|
||||
})
|
||||
|
||||
const formatLeaveYearLabel = (year: number, isForfait: boolean): string => {
|
||||
if (isForfait) return String(year)
|
||||
return `Juin ${year - 1} → Mai ${year}`
|
||||
}
|
||||
|
||||
const availableLeaveYears = computed<LeaveYearOption[]>(() => {
|
||||
if (!employee.value || currentLeaveYear.value === null) return []
|
||||
const isForfait = isForfaitContract(employee.value)
|
||||
const current = currentLeaveYear.value
|
||||
|
||||
const startDates: string[] = []
|
||||
for (const period of employee.value.contractHistory ?? []) {
|
||||
if (period.startDate) startDates.push(period.startDate)
|
||||
}
|
||||
if (employee.value.entryDate) startDates.push(employee.value.entryDate)
|
||||
|
||||
let contractFloor = current
|
||||
for (const raw of startDates) {
|
||||
const date = new Date(`${raw.substring(0, 10)}T00:00:00`)
|
||||
if (Number.isNaN(date.getTime())) continue
|
||||
const leaveYear = computeLeaveYearForDate(employee.value, date)
|
||||
if (leaveYear < contractFloor) contractFloor = leaveYear
|
||||
}
|
||||
|
||||
// Hard floor : data-start-date (env RTT_START_DATE) — le logiciel n'a pas
|
||||
// d'historique avant cette date, inutile de proposer des années antérieures.
|
||||
let dataFloor: number | null = null
|
||||
const dataStart = leaveSummary.value?.dataStartDate
|
||||
if (dataStart) {
|
||||
const dataStartDate = new Date(`${dataStart.substring(0, 10)}T00:00:00`)
|
||||
if (!Number.isNaN(dataStartDate.getTime())) {
|
||||
dataFloor = computeLeaveYearForDate(employee.value, dataStartDate)
|
||||
}
|
||||
}
|
||||
|
||||
const minYear = dataFloor !== null ? Math.max(contractFloor, dataFloor) : contractFloor
|
||||
|
||||
const years: LeaveYearOption[] = []
|
||||
for (let y = current; y >= minYear; y -= 1) {
|
||||
years.push({ value: y, label: formatLeaveYearLabel(y, isForfait) })
|
||||
}
|
||||
return years
|
||||
})
|
||||
|
||||
const initSelectedLeaveYear = () => {
|
||||
if (selectedLeaveYear.value !== null) return
|
||||
if (currentLeaveYear.value !== null) {
|
||||
selectedLeaveYear.value = currentLeaveYear.value
|
||||
}
|
||||
}
|
||||
|
||||
const loadLeaveData = async () => {
|
||||
if (!employee.value || isLeaveLoading.value) return
|
||||
initSelectedLeaveYear()
|
||||
if (selectedLeaveYear.value === null) return
|
||||
isLeaveLoading.value = true
|
||||
try {
|
||||
const isForfait = employee.value.contract?.type === CONTRACT_TYPES.FORFAIT
|
||||
const leaveYear = getLeaveYear()
|
||||
const isForfait = isForfaitContract(employee.value)
|
||||
const leaveYear = selectedLeaveYear.value
|
||||
const from = isForfait ? `${leaveYear}-01-01` : `${leaveYear - 1}-06-01`
|
||||
const to = isForfait ? `${leaveYear}-12-31` : `${leaveYear}-05-31`
|
||||
const holidayYears = isForfait ? [leaveYear] : [leaveYear - 1, leaveYear]
|
||||
@@ -46,8 +110,16 @@ export const useEmployeeLeave = (employee: Ref<Employee | null>, reloadEmployee:
|
||||
}
|
||||
}
|
||||
|
||||
const setSelectedLeaveYear = async (year: number) => {
|
||||
if (selectedLeaveYear.value === year) return
|
||||
selectedLeaveYear.value = year
|
||||
leaveDataLoaded.value = false
|
||||
await loadLeaveData()
|
||||
}
|
||||
|
||||
const resetLoaded = () => {
|
||||
leaveDataLoaded.value = false
|
||||
selectedLeaveYear.value = null
|
||||
}
|
||||
|
||||
const submitFractionedDays = async (days: number) => {
|
||||
@@ -70,6 +142,10 @@ export const useEmployeeLeave = (employee: Ref<Employee | null>, reloadEmployee:
|
||||
publicHolidays,
|
||||
isLeaveLoading,
|
||||
leaveDataLoaded,
|
||||
selectedLeaveYear,
|
||||
currentLeaveYear,
|
||||
availableLeaveYears,
|
||||
setSelectedLeaveYear,
|
||||
loadLeaveData,
|
||||
resetLoaded,
|
||||
submitFractionedDays,
|
||||
|
||||
@@ -457,6 +457,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.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'onglet-conges-fiche-employe',
|
||||
title: 'Onglet Congés (fiche employé)',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'L\'onglet "Congés" sur la fiche employé affiche un calendrier annuel des congés posés (12 mois en grille 4×3) ainsi que les compteurs (acquis, pris, reste, en cours d\'acquisition, N-1 ou samedis selon le contrat).' },
|
||||
{ type: 'paragraph', content: 'La période affichée dépend du type de contrat actuel : Janvier → Décembre pour FORFAIT, Juin (N-1) → Mai (N) pour les autres contrats.' },
|
||||
{ type: 'paragraph', content: 'Un sélecteur d\'année est disponible en bas du calendrier (zone scrollable, à gauche). Il permet de consulter les exercices passés. La plage proposée part de l\'exercice courant et remonte jusqu\'au plus récent entre (a) le premier exercice où l\'employé avait un contrat ouvert et (b) l\'exercice de mise en service du logiciel — il est inutile de remonter plus loin, aucune donnée n\'a été saisie avant.' },
|
||||
{ type: 'note', content: 'Sur un exercice passé, les boutons d\'édition "Jours fractionnés" et "Année N-1 payés" sont désactivés. La consultation reste possible, mais on n\'autorise pas la modification rétroactive d\'un exercice clos.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ecran-recap-conges',
|
||||
title: 'Écran Récap. congés',
|
||||
|
||||
@@ -160,8 +160,12 @@
|
||||
:absences="employeeAbsences"
|
||||
:summary="leaveSummary"
|
||||
:public-holidays="publicHolidays"
|
||||
:selected-year="selectedLeaveYear"
|
||||
:available-years="availableLeaveYears"
|
||||
:current-year="currentLeaveYear"
|
||||
@update-fractioned-days="submitFractionedDays"
|
||||
@update-paid-leave-days="submitPaidLeaveDays"
|
||||
@update-selected-year="setSelectedLeaveYear"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="showRttTab && activeTab === 'rtt'" class="h-full">
|
||||
@@ -253,6 +257,10 @@ const {
|
||||
leaveSummary,
|
||||
rttSummary,
|
||||
publicHolidays,
|
||||
selectedLeaveYear,
|
||||
currentLeaveYear,
|
||||
availableLeaveYears,
|
||||
setSelectedLeaveYear,
|
||||
showLeaveTab,
|
||||
showRttTab,
|
||||
contractHistory,
|
||||
|
||||
@@ -16,5 +16,6 @@ export type EmployeeLeaveSummary = {
|
||||
previousYearPaidDays: number
|
||||
presenceDaysByMonth: Record<string, number>
|
||||
presenceDaysToToday: number
|
||||
dataStartDate: string | null
|
||||
}
|
||||
|
||||
|
||||
@@ -41,4 +41,7 @@ final class EmployeeLeaveSummary
|
||||
|
||||
/** Cumul des jours de présence depuis le début de l'année de congé jusqu'à aujourd'hui (forfait). */
|
||||
public float $presenceDaysToToday = 0.0;
|
||||
|
||||
/** Date de mise en service du logiciel (env RTT_START_DATE) — borne minimale pour les sélecteurs d'historique. */
|
||||
public ?string $dataStartDate = null;
|
||||
}
|
||||
|
||||
@@ -45,6 +45,8 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
private const float CDI_NON_FORFAIT_4H_SATURDAY_ACCRUAL_PER_MONTH = 0.0;
|
||||
private const float LONG_MALADIE_MONTHLY_ACCRUAL = 2.0;
|
||||
|
||||
private ?string $dataStartDate;
|
||||
|
||||
public function __construct(
|
||||
private Security $security,
|
||||
private RequestStack $requestStack,
|
||||
@@ -58,7 +60,10 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
private PublicHolidayServiceInterface $publicHolidayService,
|
||||
private SuspensionDaysCalculator $suspensionDaysCalculator,
|
||||
private WorkHourRepository $workHourRepository,
|
||||
) {}
|
||||
string $dataStartDate = '',
|
||||
) {
|
||||
$this->dataStartDate = '' !== $dataStartDate ? $dataStartDate : null;
|
||||
}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): EmployeeLeaveSummary
|
||||
{
|
||||
@@ -86,6 +91,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
$summary = new EmployeeLeaveSummary();
|
||||
$summary->year = $year;
|
||||
$summary->ruleCode = LeaveRuleCode::UNSUPPORTED->value;
|
||||
$summary->dataStartDate = $this->dataStartDate;
|
||||
|
||||
$yearSummary = $this->computeYearSummary($employee, $year);
|
||||
if (null === $yearSummary) {
|
||||
|
||||
Reference in New Issue
Block a user