Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36fe9ae54c | ||
| 6395ffbe1c | |||
|
|
b5e7395760 | ||
| 380c72c242 | |||
|
|
107417a571 | ||
| 5ff7e356be | |||
|
|
635e24e9e1 | ||
| 4d90f2cb42 |
134
AGENTS.md
134
AGENTS.md
@@ -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 d’heures 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 d’absence (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 l’affichage/interaction et réutilise les données enrichies de l’API.
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.13'
|
||||
app.version: '0.1.17'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
77
frontend/components/PeriodStepperPicker.vue
Normal file
77
frontend/components/PeriodStepperPicker.vue
Normal 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>
|
||||
@@ -52,14 +52,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"
|
||||
@@ -197,6 +198,7 @@ const props = defineProps<{
|
||||
onToggleValidationBulk: (checked: boolean) => 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
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -419,10 +419,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)
|
||||
}
|
||||
@@ -964,6 +973,7 @@ export const useHoursPage = () => {
|
||||
toggleValidationBulk,
|
||||
getRowMetrics,
|
||||
getRowAbsenceLabel,
|
||||
getRowAbsenceStyle,
|
||||
getPresenceDayValue,
|
||||
openAbsenceDrawer,
|
||||
submitAbsence,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
:on-toggle-validation-bulk="toggleValidationBulk"
|
||||
: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"
|
||||
@@ -163,6 +164,7 @@ const {
|
||||
toggleValidationBulk,
|
||||
getRowMetrics,
|
||||
getRowAbsenceLabel,
|
||||
getRowAbsenceStyle,
|
||||
getPresenceDayValue,
|
||||
openAbsenceDrawer,
|
||||
submitAbsence,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
33
migrations/Version20260226203000.php
Normal file
33
migrations/Version20260226203000.php
Normal 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');
|
||||
}
|
||||
}
|
||||
82
migrations/Version20260226210000.php
Normal file
82
migrations/Version20260226210000.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
@@ -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')"
|
||||
),
|
||||
|
||||
@@ -26,6 +26,7 @@ final class WorkHourDayContext
|
||||
* @var list<array{
|
||||
* employeeId:int,
|
||||
* absenceLabel:?string,
|
||||
* absenceColor:?string,
|
||||
* absenceHalf:?string,
|
||||
* absentMorning:bool,
|
||||
* absentAfternoon:bool,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
17
src/Enum/ContractNature.php
Normal file
17
src/Enum/ContractNature.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user