Compare commits

...

4 Commits

Author SHA1 Message Date
gitea-actions
107417a571 chore: bump version to v0.1.15
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m16s
2026-02-27 09:22:05 +00:00
5ff7e356be fix : validation RH qui invalidé les sites
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-02-27 10:21:54 +01:00
gitea-actions
635e24e9e1 chore: bump version to v0.1.14
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m11s
2026-02-26 16:15:25 +00:00
4d90f2cb42 feat : ajout du nouveau système de contrat et ajout de filtre d'impression
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-02-26 17:15:13 +01:00
26 changed files with 862 additions and 115 deletions

134
AGENTS.md
View File

@@ -1,6 +1,6 @@
# AGENTS.md # AGENTS.md
État des lieux opérationnel du projet SIRH (backend + frontend), à utiliser comme base sur les prochaines interventions. État des lieux opérationnel du projet SIRH (backend + frontend), mis à jour après les évolutions sur heures/absences/validations.
## 1) Stack et structure ## 1) Stack et structure
@@ -25,81 +25,107 @@ Arborescence clé:
### Contrats ### Contrats
- Entité: `Contract` - Entité: `Contract`
- Champs principaux: `name`, `trackingMode`, `weeklyHours`, `isActive` - Champs principaux: `name`, `trackingMode`, `weeklyHours`, `isActive`, `type`
- `trackingMode`: - `trackingMode`:
- `TIME`: suivi par heures - `TIME`: suivi en heures
- `PRESENCE`: suivi présence demi-journées/journées - `PRESENCE`: suivi en demi-journées/journées
- Enums backend: - Enums backend:
- `App\Enum\TrackingMode` - `App\Enum\TrackingMode`
- `App\Enum\ContractType` (`FORFAIT`, `35H`, `39H`, `INTERIM`, `CUSTOM`) - `App\Enum\ContractType` (`FORFAIT`, `THIRTY_FIVE_HOURS`, `THIRTY_NINE_HOURS`, `INTERIM`, `CUSTOM`)
- `Contract::getType()` est exposé en API (`contract:read`, `employee:read`) - Historique de contrat par employé:
- table `employee_contract_periods`
- résolu par `App\Service\Contracts\EmployeeContractResolver`
### Heures / absences ### Heures / absences
- Les absences sont découpées en enregistrements journaliers (pas de période unique stockée). - Les absences sont stockées en **lignes journalières** (découpage automatique dans `AbsenceWriteProcessor`).
- Une ligne dheures validée est verrouillée côté métier. - Les absences `countAsWorkedHours=true` créditent:
- Règles de crédit absence (`countAsWorkedHours=true`) gérées dans `WorkedHoursCreditPolicy`: - minutes (contrats TIME)
- contrats présence: crédit en unités de présence - unités de présence (contrats PRESENCE)
- contrats temps: crédit en minutes selon règles contrat (35h, 39h, 4h, fallback) - Les absences AM/PM effacent les plages horaires concernées.
## 4) Écrans principaux ## 4) Validations (important)
### Page Heures (`frontend/pages/hours.vue`) ### Validation RH (admin)
- Vue Jour + Vue Semaine (semaine réservée admin) - Champ: `work_hours.is_valid`
- Toolbar dédiée: `frontend/components/hours/HoursToolbar.vue` - Endpoint API Platform standard: `PATCH /api/work_hours/{id}`
- Vue jour: `frontend/components/hours/HoursDayView.vue` - Gérée côté front par `updateWorkHourValidation`.
- Vue semaine: `frontend/components/hours/HoursWeekView.vue`
- Logique page: `frontend/composables/useHoursPage.ts`
### Points UX déjà en place ### Validation site (chef de site)
- Toolbar semaine: raccourcis semaine précédente / actuelle / suivante - Champ: `work_hours.is_site_valid`
- Légende absences affichée dans la toolbar (admin + vue semaine) - Endpoint dédié: `PATCH /api/work_hours/{id}/site-validation`
- Cellules semaine avec absence: couleur du type dabsence (plus rouge fixe) - Processor: `src/State/WorkHourSiteValidationProcessor.php`
- Pour user non-admin: restrictions dédition selon validations/absences - Autorisé uniquement aux utilisateurs "Sites" (ni `ROLE_ADMIN`, ni `ROLE_SELF`) dans leur scope site.
## 5) API / calculs hebdo ### Règles de verrouillage
- `is_valid=true`: ligne verrouillée pour tout le monde (admin inclus pour saisie heures/absence; peut toujours décocher validation RH).
- `is_site_valid=true`:
- non-admin: ligne verrouillée (heures + absences)
- admin: ligne éditable
- Toute modification de ligne (heures/présence/absence) remet:
- `is_site_valid=false`
- `is_valid=false`
## 5) Page Heures (front)
- Page: `frontend/pages/hours.vue`
- Composable principal: `frontend/composables/useHoursPage.ts`
- Composants:
- `frontend/components/hours/HoursToolbar.vue`
- `frontend/components/hours/HoursDayView.vue`
- `frontend/components/hours/HoursWeekView.vue`
### Comportement par profil (vue jour)
- Admin:
- colonne RH avec checkbox
- badge `Site validé` affiché près du site
- Chef de site:
- colonne `Validation site` avec checkbox
- colonne RH en lecture (`Validé`/`-`)
- Employé:
- colonne `Validation site` en lecture
- colonne RH en lecture
## 6) Résumé hebdo / calculs
- Provider: `src/State/WorkHourWeeklySummaryProvider.php` - Provider: `src/State/WorkHourWeeklySummaryProvider.php`
- DTOs: - DTOs:
- `src/Dto/WorkHours/WeeklySummaryRow.php` - `src/Dto/WorkHours/WeeklySummaryRow.php`
- `src/Dto/WorkHours/WeeklyDaySummary.php` - `src/Dto/WorkHours/WeeklyDaySummary.php`
- Le résumé hebdo renvoie notamment: - Inclut: contrat résolu par jour, absences, crédits, jour/nuit/total, majorations, récup.
- `trackingMode`
- `contractName`
- `contractType`
- détails journaliers (jour/nuit/total, présence, absence label/couleur)
### Heures supp Règles majorations:
- Règles métier: - Contrats <= 35h: +25% de 35h à 43h, +50% au-delà
- contrats <= 35h: tranche 25% de 35h à 43h, puis 50% au-delà - Contrats >= 39h: +25% de 39h à 43h, +50% au-delà
- contrats >= 39h: tranche 25% de 39h à 43h, puis 50% au-delà - `INTERIM`: pas de 25% / 50% / récup
- contrats `INTERIM`: pas de bonus 25/50 ni récup
## 6) Conventions techniques ## 7) Migrations sensibles
- Favoriser DTO explicites plutôt que tableaux associatifs bruts. - `migrations/Version20260226183000.php`
- Utiliser les interfaces repository dans providers/processors testés. - ajoute `work_hours.is_site_valid BOOLEAN NOT NULL DEFAULT FALSE`
- Centraliser les règles métier dans services/providers backend plutôt que dupliquer côté front. - non destructive (pas de perte de données)
- Front: éviter les calculs métier lourds; consommer les champs API déjà calculés.
## 7) Tests et qualité ## 8) Points de vigilance prod
- Les TU backend passent actuellement via `make test`. - Toujours exécuter migration avant déploiement code backend/front lié.
- Le build frontend passe via `npm run build`. - Après déploiement backend, si route manquante côté runtime:
- À chaque évolution métier: - `php bin/console cache:clear && php bin/console cache:warmup`
- mettre à jour les tests provider/processor/service impactés - Vérifier présence route:
- maintenir la cohérence des DTO TypeScript (`frontend/services/dto/*`) - `/api/work_hours/{id}/site-validation` (PATCH)
## 8) Fichiers sensibles (à lire avant modif) ## 9) Conventions techniques
- Favoriser DTO explicites plutôt que tableaux associatifs.
- Garder règles métier dans backend (providers/processors/services), front orienté affichage/interaction.
- Maintenir alignement backend DTO PHP / frontend DTO TS (`frontend/services/dto/*`).
- Mettre à jour TU si signature constructor/service change.
## 10) Fichiers à lire avant modification
- `src/State/WorkHourBulkUpsertProcessor.php`
- `src/State/AbsenceWriteProcessor.php`
- `src/State/WorkHourSiteValidationProcessor.php`
- `src/State/WorkHourWeeklySummaryProvider.php` - `src/State/WorkHourWeeklySummaryProvider.php`
- `src/Service/WorkHours/WorkedHoursCreditPolicy.php` - `src/Service/WorkHours/WorkedHoursCreditPolicy.php`
- `src/State/AbsenceWriteProcessor.php`
- `src/State/WorkHourBulkUpsertProcessor.php`
- `frontend/composables/useHoursPage.ts` - `frontend/composables/useHoursPage.ts`
- `frontend/components/hours/HoursDayView.vue`
- `frontend/components/hours/HoursWeekView.vue` - `frontend/components/hours/HoursWeekView.vue`
## 9) Décisions de conception actuelles
- Les absences sont stockées par jour (facilite verrouillage/édition fine).
- Les règles de calcul (crédits, majorations, récup) sont portées côté backend.
- Le front reste centré sur laffichage/interaction et réutilise les données enrichies de lAPI.

View File

@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.13' app.version: '0.1.15'

View File

@@ -54,6 +54,48 @@
</p> </p>
</div> </div>
<div class="space-y-2">
<p class="text-md font-semibold text-neutral-700">
Type de contrat <span class="text-red-600">*</span>
</p>
<div class="flex flex-wrap gap-4 rounded-md border border-neutral-300 px-3 py-2">
<div v-for="nature in contractNatures" :key="nature.value" class="flex items-center gap-2">
<label class="text-md" :for="`print-contract-nature-${nature.value}`">{{ nature.label }}</label>
<input
:id="`print-contract-nature-${nature.value}`"
v-model="printForm.contractNatures"
:value="nature.value"
type="checkbox"
class="h-4 w-4"
/>
</div>
</div>
<p v-if="showContractNaturesError" class="text-sm text-red-600">
Sélectionne au moins un type de contrat.
</p>
</div>
<div class="space-y-2">
<p class="text-md font-semibold text-neutral-700">
Temps de travail <span class="text-red-600">*</span>
</p>
<div class="flex flex-wrap gap-4 rounded-md border border-neutral-300 px-3 py-2">
<div v-for="workContract in workContracts" :key="workContract.id" class="flex items-center gap-2">
<label class="text-md" :for="`print-work-contract-${workContract.id}`">{{ workContract.name }}</label>
<input
:id="`print-work-contract-${workContract.id}`"
v-model="printForm.workContractIds"
:value="workContract.id"
type="checkbox"
class="h-4 w-4"
/>
</div>
</div>
<p v-if="showWorkContractsError" class="text-sm text-red-600">
Sélectionne au moins un temps de travail.
</p>
</div>
<div class="flex justify-end gap-3 pt-2"> <div class="flex justify-end gap-3 pt-2">
<button <button
type="button" type="button"
@@ -84,13 +126,27 @@ type SiteOption = {
color: string color: string
} }
type ContractNatureOption = {
value: 'CDI' | 'CDD' | 'INTERIM'
label: string
}
type WorkContractOption = {
id: number
name: string
}
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
sites: SiteOption[] sites: SiteOption[]
contractNatures: ContractNatureOption[]
workContracts: WorkContractOption[]
printForm: { printForm: {
from: string from: string
to: string to: string
siteIds: number[] siteIds: number[]
contractNatures: Array<'CDI' | 'CDD' | 'INTERIM'>
workContractIds: number[]
} }
}>() }>()
@@ -110,19 +166,36 @@ const printForm = toRef(props, 'printForm')
const validationTouched = reactive({ const validationTouched = reactive({
from: false, from: false,
to: false, to: false,
sites: false sites: false,
contractNatures: false,
workContracts: false
}) })
const isFromValid = computed(() => printForm.value.from.trim() !== '') const isFromValid = computed(() => printForm.value.from.trim() !== '')
const isToValid = computed(() => printForm.value.to.trim() !== '') const isToValid = computed(() => printForm.value.to.trim() !== '')
const isSitesValid = computed(() => printForm.value.siteIds.length > 0) const isSitesValid = computed(() => printForm.value.siteIds.length > 0)
const isContractNaturesValid = computed(() => {
if (props.contractNatures.length === 0) return true
return printForm.value.contractNatures.length > 0
})
const isWorkContractsValid = computed(() => {
if (props.workContracts.length === 0) return true
return printForm.value.workContractIds.length > 0
})
const isFormValid = computed( const isFormValid = computed(
() => isFromValid.value && isToValid.value && isSitesValid.value () =>
isFromValid.value &&
isToValid.value &&
isSitesValid.value &&
isContractNaturesValid.value &&
isWorkContractsValid.value
) )
const showFromError = computed(() => validationTouched.from && !isFromValid.value) const showFromError = computed(() => validationTouched.from && !isFromValid.value)
const showToError = computed(() => validationTouched.to && !isToValid.value) const showToError = computed(() => validationTouched.to && !isToValid.value)
const showSitesError = computed(() => validationTouched.sites && !isSitesValid.value) const showSitesError = computed(() => validationTouched.sites && !isSitesValid.value)
const showContractNaturesError = computed(() => validationTouched.contractNatures && !isContractNaturesValid.value)
const showWorkContractsError = computed(() => validationTouched.workContracts && !isWorkContractsValid.value)
const baseInputClass = const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-md text-neutral-900' 'mt-2 w-full rounded-md border px-3 py-2 text-md text-neutral-900'
@@ -150,6 +223,8 @@ const handleSubmit = () => {
validationTouched.from = true validationTouched.from = true
validationTouched.to = true validationTouched.to = true
validationTouched.sites = true validationTouched.sites = true
validationTouched.contractNatures = true
validationTouched.workContracts = true
if (!isFormValid.value) return if (!isFormValid.value) return
emit('submit') emit('submit')
} }
@@ -166,6 +241,8 @@ watch(
validationTouched.from = false validationTouched.from = false
validationTouched.to = false validationTouched.to = false
validationTouched.sites = false validationTouched.sites = false
validationTouched.contractNatures = false
validationTouched.workContracts = false
} }
} }
) )

View File

@@ -52,14 +52,15 @@
</span> </span>
</p> </p>
</div> </div>
<div class="pl-2 min-w-0 self-stretch flex flex-col 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 <p
class="w-full min-w-0 text-sm text-neutral-700 truncate" class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate"
:class="getRowAbsenceLabel(employee.id) ? '' : 'invisible'" :class="getRowAbsenceLabel(employee.id) ? 'text-white' : 'invisible'"
:title="getRowAbsenceLabel(employee.id) || ''" :title="getRowAbsenceLabel(employee.id) || ''"
> :style="getRowAbsenceStyle(employee.id)"
>
{{ getRowAbsenceLabel(employee.id) || '—' }} {{ getRowAbsenceLabel(employee.id) || '—' }}
</p> </p>
<button <button
type="button" type="button"
class="self-start text-left text-xs font-semibold underline" class="self-start text-left text-xs font-semibold underline"
@@ -197,6 +198,7 @@ const props = defineProps<{
onToggleValidationBulk: (checked: boolean) => void onToggleValidationBulk: (checked: boolean) => void
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number } getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number }
getRowAbsenceLabel: (employeeId: number) => string getRowAbsenceLabel: (employeeId: number) => string
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
getPresenceDayValue: (employeeId: number) => string getPresenceDayValue: (employeeId: number) => string
onAbsenceClick: (employeeId: number) => void onAbsenceClick: (employeeId: number) => void
formatMinutes: (minutes: number) => string formatMinutes: (minutes: number) => string

View File

@@ -419,6 +419,12 @@ export const useHoursPage = () => {
return `${dayRow.absenceLabel} (journée)` return `${dayRow.absenceLabel} (journée)`
} }
const getRowAbsenceStyle = (employeeId: number) => {
const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (!dayRow?.absenceLabel) return undefined
return { backgroundColor: dayRow.absenceColor || '#dc2626' }
}
const getPresenceDayValue = (employeeId: number) => { const getPresenceDayValue = (employeeId: number) => {
const row = rows.value[employeeId] const row = rows.value[employeeId]
const basePresence = (row?.isPresentMorning ? 0.5 : 0) + (row?.isPresentAfternoon ? 0.5 : 0) const basePresence = (row?.isPresentMorning ? 0.5 : 0) + (row?.isPresentAfternoon ? 0.5 : 0)
@@ -964,6 +970,7 @@ export const useHoursPage = () => {
toggleValidationBulk, toggleValidationBulk,
getRowMetrics, getRowMetrics,
getRowAbsenceLabel, getRowAbsenceLabel,
getRowAbsenceStyle,
getPresenceDayValue, getPresenceDayValue,
openAbsenceDrawer, openAbsenceDrawer,
submitAbsence, submitAbsence,

View File

@@ -86,6 +86,8 @@
<AbsencePrintDrawer <AbsencePrintDrawer
v-model="isPrintOpen" v-model="isPrintOpen"
:sites="sites" :sites="sites"
:contract-natures="contractNatureOptions"
:work-contracts="workContractOptions"
:print-form="printForm" :print-form="printForm"
@submit="handlePrint" @submit="handlePrint"
@cancel="closePrint" @cancel="closePrint"
@@ -221,7 +223,25 @@ const form = reactive({
const printForm = reactive({ const printForm = reactive({
from: '', from: '',
to: '', to: '',
siteIds: [] as number[] siteIds: [] as number[],
contractNatures: [] as Array<'CDI' | 'CDD' | 'INTERIM'>,
workContractIds: [] as number[]
})
const contractNatureOptions = [
{ value: 'CDI' as const, label: 'CDI' },
{ value: 'CDD' as const, label: 'CDD' },
{ value: 'INTERIM' as const, label: 'Intérim' }
]
const workContractOptions = computed(() => {
const byId = new Map<number, { id: number; name: string }>()
for (const employee of employees.value) {
const contract = employee.contract
if (!contract?.id) continue
byId.set(contract.id, { id: contract.id, name: contract.name })
}
return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name, 'fr'))
}) })
// Remet le formulaire à zéro. // Remet le formulaire à zéro.
@@ -249,6 +269,8 @@ const openPrint = () => {
printForm.from = monthStart printForm.from = monthStart
printForm.to = monthEnd printForm.to = monthEnd
printForm.siteIds = [...selectedSiteIds.value] printForm.siteIds = [...selectedSiteIds.value]
printForm.contractNatures = contractNatureOptions.map((item) => item.value)
printForm.workContractIds = workContractOptions.value.map((item) => item.id)
isPrintOpen.value = true isPrintOpen.value = true
} }
@@ -657,6 +679,12 @@ const handlePrint = async () => {
if (printForm.siteIds.length > 0) { if (printForm.siteIds.length > 0) {
params.set('sites', printForm.siteIds.join(',')) params.set('sites', printForm.siteIds.join(','))
} }
if (printForm.contractNatures.length > 0) {
params.set('contractNatures', printForm.contractNatures.join(','))
}
if (printForm.workContractIds.length > 0) {
params.set('workContracts', printForm.workContractIds.join(','))
}
await printPdf(`/absences/print?${params.toString()}`) await printPdf(`/absences/print?${params.toString()}`)
isPrintOpen.value = false isPrintOpen.value = false
} }

View File

@@ -11,7 +11,7 @@
<button <button
type="button" type="button"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500" class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
@click="isDrawerOpen = true" @click="openCreate"
> >
Ajouter un employé Ajouter un employé
</button> </button>
@@ -32,10 +32,11 @@
<div v-else class="flex-1 min-h-0 rounded-lg border border-neutral-200 bg-white overflow-hidden"> <div v-else class="flex-1 min-h-0 rounded-lg border border-neutral-200 bg-white overflow-hidden">
<div class="h-full overflow-auto"> <div class="h-full overflow-auto">
<div class="min-w-[900px]"> <div class="min-w-[900px]">
<div class="grid grid-cols-[120px_1fr_1fr_220px_200px] gap-4 border-b border-neutral-200 bg-tertiary-500 px-6 py-3 text-md font-semibold text-neutral-700 sticky top-0 z-10"> <div class="grid grid-cols-[120px_1fr_1fr_180px_180px_200px] gap-4 border-b border-neutral-200 bg-tertiary-500 px-6 py-3 text-md font-semibold text-neutral-700 sticky top-0 z-10">
<span class="text-left">Prénom</span> <span class="text-left">Prénom</span>
<span class="text-left">Nom</span> <span class="text-left">Nom</span>
<span class="text-left">Site</span> <span class="text-left">Site</span>
<span class="text-left">Nature</span>
<span class="text-left">Contrat</span> <span class="text-left">Contrat</span>
<span class="text-right">Actions</span> <span class="text-right">Actions</span>
</div> </div>
@@ -46,7 +47,7 @@
<div <div
v-for="employee in filteredEmployees" v-for="employee in filteredEmployees"
:key="employee.id" :key="employee.id"
class="grid grid-cols-[120px_1fr_1fr_220px_200px] items-center gap-4 border-b border-neutral-100 px-6 py-3 text-md text-neutral-800 last:border-b-0" class="grid grid-cols-[120px_1fr_1fr_180px_180px_200px] items-center gap-4 border-b border-neutral-100 px-6 py-3 text-md text-neutral-800 last:border-b-0"
> >
<span>{{ employee.firstName }}</span> <span>{{ employee.firstName }}</span>
<span>{{ employee.lastName }}</span> <span>{{ employee.lastName }}</span>
@@ -57,6 +58,7 @@
> >
{{ employee.site?.name ?? '-' }} {{ employee.site?.name ?? '-' }}
</span> </span>
<span>{{ contractNatureLabel(employee.currentContractNature) }}</span>
<span>{{ employee.contract?.name ?? '-' }}</span> <span>{{ employee.contract?.name ?? '-' }}</span>
<div class="flex items-center justify-end gap-2"> <div class="flex items-center justify-end gap-2">
<button <button
@@ -128,24 +130,72 @@
Le site est obligatoire. Le site est obligatoire.
</p> </p>
</div> </div>
<div> <template v-if="!editingEmployee">
<label class="text-md font-semibold text-neutral-700" for="contract"> <div>
Contrat <span class="text-red-600">*</span> <label class="text-md font-semibold text-neutral-700" for="contract-nature">
</label> Type de contrat <span class="text-red-600">*</span>
<select </label>
id="contract" <select
v-model="form.contractId" id="contract-nature"
:class="contractFieldClass" v-model="form.contractNature"
> :class="contractNatureFieldClass"
<option value="">Sélectionner un contrat</option> >
<option v-for="contract in contracts" :key="contract.id" :value="contract.id"> <option value="CDI">CDI</option>
{{ contract.name }} <option value="CDD">CDD</option>
</option> <option value="INTERIM">Intérim</option>
</select> </select>
<p v-if="showContractError" class="mt-1 text-sm text-red-600"> <p v-if="showContractNatureError" class="mt-1 text-sm text-red-600">
Le contrat est obligatoire. Le type de contrat est obligatoire.
</p> </p>
</div> </div>
<div>
<label class="text-md font-semibold text-neutral-700" for="contract">
Temps de travail <span class="text-red-600">*</span>
</label>
<select
id="contract"
v-model="form.contractId"
:class="contractFieldClass"
>
<option value="">Sélectionner un contrat</option>
<option v-for="contract in contracts" :key="contract.id" :value="contract.id">
{{ contract.name }}
</option>
</select>
<p v-if="showContractError" class="mt-1 text-sm text-red-600">
Le temps de travail est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="contract-start-date">
Début contrat <span class="text-red-600">*</span>
</label>
<input
id="contract-start-date"
v-model="form.contractStartDate"
type="date"
:class="contractStartDateFieldClass"
/>
<p v-if="showContractStartDateError" class="mt-1 text-sm text-red-600">
La date de début est obligatoire.
</p>
</div>
<div v-if="requiresContractEndDate">
<label class="text-md font-semibold text-neutral-700" for="contract-end-date">
Fin contrat
<span v-if="requiresContractEndDate" class="text-red-600">*</span>
</label>
<input
id="contract-end-date"
v-model="form.contractEndDate"
type="date"
:class="contractEndDateFieldClass"
/>
<p v-if="showContractEndDateError" class="mt-1 text-sm text-red-600">
La date de fin est obligatoire pour un CDD ou un intérim.
</p>
</div>
</template>
<div class="flex justify-end gap-3 pt-2"> <div class="flex justify-end gap-3 pt-2">
<button <button
type="button" type="button"
@@ -212,26 +262,54 @@ const filteredEmployees = computed(() => {
}) })
}) })
const contractNatureLabel = (value?: 'CDI' | 'CDD' | 'INTERIM') => {
if (value === 'CDD') return 'CDD'
if (value === 'INTERIM') return 'Intérim'
return 'CDI'
}
const form = reactive({ const form = reactive({
firstName: '', firstName: '',
lastName: '', lastName: '',
siteId: '' as number | '', siteId: '' as number | '',
contractId: '' as number | '' contractId: '' as number | '',
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
contractStartDate: '',
contractEndDate: ''
}) })
const validationTouched = reactive({ const validationTouched = reactive({
firstName: false, firstName: false,
lastName: false, lastName: false,
siteId: false, siteId: false,
contractId: false contractId: false,
contractNature: false,
contractStartDate: false,
contractEndDate: false
}) })
const isFirstNameValid = computed(() => form.firstName.trim() !== '') const isFirstNameValid = computed(() => form.firstName.trim() !== '')
const isLastNameValid = computed(() => form.lastName.trim() !== '') const isLastNameValid = computed(() => form.lastName.trim() !== '')
const isSiteValid = computed(() => form.siteId !== '') const isSiteValid = computed(() => form.siteId !== '')
const isContractValid = computed(() => form.contractId !== '') const isContractValid = computed(() => form.contractId !== '')
const isContractNatureValid = computed(() => ['CDI', 'CDD', 'INTERIM'].includes(form.contractNature))
const isContractStartDateValid = computed(() => form.contractStartDate !== '')
const requiresContractEndDate = computed(() => form.contractNature === 'CDD' || form.contractNature === 'INTERIM')
const isContractEndDateValid = computed(() => {
if (!requiresContractEndDate.value) return true
return form.contractEndDate !== ''
})
const isFormValid = computed( const isFormValid = computed(
() => isFirstNameValid.value && isLastNameValid.value && isSiteValid.value && isContractValid.value () =>
isFirstNameValid.value &&
isLastNameValid.value &&
isSiteValid.value &&
(editingEmployee.value
? true
: (isContractValid.value &&
isContractNatureValid.value &&
isContractStartDateValid.value &&
isContractEndDateValid.value))
) )
const showFirstNameError = computed( const showFirstNameError = computed(
@@ -246,6 +324,15 @@ const showSiteError = computed(
const showContractError = computed( const showContractError = computed(
() => validationTouched.contractId && !isContractValid.value () => validationTouched.contractId && !isContractValid.value
) )
const showContractNatureError = computed(
() => !editingEmployee.value && validationTouched.contractNature && !isContractNatureValid.value
)
const showContractStartDateError = computed(
() => !editingEmployee.value && validationTouched.contractStartDate && !isContractStartDateValid.value
)
const showContractEndDateError = computed(
() => !editingEmployee.value && validationTouched.contractEndDate && !isContractEndDateValid.value
)
const baseInputClass = 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' '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'
@@ -277,6 +364,26 @@ const contractFieldClass = computed(() => {
} }
return `${baseClass} border-neutral-300` return `${baseClass} border-neutral-300`
}) })
const contractNatureFieldClass = computed(() => {
const baseClass =
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
if (showContractNatureError.value) {
return `${baseClass} border-red-500`
}
return `${baseClass} border-neutral-300`
})
const contractStartDateFieldClass = computed(() => {
if (showContractStartDateError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const contractEndDateFieldClass = computed(() => {
if (showContractEndDateError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const submitButtonClass = computed(() => { const submitButtonClass = computed(() => {
if (isSubmitting.value || !isFormValid.value) { if (isSubmitting.value || !isFormValid.value) {
@@ -304,6 +411,9 @@ const loadContracts = async () => {
onMounted(async () => { onMounted(async () => {
await Promise.all([loadEmployees(), loadSites(), loadContracts()]) await Promise.all([loadEmployees(), loadSites(), loadContracts()])
if (form.contractStartDate === '') {
form.contractStartDate = new Date().toISOString().slice(0, 10)
}
}) })
watch(sites, (nextSites) => { watch(sites, (nextSites) => {
@@ -324,7 +434,12 @@ const handleSubmit = async () => {
validationTouched.firstName = true validationTouched.firstName = true
validationTouched.lastName = true validationTouched.lastName = true
validationTouched.siteId = true validationTouched.siteId = true
validationTouched.contractId = true if (!editingEmployee.value) {
validationTouched.contractId = true
validationTouched.contractNature = true
validationTouched.contractStartDate = true
validationTouched.contractEndDate = true
}
if (!isFormValid.value) return if (!isFormValid.value) return
isSubmitting.value = true isSubmitting.value = true
@@ -334,14 +449,17 @@ const handleSubmit = async () => {
firstName: form.firstName, firstName: form.firstName,
lastName: form.lastName, lastName: form.lastName,
siteId: form.siteId === '' ? null : Number(form.siteId), siteId: form.siteId === '' ? null : Number(form.siteId),
contractId: Number(form.contractId) contractId: editingEmployee.value.contract?.id ?? Number(form.contractId)
}) })
} else { } else {
await createEmployee({ await createEmployee({
firstName: form.firstName, firstName: form.firstName,
lastName: form.lastName, lastName: form.lastName,
siteId: form.siteId === '' ? null : Number(form.siteId), siteId: form.siteId === '' ? null : Number(form.siteId),
contractId: Number(form.contractId) contractId: Number(form.contractId),
contractNature: form.contractNature,
contractStartDate: form.contractStartDate,
contractEndDate: requiresContractEndDate.value ? form.contractEndDate : null
}) })
} }
@@ -349,6 +467,9 @@ const handleSubmit = async () => {
form.lastName = '' form.lastName = ''
form.siteId = '' form.siteId = ''
form.contractId = '' form.contractId = ''
form.contractNature = 'CDI'
form.contractStartDate = new Date().toISOString().slice(0, 10)
form.contractEndDate = ''
editingEmployee.value = null editingEmployee.value = null
isDrawerOpen.value = false isDrawerOpen.value = false
await loadEmployees() await loadEmployees()
@@ -363,6 +484,15 @@ watch(isDrawerOpen, (isOpen) => {
validationTouched.lastName = false validationTouched.lastName = false
validationTouched.siteId = false validationTouched.siteId = false
validationTouched.contractId = false validationTouched.contractId = false
validationTouched.contractNature = false
validationTouched.contractStartDate = false
validationTouched.contractEndDate = false
}
})
watch(requiresContractEndDate, (required) => {
if (!required) {
form.contractEndDate = ''
} }
}) })
@@ -371,7 +501,18 @@ const openEdit = (employee: Employee) => {
form.firstName = employee.firstName form.firstName = employee.firstName
form.lastName = employee.lastName form.lastName = employee.lastName
form.siteId = employee.site?.id ?? '' form.siteId = employee.site?.id ?? ''
form.contractId = employee.contract?.id ?? '' isDrawerOpen.value = true
}
const openCreate = () => {
editingEmployee.value = null
form.firstName = ''
form.lastName = ''
form.siteId = ''
form.contractId = ''
form.contractNature = 'CDI'
form.contractStartDate = new Date().toISOString().slice(0, 10)
form.contractEndDate = ''
isDrawerOpen.value = true isDrawerOpen.value = true
} }

View File

@@ -61,6 +61,7 @@
:on-toggle-validation-bulk="toggleValidationBulk" :on-toggle-validation-bulk="toggleValidationBulk"
:get-row-metrics="getRowMetrics" :get-row-metrics="getRowMetrics"
:get-row-absence-label="getRowAbsenceLabel" :get-row-absence-label="getRowAbsenceLabel"
:get-row-absence-style="getRowAbsenceStyle"
:get-presence-day-value="getPresenceDayValue" :get-presence-day-value="getPresenceDayValue"
:on-absence-click="openAbsenceDrawer" :on-absence-click="openAbsenceDrawer"
:format-minutes="formatMinutes" :format-minutes="formatMinutes"
@@ -163,6 +164,7 @@ const {
toggleValidationBulk, toggleValidationBulk,
getRowMetrics, getRowMetrics,
getRowAbsenceLabel, getRowAbsenceLabel,
getRowAbsenceStyle,
getPresenceDayValue, getPresenceDayValue,
openAbsenceDrawer, openAbsenceDrawer,
submitAbsence, submitAbsence,

View File

@@ -7,5 +7,8 @@ export type Employee = {
lastName: string lastName: string
site: Site site: Site
contract?: Contract | null contract?: Contract | null
currentContractNature?: 'CDI' | 'CDD' | 'INTERIM'
currentContractStartDate?: string | null
currentContractEndDate?: string | null
displayOrder?: number displayOrder?: number
} }

View File

@@ -70,6 +70,7 @@ export type WorkHourDayContextRow = {
employeeId: number employeeId: number
hasContractAtDate: boolean hasContractAtDate: boolean
absenceLabel?: string | null absenceLabel?: string | null
absenceColor?: string | null
absenceHalf?: 'AM' | 'PM' | null absenceHalf?: 'AM' | 'PM' | null
absentMorning: boolean absentMorning: boolean
absentAfternoon: boolean absentAfternoon: boolean

View File

@@ -26,13 +26,19 @@ export const createEmployee = async (payload: {
lastName: string lastName: string
siteId?: number | null siteId?: number | null
contractId: number contractId: number
contractNature?: 'CDI' | 'CDD' | 'INTERIM'
contractStartDate?: string
contractEndDate?: string | null
}) => { }) => {
const api = useApi() const api = useApi()
return api.post<Employee>('/employees', { return api.post<Employee>('/employees', {
firstName: payload.firstName, firstName: payload.firstName,
lastName: payload.lastName, lastName: payload.lastName,
site: payload.siteId ? `/api/sites/${payload.siteId}` : null, site: payload.siteId ? `/api/sites/${payload.siteId}` : null,
contract: `/api/contracts/${payload.contractId}` contract: `/api/contracts/${payload.contractId}`,
contractNature: payload.contractNature,
contractStartDate: payload.contractStartDate,
contractEndDate: payload.contractEndDate ?? null
}, { }, {
toastSuccessKey: 'success.employee.create', toastSuccessKey: 'success.employee.create',
toastErrorKey: 'errors.employee.create' toastErrorKey: 'errors.employee.create'
@@ -46,6 +52,9 @@ export const updateEmployee = async (
lastName: string lastName: string
siteId?: number | null siteId?: number | null
contractId: number contractId: number
contractNature?: 'CDI' | 'CDD' | 'INTERIM'
contractStartDate?: string
contractEndDate?: string | null
displayOrder?: number displayOrder?: number
} }
) => { ) => {
@@ -55,6 +64,9 @@ export const updateEmployee = async (
lastName: payload.lastName, lastName: payload.lastName,
site: payload.siteId ? `/api/sites/${payload.siteId}` : null, site: payload.siteId ? `/api/sites/${payload.siteId}` : null,
contract: `/api/contracts/${payload.contractId}`, contract: `/api/contracts/${payload.contractId}`,
contractNature: payload.contractNature,
contractStartDate: payload.contractStartDate,
contractEndDate: payload.contractEndDate ?? null,
displayOrder: payload.displayOrder displayOrder: payload.displayOrder
}, { }, {
toastSuccessKey: 'success.employee.update', toastSuccessKey: 'success.employee.update',

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260226203000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add contract nature to employee contract periods';
}
public function up(Schema $schema): void
{
$this->addSql("ALTER TABLE employee_contract_periods ADD contract_nature VARCHAR(20) DEFAULT 'CDI' NOT NULL");
$this->addSql("
UPDATE employee_contract_periods p
SET contract_nature = 'INTERIM'
FROM contracts c
WHERE p.contract_id = c.id
AND LOWER(c.name) LIKE '%interim%'
");
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE employee_contract_periods DROP contract_nature');
}
}

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
use Doctrine\Migrations\Exception\IrreversibleMigration;
final class Version20260226210000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Reassign legacy INTERIM contract to a TIME 35h contract and remove legacy INTERIM contract row';
}
public function up(Schema $schema): void
{
$this->addSql(
<<<'SQL'
DO $$
DECLARE
legacy_interim_contract_id INT;
target_time_35h_contract_id INT;
BEGIN
-- Contrat legacy "Intérim" (ancien modèle).
SELECT c.id
INTO legacy_interim_contract_id
FROM contracts c
WHERE LOWER(c.name) LIKE '%interim%'
ORDER BY c.id
LIMIT 1;
-- Si déjà supprimé, on ne fait rien.
IF legacy_interim_contract_id IS NULL THEN
RETURN;
END IF;
-- Contrat cible: suivi horaire 35h.
SELECT c.id
INTO target_time_35h_contract_id
FROM contracts c
WHERE c.tracking_mode = 'TIME'
AND c.weekly_hours = 35
AND c.id <> legacy_interim_contract_id
ORDER BY
CASE WHEN LOWER(c.name) = '35h' THEN 0 ELSE 1 END,
c.id
LIMIT 1;
IF target_time_35h_contract_id IS NULL THEN
RAISE EXCEPTION 'No TIME 35h contract found to replace legacy INTERIM contract id=%', legacy_interim_contract_id;
END IF;
-- Ré-assigne l'historique de périodes.
UPDATE employee_contract_periods
SET contract_id = target_time_35h_contract_id
WHERE contract_id = legacy_interim_contract_id;
-- Ré-assigne le pointeur actuel employé (compat legacy / affichage).
UPDATE employees
SET contract_id = target_time_35h_contract_id
WHERE contract_id = legacy_interim_contract_id;
-- Garde-fou FK avant suppression.
IF EXISTS (SELECT 1 FROM employee_contract_periods p WHERE p.contract_id = legacy_interim_contract_id)
OR EXISTS (SELECT 1 FROM employees e WHERE e.contract_id = legacy_interim_contract_id) THEN
RAISE EXCEPTION 'Legacy INTERIM contract id=% is still referenced', legacy_interim_contract_id;
END IF;
DELETE FROM contracts WHERE id = legacy_interim_contract_id;
END $$;
SQL
);
}
public function down(Schema $schema): void
{
throw new IrreversibleMigration('This migration performs data reassignment and contract deletion.');
}
}

View File

@@ -18,6 +18,8 @@ use App\State\AbsencePrintProvider;
new QueryParameter(key: 'from', required: true), new QueryParameter(key: 'from', required: true),
new QueryParameter(key: 'to', required: true), new QueryParameter(key: 'to', required: true),
new QueryParameter(key: 'sites', required: false), new QueryParameter(key: 'sites', required: false),
new QueryParameter(key: 'contractNatures', required: false),
new QueryParameter(key: 'workContracts', required: false),
], ],
security: "is_granted('ROLE_ADMIN')" security: "is_granted('ROLE_ADMIN')"
), ),

View File

@@ -26,6 +26,7 @@ final class WorkHourDayContext
* @var list<array{ * @var list<array{
* employeeId:int, * employeeId:int,
* absenceLabel:?string, * absenceLabel:?string,
* absenceColor:?string,
* absenceHalf:?string, * absenceHalf:?string,
* absentMorning:bool, * absentMorning:bool,
* absentAfternoon:bool, * absentAfternoon:bool,

View File

@@ -10,6 +10,7 @@ final class DayContextRow
public int $employeeId, public int $employeeId,
public bool $hasContractAtDate = true, public bool $hasContractAtDate = true,
public ?string $absenceLabel = null, public ?string $absenceLabel = null,
public ?string $absenceColor = null,
public ?string $absenceHalf = null, public ?string $absenceHalf = null,
public bool $absentMorning = false, public bool $absentMorning = false,
public bool $absentAfternoon = false, public bool $absentAfternoon = false,
@@ -19,6 +20,7 @@ final class DayContextRow
public function addAbsence( public function addAbsence(
?string $label, ?string $label,
?string $color,
bool $morning, bool $morning,
bool $afternoon, bool $afternoon,
int $creditedMinutes, int $creditedMinutes,
@@ -35,6 +37,14 @@ final class DayContextRow
$this->absenceLabel = 'Absences multiples'; $this->absenceLabel = 'Absences multiples';
} }
// Si plusieurs types d'absence différents sont fusionnés sur la même journée,
// on retire la couleur métier spécifique.
if (null === $this->absenceColor) {
$this->absenceColor = $color;
} elseif ($color !== $this->absenceColor) {
$this->absenceColor = null;
}
// AM/PM seulement pour les demi-journées, null pour journée complète. // AM/PM seulement pour les demi-journées, null pour journée complète.
$this->absenceHalf = $this->resolveHalfLabel($this->absentMorning, $this->absentAfternoon); $this->absenceHalf = $this->resolveHalfLabel($this->absentMorning, $this->absentAfternoon);
// Cumule les minutes créditées par les absences "comptées comme travaillées". // Cumule les minutes créditées par les absences "comptées comme travaillées".
@@ -48,6 +58,7 @@ final class DayContextRow
* employeeId:int, * employeeId:int,
* hasContractAtDate:bool, * hasContractAtDate:bool,
* absenceLabel:?string, * absenceLabel:?string,
* absenceColor:?string,
* absenceHalf:?string, * absenceHalf:?string,
* absentMorning:bool, * absentMorning:bool,
* absentAfternoon:bool, * absentAfternoon:bool,
@@ -61,6 +72,7 @@ final class DayContextRow
'employeeId' => $this->employeeId, 'employeeId' => $this->employeeId,
'hasContractAtDate' => $this->hasContractAtDate, 'hasContractAtDate' => $this->hasContractAtDate,
'absenceLabel' => $this->absenceLabel, 'absenceLabel' => $this->absenceLabel,
'absenceColor' => $this->absenceColor,
'absenceHalf' => $this->absenceHalf, 'absenceHalf' => $this->absenceHalf,
'absentMorning' => $this->absentMorning, 'absentMorning' => $this->absentMorning,
'absentAfternoon' => $this->absentAfternoon, 'absentAfternoon' => $this->absentAfternoon,

View File

@@ -6,9 +6,12 @@ namespace App\Entity;
use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use App\Enum\ContractNature;
use App\Repository\EmployeeRepository; use App\Repository\EmployeeRepository;
use App\State\EmployeeWriteProcessor; use App\State\EmployeeWriteProcessor;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
@@ -56,9 +59,25 @@ class Employee
#[ORM\Column(type: 'datetime_immutable')] #[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $createdAt; private DateTimeImmutable $createdAt;
/**
* @var Collection<int, EmployeeContractPeriod>
*/
#[ORM\OneToMany(mappedBy: 'employee', targetEntity: EmployeeContractPeriod::class)]
private Collection $contractPeriods;
#[Groups(['employee:write'])]
private ?string $contractNature = null;
#[Groups(['employee:write'])]
private ?string $contractStartDate = null;
#[Groups(['employee:write'])]
private ?string $contractEndDate = null;
public function __construct() public function __construct()
{ {
$this->createdAt = new DateTimeImmutable(); $this->createdAt = new DateTimeImmutable();
$this->contractPeriods = new ArrayCollection();
} }
public function getId(): ?int public function getId(): ?int
@@ -130,4 +149,81 @@ class Employee
return $this; return $this;
} }
public function getContractNature(): ?string
{
return $this->contractNature;
}
public function setContractNature(?string $contractNature): self
{
$this->contractNature = $contractNature;
return $this;
}
public function getContractStartDate(): ?string
{
return $this->contractStartDate;
}
public function setContractStartDate(?string $contractStartDate): self
{
$this->contractStartDate = $contractStartDate;
return $this;
}
public function getContractEndDate(): ?string
{
return $this->contractEndDate;
}
public function setContractEndDate(?string $contractEndDate): self
{
$this->contractEndDate = $contractEndDate;
return $this;
}
#[Groups(['employee:read'])]
public function getCurrentContractNature(): string
{
return $this->resolveCurrentContractPeriod()?->getContractNatureEnum()->value ?? ContractNature::CDI->value;
}
#[Groups(['employee:read'])]
public function getCurrentContractStartDate(): ?string
{
return $this->resolveCurrentContractPeriod()?->getStartDate()->format('Y-m-d');
}
#[Groups(['employee:read'])]
public function getCurrentContractEndDate(): ?string
{
return $this->resolveCurrentContractPeriod()?->getEndDate()?->format('Y-m-d');
}
private function resolveCurrentContractPeriod(): ?EmployeeContractPeriod
{
$today = new DateTimeImmutable('today');
$current = null;
foreach ($this->contractPeriods as $period) {
if ($period->getStartDate() > $today) {
continue;
}
$endDate = $period->getEndDate();
if (null !== $endDate && $endDate < $today) {
continue;
}
if (null === $current || $period->getStartDate() > $current->getStartDate()) {
$current = $period;
}
}
return $current;
}
} }

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Entity; namespace App\Entity;
use App\Enum\ContractNature;
use App\Repository\EmployeeContractPeriodRepository; use App\Repository\EmployeeContractPeriodRepository;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
@@ -19,7 +20,7 @@ class EmployeeContractPeriod
#[ORM\Column(type: 'integer')] #[ORM\Column(type: 'integer')]
private ?int $id = null; private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Employee::class)] #[ORM\ManyToOne(targetEntity: Employee::class, inversedBy: 'contractPeriods')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?Employee $employee = null; private ?Employee $employee = null;
@@ -33,6 +34,9 @@ class EmployeeContractPeriod
#[ORM\Column(type: 'date_immutable', nullable: true)] #[ORM\Column(type: 'date_immutable', nullable: true)]
private ?DateTimeImmutable $endDate = null; private ?DateTimeImmutable $endDate = null;
#[ORM\Column(type: 'string', length: 20, options: ['default' => ContractNature::CDI->value])]
private string $contractNature = ContractNature::CDI->value;
#[ORM\Column(type: 'datetime_immutable')] #[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $createdAt; private DateTimeImmutable $createdAt;
@@ -95,6 +99,24 @@ class EmployeeContractPeriod
return $this; return $this;
} }
public function getContractNature(): string
{
return $this->contractNature;
}
public function getContractNatureEnum(): ContractNature
{
return ContractNature::tryFrom($this->contractNature) ?? ContractNature::CDI;
}
public function setContractNature(ContractNature|string $contractNature): self
{
$value = $contractNature instanceof ContractNature ? $contractNature->value : $contractNature;
$this->contractNature = ContractNature::tryFrom($value)?->value ?? ContractNature::CDI->value;
return $this;
}
public function getCreatedAt(): DateTimeImmutable public function getCreatedAt(): DateTimeImmutable
{ {
return $this->createdAt; return $this->createdAt;

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Enum;
enum ContractNature: string
{
case CDI = 'CDI';
case CDD = 'CDD';
case INTERIM = 'INTERIM';
public function requiresEndDate(): bool
{
return self::CDD === $this || self::INTERIM === $this;
}
}

View File

@@ -85,6 +85,8 @@ final class EmployeeRepository extends ServiceEntityRepository implements Employ
$qb = $this->createQueryBuilder('e') $qb = $this->createQueryBuilder('e')
->leftJoin('e.site', 's') ->leftJoin('e.site', 's')
->addSelect('s') ->addSelect('s')
->leftJoin('e.contract', 'c')
->addSelect('c')
->orderBy('s.displayOrder', 'ASC') ->orderBy('s.displayOrder', 'ASC')
->addOrderBy('s.name', 'ASC') ->addOrderBy('s.name', 'ASC')
->addOrderBy('e.displayOrder', 'ASC') ->addOrderBy('e.displayOrder', 'ASC')

View File

@@ -6,6 +6,7 @@ namespace App\Service\Contracts;
use App\Entity\Contract; use App\Entity\Contract;
use App\Entity\Employee; use App\Entity\Employee;
use App\Enum\ContractNature;
use App\Repository\EmployeeContractPeriodRepository; use App\Repository\EmployeeContractPeriodRepository;
use DateTimeImmutable; use DateTimeImmutable;
@@ -22,6 +23,13 @@ readonly class EmployeeContractResolver
return $period?->getContract(); return $period?->getContract();
} }
public function resolveNatureForEmployeeAndDate(Employee $employee, DateTimeImmutable $date): ContractNature
{
$period = $this->periodRepository->findOneCoveringDate($employee, $date);
return $period?->getContractNatureEnum() ?? ContractNature::CDI;
}
/** /**
* @param list<Employee> $employees * @param list<Employee> $employees
* @param list<string> $days * @param list<string> $days
@@ -68,4 +76,51 @@ readonly class EmployeeContractResolver
return $resolved; return $resolved;
} }
/**
* @param list<Employee> $employees
* @param list<string> $days
*
* @return array<int, array<string, ContractNature>>
*/
public function resolveNaturesForEmployeesAndDays(array $employees, array $days): array
{
$resolved = [];
if ([] === $employees || [] === $days) {
return $resolved;
}
foreach ($employees as $employee) {
$employeeId = $employee->getId();
if (!$employeeId) {
continue;
}
foreach ($days as $day) {
$resolved[$employeeId][$day] = ContractNature::CDI;
}
}
$from = new DateTimeImmutable(min($days));
$to = new DateTimeImmutable(max($days));
$periods = $this->periodRepository->findByEmployeesAndDateRange($employees, $from, $to);
foreach ($periods as $period) {
$employeeId = $period->getEmployee()?->getId();
if (!$employeeId) {
continue;
}
$start = $period->getStartDate()->format('Y-m-d');
$end = $period->getEndDate()?->format('Y-m-d') ?? '9999-12-31';
$nature = $period->getContractNatureEnum();
foreach ($days as $day) {
if ($day < $start || $day > $end) {
continue;
}
$resolved[$employeeId][$day] = $nature;
}
}
return $resolved;
}
} }

View File

@@ -6,6 +6,7 @@ namespace App\State;
use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\ProviderInterface;
use App\Enum\ContractNature;
use App\Enum\HalfDay; use App\Enum\HalfDay;
use App\Repository\AbsenceRepository; use App\Repository\AbsenceRepository;
use App\Repository\EmployeeRepository; use App\Repository\EmployeeRepository;
@@ -53,9 +54,11 @@ class AbsencePrintProvider implements ProviderInterface
$fromDate = DateTimeImmutable::createFromFormat('Y-m-d', $from); $fromDate = DateTimeImmutable::createFromFormat('Y-m-d', $from);
$toDate = DateTimeImmutable::createFromFormat('Y-m-d', $to); $toDate = DateTimeImmutable::createFromFormat('Y-m-d', $to);
$siteIds = $this->parseIds($request->query->get('sites')); $siteIds = $this->parseIds($request->query->get('sites'));
$workContractIds = $this->parseIds($request->query->get('workContracts'));
$contractNatures = $this->parseContractNatures($request->query->get('contractNatures'));
$employees = $this->loadEmployees($siteIds); $employees = $this->loadEmployees($siteIds, $contractNatures, $workContractIds);
$absences = $this->loadAbsences($fromDate, $toDate, $employees); $absences = $this->loadAbsences($fromDate, $toDate, $employees);
$days = $this->buildDays($fromDate, $toDate); $days = $this->buildDays($fromDate, $toDate);
@@ -108,9 +111,19 @@ class AbsencePrintProvider implements ProviderInterface
return array_values(array_unique($ids)); return array_values(array_unique($ids));
} }
private function loadEmployees(array $siteIds): array private function loadEmployees(array $siteIds, array $contractNatures, array $workContractIds): array
{ {
return $this->employeeRepository->findForPrintBySiteIds($siteIds); $employees = $this->employeeRepository->findForPrintBySiteIds($siteIds);
return array_values(array_filter($employees, static function ($employee) use ($contractNatures, $workContractIds): bool {
$employeeNature = (string) $employee->getCurrentContractNature();
$employeeContractId = $employee->getContract()?->getId();
$natureMatches = [] === $contractNatures || in_array($employeeNature, $contractNatures, true);
$contractMatches = [] === $workContractIds || (null !== $employeeContractId && in_array($employeeContractId, $workContractIds, true));
return $natureMatches && $contractMatches;
}));
} }
private function loadAbsences(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array private function loadAbsences(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array
@@ -209,4 +222,24 @@ class AbsencePrintProvider implements ProviderInterface
return $map; return $map;
} }
/**
* @return list<string>
*/
private function parseContractNatures(?string $value): array
{
if (null === $value || '' === trim($value)) {
return [];
}
$values = [];
foreach (explode(',', $value) as $part) {
$nature = strtoupper(trim($part));
if (null !== ContractNature::tryFrom($nature)) {
$values[] = $nature;
}
}
return array_values(array_unique($values));
}
} }

View File

@@ -10,10 +10,12 @@ use ApiPlatform\State\ProcessorInterface;
use App\Entity\Contract; use App\Entity\Contract;
use App\Entity\Employee; use App\Entity\Employee;
use App\Entity\EmployeeContractPeriod; use App\Entity\EmployeeContractPeriod;
use App\Enum\ContractNature;
use App\Repository\EmployeeContractPeriodRepository; use App\Repository\EmployeeContractPeriodRepository;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
final readonly class EmployeeWriteProcessor implements ProcessorInterface final readonly class EmployeeWriteProcessor implements ProcessorInterface
{ {
@@ -49,27 +51,46 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
return $result; return $result;
} }
$today = new DateTimeImmutable('today'); $today = new DateTimeImmutable('today');
$requestedContractNature = $this->resolveContractNature($data->getContractNature());
$requestedStartDate = $this->parseOptionalYmd($data->getContractStartDate(), 'contractStartDate');
$requestedEndDate = $this->parseOptionalYmd($data->getContractEndDate(), 'contractEndDate');
if ($isNew) { if ($isNew) {
$this->ensureContractPeriodExists($data, $currentContract, new DateTimeImmutable('1970-01-01')); $startDate = $requestedStartDate ?? new DateTimeImmutable('1970-01-01');
$nature = $requestedContractNature ?? ContractNature::CDI;
$this->assertPeriodDates($startDate, $requestedEndDate, $nature);
$this->ensureContractPeriodExists($data, $currentContract, $startDate, $requestedEndDate, $nature);
return $result; return $result;
} }
if ($this->isSameContract($previousContract, $currentContract)) { $hasPeriodChangeRequest = null !== $requestedContractNature || null !== $requestedStartDate || null !== $requestedEndDate;
if ($this->isSameContract($previousContract, $currentContract) && !$hasPeriodChangeRequest) {
return $result; return $result;
} }
$startDate = $requestedStartDate ?? $today;
$todayPeriod = $this->periodRepository->findOneCoveringDate($data, $today); $todayPeriod = $this->periodRepository->findOneCoveringDate($data, $today);
if (null !== $todayPeriod && null === $todayPeriod->getEndDate() && $todayPeriod->getStartDate()->format('Y-m-d') === $today->format('Y-m-d')) { $nature = $requestedContractNature ?? $todayPeriod?->getContractNatureEnum() ?? ContractNature::CDI;
$endDate = $requestedEndDate;
$this->assertPeriodDates($startDate, $endDate, $nature);
if (
null !== $todayPeriod
&& null === $todayPeriod->getEndDate()
&& $todayPeriod->getStartDate()->format('Y-m-d') === $startDate->format('Y-m-d')
) {
$todayPeriod->setContract($currentContract); $todayPeriod->setContract($currentContract);
$todayPeriod->setContractNature($nature);
$todayPeriod->setEndDate($endDate);
$this->entityManager->flush(); $this->entityManager->flush();
return $result; return $result;
} }
$this->periodRepository->closeOpenPeriods($data, $today->modify('-1 day')); $this->periodRepository->closeOpenPeriods($data, $startDate->modify('-1 day'));
$this->createPeriod($data, $currentContract, $today); $this->createPeriod($data, $currentContract, $startDate, $endDate, $nature);
$this->entityManager->flush(); $this->entityManager->flush();
return $result; return $result;
@@ -96,26 +117,80 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
return $first->getId() === $second->getId(); return $first->getId() === $second->getId();
} }
private function ensureContractPeriodExists(Employee $employee, Contract $contract, DateTimeImmutable $startDate): void private function ensureContractPeriodExists(
{ Employee $employee,
Contract $contract,
DateTimeImmutable $startDate,
?DateTimeImmutable $endDate,
ContractNature $nature,
): void {
$covered = $this->periodRepository->findOneCoveringDate($employee, $startDate); $covered = $this->periodRepository->findOneCoveringDate($employee, $startDate);
if (null !== $covered) { if (null !== $covered) {
return; return;
} }
$this->createPeriod($employee, $contract, $startDate); $this->createPeriod($employee, $contract, $startDate, $endDate, $nature);
$this->entityManager->flush(); $this->entityManager->flush();
} }
private function createPeriod(Employee $employee, Contract $contract, DateTimeImmutable $startDate): void private function createPeriod(
{ Employee $employee,
Contract $contract,
DateTimeImmutable $startDate,
?DateTimeImmutable $endDate,
ContractNature $nature,
): void {
$period = new EmployeeContractPeriod() $period = new EmployeeContractPeriod()
->setEmployee($employee) ->setEmployee($employee)
->setContract($contract) ->setContract($contract)
->setStartDate($startDate) ->setStartDate($startDate)
->setEndDate(null) ->setEndDate($endDate)
->setContractNature($nature)
; ;
$this->entityManager->persist($period); $this->entityManager->persist($period);
} }
private function resolveContractNature(?string $raw): ?ContractNature
{
if (null === $raw || '' === trim($raw)) {
return null;
}
return ContractNature::tryFrom(trim($raw))
?? throw new UnprocessableEntityHttpException('contractNature must be one of CDI, CDD, INTERIM.');
}
private function parseOptionalYmd(?string $raw, string $field): ?DateTimeImmutable
{
if (null === $raw || '' === trim($raw)) {
return null;
}
$value = trim($raw);
$date = DateTimeImmutable::createFromFormat('Y-m-d', $value);
if (!$date || $date->format('Y-m-d') !== $value) {
throw new UnprocessableEntityHttpException(sprintf('%s must use Y-m-d format.', $field));
}
return $date;
}
private function assertPeriodDates(
DateTimeImmutable $startDate,
?DateTimeImmutable $endDate,
ContractNature $nature
): void {
if (null !== $endDate && $endDate < $startDate) {
throw new UnprocessableEntityHttpException('contractEndDate cannot be before contractStartDate.');
}
if ($nature->requiresEndDate() && null === $endDate) {
throw new UnprocessableEntityHttpException('contractEndDate is required for CDD and INTERIM.');
}
if (ContractNature::CDI === $nature && null !== $endDate) {
throw new UnprocessableEntityHttpException('contractEndDate must be empty for CDI.');
}
}
} }

View File

@@ -125,6 +125,14 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
continue; continue;
} }
// Si aucune donnée n'a changé, on ne touche pas la ligne:
// cela évite de perdre les validations existantes (site/RH) sur un simple enregistrement.
if (null !== $existing && $this->isSameAsExisting($existing, $normalized)) {
++$result->processed;
continue;
}
if ($this->isEntryEmpty($normalized)) { if ($this->isEntryEmpty($normalized)) {
// Convention choisie: une ligne vide supprime l'enregistrement existant. // Convention choisie: une ligne vide supprime l'enregistrement existant.
if ($existing) { if ($existing) {

View File

@@ -77,6 +77,7 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface
$creditedPresenceUnits = $this->workedHoursCreditPolicy->computeCreditedPresenceUnits($absence, $dateKey, $absentMorning, $absentAfternoon); $creditedPresenceUnits = $this->workedHoursCreditPolicy->computeCreditedPresenceUnits($absence, $dateKey, $absentMorning, $absentAfternoon);
$rowsByEmployeeId[$employeeId]->addAbsence( $rowsByEmployeeId[$employeeId]->addAbsence(
label: $absence->getType()?->getLabel(), label: $absence->getType()?->getLabel(),
color: $absence->getType()?->getColor(),
morning: $absentMorning, morning: $absentMorning,
afternoon: $absentAfternoon, afternoon: $absentAfternoon,
creditedMinutes: $creditedMinutes, creditedMinutes: $creditedMinutes,

View File

@@ -15,6 +15,7 @@ use App\Entity\Contract;
use App\Entity\Employee; use App\Entity\Employee;
use App\Entity\User; use App\Entity\User;
use App\Entity\WorkHour; use App\Entity\WorkHour;
use App\Enum\ContractNature;
use App\Enum\ContractType; use App\Enum\ContractType;
use App\Enum\TrackingMode; use App\Enum\TrackingMode;
use App\Repository\Contract\AbsenceReadRepositoryInterface; use App\Repository\Contract\AbsenceReadRepositoryInterface;
@@ -113,8 +114,9 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
*/ */
private function buildRows(array $employees, array $workHours, array $absences, array $days, string $anchorDateYmd): array private function buildRows(array $employees, array $workHours, array $absences, array $days, string $anchorDateYmd): array
{ {
$contractsByEmployeeDate = $this->contractResolver->resolveForEmployeesAndDays($employees, $days); $contractsByEmployeeDate = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
$metricsByEmployeeDate = []; $contractNaturesByEmployeeDate = $this->contractResolver->resolveNaturesForEmployeesAndDays($employees, $days);
$metricsByEmployeeDate = [];
foreach ($workHours as $workHour) { foreach ($workHours as $workHour) {
$employeeId = $workHour->getEmployee()?->getId(); $employeeId = $workHour->getEmployee()?->getId();
if (!$employeeId) { if (!$employeeId) {
@@ -182,6 +184,9 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
$weekAnchorContract = $contractsByEmployeeDate[$employeeId][$anchorDateYmd] $weekAnchorContract = $contractsByEmployeeDate[$employeeId][$anchorDateYmd]
?? $contractsByEmployeeDate[$employeeId][$days[0]] ?? $contractsByEmployeeDate[$employeeId][$days[0]]
?? null; ?? null;
$weekAnchorContractNature = $contractNaturesByEmployeeDate[$employeeId][$anchorDateYmd]
?? $contractNaturesByEmployeeDate[$employeeId][$days[0]]
?? ContractNature::CDI;
$employeeContractsByDate = []; $employeeContractsByDate = [];
foreach ($days as $date) { foreach ($days as $date) {
$employeeContractsByDate[$date] = $contractsByEmployeeDate[$employeeId][$date] ?? null; $employeeContractsByDate[$date] = $contractsByEmployeeDate[$employeeId][$date] ?? null;
@@ -223,7 +228,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
} }
$isWeekPresenceTracking = TrackingMode::PRESENCE->value === $weekAnchorContract?->getTrackingMode(); $isWeekPresenceTracking = TrackingMode::PRESENCE->value === $weekAnchorContract?->getTrackingMode();
$disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($weekAnchorContract); $disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($weekAnchorContract, $weekAnchorContractNature);
$overtimeReferenceMinutes = $this->computeWeeklyOvertimeReferenceMinutes($days, $employeeContractsByDate); $overtimeReferenceMinutes = $this->computeWeeklyOvertimeReferenceMinutes($days, $employeeContractsByDate);
$overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($days, $employeeContractsByDate); $overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($days, $employeeContractsByDate);
$weeklyOvertimeTotalMinutes = $isWeekPresenceTracking $weeklyOvertimeTotalMinutes = $isWeekPresenceTracking
@@ -407,8 +412,12 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
return (int) round($trancheMinutes * 0.5); return (int) round($trancheMinutes * 0.5);
} }
private function hasDisabledOvertimeBonuses(?Contract $contract): bool private function hasDisabledOvertimeBonuses(?Contract $contract, ContractNature $contractNature): bool
{ {
if (ContractNature::INTERIM === $contractNature) {
return true;
}
$type = ContractType::resolve( $type = ContractType::resolve(
$contract?->getName(), $contract?->getName(),
$contract?->getTrackingMode(), $contract?->getTrackingMode(),