From ea06059c0bfc016d100d5b68a776c0d860c0f76f Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 2 Mar 2026 09:50:09 +0100 Subject: [PATCH 01/12] =?UTF-8?q?feat=20:=20modification=20de=20la=20page?= =?UTF-8?q?=20employ=C3=A9=20WIP=20+=20ajout=20d'une=20navbar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 7 + doc/functional-rules.md | 134 ++++++++++++++++++ frontend/components/AppTopNav.vue | 43 ++++++ .../components/EmployeeNameFilterInput.vue | 26 ++-- frontend/components/SiteFilterSelector.vue | 67 +++++++-- frontend/layouts/default.vue | 9 +- frontend/pages/employees/[id].vue | 71 ++++++++++ .../{employees.vue => employees/index.vue} | 98 +++++-------- frontend/services/employees.ts | 5 + 9 files changed, 375 insertions(+), 85 deletions(-) create mode 100644 doc/functional-rules.md create mode 100644 frontend/components/AppTopNav.vue create mode 100644 frontend/pages/employees/[id].vue rename frontend/pages/{employees.vue => employees/index.vue} (83%) diff --git a/AGENTS.md b/AGENTS.md index 02f1a16..b91a212 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,13 @@ Arborescence clé: - `tests/`: TU backend (PHPUnit) - `frontend/`: app Nuxt (pages, composants, composables, services) - `migrations/`: migrations Doctrine +- `doc/`: documentation fonctionnelle et règles métier de référence + +## 1.1) Référentiel Fonctionnel (obligatoire) + +- Référence principale des règles métier: `doc/functional-rules.md` +- Toute intervention doit commencer par une vérification de cohérence avec cette documentation. +- Règle permanente: à chaque développement qui modifie le fonctionnel, la documentation dans `doc/` doit être mise à jour automatiquement dans la même intervention (pas de report). ## 2) Commandes utiles diff --git a/doc/functional-rules.md b/doc/functional-rules.md new file mode 100644 index 0000000..93902b1 --- /dev/null +++ b/doc/functional-rules.md @@ -0,0 +1,134 @@ +# Règles Fonctionnelles SIRH + +Ce document centralise les règles métier actuellement implémentées dans l'application. + +## 1) Utilisateurs et accès + +- `ROLE_ADMIN` + - accès complet aux écrans d'administration + - vue semaine des heures + - validation RH des lignes d'heures +- `ROLE_SELF` + - accès limité à son périmètre personnel +- Accès "Sites" (via `user_site_roles` avec rôle `SITE_ACCESS`) + - accès au périmètre des sites autorisés + - validation site des lignes d'heures + +## 2) Contrats + +- Le profil de temps de travail est porté par `Contract`: + - `trackingMode`: `TIME` ou `PRESENCE` + - `weeklyHours` (ex: 35, 39, 4, etc.) +- La nature RH est portée par période employé: + - `CDI`, `CDD`, `INTERIM` +- Historique des contrats employé: + - table `employee_contract_periods` + - un employé peut avoir plusieurs périodes + +### Règles de période + +- `CDI`: + - `endDate` doit être vide +- `CDD` / `INTERIM`: + - `endDate` obligatoire +- `endDate` ne peut pas être antérieure à `startDate` + +## 3) Heures (vue jour) + +- Saisie par salarié et par date: + - matin / après-midi / soir + - pour `PRESENCE`: demi-journées matin/après-midi +- Calculs affichés: + - `Jour`, `Nuit`, `Total` +- Heures de nuit: + - fenêtres `00:00-06:00` et `21:00-24:00` + +## 4) Absences + +- Les absences sont stockées par jour (découpage lors de l'écriture) +- Une absence peut être: + - journée complète + - demi-journée `AM` ou `PM` +- Colonne absence (vue jour): + - affiche le libellé + - fond coloré selon le type d'absence +- Si plusieurs absences de couleurs différentes sur le même jour: + - fallback rouge + +### Effet absence sur les heures + +- Absence `AM`: + - efface les heures du matin +- Absence `PM`: + - efface les heures d'après-midi et du soir +- Absence journée: + - efface toutes les plages horaires + +### Absences "comptées comme travaillées" + +- Si `countAsWorkedHours = true`: + - `TIME`: crédit de minutes selon contrat actif du jour + - `PRESENCE`: crédit d'unités (0.5 / demi-journée) + +## 5) Validations des lignes d'heures + +- Validation RH (`isValid`) + - action admin +- Validation site (`isSiteValid`) + - action chef de site + +### Verrouillage + +- Ligne validée RH: + - verrouillée pour modifications heures/absences +- Ligne validée site: + - verrouillée pour non-admin + - admin peut corriger +- Toute vraie modification d'une ligne: + - remet `isSiteValid = false` + - remet `isValid = false` +- Si aucun changement réel à l'enregistrement: + - les validations existantes ne sont pas altérées + +## 6) Heures supplémentaires (vue semaine) + +- Base de calcul: + - dépend du contrat actif par jour +- Tranche 25%: + - contrats <= 35h: de 35h à 43h + - contrats >= 39h: de 39h à 43h +- Tranche 50%: + - au-delà de 43h +- Nature `INTERIM`: + - pas de bonus 25% + - pas de bonus 50% + - pas de total récup + +## 7) Fériés + +- Les jours fériés sont identifiés et affichés +- Règle courante: + - absences bloquées sur jour férié + - saisie d'heures autorisée + +## 8) Impression absences (PDF) + +Filtres disponibles: +- période `from` / `to` +- sites +- nature de contrat (`CDI`, `CDD`, `INTERIM`) +- temps de travail (contrats de type Forfait, 35h, 39h, etc.) + +Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer. + +## 9) Employés + +- Création employé: + - prénom, nom, site + - type de contrat (nature RH) + - temps de travail + - dates début/fin (selon règles nature) +- Modification employé: + - uniquement prénom, nom, site + - pas de modification de contrat depuis ce drawer + diff --git a/frontend/components/AppTopNav.vue b/frontend/components/AppTopNav.vue new file mode 100644 index 0000000..4ceed70 --- /dev/null +++ b/frontend/components/AppTopNav.vue @@ -0,0 +1,43 @@ + + + diff --git a/frontend/components/EmployeeNameFilterInput.vue b/frontend/components/EmployeeNameFilterInput.vue index 8bc04fb..f77a596 100644 --- a/frontend/components/EmployeeNameFilterInput.vue +++ b/frontend/components/EmployeeNameFilterInput.vue @@ -1,18 +1,26 @@ diff --git a/frontend/components/SiteFilterSelector.vue b/frontend/components/SiteFilterSelector.vue index 405e40b..9e50590 100644 --- a/frontend/components/SiteFilterSelector.vue +++ b/frontend/components/SiteFilterSelector.vue @@ -1,25 +1,70 @@ diff --git a/frontend/layouts/default.vue b/frontend/layouts/default.vue index ead9d69..bc96701 100644 --- a/frontend/layouts/default.vue +++ b/frontend/layouts/default.vue @@ -73,9 +73,12 @@ -
- -
+
+ +
+ +
+
diff --git a/frontend/pages/employees/[id].vue b/frontend/pages/employees/[id].vue new file mode 100644 index 0000000..d88e610 --- /dev/null +++ b/frontend/pages/employees/[id].vue @@ -0,0 +1,71 @@ + + + diff --git a/frontend/pages/employees.vue b/frontend/pages/employees/index.vue similarity index 83% rename from frontend/pages/employees.vue rename to frontend/pages/employees/index.vue index aa08583..a1a6d0b 100644 --- a/frontend/pages/employees.vue +++ b/frontend/pages/employees/index.vue @@ -1,24 +1,22 @@ diff --git a/frontend/components/employees/LeaveTab.vue b/frontend/components/employees/LeaveTab.vue new file mode 100644 index 0000000..248a6f9 --- /dev/null +++ b/frontend/components/employees/LeaveTab.vue @@ -0,0 +1,7 @@ + diff --git a/frontend/components/employees/RttTab.vue b/frontend/components/employees/RttTab.vue new file mode 100644 index 0000000..e117f17 --- /dev/null +++ b/frontend/components/employees/RttTab.vue @@ -0,0 +1,7 @@ + diff --git a/frontend/composables/useEmployeeDetailPage.ts b/frontend/composables/useEmployeeDetailPage.ts new file mode 100644 index 0000000..4d0f4cb --- /dev/null +++ b/frontend/composables/useEmployeeDetailPage.ts @@ -0,0 +1,296 @@ +import type { Contract } from '~/services/dto/contract' +import type { ContractHistoryItem, Employee } from '~/services/dto/employee' +import { CONTRACT_TYPES } from '~/services/dto/contract' +import { listContracts } from '~/services/contracts' +import { getEmployee, updateEmployee } from '~/services/employees' +import { formatNullableYmdToFr, getTodayYmd, shiftYmd } from '~/utils/date' +import { contractNatureLabel, isContractNature, requiresContractEndDate } from '~/utils/contract' + +export const useEmployeeDetailPage = () => { + const route = useRoute() + const toast = useToast() + const employee = ref(null) + const isLoading = ref(false) + const activeTab = ref<'contract' | 'leave' | 'rtt'>('contract') + const contracts = ref([]) + const isContractDrawerOpen = ref(false) + const isContractSubmitting = ref(false) + const isCreateContractDrawerOpen = ref(false) + const isCreateContractSubmitting = ref(false) + + const contractForm = reactive({ + contractId: '' as number | '', + contractName: '', + weeklyHours: null as number | null, + contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM', + startDate: '', + endDate: '' + }) + + const validationTouched = reactive({ + endDate: false + }) + + const createContractForm = reactive({ + contractId: '' as number | '', + contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM', + startDate: '', + endDate: '' + }) + + const createValidationTouched = reactive({ + contractId: false, + contractNature: false, + startDate: false, + endDate: false + }) + + const contractHistory = computed(() => employee.value?.contractHistory ?? []) + const employeeContractWorkLabel = computed(() => { + const contract = employee.value?.contract + if (!contract) return '-' + if (contract.type === CONTRACT_TYPES.FORFAIT) return 'Forfait' + if (contract.weeklyHours !== null && contract.weeklyHours !== undefined) return `${contract.weeklyHours} heures` + return contract.name || '-' + }) + + const formatDate = (value?: string | null) => formatNullableYmdToFr(value) + + const contractHistoryLabel = (item: ContractHistoryItem) => { + if (item.weeklyHours !== null && item.weeklyHours !== undefined) { + return `${item.weeklyHours} heures` + } + return item.contractName ?? '-' + } + + const currentActiveContractPeriod = computed(() => { + const today = getTodayYmd() + const history = employee.value?.contractHistory ?? [] + return history.find((item) => item.startDate <= today && (!item.endDate || item.endDate >= today)) ?? null + }) + + const canCloseCurrentContract = computed(() => { + const active = currentActiveContractPeriod.value + if (!active) return false + if (!active.endDate) return true + return active.endDate > getTodayYmd() + }) + + const canCreateContract = computed(() => { + const active = currentActiveContractPeriod.value + if (!active) return true + return !!active.endDate + }) + + const isContractEndDateValid = computed(() => contractForm.endDate !== '') + const showContractEndDateError = computed(() => validationTouched.endDate && !isContractEndDateValid.value) + + const requiresCreateContractEndDate = computed(() => requiresContractEndDate(createContractForm.contractNature)) + const isCreateContractValid = computed(() => createContractForm.contractId !== '') + const isCreateContractNatureValid = computed(() => isContractNature(createContractForm.contractNature)) + const isCreateContractStartDateValid = computed(() => createContractForm.startDate !== '') + const isCreateContractEndDateValid = computed(() => !requiresCreateContractEndDate.value || createContractForm.endDate !== '') + const isCreateContractFormValid = computed(() => + isCreateContractValid.value && + isCreateContractNatureValid.value && + isCreateContractStartDateValid.value && + isCreateContractEndDateValid.value + ) + + const baseInputClass = + 'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20' + const readonlyFieldClass = `${baseInputClass} border-neutral-300 bg-neutral-100 text-neutral-700` + const contractEndDateFieldClass = computed(() => showContractEndDateError.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`) + const baseSelectClass = 'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900' + const createContractFieldClass = computed(() => createValidationTouched.contractId && !isCreateContractValid.value ? `${baseSelectClass} border-red-500` : `${baseSelectClass} border-neutral-300`) + const createContractNatureFieldClass = computed(() => createValidationTouched.contractNature && !isCreateContractNatureValid.value ? `${baseSelectClass} border-red-500` : `${baseSelectClass} border-neutral-300`) + const createContractStartDateFieldClass = computed(() => createValidationTouched.startDate && !isCreateContractStartDateValid.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`) + const createContractEndDateFieldClass = computed(() => createValidationTouched.endDate && !isCreateContractEndDateValid.value ? `${baseInputClass} border-red-500` : `${baseInputClass} border-neutral-300`) + const closeContractWorkedHoursLabel = computed(() => { + if (contractForm.weeklyHours !== null && contractForm.weeklyHours !== undefined) return `${contractForm.weeklyHours} heures` + return contractForm.contractName || '-' + }) + + const resetContractValidation = () => { + validationTouched.endDate = false + } + + const hydrateContractFormFromCurrent = () => { + const current = employee.value + const active = currentActiveContractPeriod.value + if (!current || !active) return + + contractForm.contractId = active.contractId ?? current.contract?.id ?? '' + contractForm.contractName = active.contractName ?? current.contract?.name ?? '' + contractForm.weeklyHours = active.weeklyHours ?? current.contract?.weeklyHours ?? null + contractForm.contractNature = active.contractNature + contractForm.startDate = active.startDate + contractForm.endDate = getTodayYmd() + } + + const openCloseContractDrawer = () => { + if (!employee.value || !canCloseCurrentContract.value) return + hydrateContractFormFromCurrent() + resetContractValidation() + isContractDrawerOpen.value = true + } + + const setContractDrawerOpen = (open: boolean) => { + isContractDrawerOpen.value = open + } + + const resetCreateValidation = () => { + createValidationTouched.contractId = false + createValidationTouched.contractNature = false + createValidationTouched.startDate = false + createValidationTouched.endDate = false + } + + const openCreateContractDrawer = () => { + if (!employee.value || !canCreateContract.value) return + createContractForm.contractId = '' + createContractForm.contractNature = 'CDI' + createContractForm.endDate = '' + createContractForm.startDate = currentActiveContractPeriod.value?.endDate + ? (shiftYmd(currentActiveContractPeriod.value.endDate, 1) ?? currentActiveContractPeriod.value.endDate) + : getTodayYmd() + resetCreateValidation() + isCreateContractDrawerOpen.value = true + } + + const setCreateContractDrawerOpen = (open: boolean) => { + isCreateContractDrawerOpen.value = open + } + + const loadEmployee = async () => { + const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id + const employeeId = Number(idParam) + if (!Number.isInteger(employeeId) || employeeId <= 0) { + return + } + + isLoading.value = true + try { + employee.value = await getEmployee(employeeId) + } finally { + isLoading.value = false + } + } + + const submitContractUpdate = async () => { + if (!employee.value || isContractSubmitting.value || !currentActiveContractPeriod.value) return + + validationTouched.endDate = true + if (!isContractEndDateValid.value) return + + if (contractForm.endDate < currentActiveContractPeriod.value.startDate) { + toast.error({ + title: 'Erreur', + message: `La date de fin doit être postérieure au ${formatDate(currentActiveContractPeriod.value.startDate)}.` + }) + return + } + + isContractSubmitting.value = true + try { + await updateEmployee(employee.value.id, { + firstName: employee.value.firstName, + lastName: employee.value.lastName, + siteId: employee.value.site?.id ?? null, + contractId: Number(contractForm.contractId), + contractEndDate: contractForm.endDate || null + }) + + isContractDrawerOpen.value = false + await loadEmployee() + } finally { + isContractSubmitting.value = false + } + } + + const submitCreateContract = async () => { + if (!employee.value || isCreateContractSubmitting.value) return + + createValidationTouched.contractId = true + createValidationTouched.contractNature = true + createValidationTouched.startDate = true + createValidationTouched.endDate = true + if (!isCreateContractFormValid.value) return + + if (currentActiveContractPeriod.value?.endDate) { + const minStartDate = shiftYmd(currentActiveContractPeriod.value.endDate, 1) ?? currentActiveContractPeriod.value.endDate + if (createContractForm.startDate < minStartDate) { + toast.error({ + title: 'Erreur', + message: `La date de début doit être au moins le ${formatDate(minStartDate)}.` + }) + return + } + } + + isCreateContractSubmitting.value = true + try { + await updateEmployee(employee.value.id, { + firstName: employee.value.firstName, + lastName: employee.value.lastName, + siteId: employee.value.site?.id ?? null, + contractId: Number(createContractForm.contractId), + contractNature: createContractForm.contractNature, + contractStartDate: createContractForm.startDate, + contractEndDate: createContractForm.endDate || null + }) + isCreateContractDrawerOpen.value = false + await loadEmployee() + } finally { + isCreateContractSubmitting.value = false + } + } + + watch(requiresCreateContractEndDate, (required) => { + if (!required) { + createContractForm.endDate = '' + } + }) + + onMounted(async () => { + contracts.value = await listContracts() + await loadEmployee() + }) + + return { + employee, + isLoading, + activeTab, + contracts, + contractHistory, + employeeContractWorkLabel, + contractForm, + createContractForm, + isContractDrawerOpen, + isContractSubmitting, + isCreateContractDrawerOpen, + isCreateContractSubmitting, + canCloseCurrentContract, + canCreateContract, + readonlyFieldClass, + closeContractWorkedHoursLabel, + contractEndDateFieldClass, + showContractEndDateError, + isContractEndDateValid, + createContractNatureFieldClass, + createContractFieldClass, + createContractStartDateFieldClass, + requiresCreateContractEndDate, + createContractEndDateFieldClass, + isCreateContractFormValid, + contractNatureLabel, + contractHistoryLabel, + formatDate, + openCloseContractDrawer, + openCreateContractDrawer, + setContractDrawerOpen, + setCreateContractDrawerOpen, + submitContractUpdate, + submitCreateContract + } +} diff --git a/frontend/pages/employees/[id].vue b/frontend/pages/employees/[id].vue index fd8530c..49355df 100644 --- a/frontend/pages/employees/[id].vue +++ b/frontend/pages/employees/[id].vue @@ -14,7 +14,7 @@

{{ employee.firstName }} {{ employee.lastName }}

-

{{ contractNatureLabel(employee.currentContractNature) }} {{ employee.contract?.weeklyHours ?? '-' }} heures

+

{{ contractNatureLabel(employee.currentContractNature) }} {{ employeeContractWorkLabel }}

{{ employee.site?.name ?? '-' }}

@@ -53,97 +53,86 @@ -
-
-
-

Contrat

-

Heures

-

Date de début

-

Date de fin

-
-
- Aucun historique de contrat. -
-
-
-

{{ contractNatureLabel(item.contractNature) }}

-

{{ contractHistoryLabel(item) }}

-

{{ formatDate(item.startDate) }}

-

{{ formatDate(item.endDate) }}

-
-
-
-
- - -
-
-
- -
-
- -
+ + + diff --git a/frontend/pages/employees/index.vue b/frontend/pages/employees/index.vue index 951cf65..887f790 100644 --- a/frontend/pages/employees/index.vue +++ b/frontend/pages/employees/index.vue @@ -154,10 +154,10 @@ La date de début est obligatoire.

-
+
(() => { }) }) -const contractNatureLabel = (value?: 'CDI' | 'CDD' | 'INTERIM') => { - if (value === 'CDD') return 'CDD' - if (value === 'INTERIM') return 'Intérim' - return 'CDI' -} - const form = reactive({ firstName: '', lastName: '', @@ -266,11 +261,11 @@ const isFirstNameValid = computed(() => form.firstName.trim() !== '') const isLastNameValid = computed(() => form.lastName.trim() !== '') const isSiteValid = computed(() => form.siteId !== '') const isContractValid = computed(() => form.contractId !== '') -const isContractNatureValid = computed(() => ['CDI', 'CDD', 'INTERIM'].includes(form.contractNature)) +const isContractNatureValid = computed(() => isContractNature(form.contractNature)) const isContractStartDateValid = computed(() => form.contractStartDate !== '') -const requiresContractEndDate = computed(() => form.contractNature === 'CDD' || form.contractNature === 'INTERIM') +const requiresContractEndDateComputed = computed(() => requiresContractEndDate(form.contractNature)) const isContractEndDateValid = computed(() => { - if (!requiresContractEndDate.value) return true + if (!requiresContractEndDateComputed.value) return true return form.contractEndDate !== '' }) const isFormValid = computed( @@ -433,7 +428,7 @@ const handleSubmit = async () => { contractId: Number(form.contractId), contractNature: form.contractNature, contractStartDate: form.contractStartDate, - contractEndDate: requiresContractEndDate.value ? form.contractEndDate : null + contractEndDate: requiresContractEndDateComputed.value ? form.contractEndDate : null }) } @@ -464,7 +459,7 @@ watch(isDrawerOpen, (isOpen) => { } }) -watch(requiresContractEndDate, (required) => { +watch(requiresContractEndDateComputed, (required) => { if (!required) { form.contractEndDate = '' } diff --git a/frontend/services/employees.ts b/frontend/services/employees.ts index 3b8c92f..c0eaad4 100644 --- a/frontend/services/employees.ts +++ b/frontend/services/employees.ts @@ -56,7 +56,7 @@ export const updateEmployee = async ( firstName: string lastName: string siteId?: number | null - contractId: number + contractId?: number contractNature?: 'CDI' | 'CDD' | 'INTERIM' contractStartDate?: string contractEndDate?: string | null @@ -64,16 +64,27 @@ export const updateEmployee = async ( } ) => { const api = useApi() - return api.patch(`/employees/${id}`, { + const body: Record = { firstName: payload.firstName, lastName: payload.lastName, site: payload.siteId ? `/api/sites/${payload.siteId}` : null, - contract: `/api/contracts/${payload.contractId}`, - contractNature: payload.contractNature, - contractStartDate: payload.contractStartDate, - contractEndDate: payload.contractEndDate ?? null, displayOrder: payload.displayOrder - }, { + } + + if (payload.contractId !== undefined) { + body.contract = `/api/contracts/${payload.contractId}` + } + if (payload.contractNature !== undefined) { + body.contractNature = payload.contractNature + } + if (payload.contractStartDate !== undefined) { + body.contractStartDate = payload.contractStartDate + } + if (payload.contractEndDate !== undefined) { + body.contractEndDate = payload.contractEndDate ?? null + } + + return api.patch(`/employees/${id}`, body, { toastSuccessKey: 'success.employee.update', toastErrorKey: 'errors.employee.update' }) diff --git a/frontend/utils/contract.ts b/frontend/utils/contract.ts new file mode 100644 index 0000000..6b8a3a2 --- /dev/null +++ b/frontend/utils/contract.ts @@ -0,0 +1,17 @@ +export const CONTRACT_NATURES = ['CDI', 'CDD', 'INTERIM'] as const + +export type ContractNature = (typeof CONTRACT_NATURES)[number] + +export const contractNatureLabel = (value?: ContractNature) => { + if (value === 'CDD') return 'CDD' + if (value === 'INTERIM') return 'Intérim' + return 'CDI' +} + +export const requiresContractEndDate = (nature: ContractNature) => { + return nature === 'CDD' || nature === 'INTERIM' +} + +export const isContractNature = (value: string): value is ContractNature => { + return (CONTRACT_NATURES as readonly string[]).includes(value) +} diff --git a/frontend/utils/date.ts b/frontend/utils/date.ts index 2acd56f..d165856 100644 --- a/frontend/utils/date.ts +++ b/frontend/utils/date.ts @@ -6,6 +6,17 @@ export const toYmd = (year: number, month: number, day: number) => { export const normalizeDate = (value: string) => value.slice(0, 10) +export const formatYmdToFr = (value: string) => { + const [year, month, day] = value.split('-') + if (!year || !month || !day) return value + return `${day}/${month}/${year}` +} + +export const formatNullableYmdToFr = (value?: string | null, fallback = 'En cours') => { + if (!value) return fallback + return formatYmdToFr(value) +} + export const parseYmd = (value: string) => { const [year, month, day] = value.split('-').map(Number) if (!year || !month || !day) return null diff --git a/src/State/EmployeeWriteProcessor.php b/src/State/EmployeeWriteProcessor.php index 1d27a18..998560f 100644 --- a/src/State/EmployeeWriteProcessor.php +++ b/src/State/EmployeeWriteProcessor.php @@ -70,26 +70,53 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface return $result; } - $startDate = $requestedStartDate ?? $today; - $todayPeriod = $this->periodRepository->findOneCoveringDate($data, $today); - $nature = $requestedContractNature ?? $todayPeriod?->getContractNatureEnum() ?? ContractNature::CDI; - $endDate = $requestedEndDate; - $this->assertPeriodDates($startDate, $endDate, $nature); + $todayPeriod = $this->periodRepository->findOneCoveringDate($data, $today); + $currentPeriodContract = $todayPeriod?->getContract(); + $contractChanged = $currentPeriodContract instanceof Contract + ? $currentPeriodContract->getId() !== $currentContract->getId() + : true; + $isCloseOnlyRequest = !$contractChanged + && null === $requestedStartDate + && null === $requestedContractNature + && null !== $requestedEndDate; - if ( - null !== $todayPeriod - && null === $todayPeriod->getEndDate() - && $todayPeriod->getStartDate()->format('Y-m-d') === $startDate->format('Y-m-d') - ) { - $todayPeriod->setContract($currentContract); - $todayPeriod->setContractNature($nature); - $todayPeriod->setEndDate($endDate); + if ($isCloseOnlyRequest) { + if (null === $todayPeriod) { + throw new UnprocessableEntityHttpException('No active contract period to close.'); + } + + $currentNature = $todayPeriod->getContractNatureEnum(); + $this->assertPeriodDates($todayPeriod->getStartDate(), $requestedEndDate, $currentNature, true); + + $currentEndDate = $todayPeriod->getEndDate(); + if (null !== $currentEndDate && $requestedEndDate > $currentEndDate) { + throw new UnprocessableEntityHttpException('contractEndDate cannot be increased on current contract.'); + } + + $todayPeriod->setEndDate($requestedEndDate); $this->entityManager->flush(); return $result; } - $this->periodRepository->closeOpenPeriods($data, $startDate->modify('-1 day')); + $startDate = $requestedStartDate ?? $today; + $nature = $requestedContractNature ?? $todayPeriod?->getContractNatureEnum() ?? ContractNature::CDI; + $endDate = $requestedEndDate; + $this->assertPeriodDates($startDate, $endDate, $nature); + + if (null !== $todayPeriod) { + $currentEndDate = $todayPeriod->getEndDate(); + if (null === $currentEndDate) { + if ($startDate <= $todayPeriod->getStartDate()) { + throw new UnprocessableEntityHttpException('contractStartDate must be after current contract start date.'); + } + + $todayPeriod->setEndDate($startDate->modify('-1 day')); + } elseif ($startDate <= $currentEndDate) { + throw new UnprocessableEntityHttpException('contractStartDate must be after current contract end date.'); + } + } + $this->createPeriod($data, $currentContract, $startDate, $endDate, $nature); $this->entityManager->flush(); @@ -179,7 +206,8 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface private function assertPeriodDates( DateTimeImmutable $startDate, ?DateTimeImmutable $endDate, - ContractNature $nature + ContractNature $nature, + bool $allowCdiEndDate = false ): void { if (null !== $endDate && $endDate < $startDate) { throw new UnprocessableEntityHttpException('contractEndDate cannot be before contractStartDate.'); @@ -189,7 +217,7 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface throw new UnprocessableEntityHttpException('contractEndDate is required for CDD and INTERIM.'); } - if (ContractNature::CDI === $nature && null !== $endDate) { + if (!$allowCdiEndDate && ContractNature::CDI === $nature && null !== $endDate) { throw new UnprocessableEntityHttpException('contractEndDate must be empty for CDI.'); } } -- 2.39.5 From 20a651895f3dba4eb4269ff507cc1aef13fa7135 Mon Sep 17 00:00:00 2001 From: tristan Date: Thu, 5 Mar 2026 14:09:50 +0100 Subject: [PATCH 06/12] =?UTF-8?q?feat=20:=20ajout=20de=20la=20gestion=20Co?= =?UTF-8?q?ng=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/SIRH.iml | 2 + .idea/php.xml | 2 + .idea/sqldialects.xml | 6 + composer.json | 1 + composer.lock | 171 ++++- config/bundles.php | 2 + config/services.yaml | 2 + doc/functional-rules.md | 57 +- doc/leave-rollover.md | 226 ++++++ frontend/components/employees/ContractTab.vue | 13 + frontend/components/employees/LeaveTab.vue | 236 ++++++- frontend/composables/useEmployeeDetailPage.ts | 47 +- frontend/pages/employees/[id].vue | 14 +- frontend/services/absences.ts | 4 + .../services/dto/employee-leave-summary.ts | 14 + frontend/services/employee-leave-summary.ts | 10 + frontend/services/employees.ts | 4 + makefile | 2 +- migrations/Version20260304140000.php | 26 + migrations/Version20260304153000.php | 29 + migrations/Version20260304170000.php | 48 ++ migrations/Version20260304173000.php | 250 +++++++ src/ApiResource/EmployeeLeaveSummary.php | 34 + src/Command/LeaveRolloverCommand.php | 188 +++++ src/DataFixtures/AbsenceFixtures.php | 97 +++ src/DataFixtures/AbsenceTypeFixtures.php | 39 ++ src/DataFixtures/ContractFixtures.php | 55 ++ .../EmployeeContractPeriodFixtures.php | 91 +++ src/DataFixtures/EmployeeFixtures.php | 71 ++ .../EmployeeLeaveBalanceFixtures.php | 80 +++ src/DataFixtures/FixtureReferences.php | 31 + src/DataFixtures/SiteFixtures.php | 26 + src/DataFixtures/UserFixtures.php | 26 + src/Entity/Absence.php | 2 +- src/Entity/Employee.php | 15 + src/Entity/EmployeeContractPeriod.php | 15 + src/Entity/EmployeeLeaveBalance.php | 238 +++++++ src/Enum/LeaveRuleCode.php | 12 + src/Repository/AbsenceRepository.php | 25 + ...eContractPeriodReadRepositoryInterface.php | 14 + .../EmployeeContractPeriodRepository.php | 55 +- .../EmployeeLeaveBalanceRepository.php | 59 ++ .../EmployeeContractChangeRequest.php | 34 + .../EmployeeContractChangeRequestFactory.php | 48 ++ .../EmployeeContractPeriodBuilder.php | 30 + .../EmployeeContractPeriodManager.php | 96 +++ ...EmployeeContractPeriodManagerInterface.php | 37 + .../EmployeeContractPeriodValidator.php | 63 ++ .../Leave/LeaveBalanceComputationService.php | 384 ++++++++++ src/State/EmployeeLeaveSummaryProvider.php | 653 ++++++++++++++++++ src/State/EmployeeWriteProcessor.php | 165 +---- symfony.lock | 12 + ...ployeeContractChangeRequestFactoryTest.php | 72 ++ .../EmployeeContractPeriodValidatorTest.php | 118 ++++ tests/State/EmployeeWriteProcessorTest.php | 264 +++++++ 55 files changed, 4171 insertions(+), 144 deletions(-) create mode 100644 .idea/sqldialects.xml create mode 100644 doc/leave-rollover.md create mode 100644 frontend/services/dto/employee-leave-summary.ts create mode 100644 frontend/services/employee-leave-summary.ts create mode 100644 migrations/Version20260304140000.php create mode 100644 migrations/Version20260304153000.php create mode 100644 migrations/Version20260304170000.php create mode 100644 migrations/Version20260304173000.php create mode 100644 src/ApiResource/EmployeeLeaveSummary.php create mode 100644 src/Command/LeaveRolloverCommand.php create mode 100644 src/DataFixtures/AbsenceFixtures.php create mode 100644 src/DataFixtures/AbsenceTypeFixtures.php create mode 100644 src/DataFixtures/ContractFixtures.php create mode 100644 src/DataFixtures/EmployeeContractPeriodFixtures.php create mode 100644 src/DataFixtures/EmployeeFixtures.php create mode 100644 src/DataFixtures/EmployeeLeaveBalanceFixtures.php create mode 100644 src/DataFixtures/FixtureReferences.php create mode 100644 src/DataFixtures/SiteFixtures.php create mode 100644 src/DataFixtures/UserFixtures.php create mode 100644 src/Entity/EmployeeLeaveBalance.php create mode 100644 src/Enum/LeaveRuleCode.php create mode 100644 src/Repository/Contract/EmployeeContractPeriodReadRepositoryInterface.php create mode 100644 src/Repository/EmployeeLeaveBalanceRepository.php create mode 100644 src/Service/Contracts/EmployeeContractChangeRequest.php create mode 100644 src/Service/Contracts/EmployeeContractChangeRequestFactory.php create mode 100644 src/Service/Contracts/EmployeeContractPeriodBuilder.php create mode 100644 src/Service/Contracts/EmployeeContractPeriodManager.php create mode 100644 src/Service/Contracts/EmployeeContractPeriodManagerInterface.php create mode 100644 src/Service/Contracts/EmployeeContractPeriodValidator.php create mode 100644 src/Service/Leave/LeaveBalanceComputationService.php create mode 100644 src/State/EmployeeLeaveSummaryProvider.php create mode 100644 tests/Service/Contracts/EmployeeContractChangeRequestFactoryTest.php create mode 100644 tests/Service/Contracts/EmployeeContractPeriodValidatorTest.php create mode 100644 tests/State/EmployeeWriteProcessorTest.php diff --git a/.idea/SIRH.iml b/.idea/SIRH.iml index c7476c0..47ac152 100644 --- a/.idea/SIRH.iml +++ b/.idea/SIRH.iml @@ -152,6 +152,8 @@ + + diff --git a/.idea/php.xml b/.idea/php.xml index 6df8250..c4734e1 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -153,6 +153,8 @@ + + diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml new file mode 100644 index 0000000..3fadc3d --- /dev/null +++ b/.idea/sqldialects.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/composer.json b/composer.json index bf8f442..c7ed2ef 100644 --- a/composer.json +++ b/composer.json @@ -87,6 +87,7 @@ } }, "require-dev": { + "doctrine/doctrine-fixtures-bundle": "^4.3", "friendsofphp/php-cs-fixer": "^3.93", "phpunit/phpunit": "^12.5" } diff --git a/composer.lock b/composer.lock index 58cd62f..cb7c9e7 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "71d28cc0a29fa3f385b067186aa43678", + "content-hash": "b540b6cb25ef55c5eebccb57c76da584", "packages": [ { "name": "api-platform/doctrine-common", @@ -8504,6 +8504,175 @@ ], "time": "2024-05-06T16:37:16+00:00" }, + { + "name": "doctrine/data-fixtures", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/data-fixtures.git", + "reference": "7a615ba135e45d67674bb623d90f34f6c7b6bd97" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/data-fixtures/zipball/7a615ba135e45d67674bb623d90f34f6c7b6bd97", + "reference": "7a615ba135e45d67674bb623d90f34f6c7b6bd97", + "shasum": "" + }, + "require": { + "doctrine/persistence": "^3.1 || ^4.0", + "php": "^8.1", + "psr/log": "^1.1 || ^2 || ^3" + }, + "conflict": { + "doctrine/dbal": "<3.5 || >=5", + "doctrine/orm": "<2.14 || >=4", + "doctrine/phpcr-odm": "<1.3.0" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "doctrine/dbal": "^3.5 || ^4", + "doctrine/mongodb-odm": "^1.3.0 || ^2.0.0", + "doctrine/orm": "^2.14 || ^3", + "ext-sqlite3": "*", + "fig/log-test": "^1", + "phpstan/phpstan": "2.1.31", + "phpunit/phpunit": "10.5.45 || 12.4.0", + "symfony/cache": "^6.4 || ^7", + "symfony/var-exporter": "^6.4 || ^7" + }, + "suggest": { + "alcaeus/mongo-php-adapter": "For using MongoDB ODM 1.3 with PHP 7 (deprecated)", + "doctrine/mongodb-odm": "For loading MongoDB ODM fixtures", + "doctrine/orm": "For loading ORM fixtures", + "doctrine/phpcr-odm": "For loading PHPCR ODM fixtures" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\DataFixtures\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + } + ], + "description": "Data Fixtures for all Doctrine Object Managers", + "homepage": "https://www.doctrine-project.org", + "keywords": [ + "database" + ], + "support": { + "issues": "https://github.com/doctrine/data-fixtures/issues", + "source": "https://github.com/doctrine/data-fixtures/tree/2.2.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdata-fixtures", + "type": "tidelift" + } + ], + "time": "2025-10-17T20:06:20+00:00" + }, + { + "name": "doctrine/doctrine-fixtures-bundle", + "version": "4.3.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/DoctrineFixturesBundle.git", + "reference": "9e013ed10d49bf7746b07204d336384a7d9b5a4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/DoctrineFixturesBundle/zipball/9e013ed10d49bf7746b07204d336384a7d9b5a4d", + "reference": "9e013ed10d49bf7746b07204d336384a7d9b5a4d", + "shasum": "" + }, + "require": { + "doctrine/data-fixtures": "^2.2", + "doctrine/doctrine-bundle": "^2.2 || ^3.0", + "doctrine/orm": "^2.14.0 || ^3.0", + "doctrine/persistence": "^2.4 || ^3.0 || ^4.0", + "php": "^8.1", + "psr/log": "^2 || ^3", + "symfony/config": "^6.4 || ^7.0 || ^8.0", + "symfony/console": "^6.4 || ^7.0 || ^8.0", + "symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0", + "symfony/deprecation-contracts": "^2.1 || ^3", + "symfony/doctrine-bridge": "^6.4.16 || ^7.1.9 || ^8.0", + "symfony/http-kernel": "^6.4 || ^7.0 || ^8.0" + }, + "conflict": { + "doctrine/dbal": "< 3" + }, + "require-dev": { + "doctrine/coding-standard": "14.0.0", + "phpstan/phpstan": "2.1.11", + "phpunit/phpunit": "^10.5.38 || 11.4.14" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Doctrine\\Bundle\\FixturesBundle\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Doctrine Project", + "homepage": "https://www.doctrine-project.org" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony DoctrineFixturesBundle", + "homepage": "https://www.doctrine-project.org", + "keywords": [ + "Fixture", + "persistence" + ], + "support": { + "issues": "https://github.com/doctrine/DoctrineFixturesBundle/issues", + "source": "https://github.com/doctrine/DoctrineFixturesBundle/tree/4.3.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdoctrine-fixtures-bundle", + "type": "tidelift" + } + ], + "time": "2025-12-03T16:05:42+00:00" + }, { "name": "evenement/evenement", "version": "v3.0.2", diff --git a/config/bundles.php b/config/bundles.php index 746539c..1c5d047 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -4,6 +4,7 @@ declare(strict_types=1); use ApiPlatform\Symfony\Bundle\ApiPlatformBundle; use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; +use Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle; use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle; use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle; use Nelmio\CorsBundle\NelmioCorsBundle; @@ -22,4 +23,5 @@ return [ ApiPlatformBundle::class => ['all' => true], LexikJWTAuthenticationBundle::class => ['all' => true], MonologBundle::class => ['all' => true], + DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], ]; diff --git a/config/services.yaml b/config/services.yaml index 9a7408a..f1d8a11 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -23,8 +23,10 @@ services: resource: '../src/' App\Repository\Contract\AbsenceReadRepositoryInterface: '@App\Repository\AbsenceRepository' + App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface: '@App\Repository\EmployeeContractPeriodRepository' App\Repository\Contract\EmployeeScopedRepositoryInterface: '@App\Repository\EmployeeRepository' App\Repository\Contract\WorkHourReadRepositoryInterface: '@App\Repository\WorkHourRepository' + App\Service\Contracts\EmployeeContractPeriodManagerInterface: '@App\Service\Contracts\EmployeeContractPeriodManager' # add more service definitions when explicit configuration is needed # please note that last definitions always *replace* previous ones diff --git a/doc/functional-rules.md b/doc/functional-rules.md index e3f5509..c1d6a89 100644 --- a/doc/functional-rules.md +++ b/doc/functional-rules.md @@ -2,6 +2,9 @@ Ce document centralise les règles métier actuellement implémentées dans l'application. +Document complementaire (rollover conges et checklist de lancement): +- `doc/leave-rollover.md` + ## 1) Utilisateurs et accès - `ROLE_ADMIN` @@ -138,11 +141,61 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer. - action `Clôturer`: - bouton actif uniquement s'il existe un contrat en cours non déjà clôturé à la date du jour - ouvre un drawer en lecture seule (type/temps de travail/date de début) - - seule la date de fin est saisissable (préremplie à aujourd'hui) - - backend: en mode clôture, seule `contractEndDate` est acceptée + - champs saisissables: + - `contractEndDate` (prérempli à aujourd'hui) + - `contractPaidLeaveSettled` (checkbox "Soldé dans le solde de tout compte") + - backend: en mode clôture, le flag `contractPaidLeaveSettled` est persisté sur la période clôturée - action `Ajouter`: - conserve le flux d'ajout d'un nouveau contrat via drawer dédié - disponible uniquement s'il n'y a pas de contrat en cours, ou si le contrat en cours a déjà une date de fin + - onglet `Congé`: + - endpoint de synthèse: `GET /api/employees/{id}/leave-summary?year=YYYY` + - phase 1 métier (`CDI`/`CDD` non forfait + `FORFAIT`): + - exercice CP: + - `CDI`/`CDD` non forfait: du `1er juin (YYYY-1)` au `31 mai (YYYY)` (paramètre `year` = année de fin d'exercice) + - `FORFAIT`: du `1er janvier (YYYY)` au `31 décembre (YYYY)` (paramètre `year` = année civile) + - contrats `39h` / `35h` / `25h` (et plus largement CDI/CDD non forfait hors `4h`): + - acquis annuel CP: `25` + - acquis annuel samedi: `5` + - en cours d'acquisition jours: `25/12 = 2,08` jours/mois + - en cours d'acquisition samedis: `5/12 = 0,42` samedi/mois (non detaille en UI) + - samedis acquis affiches: uniquement `opening_saturdays` (report N-1) + - contrat `4h`: + - acquis annuel CP: `10` + - acquis annuel samedi: `0` + - en cours d'acquisition: `0.83` jour/mois + - contrat `FORFAIT`: + - base annuelle: `jours ouvrés de l'exercice (lundi-vendredi, hors jours fériés métropole) - 218` + - prorata: en cas de démarrage/fin de contrat en cours d'année civile, le calcul ne couvre que l'intervalle actif du contrat dans l'année + - reste à prendre: `acquis - absences` (toutes absences, demi-journées incluses) + - pas de samedi (`0`) + - pas de jours en cours d'acquisition (`0`) + - fractionné: `0` (saisie RH ultérieure, non calculée automatiquement) + - pour `CDI`/`CDD` non forfait: + - pris CP: basé sur absences de type code `C` (CONGÉ), en tenant compte des demi-journées + - samedi pris: absences `C` posées le samedi (demi-journée incluse) + - restants = acquis - pris (borné à 0) + - pour `FORFAIT`: + - pris: basé sur toutes les absences (demi-journées incluses) + - restants = acquis - pris (borné à 0) + - report annuel: + - le reliquat (`restants`) de l'exercice précédent est reporté dans les acquis de l'exercice courant + - pour `CDI`/`CDD` non forfait: report séparé jours + samedis + - pour `FORFAIT`: report uniquement sur les jours + - si un solde d'ouverture existe en base (`employee_leave_balances`) pour l'exercice courant, ce solde devient la source prioritaire du report + - si une clôture de contrat est marquée `contractPaidLeaveSettled=true` sur l'exercice précédent, le report vers l'exercice suivant est remis à `0` + - si une clôture `contractPaidLeaveSettled=true` existe dans l'exercice courant, le calcul est réinitialisé à partir du lendemain de cette clôture (pas de continuité intra-exercice) + - lecture des compteurs: + - `acquis` = droits reportés de l'exercice N-1 (après application des règles de soldé) + - `en cours d'acquisition` = total droits générés sur l'exercice N (jours + samedis en cours), sans detail séparé en UI + - règle de consommation: + - les absences s'imputent d'abord sur `acquis`, puis sur `en cours d'acquisition` + - la prise sur `en cours d'acquisition` est autorisée (usage anticipé) + - `en cours d'acquisition` peut devenir négatif si la prise dépasse le généré (ex: `2.08 - 3 = -0.92`), puis se reconstitue avec les acquisitions suivantes + - date d'arret de calcul: + - les compteurs sont calculés jusqu'au dernier jour du mois précédent (le mois en cours est exclu) + - exemple: au `04/03/2026`, l'arret de calcul est le `28/02/2026` (ou `29/02` en année bissextile) + - hors périmètre phase 1: `INTERIM` (retour non supporté) ## 10) Notifications diff --git a/doc/leave-rollover.md b/doc/leave-rollover.md new file mode 100644 index 0000000..50e953e --- /dev/null +++ b/doc/leave-rollover.md @@ -0,0 +1,226 @@ +# Rollover Conges - Regles et Mise en Production + +Document de reference pour expliquer le fonctionnement metier du report N-1 et preparer le lancement en production. + +## 1) Objectif + +Eviter les recalculs "depuis le debut du contrat" et fiabiliser les soldes. + +Principe: +- le solde est stocké par exercice +- au changement d'exercice, on ouvre la nouvelle période avec un "solde d'ouverture" (report N-1) +- un indicateur de cloture (`contractPaidLeaveSettled`) permet de couper la continuité entre 2 contrats + +## 2) Exercices metier + +- `CDI` / `CDD` non forfait: + - exercice: `1er juin` au `31 mai` + - `year` = annee de fin d'exercice (ex: `2026` = 01/06/2025 -> 31/05/2026) +- `FORFAIT`: + - exercice: `1er janvier` au `31 decembre` + - `year` = annee civile +- `INTERIM`: + - hors perimetre conges + +## 3) Logique de compteurs + +- `acquis`: + - correspond au report N-1 (solde d'ouverture) +- `en cours d'acquisition`: + - correspond aux droits generes sur l'exercice en cours +- `pris`: + - non forfait: absences type `C` (conge) + - forfait: toutes absences +- `restant`: + - `acquis + en_cours - pris` (borne a 0 dans l'affichage) + +## 4) Effet du "solde de tout compte" + +Le champ de cloture `contractPaidLeaveSettled` est saisi lors de la fermeture d'une periode contrat. + +- `false`: + - continuite des droits entre contrats +- `true`: + - pas de reprise des droits precedents + - reset de continuite au lendemain de la date de cloture + +## 5) Table cible + +Table `employee_leave_balances` (une ligne par employe et exercice): +- `employee_id` +- `rule_code` (`CDI_CDD_NON_FORFAIT` ou `FORFAIT_218`) +- `year` +- `opening_days` +- `opening_saturdays` +- `accrued_days` +- `accrued_saturdays` (optionnel selon implementation) +- `taken_days` +- `taken_saturdays` +- `closing_days` +- `closing_saturdays` +- `is_locked` +- `created_at`, `updated_at` + +Contrainte unique recommandee: +- `(employee_id, rule_code, year)` + +Etat implementation: +- la table est creee +- le calcul de synthese conges lit en priorite `opening_days/opening_saturdays` de cette table quand une ligne existe pour `(employee, rule_code, year)` +- si aucune ligne n'existe, le calcul reste base sur le report dynamique N-1 +- la commande `app:leave:rollover` calcule aussi le report dynamique N-1 si la ligne N-1 n'est pas encore persistée (pas de reset a 0 par defaut) + +### Definition des colonnes + +- `employee_id`: + - identifiant employe (FK vers `employees`) + - une ligne de solde par employe / regle / exercice +- `rule_code`: + - code de regle appliquee (`CDI_CDD_NON_FORFAIT`, `FORFAIT_218`) + - permet de savoir quelles regles de calcul sont utilisees +- `year`: + - annee d'exercice + - non forfait: annee de fin d'exercice (`2026` = 01/06/2025 -> 31/05/2026) + - forfait: annee civile (`2026` = 01/01/2026 -> 31/12/2026) +- `opening_days`: + - report N-1 en jours (solde d'ouverture) +- `opening_saturdays`: + - report N-1 "samedis" (0 pour forfait) +- `accrued_days`: + - droits generes sur l'exercice courant (N) +- `accrued_saturdays`: + - droits samedis generes sur N (0 pour forfait) +- `taken_days`: + - jours poses sur l'exercice +- `taken_saturdays`: + - samedis poses sur l'exercice (0 pour forfait) +- `closing_days`: + - solde de cloture jours (`opening_days + accrued_days - taken_days`) +- `closing_saturdays`: + - solde de cloture samedis (`opening_saturdays + accrued_saturdays - taken_saturdays`) +- `is_locked`: + - `false` sur exercice ouvert (recalcul possible) + - `true` apres validation RH (exercice fige) +- `created_at`, `updated_at`: + - trace technique creation / mise a jour + +## 6) Rollover automatique + +Commande quotidienne (cron) idempotente. + +- commande Symfony: `php bin/console app:leave:rollover` +- comportement date metier: + - le `01/01`: traite uniquement `FORFAIT_218` + - le `01/06`: traite uniquement `CDI_CDD_NON_FORFAIT` + - les autres jours: sortie sans action +- option manuelle: `--force` pour executer hors date metier (reprise/correction) + +Date d'effet: +- forfait: au `1er janvier` +- non forfait: au `1er juin` + +Traitement par employe: +1. lire l'exercice precedent +2. determiner le report: + - si cloture `paidLeaveSettled=true` sur la periode precedente => report `0` + - sinon report = `closing` exercice precedent +3. creer la ligne du nouvel exercice avec ce report en `opening_*` +4. initialiser `accrued/taken/closing` pour le nouvel exercice + +## 7) Donnees a fournir au go-live + +La RH doit fournir un import d'ouverture: + +Colonnes minimales: +- `employee_identifier` (id interne ou matricule) +- `rule_code` +- `year` +- `opening_days` +- `opening_saturdays` (0 pour forfait) +- `source_date` (date de reference du relevé RH) +- `comment` (optionnel) + +Format recommande: +- CSV UTF-8 +- separateur `;` +- decimales en point (`7.5`) + +Exemple: +```csv +employee_id;rule_code;year;opening_days;opening_saturdays;source_date;comment +42;CDI_CDD_NON_FORFAIT;2026;12.5;2;2026-05-31;Reprise fichier RH +17;FORFAIT_218;2026;8;0;2025-12-31;Reprise fichier RH +``` + +## 8) Checklist mise en prod + +1. Valider le mapping employe RH -> employe applicatif +2. Importer les soldes d'ouverture N-1 +3. Verifier 5 cas metier: + - CDI simple sans changement de contrat + - CDD -> CDI avec `paidLeaveSettled=false` + - CDD -> CDI avec `paidLeaveSettled=true` + - Forfait sur annee complete + - Forfait avec debut en cours d'annee +4. Activer le cron de rollover +5. Geler (`is_locked`) les exercices historicises valides + +Exemple cron (tous les jours a 02:10): +Dev +```cron +10 2 * * * cd /var/www/html && php bin/console app:leave:rollover --no-interaction >> var/log/leave-rollover.log 2>&1 +``` +Prod +```cron +10 2 * * * cd /var/www/sirh && php bin/console app:leave:rollover --no-interaction >> var/log/leave-rollover.log 2>&1 +``` +Explication de la ligne cron: +- `10 2 * * *`: planification + - `10` = minute + - `2` = heure + - `*` = tous les jours du mois + - `*` = tous les mois + - `*` = tous les jours de la semaine +- `cd /var/www/html`: se place dans le dossier de l application Symfony +- `php bin/console app:leave:rollover --no-interaction`: execute le rollover sans demander de confirmation + - hors `01/01` et `01/06`, la commande sort en no-op (normal) +- `>> var/log/leave-rollover.log`: ajoute la sortie standard dans le fichier de log (sans ecraser l historique) +- `2>&1`: redirige aussi les erreurs dans le meme fichier de log + +Execution manuelle forcee: +```bash +php bin/console app:leave:rollover --force --no-interaction +``` + +Exemple de verification rapide: +```bash +tail -n 50 /var/www/html/var/log/leave-rollover.log +``` + +## 9) Points de vigilance + +- Ne jamais recalculer les soldes historiques apres validation RH sans procedure explicite +- Garder une trace de toute correction manuelle (auteur, date, motif) +- Aligner strictement les regles UI et API sur les memes compteurs (pas de formule differente front/back) + +## 10) Regle de consommation des droits + +Regle metier: +- un employe peut poser des conges en cours d'acquisition +- la consommation se fait par ordre: + 1. `acquis` (report N-1) + 2. `en cours d'acquisition` (droits N) + +Effet attendu: +- si `acquis = 0` et `en cours = 7.5`, puis prise de `7`, alors: + - `acquis` reste `0` + - `en cours` devient `0.5` +- si `acquis = 0` et `en cours = 2.5`, puis prise de `3`, alors: + - `acquis` reste `0` + - `en cours` devient `-0.5` (dette) + - le mois suivant, une acquisition de `2.5` ramené `en cours` a `2.0` + +Formule de lecture recommandée: +- `restant_acquis = max(0, acquis - pris)` +- `reste_a_imputer_sur_en_cours = max(0, pris - acquis)` +- `restant_en_cours = en_cours - reste_a_imputer_sur_en_cours` (valeur negative autorisee) diff --git a/frontend/components/employees/ContractTab.vue b/frontend/components/employees/ContractTab.vue index 9caf238..b464f26 100644 --- a/frontend/components/employees/ContractTab.vue +++ b/frontend/components/employees/ContractTab.vue @@ -86,6 +86,18 @@

La date de fin est obligatoire.

+
+ +
+