Compare commits

..

10 Commits

Author SHA1 Message Date
gitea-actions
ae42c70d50 chore: bump version to v0.1.18
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m18s
2026-03-03 08:35:44 +00:00
812215f5f6 fix : validation bulk des heures. Moins de lag et de bug
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-03 09:33:53 +01:00
gitea-actions
36fe9ae54c chore: bump version to v0.1.17
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m11s
2026-03-02 09:50:14 +00:00
6395ffbe1c feat : modification des sélecteurs de date sur le calendrier
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-02 10:50:02 +01:00
gitea-actions
b5e7395760 chore: bump version to v0.1.16
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m26s
2026-03-02 09:34:21 +00:00
380c72c242 fix : règle de calcule des heures travaillées sur les contrats Forfait
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-02 10:33:42 +01:00
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
37 changed files with 1577 additions and 223 deletions

134
AGENTS.md
View File

@@ -1,6 +1,6 @@
# 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
@@ -25,81 +25,107 @@ Arborescence clé:
### Contrats
- Entité: `Contract`
- Champs principaux: `name`, `trackingMode`, `weeklyHours`, `isActive`
- Champs principaux: `name`, `trackingMode`, `weeklyHours`, `isActive`, `type`
- `trackingMode`:
- `TIME`: suivi par heures
- `PRESENCE`: suivi présence demi-journées/journées
- `TIME`: suivi en heures
- `PRESENCE`: suivi en demi-journées/journées
- Enums backend:
- `App\Enum\TrackingMode`
- `App\Enum\ContractType` (`FORFAIT`, `35H`, `39H`, `INTERIM`, `CUSTOM`)
- `Contract::getType()` est exposé en API (`contract:read`, `employee:read`)
- `App\Enum\ContractType` (`FORFAIT`, `THIRTY_FIVE_HOURS`, `THIRTY_NINE_HOURS`, `INTERIM`, `CUSTOM`)
- Historique de contrat par employé:
- table `employee_contract_periods`
- résolu par `App\Service\Contracts\EmployeeContractResolver`
### Heures / absences
- Les absences sont découpées en enregistrements journaliers (pas de période unique stockée).
- Une ligne dheures validée est verrouillée côté métier.
- Règles de crédit absence (`countAsWorkedHours=true`) gérées dans `WorkedHoursCreditPolicy`:
- contrats présence: crédit en unités de présence
- contrats temps: crédit en minutes selon règles contrat (35h, 39h, 4h, fallback)
- Les absences sont stockées en **lignes journalières** (découpage automatique dans `AbsenceWriteProcessor`).
- Les absences `countAsWorkedHours=true` créditent:
- minutes (contrats TIME)
- unités de présence (contrats PRESENCE)
- Les absences AM/PM effacent les plages horaires concernées.
## 4) Écrans principaux
## 4) Validations (important)
### Page Heures (`frontend/pages/hours.vue`)
- Vue Jour + Vue Semaine (semaine réservée admin)
- Toolbar dédiée: `frontend/components/hours/HoursToolbar.vue`
- Vue jour: `frontend/components/hours/HoursDayView.vue`
- Vue semaine: `frontend/components/hours/HoursWeekView.vue`
- Logique page: `frontend/composables/useHoursPage.ts`
### Validation RH (admin)
- Champ: `work_hours.is_valid`
- Endpoint API Platform standard: `PATCH /api/work_hours/{id}`
- Gérée côté front par `updateWorkHourValidation`.
### Points UX déjà en place
- Toolbar semaine: raccourcis semaine précédente / actuelle / suivante
- Légende absences affichée dans la toolbar (admin + vue semaine)
- Cellules semaine avec absence: couleur du type dabsence (plus rouge fixe)
- Pour user non-admin: restrictions dédition selon validations/absences
### Validation site (chef de site)
- Champ: `work_hours.is_site_valid`
- Endpoint dédié: `PATCH /api/work_hours/{id}/site-validation`
- Processor: `src/State/WorkHourSiteValidationProcessor.php`
- 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`
- DTOs:
- `src/Dto/WorkHours/WeeklySummaryRow.php`
- `src/Dto/WorkHours/WeeklyDaySummary.php`
- Le résumé hebdo renvoie notamment:
- `trackingMode`
- `contractName`
- `contractType`
- détails journaliers (jour/nuit/total, présence, absence label/couleur)
- Inclut: contrat résolu par jour, absences, crédits, jour/nuit/total, majorations, récup.
### Heures supp
- Règles métier:
- contrats <= 35h: tranche 25% de 35h à 43h, puis 50% au-delà
- contrats >= 39h: tranche 25% de 39h à 43h, puis 50% au-delà
- contrats `INTERIM`: pas de bonus 25/50 ni récup
Règles majorations:
- Contrats <= 35h: +25% de 35h à 43h, +50% au-delà
- Contrats >= 39h: +25% de 39h à 43h, +50% au-delà
- `INTERIM`: pas de 25% / 50% / récup
## 6) Conventions techniques
## 7) Migrations sensibles
- Favoriser DTO explicites plutôt que tableaux associatifs bruts.
- Utiliser les interfaces repository dans providers/processors testés.
- Centraliser les règles métier dans services/providers backend plutôt que dupliquer côté front.
- Front: éviter les calculs métier lourds; consommer les champs API déjà calculés.
- `migrations/Version20260226183000.php`
- ajoute `work_hours.is_site_valid BOOLEAN NOT NULL DEFAULT FALSE`
- non destructive (pas de perte de données)
## 7) Tests et qualité
## 8) Points de vigilance prod
- Les TU backend passent actuellement via `make test`.
- Le build frontend passe via `npm run build`.
- À chaque évolution métier:
- mettre à jour les tests provider/processor/service impactés
- maintenir la cohérence des DTO TypeScript (`frontend/services/dto/*`)
- Toujours exécuter migration avant déploiement code backend/front lié.
- Après déploiement backend, si route manquante côté runtime:
- `php bin/console cache:clear && php bin/console cache:warmup`
- Vérifier présence route:
- `/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/Service/WorkHours/WorkedHoursCreditPolicy.php`
- `src/State/AbsenceWriteProcessor.php`
- `src/State/WorkHourBulkUpsertProcessor.php`
- `frontend/composables/useHoursPage.ts`
- `frontend/components/hours/HoursDayView.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:
app.version: '0.1.13'
app.version: '0.1.18'

View File

@@ -54,6 +54,48 @@
</p>
</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">
<button
type="button"
@@ -84,13 +126,27 @@ type SiteOption = {
color: string
}
type ContractNatureOption = {
value: 'CDI' | 'CDD' | 'INTERIM'
label: string
}
type WorkContractOption = {
id: number
name: string
}
const props = defineProps<{
modelValue: boolean
sites: SiteOption[]
contractNatures: ContractNatureOption[]
workContracts: WorkContractOption[]
printForm: {
from: string
to: string
siteIds: number[]
contractNatures: Array<'CDI' | 'CDD' | 'INTERIM'>
workContractIds: number[]
}
}>()
@@ -110,19 +166,36 @@ const printForm = toRef(props, 'printForm')
const validationTouched = reactive({
from: false,
to: false,
sites: false
sites: false,
contractNatures: false,
workContracts: false
})
const isFromValid = computed(() => printForm.value.from.trim() !== '')
const isToValid = computed(() => printForm.value.to.trim() !== '')
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(
() => isFromValid.value && isToValid.value && isSitesValid.value
() =>
isFromValid.value &&
isToValid.value &&
isSitesValid.value &&
isContractNaturesValid.value &&
isWorkContractsValid.value
)
const showFromError = computed(() => validationTouched.from && !isFromValid.value)
const showToError = computed(() => validationTouched.to && !isToValid.value)
const showSitesError = computed(() => validationTouched.sites && !isSitesValid.value)
const showContractNaturesError = computed(() => validationTouched.contractNatures && !isContractNaturesValid.value)
const showWorkContractsError = computed(() => validationTouched.workContracts && !isWorkContractsValid.value)
const baseInputClass =
'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.to = true
validationTouched.sites = true
validationTouched.contractNatures = true
validationTouched.workContracts = true
if (!isFormValid.value) return
emit('submit')
}
@@ -166,6 +241,8 @@ watch(
validationTouched.from = false
validationTouched.to = false
validationTouched.sites = false
validationTouched.contractNatures = false
validationTouched.workContracts = false
}
}
)

View File

@@ -0,0 +1,77 @@
<template>
<div class="relative inline-flex h-10 items-center overflow-hidden rounded-md border border-primary-500 bg-white" :class="widthClass">
<input
ref="nativeInput"
:value="pickerValue"
:type="pickerType"
class="pointer-events-none absolute inset-0 h-full w-full opacity-0"
tabindex="-1"
aria-hidden="true"
@input="onPickerInput"
@change="onPickerInput"
/>
<button
type="button"
class="h-10 px-3 text-lg font-semibold text-primary-500 hover:bg-tertiary-500 active:bg-tertiary-500 active:scale-[0.96]"
:aria-label="prevAriaLabel"
@click="emit('prev')"
>
</button>
<button
type="button"
class="h-10 flex-1 border-x border-primary-500 px-4 text-sm font-semibold text-primary-500 text-center hover:bg-tertiary-500 active:bg-tertiary-500"
@click="openPicker"
>
{{ label }}
</button>
<button
type="button"
class="h-10 px-3 text-lg font-semibold text-primary-500 hover:bg-tertiary-500 active:bg-tertiary-500 active:scale-[0.96]"
:aria-label="nextAriaLabel"
@click="emit('next')"
>
</button>
</div>
</template>
<script setup lang="ts">
const props = withDefaults(defineProps<{
label: string
pickerType: 'date' | 'week' | 'month'
pickerValue: string
widthClass?: string
prevAriaLabel?: string
nextAriaLabel?: string
}>(), {
widthClass: 'w-[320px]',
prevAriaLabel: 'Précédent',
nextAriaLabel: 'Suivant'
})
const emit = defineEmits<{
(e: 'prev'): void
(e: 'next'): void
(e: 'pick', value: string): void
}>()
const nativeInput = ref<HTMLInputElement | null>(null)
const openPicker = () => {
const input = nativeInput.value
if (!input) return
if (typeof input.showPicker === 'function') {
input.showPicker()
return
}
input.focus()
input.click()
}
const onPickerInput = (event: Event) => {
const value = (event.target as HTMLInputElement).value
if (!value) return
emit('pick', value)
}
</script>

View File

@@ -26,6 +26,18 @@
@change="onBulkValidationChange"
/>
</span>
<span v-else-if="isSiteManager" class="inline-flex items-center gap-2">
<span>Site</span>
<input
ref="bulkSiteValidationInput"
:checked="isBulkSiteValidationChecked"
type="checkbox"
class="h-4 w-4"
:class="canBulkToggleSiteValidation ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'"
:disabled="!canBulkToggleSiteValidation"
@change="onBulkSiteValidationChange"
/>
</span>
<span v-else>Site <Icon name="mdi:check-bold" class="ml-1"/></span>
<span v-if="!isAdmin">RH <Icon name="mdi:check-bold" class="ml-1"/></span>
</div>
@@ -52,14 +64,15 @@
</span>
</p>
</div>
<div class="pl-2 min-w-0 self-stretch flex flex-col justify-between py-0.5">
<p
class="w-full min-w-0 text-sm text-neutral-700 truncate"
:class="getRowAbsenceLabel(employee.id) ? '' : 'invisible'"
<div class="pl-2 min-w-0 self-stretch flex flex-col gap-1 justify-between py-0.5">
<p
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate"
:class="getRowAbsenceLabel(employee.id) ? 'text-white' : 'invisible'"
:title="getRowAbsenceLabel(employee.id) || ''"
>
:style="getRowAbsenceStyle(employee.id)"
>
{{ getRowAbsenceLabel(employee.id) || '—' }}
</p>
</p>
<button
type="button"
class="self-start text-left text-xs font-semibold underline"
@@ -172,6 +185,7 @@ import type { HourRow } from './types'
const rows = defineModel<Record<number, HourRow>>('rows', { required: true })
const bulkValidationInput = ref<HTMLInputElement | null>(null)
const bulkSiteValidationInput = ref<HTMLInputElement | null>(null)
const props = defineProps<{
employees: Employee[]
@@ -192,11 +206,16 @@ const props = defineProps<{
canToggleSiteValidation: (employeeId: number) => boolean
isBulkValidationChecked: boolean
isBulkValidationIndeterminate: boolean
isBulkSiteValidationChecked: boolean
isBulkSiteValidationIndeterminate: boolean
canBulkToggleSiteValidation: boolean
onToggleValidation: (employeeId: number, checked: boolean) => void
onToggleSiteValidation: (employeeId: number, checked: boolean) => void
onToggleValidationBulk: (checked: boolean) => void
onToggleValidationBulk: (checked: boolean) => Promise<void> | void
onToggleSiteValidationBulk: (checked: boolean) => Promise<void> | void
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number }
getRowAbsenceLabel: (employeeId: number) => string
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
getPresenceDayValue: (employeeId: number) => string
onAbsenceClick: (employeeId: number) => void
formatMinutes: (minutes: number) => string
@@ -206,6 +225,10 @@ const onBulkValidationChange = (event: Event) => {
props.onToggleValidationBulk((event.target as HTMLInputElement).checked)
}
const onBulkSiteValidationChange = (event: Event) => {
props.onToggleSiteValidationBulk((event.target as HTMLInputElement).checked)
}
const onToggleSiteValidation = (employeeId: number, checked: boolean) => {
props.onToggleSiteValidation(employeeId, checked)
}
@@ -218,4 +241,13 @@ watch(
},
{ immediate: true }
)
watch(
() => props.isBulkSiteValidationIndeterminate,
(isIndeterminate) => {
if (!bulkSiteValidationInput.value) return
bulkSiteValidationInput.value.indeterminate = isIndeterminate
},
{ immediate: true }
)
</script>

View File

@@ -64,41 +64,17 @@
</button>
</div>
<div class="relative inline-flex h-10 w-[320px] items-center overflow-hidden rounded-md border border-primary-500 bg-white">
<input
ref="nativeDateInput"
:value="pickerValue"
:type="viewMode === 'week' ? 'week' : 'date'"
class="pointer-events-none absolute inset-0 h-full w-full opacity-0"
tabindex="-1"
aria-hidden="true"
@input="onPickerInput"
@change="onPickerInput"
/>
<button
type="button"
class="h-10 px-3 text-lg font-semibold text-primary-500 hover:bg-tertiary-500 active:bg-tertiary-500 active:scale-[0.96] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
aria-label="Période précédente"
@click="emit('shift-date', -1)"
>
</button>
<button
type="button"
class="h-10 flex-1 border-x border-primary-500 px-4 text-sm font-semibold text-primary-500 text-center hover:bg-tertiary-500 active:bg-tertiary-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
@click="openDatePicker"
>
{{ formattedSelectedDate }}
</button>
<button
type="button"
class="h-10 px-3 text-lg font-semibold text-primary-500 hover:bg-tertiary-500 active:bg-tertiary-500 active:scale-[0.96] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
aria-label="Période suivante"
@click="emit('shift-date', 1)"
>
</button>
</div>
<PeriodStepperPicker
width-class="w-[320px]"
:label="formattedSelectedDate"
:picker-type="viewMode === 'week' ? 'week' : 'date'"
:picker-value="pickerValue"
prev-aria-label="Période précédente"
next-aria-label="Période suivante"
@prev="emit('shift-date', -1)"
@next="emit('shift-date', 1)"
@pick="onPickerValue"
/>
</div>
<div v-if="isAdmin" class="inline-flex h-10 overflow-hidden rounded-md border border-primary-500 bg-white">
@@ -145,6 +121,7 @@ import type { Site } from '~/services/dto/site'
import type { AbsenceType } from '~/services/dto/absence-type'
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
import PeriodStepperPicker from '~/components/PeriodStepperPicker.vue'
import { weekInputValueToYmd, ymdToWeekInputValue } from '~/utils/date'
const selectedDate = defineModel<string>('selectedDate', { required: true })
@@ -172,7 +149,6 @@ const emit = defineEmits<{
(e: 'shift-date', value: number): void
}>()
const nativeDateInput = ref<HTMLInputElement | null>(null)
const pickerValue = computed(() => {
if (viewMode.value === 'week') return ymdToWeekInputValue(selectedDate.value)
return selectedDate.value
@@ -186,19 +162,7 @@ const viewModeButtonClass = (mode: 'day' | 'week') => {
return 'bg-white text-primary-500 hover:bg-tertiary-500'
}
const openDatePicker = () => {
const input = nativeDateInput.value
if (!input) return
if (typeof input.showPicker === 'function') {
input.showPicker()
return
}
input.focus()
input.click()
}
const onPickerInput = (event: Event) => {
const value = (event.target as HTMLInputElement).value
const onPickerValue = (value: string) => {
if (!value) return
if (viewMode.value === 'week') {

View File

@@ -12,6 +12,8 @@ import { listAbsenceTypes } from '~/services/absence-types'
import { createAbsence, deleteAbsence, listAbsences, updateAbsence } from '~/services/absences'
import { listPublicHolidays } from '~/services/public-holidays'
import {
bulkUpdateWorkHourSiteValidation,
bulkUpdateWorkHourValidation,
bulkUpsertWorkHours,
getWorkHourDayContext,
getWeeklyWorkHourSummary,
@@ -136,25 +138,61 @@ export const useHoursPage = () => {
return true
}
const validatableEmployeeIds = computed(() => {
return employees.value
const canCreateValidationRowFromAbsence = (employeeId: number) => {
const row = rows.value[employeeId]
if (row?.workHourId) return false
const dayRow = dayContextByEmployeeId.value.get(employeeId)
return !!dayRow?.absenceLabel && hasContractAtSelectedDate(employeeId)
}
const canCreateSiteValidationRowFromAbsence = (employeeId: number) => {
const row = rows.value[employeeId]
if (row?.workHourId) return false
const dayRow = dayContextByEmployeeId.value.get(employeeId)
return !!dayRow?.absenceLabel && hasContractAtSelectedDate(employeeId)
}
const bulkValidatableEmployeeIds = computed(() => {
return visibleEmployees.value
.map((employee) => employee.id)
.filter((employeeId) => canToggleValidation(employeeId))
.filter((employeeId) => canToggleValidation(employeeId) || canCreateValidationRowFromAbsence(employeeId))
})
const isBulkValidationChecked = computed(() => {
const ids = validatableEmployeeIds.value
const ids = bulkValidatableEmployeeIds.value
if (ids.length === 0) return false
return ids.every((employeeId) => rows.value[employeeId]?.isValid ?? false)
})
const isBulkValidationIndeterminate = computed(() => {
const ids = validatableEmployeeIds.value
const ids = bulkValidatableEmployeeIds.value
if (ids.length === 0) return false
const checkedCount = ids.filter((employeeId) => rows.value[employeeId]?.isValid ?? false).length
return checkedCount > 0 && checkedCount < ids.length
})
const bulkSiteValidatableEmployeeIds = computed(() => {
if (!isSiteManager.value) return []
return visibleEmployees.value
.map((employee) => employee.id)
.filter((employeeId) => canToggleSiteValidation(employeeId) || canCreateSiteValidationRowFromAbsence(employeeId))
})
const isBulkSiteValidationChecked = computed(() => {
const ids = bulkSiteValidatableEmployeeIds.value
if (ids.length === 0) return false
return ids.every((employeeId) => rows.value[employeeId]?.isSiteValid ?? false)
})
const isBulkSiteValidationIndeterminate = computed(() => {
const ids = bulkSiteValidatableEmployeeIds.value
if (ids.length === 0) return false
const checkedCount = ids.filter((employeeId) => rows.value[employeeId]?.isSiteValid ?? false).length
return checkedCount > 0 && checkedCount < ids.length
})
const canBulkToggleSiteValidation = computed(() => bulkSiteValidatableEmployeeIds.value.length > 0)
const dayContextByEmployeeId = computed(() => {
const map = new Map<number, WorkHourDayContext['rows'][number]>()
for (const row of dayContext.value?.rows ?? []) {
@@ -419,10 +457,19 @@ export const useHoursPage = () => {
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 row = rows.value[employeeId]
const basePresence = (row?.isPresentMorning ? 0.5 : 0) + (row?.isPresentAfternoon ? 0.5 : 0)
const creditedPresence = dayContextByEmployeeId.value.get(employeeId)?.creditedPresenceUnits ?? 0
const dayRow = dayContextByEmployeeId.value.get(employeeId)
const absentMorning = dayRow?.absentMorning ?? false
const absentAfternoon = dayRow?.absentAfternoon ?? false
const basePresence = ((row?.isPresentMorning && !absentMorning) ? 0.5 : 0) + ((row?.isPresentAfternoon && !absentAfternoon) ? 0.5 : 0)
const creditedPresence = dayRow?.creditedPresenceUnits ?? 0
const total = Math.min(1, basePresence + creditedPresence)
return Number.isInteger(total) ? String(total) : total.toFixed(1)
}
@@ -739,44 +786,169 @@ export const useHoursPage = () => {
}
const toggleValidationBulk = async (checked: boolean) => {
const employeeIds = validatableEmployeeIds.value
const employeeIds = bulkValidatableEmployeeIds.value
if (employeeIds.length === 0) return
let successCount = 0
let failedCount = 0
const pendingIds = new Set(validatingRowIds.value)
const availableEmployeeIds = employeeIds.filter((employeeId) => !pendingIds.has(employeeId))
if (availableEmployeeIds.length === 0) return
for (const employeeId of employeeIds) {
if (isValidationPending(employeeId)) continue
try {
await toggleValidation(employeeId, checked, { toast: false })
successCount += 1
} catch {
failedCount += 1
if (checked) {
const toCreateIds = availableEmployeeIds.filter((employeeId) => canCreateValidationRowFromAbsence(employeeId))
if (toCreateIds.length > 0) {
await bulkUpsertWorkHours({
workDate: selectedDate.value,
entries: toCreateIds.map((employeeId) => ({
employeeId,
morningFrom: null,
morningTo: null,
afternoonFrom: null,
afternoonTo: null,
eveningFrom: null,
eveningTo: null,
isPresentMorning: false,
isPresentAfternoon: false
}))
}, { toast: false })
await loadWorkHours()
}
}
if (failedCount === 0) {
toast.success({
title: 'Succès',
message: checked
? `${successCount} ligne(s) validée(s).`
: `${successCount} validation(s) retirée(s).`
const targetEmployeeIds = availableEmployeeIds.filter((employeeId) => canToggleValidation(employeeId))
if (targetEmployeeIds.length === 0) {
toast.error({
title: 'Validation impossible',
message: 'Aucune ligne ne peut être validée.'
})
return
}
if (successCount === 0) {
validatingRowIds.value = Array.from(new Set([...validatingRowIds.value, ...targetEmployeeIds]))
try {
const result = await bulkUpdateWorkHourValidation({
workDate: selectedDate.value,
isValid: checked,
employeeIds: targetEmployeeIds
}, { toast: false })
await loadWorkHours()
if (result.updated === 0) {
toast.error({
title: 'Erreur',
message: 'Aucune ligne mise à jour.'
})
return
}
if (result.skipped > 0) {
toast.success({
title: 'Succès partiel',
message: `${result.updated} mise(s) à jour, ${result.skipped} ignorée(s).`
})
return
}
toast.success({
title: 'Succès',
message: checked
? `${result.updated} ligne(s) validée(s).`
: `${result.updated} validation(s) retirée(s).`
})
} catch {
toast.error({
title: 'Erreur',
message: 'Impossible de mettre à jour les validations.'
})
} finally {
validatingRowIds.value = validatingRowIds.value.filter((id) => !targetEmployeeIds.includes(id))
}
}
const toggleSiteValidationBulk = async (checked: boolean) => {
if (!isSiteManager.value) return
const employeeIds = bulkSiteValidatableEmployeeIds.value
if (employeeIds.length === 0) return
const pendingIds = new Set(siteValidatingRowIds.value)
const availableEmployeeIds = employeeIds.filter((employeeId) => !pendingIds.has(employeeId))
if (availableEmployeeIds.length === 0) return
if (checked) {
const toCreateIds = availableEmployeeIds.filter((employeeId) => canCreateSiteValidationRowFromAbsence(employeeId))
if (toCreateIds.length > 0) {
await bulkUpsertWorkHours({
workDate: selectedDate.value,
entries: toCreateIds.map((employeeId) => ({
employeeId,
morningFrom: null,
morningTo: null,
afternoonFrom: null,
afternoonTo: null,
eveningFrom: null,
eveningTo: null,
isPresentMorning: false,
isPresentAfternoon: false
}))
}, { toast: false })
await loadWorkHours()
}
}
const targetEmployeeIds = availableEmployeeIds.filter((employeeId) => canToggleSiteValidation(employeeId))
if (targetEmployeeIds.length === 0) {
toast.error({
title: 'Validation impossible',
message: 'Aucune ligne ne peut être validée côté site.'
})
return
}
toast.error({
title: 'Erreur',
message: `${successCount} mise(s) à jour, ${failedCount} en échec.`
})
siteValidatingRowIds.value = Array.from(new Set([...siteValidatingRowIds.value, ...targetEmployeeIds]))
try {
const result = await bulkUpdateWorkHourSiteValidation({
workDate: selectedDate.value,
isSiteValid: checked,
employeeIds: targetEmployeeIds
}, { toast: false })
await loadWorkHours()
if (result.updated === 0) {
toast.error({
title: 'Erreur',
message: 'Aucune ligne site mise à jour.'
})
return
}
if (result.skipped > 0) {
toast.success({
title: 'Succès partiel',
message: `${result.updated} mise(s) à jour, ${result.skipped} ignorée(s).`
})
return
}
toast.success({
title: 'Succès',
message: checked
? `${result.updated} validation(s) site enregistrée(s).`
: `${result.updated} validation(s) site retirée(s).`
})
} catch {
toast.error({
title: 'Erreur',
message: 'Impossible de mettre à jour les validations site.'
})
} finally {
siteValidatingRowIds.value = siteValidatingRowIds.value.filter((id) => !targetEmployeeIds.includes(id))
}
}
const loadEmployees = async () => {
@@ -956,14 +1128,18 @@ export const useHoursPage = () => {
isSiteValidationPending,
canToggleValidation,
canToggleSiteValidation,
validatableEmployeeIds,
isBulkValidationChecked,
isBulkValidationIndeterminate,
isBulkSiteValidationChecked,
isBulkSiteValidationIndeterminate,
canBulkToggleSiteValidation,
toggleValidation,
toggleSiteValidation,
toggleValidationBulk,
toggleSiteValidationBulk,
getRowMetrics,
getRowAbsenceLabel,
getRowAbsenceStyle,
getPresenceDayValue,
openAbsenceDrawer,
submitAbsence,

View File

@@ -30,22 +30,17 @@
<div class="w-80">
<EmployeeNameFilterInput v-model="employeeFilter"/>
</div>
<select
v-model="selectedMonth"
class="h-10 rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
>
<option v-for="month in months" :key="month.value" :value="month.value">
{{ month.label }}
</option>
</select>
<select
v-model="selectedYear"
class="h-10 rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
>
<option v-for="year in years" :key="year" :value="year">
{{ year }}
</option>
</select>
<PeriodStepperPicker
width-class="w-[260px]"
:label="selectedMonthLabel"
picker-type="month"
:picker-value="monthPickerValue"
prev-aria-label="Mois précédent"
next-aria-label="Mois suivant"
@prev="shiftMonth(-1)"
@next="shiftMonth(1)"
@pick="onMonthPickerValue"
/>
</div>
</div>
<div class="flex flex-wrap items-center gap-6 py-2">
@@ -86,6 +81,8 @@
<AbsencePrintDrawer
v-model="isPrintOpen"
:sites="sites"
:contract-natures="contractNatureOptions"
:work-contracts="workContractOptions"
:print-form="printForm"
@submit="handlePrint"
@cancel="closePrint"
@@ -109,6 +106,7 @@ import CalendarGrid from '~/components/CalendarGrid.vue'
import AbsenceFormDrawer from '~/components/AbsenceFormDrawer.vue'
import AbsencePrintDrawer from '~/components/AbsencePrintDrawer.vue'
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
import PeriodStepperPicker from '~/components/PeriodStepperPicker.vue'
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
useHead({
@@ -193,8 +191,8 @@ const months = [
{value: 11, label: 'Décembre'}
]
const years = Array.from({length: 5}, (unusedValue, index) => now.getFullYear() - 2 + index)
const selectedMonthLabel = computed(() => `${months[selectedMonth.value]?.label ?? ''}`)
const monthPickerValue = computed(() => `${selectedYear.value}-${String(selectedMonth.value + 1).padStart(2, '0')}`)
// Infos de calendrier calculées.
const daysInMonth = computed(() => getDaysInMonth(selectedYear.value, selectedMonth.value))
@@ -221,7 +219,25 @@ const form = reactive({
const printForm = reactive({
from: '',
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.
@@ -249,6 +265,8 @@ const openPrint = () => {
printForm.from = monthStart
printForm.to = monthEnd
printForm.siteIds = [...selectedSiteIds.value]
printForm.contractNatures = contractNatureOptions.map((item) => item.value)
printForm.workContractIds = workContractOptions.value.map((item) => item.id)
isPrintOpen.value = true
}
@@ -294,6 +312,22 @@ const addMonths = (date: Date, months: number) => {
return next
}
const shiftMonth = (delta: number) => {
const next = new Date(selectedYear.value, selectedMonth.value + delta, 1)
selectedYear.value = next.getFullYear()
selectedMonth.value = next.getMonth()
}
const onMonthPickerValue = (value: string) => {
if (!value) return
const [yearStr, monthStr] = value.split('-')
const year = Number(yearStr)
const month = Number(monthStr)
if (!Number.isInteger(year) || !Number.isInteger(month) || month < 1 || month > 12) return
selectedYear.value = year
selectedMonth.value = month - 1
}
// Limite l'intervalle d'impression à 2 mois max.
const enforcePrintRange = () => {
if (!printForm.from) return
@@ -657,6 +691,12 @@ const handlePrint = async () => {
if (printForm.siteIds.length > 0) {
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()}`)
isPrintOpen.value = false
}

View File

@@ -11,7 +11,7 @@
<button
type="button"
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é
</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 class="h-full overflow-auto">
<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">Nom</span>
<span class="text-left">Site</span>
<span class="text-left">Nature</span>
<span class="text-left">Contrat</span>
<span class="text-right">Actions</span>
</div>
@@ -46,7 +47,7 @@
<div
v-for="employee in filteredEmployees"
: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.lastName }}</span>
@@ -57,6 +58,7 @@
>
{{ employee.site?.name ?? '-' }}
</span>
<span>{{ contractNatureLabel(employee.currentContractNature) }}</span>
<span>{{ employee.contract?.name ?? '-' }}</span>
<div class="flex items-center justify-end gap-2">
<button
@@ -128,24 +130,72 @@
Le site est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="contract">
Contrat <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 contrat est obligatoire.
</p>
</div>
<template v-if="!editingEmployee">
<div>
<label class="text-md font-semibold text-neutral-700" for="contract-nature">
Type de contrat <span class="text-red-600">*</span>
</label>
<select
id="contract-nature"
v-model="form.contractNature"
:class="contractNatureFieldClass"
>
<option value="CDI">CDI</option>
<option value="CDD">CDD</option>
<option value="INTERIM">Intérim</option>
</select>
<p v-if="showContractNatureError" class="mt-1 text-sm text-red-600">
Le type de contrat est obligatoire.
</p>
</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">
<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({
firstName: '',
lastName: '',
siteId: '' as number | '',
contractId: '' as number | ''
contractId: '' as number | '',
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
contractStartDate: '',
contractEndDate: ''
})
const validationTouched = reactive({
firstName: false,
lastName: false,
siteId: false,
contractId: false
contractId: false,
contractNature: false,
contractStartDate: false,
contractEndDate: false
})
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 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(
() => 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(
@@ -246,6 +324,15 @@ const showSiteError = computed(
const showContractError = computed(
() => 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 =
'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`
})
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(() => {
if (isSubmitting.value || !isFormValid.value) {
@@ -304,6 +411,9 @@ const loadContracts = async () => {
onMounted(async () => {
await Promise.all([loadEmployees(), loadSites(), loadContracts()])
if (form.contractStartDate === '') {
form.contractStartDate = new Date().toISOString().slice(0, 10)
}
})
watch(sites, (nextSites) => {
@@ -324,7 +434,12 @@ const handleSubmit = async () => {
validationTouched.firstName = true
validationTouched.lastName = 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
isSubmitting.value = true
@@ -334,14 +449,17 @@ const handleSubmit = async () => {
firstName: form.firstName,
lastName: form.lastName,
siteId: form.siteId === '' ? null : Number(form.siteId),
contractId: Number(form.contractId)
contractId: editingEmployee.value.contract?.id ?? Number(form.contractId)
})
} else {
await createEmployee({
firstName: form.firstName,
lastName: form.lastName,
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.siteId = ''
form.contractId = ''
form.contractNature = 'CDI'
form.contractStartDate = new Date().toISOString().slice(0, 10)
form.contractEndDate = ''
editingEmployee.value = null
isDrawerOpen.value = false
await loadEmployees()
@@ -363,6 +484,15 @@ watch(isDrawerOpen, (isOpen) => {
validationTouched.lastName = false
validationTouched.siteId = 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.lastName = employee.lastName
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
}

View File

@@ -56,11 +56,16 @@
:can-toggle-site-validation="canToggleSiteValidation"
:is-bulk-validation-checked="isBulkValidationChecked"
:is-bulk-validation-indeterminate="isBulkValidationIndeterminate"
:is-bulk-site-validation-checked="isBulkSiteValidationChecked"
:is-bulk-site-validation-indeterminate="isBulkSiteValidationIndeterminate"
:can-bulk-toggle-site-validation="canBulkToggleSiteValidation"
:on-toggle-validation="toggleValidation"
:on-toggle-site-validation="toggleSiteValidation"
:on-toggle-validation-bulk="toggleValidationBulk"
:on-toggle-site-validation-bulk="toggleSiteValidationBulk"
:get-row-metrics="getRowMetrics"
:get-row-absence-label="getRowAbsenceLabel"
:get-row-absence-style="getRowAbsenceStyle"
:get-presence-day-value="getPresenceDayValue"
:on-absence-click="openAbsenceDrawer"
:format-minutes="formatMinutes"
@@ -158,11 +163,16 @@ const {
canToggleSiteValidation,
isBulkValidationChecked,
isBulkValidationIndeterminate,
isBulkSiteValidationChecked,
isBulkSiteValidationIndeterminate,
canBulkToggleSiteValidation,
toggleValidation,
toggleSiteValidation,
toggleValidationBulk,
toggleSiteValidationBulk,
getRowMetrics,
getRowAbsenceLabel,
getRowAbsenceStyle,
getPresenceDayValue,
openAbsenceDrawer,
submitAbsence,

View File

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

View File

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

View File

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

View File

@@ -58,6 +58,29 @@ export const updateWorkHourValidation = async (
)
}
export const bulkUpdateWorkHourValidation = async (payload: {
workDate: string
isValid: boolean
employeeIds: number[]
}, options?: { toast?: boolean }) => {
const api = useApi()
return api.post<{
requested: number
updated: number
skipped: number
updatedEmployeeIds: number[]
skippedEmployeeIds: number[]
}>(
'/work-hours/bulk-validation',
payload,
{
toast: options?.toast ?? true,
toastSuccessMessage: payload.isValid ? 'Validations enregistrées.' : 'Validations retirées.',
toastErrorMessage: "Impossible de mettre à jour les validations."
}
)
}
export const updateWorkHourSiteValidation = async (
id: number,
isSiteValid: boolean,
@@ -75,6 +98,29 @@ export const updateWorkHourSiteValidation = async (
)
}
export const bulkUpdateWorkHourSiteValidation = async (payload: {
workDate: string
isSiteValid: boolean
employeeIds: number[]
}, options?: { toast?: boolean }) => {
const api = useApi()
return api.post<{
requested: number
updated: number
skipped: number
updatedEmployeeIds: number[]
skippedEmployeeIds: number[]
}>(
'/work-hours/site-bulk-validation',
payload,
{
toast: options?.toast ?? true,
toastSuccessMessage: payload.isSiteValid ? 'Validations site enregistrées.' : 'Validations site retirées.',
toastErrorMessage: "Impossible de mettre à jour les validations site."
}
)
}
export const getWeeklyWorkHourSummary = async (weekStart: string) => {
const api = useApi()
return api.get<WeeklyWorkHourSummary>(

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: 'to', required: true),
new QueryParameter(key: 'sites', required: false),
new QueryParameter(key: 'contractNatures', required: false),
new QueryParameter(key: 'workContracts', required: false),
],
security: "is_granted('ROLE_ADMIN')"
),

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\State\WorkHourBulkSiteValidationProcessor;
#[ApiResource(
operations: [
new Post(
uriTemplate: '/work-hours/site-bulk-validation',
security: "is_granted('ROLE_USER')",
output: WorkHourBulkValidationResult::class,
processor: WorkHourBulkSiteValidationProcessor::class
),
]
)]
final class WorkHourBulkSiteValidation
{
public string $workDate = '';
public bool $isSiteValid = false;
/**
* @var list<int>
*/
public array $employeeIds = [];
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\State\WorkHourBulkValidationProcessor;
#[ApiResource(
operations: [
new Post(
uriTemplate: '/work-hours/bulk-validation',
security: "is_granted('ROLE_ADMIN')",
output: WorkHourBulkValidationResult::class,
processor: WorkHourBulkValidationProcessor::class
),
]
)]
final class WorkHourBulkValidation
{
public string $workDate = '';
public bool $isValid = false;
/**
* @var list<int>
*/
public array $employeeIds = [];
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
final class WorkHourBulkValidationResult
{
public int $requested = 0;
public int $updated = 0;
public int $skipped = 0;
/**
* @var list<int>
*/
public array $updatedEmployeeIds = [];
/**
* @var list<int>
*/
public array $skippedEmployeeIds = [];
}

View File

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

View File

@@ -10,6 +10,7 @@ final class DayContextRow
public int $employeeId,
public bool $hasContractAtDate = true,
public ?string $absenceLabel = null,
public ?string $absenceColor = null,
public ?string $absenceHalf = null,
public bool $absentMorning = false,
public bool $absentAfternoon = false,
@@ -19,6 +20,7 @@ final class DayContextRow
public function addAbsence(
?string $label,
?string $color,
bool $morning,
bool $afternoon,
int $creditedMinutes,
@@ -35,6 +37,14 @@ final class DayContextRow
$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.
$this->absenceHalf = $this->resolveHalfLabel($this->absentMorning, $this->absentAfternoon);
// Cumule les minutes créditées par les absences "comptées comme travaillées".
@@ -48,6 +58,7 @@ final class DayContextRow
* employeeId:int,
* hasContractAtDate:bool,
* absenceLabel:?string,
* absenceColor:?string,
* absenceHalf:?string,
* absentMorning:bool,
* absentAfternoon:bool,
@@ -61,6 +72,7 @@ final class DayContextRow
'employeeId' => $this->employeeId,
'hasContractAtDate' => $this->hasContractAtDate,
'absenceLabel' => $this->absenceLabel,
'absenceColor' => $this->absenceColor,
'absenceHalf' => $this->absenceHalf,
'absentMorning' => $this->absentMorning,
'absentAfternoon' => $this->absentAfternoon,

View File

@@ -6,9 +6,12 @@ namespace App\Entity;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use App\Enum\ContractNature;
use App\Repository\EmployeeRepository;
use App\State\EmployeeWriteProcessor;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
@@ -56,9 +59,25 @@ class Employee
#[ORM\Column(type: 'datetime_immutable')]
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()
{
$this->createdAt = new DateTimeImmutable();
$this->createdAt = new DateTimeImmutable();
$this->contractPeriods = new ArrayCollection();
}
public function getId(): ?int
@@ -130,4 +149,81 @@ class Employee
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;
use App\Enum\ContractNature;
use App\Repository\EmployeeContractPeriodRepository;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
@@ -19,7 +20,7 @@ class EmployeeContractPeriod
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Employee::class)]
#[ORM\ManyToOne(targetEntity: Employee::class, inversedBy: 'contractPeriods')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?Employee $employee = null;
@@ -33,6 +34,9 @@ class EmployeeContractPeriod
#[ORM\Column(type: 'date_immutable', nullable: true)]
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')]
private DateTimeImmutable $createdAt;
@@ -95,6 +99,24 @@ class EmployeeContractPeriod
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
{
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')
->leftJoin('e.site', 's')
->addSelect('s')
->leftJoin('e.contract', 'c')
->addSelect('c')
->orderBy('s.displayOrder', 'ASC')
->addOrderBy('s.name', 'ASC')
->addOrderBy('e.displayOrder', 'ASC')

View File

@@ -6,6 +6,7 @@ namespace App\Service\Contracts;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Enum\ContractNature;
use App\Repository\EmployeeContractPeriodRepository;
use DateTimeImmutable;
@@ -22,6 +23,13 @@ readonly class EmployeeContractResolver
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<string> $days
@@ -68,4 +76,51 @@ readonly class EmployeeContractResolver
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

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Service\WorkHours;
use App\ApiResource\WorkHourBulkValidationResult;
use App\Entity\User;
use App\Entity\WorkHour;
use App\Repository\EmployeeRepository;
use App\Repository\WorkHourRepository;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
final readonly class WorkHourBulkValidationExecutor
{
public function __construct(
private EntityManagerInterface $entityManager,
private EmployeeRepository $employeeRepository,
private WorkHourRepository $workHourRepository,
) {}
/**
* @param list<mixed> $employeeIds
* @param callable(?WorkHour, int): bool $shouldSkip
* @param callable(WorkHour, int): void $applyUpdate
*/
public function execute(
User $user,
string $workDateValue,
array $employeeIds,
callable $shouldSkip,
callable $applyUpdate
): WorkHourBulkValidationResult {
$workDate = DateTimeImmutable::createFromFormat('Y-m-d', $workDateValue);
if (!$workDate || $workDate->format('Y-m-d') !== $workDateValue) {
throw new UnprocessableEntityHttpException('workDate must use Y-m-d format.');
}
$normalizedEmployeeIds = $this->normalizeEmployeeIds($employeeIds);
if ([] === $normalizedEmployeeIds) {
throw new UnprocessableEntityHttpException('employeeIds must contain at least one employee.');
}
$employeesById = $this->employeeRepository->findAccessibleByIds($normalizedEmployeeIds, $user);
if (count($employeesById) !== count($normalizedEmployeeIds)) {
throw new AccessDeniedHttpException('At least one employee is unknown or outside your scope.');
}
$existingByEmployeeId = $this->workHourRepository
->findByDateAndEmployeesIndexedByEmployeeId($workDate, array_values($employeesById))
;
$result = new WorkHourBulkValidationResult();
$result->requested = count($normalizedEmployeeIds);
foreach ($normalizedEmployeeIds as $employeeId) {
$workHour = $existingByEmployeeId[$employeeId] ?? null;
if (null === $workHour || $shouldSkip($workHour, $employeeId)) {
++$result->skipped;
$result->skippedEmployeeIds[] = $employeeId;
continue;
}
$applyUpdate($workHour, $employeeId);
++$result->updated;
$result->updatedEmployeeIds[] = $employeeId;
}
if ($result->updated > 0) {
$this->entityManager->flush();
}
return $result;
}
/**
* @param list<mixed> $employeeIds
*
* @return list<int>
*/
private function normalizeEmployeeIds(array $employeeIds): array
{
$normalized = [];
foreach ($employeeIds as $index => $rawId) {
$employeeId = (int) $rawId;
if ($employeeId <= 0) {
throw new UnprocessableEntityHttpException(sprintf('employeeIds[%d] must be a positive integer.', $index));
}
if (isset($normalized[$employeeId])) {
throw new UnprocessableEntityHttpException(sprintf('Employee %d appears multiple times in payload.', $employeeId));
}
$normalized[$employeeId] = $employeeId;
}
return array_values($normalized);
}
}

View File

@@ -60,11 +60,6 @@ final readonly class WorkedHoursCreditPolicy
bool $absentMorning,
bool $absentAfternoon
): float {
$type = $absence->getType();
if (!$type?->getCountAsWorkedHours()) {
return 0.0;
}
$employee = $absence->getEmployee();
if (null === $employee) {
return 0.0;
@@ -74,9 +69,14 @@ final readonly class WorkedHoursCreditPolicy
return 0.0;
}
$halfUnits = ($absentMorning ? 1 : 0) + ($absentAfternoon ? 1 : 0);
// Règle forfait:
// - demi-journée d'absence => 0.5 travaillé
// - journée complète d'absence => 0 travaillé
if ($absentMorning xor $absentAfternoon) {
return 0.5;
}
return $halfUnits * 0.5;
return 0.0;
}
public function resolveContractDayMinutes(?int $weeklyHours, int $isoWeekDay): int

View File

@@ -6,6 +6,7 @@ namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Enum\ContractNature;
use App\Enum\HalfDay;
use App\Repository\AbsenceRepository;
use App\Repository\EmployeeRepository;
@@ -53,9 +54,11 @@ class AbsencePrintProvider implements ProviderInterface
$fromDate = DateTimeImmutable::createFromFormat('Y-m-d', $from);
$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);
$days = $this->buildDays($fromDate, $toDate);
@@ -108,9 +111,19 @@ class AbsencePrintProvider implements ProviderInterface
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
@@ -209,4 +222,24 @@ class AbsencePrintProvider implements ProviderInterface
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\Employee;
use App\Entity\EmployeeContractPeriod;
use App\Enum\ContractNature;
use App\Repository\EmployeeContractPeriodRepository;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
final readonly class EmployeeWriteProcessor implements ProcessorInterface
{
@@ -49,27 +51,46 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
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) {
$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;
}
if ($this->isSameContract($previousContract, $currentContract)) {
$hasPeriodChangeRequest = null !== $requestedContractNature || null !== $requestedStartDate || null !== $requestedEndDate;
if ($this->isSameContract($previousContract, $currentContract) && !$hasPeriodChangeRequest) {
return $result;
}
$startDate = $requestedStartDate ?? $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->setContractNature($nature);
$todayPeriod->setEndDate($endDate);
$this->entityManager->flush();
return $result;
}
$this->periodRepository->closeOpenPeriods($data, $today->modify('-1 day'));
$this->createPeriod($data, $currentContract, $today);
$this->periodRepository->closeOpenPeriods($data, $startDate->modify('-1 day'));
$this->createPeriod($data, $currentContract, $startDate, $endDate, $nature);
$this->entityManager->flush();
return $result;
@@ -96,26 +117,80 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
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);
if (null !== $covered) {
return;
}
$this->createPeriod($employee, $contract, $startDate);
$this->createPeriod($employee, $contract, $startDate, $endDate, $nature);
$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()
->setEmployee($employee)
->setContract($contract)
->setStartDate($startDate)
->setEndDate(null)
->setEndDate($endDate)
->setContractNature($nature)
;
$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

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\WorkHourBulkSiteValidation;
use App\ApiResource\WorkHourBulkValidationResult;
use App\Entity\User;
use App\Entity\WorkHour;
use App\Service\WorkHours\WorkHourBulkValidationExecutor;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
final readonly class WorkHourBulkSiteValidationProcessor implements ProcessorInterface
{
public function __construct(
private Security $security,
private WorkHourBulkValidationExecutor $executor,
) {}
public function process(
mixed $data,
Operation $operation,
array $uriVariables = [],
array $context = []
): WorkHourBulkValidationResult {
if (!$data instanceof WorkHourBulkSiteValidation) {
throw new BadRequestHttpException('Invalid payload.');
}
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException('Authentication required.');
}
if (in_array('ROLE_ADMIN', $user->getRoles(), true) || in_array('ROLE_SELF', $user->getRoles(), true)) {
throw new AccessDeniedHttpException('Only site managers can bulk update site validation.');
}
return $this->executor->execute(
user: $user,
workDateValue: $data->workDate,
employeeIds: $data->employeeIds,
shouldSkip: static fn (WorkHour $workHour): bool => $workHour->isValid() || $workHour->isSiteValid() === $data->isSiteValid,
applyUpdate: static function (WorkHour $workHour) use ($data): void {
$workHour->setIsSiteValid($data->isSiteValid);
}
);
}
}

View File

@@ -125,6 +125,14 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
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)) {
// Convention choisie: une ligne vide supprime l'enregistrement existant.
if ($existing) {

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\WorkHourBulkValidation;
use App\ApiResource\WorkHourBulkValidationResult;
use App\Entity\User;
use App\Entity\WorkHour;
use App\Service\WorkHours\WorkHourBulkValidationExecutor;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
final readonly class WorkHourBulkValidationProcessor implements ProcessorInterface
{
public function __construct(
private Security $security,
private WorkHourBulkValidationExecutor $executor,
) {}
public function process(
mixed $data,
Operation $operation,
array $uriVariables = [],
array $context = []
): WorkHourBulkValidationResult {
if (!$data instanceof WorkHourBulkValidation) {
throw new BadRequestHttpException('Invalid payload.');
}
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException('Authentication required.');
}
if (!in_array('ROLE_ADMIN', $user->getRoles(), true)) {
throw new AccessDeniedHttpException('Only admins can bulk validate work hours.');
}
return $this->executor->execute(
user: $user,
workDateValue: $data->workDate,
employeeIds: $data->employeeIds,
shouldSkip: static fn (WorkHour $workHour): bool => $workHour->isValid() === $data->isValid,
applyUpdate: static function (WorkHour $workHour) use ($data): void {
$workHour->setIsValid($data->isValid);
}
);
}
}

View File

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

View File

@@ -15,6 +15,7 @@ use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\User;
use App\Entity\WorkHour;
use App\Enum\ContractNature;
use App\Enum\ContractType;
use App\Enum\TrackingMode;
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
{
$contractsByEmployeeDate = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
$metricsByEmployeeDate = [];
$contractsByEmployeeDate = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
$contractNaturesByEmployeeDate = $this->contractResolver->resolveNaturesForEmployeesAndDays($employees, $days);
$metricsByEmployeeDate = [];
foreach ($workHours as $workHour) {
$employeeId = $workHour->getEmployee()?->getId();
if (!$employeeId) {
@@ -133,6 +135,8 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
$creditedByEmployeeDate = [];
$creditedPresenceByEmployeeDate = [];
$absenceByEmployeeDate = [];
$absentMorningByEmployeeDate = [];
$absentAfternoonByEmployeeDate = [];
$absenceLabelByEmployeeDate = [];
$absenceColorByEmployeeDate = [];
foreach ($absences as $absence) {
@@ -151,7 +155,9 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
[$absentMorning, $absentAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date);
if ($absentMorning || $absentAfternoon) {
$absenceByEmployeeDate[$employeeId][$date] = true;
$absenceByEmployeeDate[$employeeId][$date] = true;
$absentMorningByEmployeeDate[$employeeId][$date] = ($absentMorningByEmployeeDate[$employeeId][$date] ?? false) || $absentMorning;
$absentAfternoonByEmployeeDate[$employeeId][$date] = ($absentAfternoonByEmployeeDate[$employeeId][$date] ?? false) || $absentAfternoon;
if (!isset($absenceLabelByEmployeeDate[$employeeId][$date])) {
$absenceLabelByEmployeeDate[$employeeId][$date] = $absence->getType()?->getLabel();
}
@@ -182,6 +188,9 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
$weekAnchorContract = $contractsByEmployeeDate[$employeeId][$anchorDateYmd]
?? $contractsByEmployeeDate[$employeeId][$days[0]]
?? null;
$weekAnchorContractNature = $contractNaturesByEmployeeDate[$employeeId][$anchorDateYmd]
?? $contractNaturesByEmployeeDate[$employeeId][$days[0]]
?? ContractNature::CDI;
$employeeContractsByDate = [];
foreach ($days as $date) {
$employeeContractsByDate[$date] = $contractsByEmployeeDate[$employeeId][$date] ?? null;
@@ -197,8 +206,10 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
$metrics->addCreditedMinutes($creditedMinutes);
$present = null;
if ($isPresenceTracking) {
$morning = ($entry['isPresentMorning'] ?? false) ? 0.5 : 0.0;
$afternoon = ($entry['isPresentAfternoon'] ?? false) ? 0.5 : 0.0;
$absentMorning = $absentMorningByEmployeeDate[$employeeId][$date] ?? false;
$absentAfternoon = $absentAfternoonByEmployeeDate[$employeeId][$date] ?? false;
$morning = (($entry['isPresentMorning'] ?? false) && !$absentMorning) ? 0.5 : 0.0;
$afternoon = (($entry['isPresentAfternoon'] ?? false) && !$absentAfternoon) ? 0.5 : 0.0;
$creditedPresence = $creditedPresenceByEmployeeDate[$employeeId][$date] ?? 0.0;
$present = min(1.0, $morning + $afternoon + $creditedPresence);
}
@@ -223,7 +234,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
}
$isWeekPresenceTracking = TrackingMode::PRESENCE->value === $weekAnchorContract?->getTrackingMode();
$disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($weekAnchorContract);
$disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($weekAnchorContract, $weekAnchorContractNature);
$overtimeReferenceMinutes = $this->computeWeeklyOvertimeReferenceMinutes($days, $employeeContractsByDate);
$overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($days, $employeeContractsByDate);
$weeklyOvertimeTotalMinutes = $isWeekPresenceTracking
@@ -407,8 +418,12 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
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(
$contract?->getName(),
$contract?->getTrackingMode(),

View File

@@ -135,7 +135,7 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
self::assertSame(210, $result->rows[0]->weeklyOvertime50Minutes);
self::assertSame(1230, $result->rows[0]->weeklyRecoveryMinutes);
self::assertSame(1.0, $result->rows[1]->weeklyPresenceCount);
self::assertSame(0.0, $result->rows[1]->weeklyPresenceCount);
self::assertTrue($result->rows[1]->daily[0]->hasAbsence);
self::assertSame('Congé', $result->rows[1]->daily[0]->absenceLabel);
self::assertSame('#000', $result->rows[1]->daily[0]->absenceColor);