Compare commits

..

18 Commits

Author SHA1 Message Date
gitea-actions 36fe9ae54c chore: bump version to v0.1.17
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m11s
2026-03-02 09:50:14 +00:00
tristan 6395ffbe1c feat : modification des sélecteurs de date sur le calendrier
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
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m26s
2026-03-02 09:34:21 +00:00
tristan 380c72c242 fix : règle de calcule des heures travaillées sur les contrats Forfait
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
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m16s
2026-02-27 09:22:05 +00:00
tristan 5ff7e356be fix : validation RH qui invalidé les sites
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
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m11s
2026-02-26 16:15:25 +00:00
tristan 4d90f2cb42 feat : ajout du nouveau système de contrat et ajout de filtre d'impression
Auto Tag Develop / tag (push) Successful in 5s
2026-02-26 17:15:13 +01:00
gitea-actions 9261cb5b1a chore: bump version to v0.1.13
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m14s
2026-02-26 13:49:38 +00:00
tristan b68fef61c4 fix : correction des Heures et ajout d'une validation pour les chefs de site
Auto Tag Develop / tag (push) Has been cancelled
2026-02-26 14:49:28 +01:00
gitea-actions 5cced46254 chore: bump version to v0.1.12
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m12s
2026-02-23 14:36:06 +00:00
tristan 07b84a2512 fix : modification du composant TimeSelect.vue pour pouvoir taper les heures au clavier
Auto Tag Develop / tag (push) Successful in 5s
2026-02-23 15:35:57 +01:00
gitea-actions ca26b7f934 chore: bump version to v0.1.11
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m18s
2026-02-23 13:51:19 +00:00
tristan 9cf978f0f2 feat : ajout des titles + update README.md
Auto Tag Develop / tag (push) Has been cancelled
2026-02-23 14:51:06 +01:00
gitea-actions ad9e8705ae chore: bump version to v0.1.10
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m10s
2026-02-20 16:17:28 +00:00
tristan f8ca5e50a0 [#339] Ajout d'une page listant les règles de calcules (#5)
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|        #339          |        Ajout d'une page listant les règles de calcules         |

## Description de la PR
[#339] Ajout d'une page listant les règles de calcules

## Modification du .env

## Check list

- [ ] Pas de régression
- [x] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #5
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-02-20 16:17:22 +00:00
gitea-actions 49fecfc27a chore: bump version to v0.1.9
Auto Tag Develop / tag (push) Successful in 3s
Build Release Artefact / build (push) Successful in 1m13s
2026-02-20 11:23:59 +00:00
tristan ee16779777 [#322] Page horaire (#4)
Auto Tag Develop / tag (push) Successful in 5s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|        #322          |        Page horaire         |

## Description de la PR
[#322] Page horaire

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #4
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-02-20 11:23:52 +00:00
81 changed files with 5151 additions and 366 deletions
+7
View File
@@ -145,6 +145,13 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/monolog/monolog" /> <excludeFolder url="file://$MODULE_DIR$/vendor/monolog/monolog" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bridge" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bridge" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bundle" /> <excludeFolder url="file://$MODULE_DIR$/vendor/symfony/monolog-bundle" />
<excludeFolder url="file://$MODULE_DIR$/LOG" />
<excludeFolder url="file://$MODULE_DIR$/frontend/.nuxt" />
<excludeFolder url="file://$MODULE_DIR$/frontend/.output" />
<excludeFolder url="file://$MODULE_DIR$/frontend/dist" />
<excludeFolder url="file://$MODULE_DIR$/frontend/node_modules" />
<excludeFolder url="file://$MODULE_DIR$/public" />
<excludeFolder url="file://$MODULE_DIR$/var" />
</content> </content>
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
+131
View File
@@ -0,0 +1,131 @@
# AGENTS.md
État des lieux opérationnel du projet SIRH (backend + frontend), mis à jour après les évolutions sur heures/absences/validations.
## 1) Stack et structure
- Backend: Symfony + API Platform + Doctrine ORM
- Frontend: Nuxt 4 + Vue 3 + TypeScript + Tailwind
- Exécution locale: Docker via `makefile`
Arborescence clé:
- `src/`: domaine, API resources, state providers/processors, services
- `tests/`: TU backend (PHPUnit)
- `frontend/`: app Nuxt (pages, composants, composables, services)
- `migrations/`: migrations Doctrine
## 2) Commandes utiles
- Démarrer stack: `make start`
- Tests backend: `make test`
- Build frontend: `cd frontend && npm run build`
- Dev frontend: `make dev-nuxt`
## 3) Domaine métier (résumé)
### Contrats
- Entité: `Contract`
- Champs principaux: `name`, `trackingMode`, `weeklyHours`, `isActive`, `type`
- `trackingMode`:
- `TIME`: suivi en heures
- `PRESENCE`: suivi en demi-journées/journées
- Enums backend:
- `App\Enum\TrackingMode`
- `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 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) Validations (important)
### 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`.
### 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.
### 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`
- Inclut: contrat résolu par jour, absences, crédits, jour/nuit/total, majorations, 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
## 7) Migrations sensibles
- `migrations/Version20260226183000.php`
- ajoute `work_hours.is_site_valid BOOLEAN NOT NULL DEFAULT FALSE`
- non destructive (pas de perte de données)
## 8) Points de vigilance prod
- 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)
## 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`
- `frontend/composables/useHoursPage.ts`
- `frontend/components/hours/HoursDayView.vue`
- `frontend/components/hours/HoursWeekView.vue`
+17
View File
@@ -1,2 +1,19 @@
# SIRH # SIRH
Application de gestion des absences employée Application de gestion des absences employée
## Importer un dump de prod en dev
Sur adminer fait un export bdd :
- Sortie : enregistrer
- Format : SQL
- Tables : DROP+CREATE, Incrément automatique, Déclencheurs
- Données : INSERT
Supprime la bdd et créer la bdd :
```shell
docker compose exec -T db psql -U root -d sirh -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
```
Remplie la base avec le dump :
```shell
docker compose exec -T db psql -U root -d sirh < sirh.sql
```
+4
View File
@@ -22,5 +22,9 @@ services:
App\: App\:
resource: '../src/' resource: '../src/'
App\Repository\Contract\AbsenceReadRepositoryInterface: '@App\Repository\AbsenceRepository'
App\Repository\Contract\EmployeeScopedRepositoryInterface: '@App\Repository\EmployeeRepository'
App\Repository\Contract\WorkHourReadRepositoryInterface: '@App\Repository\WorkHourRepository'
# add more service definitions when explicit configuration is needed # add more service definitions when explicit configuration is needed
# please note that last definitions always *replace* previous ones # please note that last definitions always *replace* previous ones
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.8' app.version: '0.1.17'
+7 -1
View File
@@ -9,6 +9,7 @@
id="employee" id="employee"
v-model="absenceForm.employeeId" v-model="absenceForm.employeeId"
:class="employeeFieldClass" :class="employeeFieldClass"
:disabled="props.lockEmployee"
> >
<option value="" disabled>Choisir un employé</option> <option value="" disabled>Choisir un employé</option>
<option v-for="employee in employees" :key="employee.id" :value="employee.id"> <option v-for="employee in employees" :key="employee.id" :value="employee.id">
@@ -48,6 +49,7 @@
v-model="absenceForm.startDate" v-model="absenceForm.startDate"
type="date" type="date"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900" class="w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
:disabled="props.lockDates"
/> />
<select <select
v-model="absenceForm.startHalf" v-model="absenceForm.startHalf"
@@ -67,6 +69,7 @@
v-model="absenceForm.endDate" v-model="absenceForm.endDate"
type="date" type="date"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900" class="w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
:disabled="props.lockDates"
/> />
<select <select
v-model="absenceForm.endHalf" v-model="absenceForm.endHalf"
@@ -80,7 +83,7 @@
</div> </div>
</div> </div>
<div> <div v-if="props.showComment !== false">
<label class="text-md font-semibold text-neutral-700" for="comment">Commentaire</label> <label class="text-md font-semibold text-neutral-700" for="comment">Commentaire</label>
<textarea <textarea
id="comment" id="comment"
@@ -142,6 +145,9 @@ const props = defineProps<{
} }
editingAbsence: Absence | null editingAbsence: Absence | null
isSubmitting: boolean isSubmitting: boolean
lockEmployee?: boolean
lockDates?: boolean
showComment?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
+79 -2
View File
@@ -54,6 +54,48 @@
</p> </p>
</div> </div>
<div class="space-y-2">
<p class="text-md font-semibold text-neutral-700">
Type de contrat <span class="text-red-600">*</span>
</p>
<div class="flex flex-wrap gap-4 rounded-md border border-neutral-300 px-3 py-2">
<div v-for="nature in contractNatures" :key="nature.value" class="flex items-center gap-2">
<label class="text-md" :for="`print-contract-nature-${nature.value}`">{{ nature.label }}</label>
<input
:id="`print-contract-nature-${nature.value}`"
v-model="printForm.contractNatures"
:value="nature.value"
type="checkbox"
class="h-4 w-4"
/>
</div>
</div>
<p v-if="showContractNaturesError" class="text-sm text-red-600">
Sélectionne au moins un type de contrat.
</p>
</div>
<div class="space-y-2">
<p class="text-md font-semibold text-neutral-700">
Temps de travail <span class="text-red-600">*</span>
</p>
<div class="flex flex-wrap gap-4 rounded-md border border-neutral-300 px-3 py-2">
<div v-for="workContract in workContracts" :key="workContract.id" class="flex items-center gap-2">
<label class="text-md" :for="`print-work-contract-${workContract.id}`">{{ workContract.name }}</label>
<input
:id="`print-work-contract-${workContract.id}`"
v-model="printForm.workContractIds"
:value="workContract.id"
type="checkbox"
class="h-4 w-4"
/>
</div>
</div>
<p v-if="showWorkContractsError" class="text-sm text-red-600">
Sélectionne au moins un temps de travail.
</p>
</div>
<div class="flex justify-end gap-3 pt-2"> <div class="flex justify-end gap-3 pt-2">
<button <button
type="button" type="button"
@@ -84,13 +126,27 @@ type SiteOption = {
color: string color: string
} }
type ContractNatureOption = {
value: 'CDI' | 'CDD' | 'INTERIM'
label: string
}
type WorkContractOption = {
id: number
name: string
}
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
sites: SiteOption[] sites: SiteOption[]
contractNatures: ContractNatureOption[]
workContracts: WorkContractOption[]
printForm: { printForm: {
from: string from: string
to: string to: string
siteIds: number[] siteIds: number[]
contractNatures: Array<'CDI' | 'CDD' | 'INTERIM'>
workContractIds: number[]
} }
}>() }>()
@@ -110,19 +166,36 @@ const printForm = toRef(props, 'printForm')
const validationTouched = reactive({ const validationTouched = reactive({
from: false, from: false,
to: false, to: false,
sites: false sites: false,
contractNatures: false,
workContracts: false
}) })
const isFromValid = computed(() => printForm.value.from.trim() !== '') const isFromValid = computed(() => printForm.value.from.trim() !== '')
const isToValid = computed(() => printForm.value.to.trim() !== '') const isToValid = computed(() => printForm.value.to.trim() !== '')
const isSitesValid = computed(() => printForm.value.siteIds.length > 0) const isSitesValid = computed(() => printForm.value.siteIds.length > 0)
const isContractNaturesValid = computed(() => {
if (props.contractNatures.length === 0) return true
return printForm.value.contractNatures.length > 0
})
const isWorkContractsValid = computed(() => {
if (props.workContracts.length === 0) return true
return printForm.value.workContractIds.length > 0
})
const isFormValid = computed( const isFormValid = computed(
() => isFromValid.value && isToValid.value && isSitesValid.value () =>
isFromValid.value &&
isToValid.value &&
isSitesValid.value &&
isContractNaturesValid.value &&
isWorkContractsValid.value
) )
const showFromError = computed(() => validationTouched.from && !isFromValid.value) const showFromError = computed(() => validationTouched.from && !isFromValid.value)
const showToError = computed(() => validationTouched.to && !isToValid.value) const showToError = computed(() => validationTouched.to && !isToValid.value)
const showSitesError = computed(() => validationTouched.sites && !isSitesValid.value) const showSitesError = computed(() => validationTouched.sites && !isSitesValid.value)
const showContractNaturesError = computed(() => validationTouched.contractNatures && !isContractNaturesValid.value)
const showWorkContractsError = computed(() => validationTouched.workContracts && !isWorkContractsValid.value)
const baseInputClass = const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-md text-neutral-900' 'mt-2 w-full rounded-md border px-3 py-2 text-md text-neutral-900'
@@ -150,6 +223,8 @@ const handleSubmit = () => {
validationTouched.from = true validationTouched.from = true
validationTouched.to = true validationTouched.to = true
validationTouched.sites = true validationTouched.sites = true
validationTouched.contractNatures = true
validationTouched.workContracts = true
if (!isFormValid.value) return if (!isFormValid.value) return
emit('submit') emit('submit')
} }
@@ -166,6 +241,8 @@ watch(
validationTouched.from = false validationTouched.from = false
validationTouched.to = false validationTouched.to = false
validationTouched.sites = false validationTouched.sites = false
validationTouched.contractNatures = false
validationTouched.workContracts = false
} }
} }
) )
@@ -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>
+135 -24
View File
@@ -1,22 +1,33 @@
<template> <template>
<div class="rounded-lg border border-neutral-200 bg-white overflow-hidden flex flex-1 min-h-0 flex-col"> <div class="rounded-lg border border-neutral-200 bg-white overflow-hidden flex min-h-0 flex-col">
<div class="overflow-y-auto min-h-0"> <div class="overflow-y-auto min-h-0">
<div <div
class="grid w-full min-w-0 gap-1 border-b border-neutral-200 bg-tertiary-500 px-4 py-3 text-sm font-semibold text-neutral-700 sticky top-0 z-10" class="grid w-full min-w-0 gap-1 border-b border-neutral-200 bg-tertiary-500 px-4 py-3 text-sm font-semibold text-neutral-700 sticky top-0 z-10"
:style="{ gridTemplateColumns: dayGridCols }" :style="{ gridTemplateColumns: dayGridCols }"
> >
<span>Nom</span> <span>Nom</span>
<span class="pl-4">Début matin</span> <span class="pl-2">Absence</span>
<span class="pr-2">Fin matin</span> <span class="pl-4">Début matin</span>
<span class="pl-2">Début après-midi</span> <span class="pr-2">Fin matin</span>
<span class="pr-2">Fin après-midi</span> <span class="pl-2">Début après-midi</span>
<span class="pl-2">Début soir</span> <span class="pr-2">Fin après-midi</span>
<span class="pr-2">Fin soir</span> <span class="pl-2">Début soir</span>
<span class="pl-2">Présent</span> <span class="pr-2">Fin soir</span>
<span class="pl-2">Jour</span> <span class="pl-2">Jour</span>
<span>Nuit</span> <span>Nuit</span>
<span>Total</span> <span>Total</span>
<span v-if="isAdmin">Valider</span> <span v-if="isAdmin" class="inline-flex items-center gap-2">
<span>Valider</span>
<input
ref="bulkValidationInput"
:checked="isBulkValidationChecked"
type="checkbox"
class="h-4 w-4 cursor-pointer"
@change="onBulkValidationChange"
/>
</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> </div>
<div <div
@@ -30,41 +41,92 @@
{{ employee.firstName }} {{ employee.lastName }} {{ employee.firstName }} {{ employee.lastName }}
<span class="font-normal text-neutral-600">({{ contractLabel(employee) }})</span> <span class="font-normal text-neutral-600">({{ contractLabel(employee) }})</span>
</p> </p>
<p class="text-neutral-500 truncate">{{ employee.site?.name ?? 'Sans site' }}</p> <p class="text-neutral-500 truncate inline-flex items-center gap-2">
<span>{{ employee.site?.name ?? 'Sans site' }}</span>
<span
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
class="rounded-full bg-green-500 flex justify-center item-center text-white p-0.5"
title="Validation site"
>
<Icon name="mdi:check"/>
</span>
</p>
</div> </div>
<div class="pl-4"> <div class="pl-2 min-w-0 self-stretch flex flex-col gap-1 justify-between py-0.5">
<TimeSelect v-if="isTimeTracking(employee)" v-model="rows[employee.id].morningFrom" :disabled="isRowLocked(employee.id)"/> <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>
<button
type="button"
class="self-start text-left text-xs font-semibold underline"
:class="isHoliday || isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
:disabled="isHoliday || isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
@click="onAbsenceClick(employee.id)"
>
Modifier
</button>
</div>
<div class="pl-4">
<TimeSelect
v-if="isTimeTracking(employee)"
v-model="rows[employee.id].morningFrom"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
/>
<input <input
v-else-if="isPresenceTracking(employee)" v-else-if="isPresenceTracking(employee)"
v-model="rows[employee.id].isPresentMorning" v-model="rows[employee.id].isPresentMorning"
type="checkbox" type="checkbox"
class="cursor-pointer h-4 w-4" class="cursor-pointer h-4 w-4"
:disabled="isRowLocked(employee.id)" :disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
/> />
</div> </div>
<div class="pr-2"> <div class="pr-2">
<TimeSelect v-if="isTimeTracking(employee)" v-model="rows[employee.id].morningTo" :disabled="isRowLocked(employee.id)"/> <TimeSelect
v-if="isTimeTracking(employee)"
v-model="rows[employee.id].morningTo"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
/>
</div> </div>
<div class="pl-2"> <div class="pl-2">
<TimeSelect v-if="isTimeTracking(employee)" v-model="rows[employee.id].afternoonFrom" :disabled="isRowLocked(employee.id)"/> <TimeSelect
v-if="isTimeTracking(employee)"
v-model="rows[employee.id].afternoonFrom"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
/>
<input <input
v-else-if="isPresenceTracking(employee)" v-else-if="isPresenceTracking(employee)"
v-model="rows[employee.id].isPresentAfternoon" v-model="rows[employee.id].isPresentAfternoon"
type="checkbox" type="checkbox"
class="cursor-pointer h-4 w-4" class="cursor-pointer h-4 w-4"
:disabled="isRowLocked(employee.id)" :disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
/> />
</div> </div>
<div class="pr-2"> <div class="pr-2">
<TimeSelect v-if="isTimeTracking(employee)" v-model="rows[employee.id].afternoonTo" :disabled="isRowLocked(employee.id)"/> <TimeSelect
v-if="isTimeTracking(employee)"
v-model="rows[employee.id].afternoonTo"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
/>
</div> </div>
<div class="pl-2"> <div class="pl-2">
<TimeSelect v-if="isTimeTracking(employee)" v-model="rows[employee.id].eveningFrom" :disabled="isRowLocked(employee.id)"/> <TimeSelect
v-if="isTimeTracking(employee)"
v-model="rows[employee.id].eveningFrom"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
/>
</div> </div>
<div class="pr-2"> <div class="pr-2">
<TimeSelect v-if="isTimeTracking(employee)" v-model="rows[employee.id].eveningTo" :disabled="isRowLocked(employee.id)"/> <TimeSelect
v-if="isTimeTracking(employee)"
v-model="rows[employee.id].eveningTo"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
/>
</div> </div>
<div class="pl-2"></div>
<div class="pl-2 text-sm font-semibold text-neutral-700"> <div class="pl-2 text-sm font-semibold text-neutral-700">
<div v-if="isTimeTracking(employee)">{{ formatMinutes(getRowMetrics(employee.id).dayMinutes) }}</div> <div v-if="isTimeTracking(employee)">{{ formatMinutes(getRowMetrics(employee.id).dayMinutes) }}</div>
</div> </div>
@@ -73,17 +135,32 @@
</div> </div>
<div class="text-sm font-semibold text-neutral-700"> <div class="text-sm font-semibold text-neutral-700">
<div v-if="isTimeTracking(employee)">{{ formatMinutes(getRowMetrics(employee.id).totalMinutes) }}</div> <div v-if="isTimeTracking(employee)">{{ formatMinutes(getRowMetrics(employee.id).totalMinutes) }}</div>
<div v-else>{{ getPresenceDayValue(employee.id) }}</div>
</div> </div>
<div v-if="isAdmin"> <div v-if="isAdmin">
<input <input
:checked="rows[employee.id]?.isValid ?? false" :checked="rows[employee.id]?.isValid ?? false"
type="checkbox" type="checkbox"
class="h-4 w-4" class="h-4 w-4 cursor-pointer"
:class="rows[employee.id]?.workHourId ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
:disabled="!rows[employee.id]?.workHourId || isValidationPending(employee.id)"
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)" @change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
/> />
</div> </div>
<div v-else>
<input
v-if="isSiteManager"
:checked="rows[employee.id]?.isSiteValid ?? false"
type="checkbox"
class="h-4 w-4 cursor-pointer"
:disabled="!canToggleSiteValidation(employee.id) || isSiteValidationPending(employee.id)"
@change="onToggleSiteValidation(employee.id, ($event.target as HTMLInputElement).checked)"
/>
<span v-else-if="rows[employee.id]?.isSiteValid" class="text-xs font-semibold text-neutral-700">Validé</span>
<span v-else class="text-xs text-neutral-500">-</span>
</div>
<div v-if="!isAdmin">
<span v-if="rows[employee.id]?.isValid" class="text-xs font-semibold text-neutral-700">Validé</span>
<span v-else class="text-xs text-neutral-500">-</span>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -95,18 +172,52 @@ import TimeSelect from '~/components/ui/TimeSelect.vue'
import type { HourRow } from './types' import type { HourRow } from './types'
const rows = defineModel<Record<number, HourRow>>('rows', { required: true }) const rows = defineModel<Record<number, HourRow>>('rows', { required: true })
const bulkValidationInput = ref<HTMLInputElement | null>(null)
defineProps<{ const props = defineProps<{
employees: Employee[] employees: Employee[]
isAdmin: boolean isAdmin: boolean
isSiteManager: boolean
dayGridCols: string dayGridCols: string
isHoliday: boolean
contractLabel: (employee: Employee) => string contractLabel: (employee: Employee) => string
isTimeTracking: (employee: Employee) => boolean isTimeTracking: (employee: Employee) => boolean
isPresenceTracking: (employee: Employee) => boolean isPresenceTracking: (employee: Employee) => boolean
isRowLocked: (employeeId: number) => boolean isRowLocked: (employeeId: number) => boolean
isHalfLockedByAbsence: (employeeId: number, half: 'AM' | 'PM') => boolean
isEveningLockedByAbsence: (employeeId: number) => boolean
hasContractAtSelectedDate: (employeeId: number) => boolean
isValidationPending: (employeeId: number) => boolean isValidationPending: (employeeId: number) => boolean
isSiteValidationPending: (employeeId: number) => boolean
canToggleValidation: (employeeId: number) => boolean
canToggleSiteValidation: (employeeId: number) => boolean
isBulkValidationChecked: boolean
isBulkValidationIndeterminate: boolean
onToggleValidation: (employeeId: number, checked: boolean) => void onToggleValidation: (employeeId: number, checked: boolean) => void
onToggleSiteValidation: (employeeId: number, checked: boolean) => void
onToggleValidationBulk: (checked: boolean) => void
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number } getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number }
getRowAbsenceLabel: (employeeId: number) => string
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
getPresenceDayValue: (employeeId: number) => string
onAbsenceClick: (employeeId: number) => void
formatMinutes: (minutes: number) => string formatMinutes: (minutes: number) => string
}>() }>()
const onBulkValidationChange = (event: Event) => {
props.onToggleValidationBulk((event.target as HTMLInputElement).checked)
}
const onToggleSiteValidation = (employeeId: number, checked: boolean) => {
props.onToggleSiteValidation(employeeId, checked)
}
watch(
() => props.isBulkValidationIndeterminate,
(isIndeterminate) => {
if (!bulkValidationInput.value) return
bulkValidationInput.value.indeterminate = isIndeterminate
},
{ immediate: true }
)
</script> </script>
+63 -30
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="py-6 flex flex-col gap-3"> <div class="py-6 flex flex-col gap-3">
<SiteFilterSelector v-if="sites.length > 0" v-model="selectedSiteIds" :sites="sites" /> <SiteFilterSelector v-if="sites.length > 0 && isAdmin" v-model="selectedSiteIds" :sites="sites" />
<div class="flex justify-between items-center gap-4"> <div class="flex justify-between items-center gap-4">
<div class="flex gap-4 flex-wrap"> <div class="flex gap-4 flex-wrap">
@@ -34,39 +34,47 @@
</button> </button>
</div> </div>
<div class="relative inline-flex h-10 w-[320px] items-center overflow-hidden rounded-md border border-primary-500 bg-white"> <div
<input v-else
ref="nativeDateInput" class="inline-flex h-10 w-[320px] overflow-hidden rounded-md border border-primary-500 bg-white"
v-model="selectedDate" >
type="date"
class="pointer-events-none absolute inset-0 h-full w-full opacity-0"
tabindex="-1"
aria-hidden="true"
/>
<button <button
type="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" class="flex-1 px-4 py-2 text-sm font-semibold active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
aria-label="Période précédente" :class="weekShortcutButtonClass('previousWeek')"
@click="emit('shift-date', -1)" @click="emit('set-previous-week')"
> >
{{ getWeekShortcutLabel('previousWeek') }}
</button> </button>
<button <button
type="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" class="flex-1 border-x border-primary-500 px-4 py-2 text-sm font-semibold active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
@click="openDatePicker" :class="weekShortcutButtonClass('thisWeek')"
@click="emit('set-this-week')"
> >
{{ formattedSelectedDate }} {{ getWeekShortcutLabel('thisWeek') }}
</button> </button>
<button <button
type="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" class="flex-1 px-4 py-2 text-sm font-semibold active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
aria-label="Période suivante" :class="weekShortcutButtonClass('nextWeek')"
@click="emit('shift-date', 1)" @click="emit('set-next-week')"
> >
{{ getWeekShortcutLabel('nextWeek') }}
</button> </button>
</div> </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>
<div v-if="isAdmin" class="inline-flex h-10 overflow-hidden rounded-md border border-primary-500 bg-white"> <div v-if="isAdmin" class="inline-flex h-10 overflow-hidden rounded-md border border-primary-500 bg-white">
@@ -91,16 +99,30 @@
</div> </div>
</div> </div>
<div class="w-80 max-w-full"> <div v-if="isAdmin" class="w-80 max-w-full">
<EmployeeNameFilterInput v-model="employeeFilter" /> <EmployeeNameFilterInput v-model="employeeFilter" />
</div> </div>
<div
v-if="isAdmin && viewMode === 'week' && absenceTypes.length > 0"
class="flex flex-wrap items-center gap-6"
>
<p class="font-bold">Légende :</p>
<div v-for="type in absenceTypes" :key="type.id" class="flex items-center gap-2">
<div :style="{ backgroundColor: type.color }" class="h-4 w-4 rounded"></div>
<p>{{ type.label }}</p>
</div>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Site } from '~/services/dto/site' import type { Site } from '~/services/dto/site'
import type { AbsenceType } from '~/services/dto/absence-type'
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue' import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
import SiteFilterSelector from '~/components/SiteFilterSelector.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 }) const selectedDate = defineModel<string>('selectedDate', { required: true })
const viewMode = defineModel<'day' | 'week'>('viewMode', { required: true }) const viewMode = defineModel<'day' | 'week'>('viewMode', { required: true })
@@ -110,18 +132,27 @@ const employeeFilter = defineModel<string>('employeeFilter', { required: true })
defineProps<{ defineProps<{
isAdmin: boolean isAdmin: boolean
sites: Site[] sites: Site[]
absenceTypes: AbsenceType[]
formattedSelectedDate: string formattedSelectedDate: string
shortcutButtonClass: (target: 'yesterday' | 'today' | 'tomorrow') => string shortcutButtonClass: (target: 'yesterday' | 'today' | 'tomorrow') => string
weekShortcutButtonClass: (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => string
getWeekShortcutLabel: (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => string
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'set-yesterday'): void (e: 'set-yesterday'): void
(e: 'set-today'): void (e: 'set-today'): void
(e: 'set-tomorrow'): void (e: 'set-tomorrow'): void
(e: 'set-previous-week'): void
(e: 'set-this-week'): void
(e: 'set-next-week'): void
(e: 'shift-date', value: number): void (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
})
const viewModeButtonClass = (mode: 'day' | 'week') => { const viewModeButtonClass = (mode: 'day' | 'week') => {
if (viewMode.value === mode) { if (viewMode.value === mode) {
@@ -131,14 +162,16 @@ const viewModeButtonClass = (mode: 'day' | 'week') => {
return 'bg-white text-primary-500 hover:bg-tertiary-500' return 'bg-white text-primary-500 hover:bg-tertiary-500'
} }
const openDatePicker = () => { const onPickerValue = (value: string) => {
const input = nativeDateInput.value if (!value) return
if (!input) return
if (typeof input.showPicker === 'function') { if (viewMode.value === 'week') {
input.showPicker() const ymd = weekInputValueToYmd(value)
if (!ymd) return
selectedDate.value = ymd
return return
} }
input.focus()
input.click() selectedDate.value = value
} }
</script> </script>
+35 -7
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="rounded-lg border border-neutral-200 bg-white overflow-hidden flex flex-1 min-h-0 flex-col"> <div class="rounded-lg border border-neutral-200 bg-white overflow-hidden flex min-h-0 flex-col">
<div v-if="isWeekLoading" class="p-6 text-md text-neutral-600">Chargement de la semaine...</div> <div v-if="isWeekLoading" class="p-6 text-md text-neutral-600">Chargement de la semaine...</div>
<div v-else class="overflow-y-auto min-h-0"> <div v-else class="overflow-y-auto min-h-0">
<div <div
@@ -8,16 +8,18 @@
> >
<span>Nom</span> <span>Nom</span>
<span v-for="day in weekDayHeaders" :key="day.date" class="text-left">{{ day.label }}</span> <span v-for="day in weekDayHeaders" :key="day.date" class="text-left">{{ day.label }}</span>
<span>Jour/Nuit sem.</span> <span>Jour/Nuit <br>sem.</span>
<span>Total sem.</span> <span>Total <br>sem.</span>
<span>Total <br>h. supp.</span>
<span>+25%</span> <span>+25%</span>
<span>+50%</span> <span>+50%</span>
<span>Total <br>récup.</span>
</div> </div>
<div <div
v-for="row in weeklySummary?.rows ?? []" v-for="row in weeklySummary?.rows ?? []"
:key="row.employeeId" :key="row.employeeId"
class="grid w-full min-w-0 items-center gap-1 border-b border-neutral-100 px-4 py-2 text-sm last:border-b-0" class="grid w-full min-w-0 items-center gap-1 border-b border-neutral-100 px-4 py-2 text-sm last:border-b-0 hover:bg-tertiary-500"
:style="{ gridTemplateColumns: weekGridCols }" :style="{ gridTemplateColumns: weekGridCols }"
> >
<div class="text-neutral-900 min-w-0"> <div class="text-neutral-900 min-w-0">
@@ -28,7 +30,14 @@
<p class="text-[11px] text-neutral-500 truncate">{{ row.siteName ?? 'Sans site' }}</p> <p class="text-[11px] text-neutral-500 truncate">{{ row.siteName ?? 'Sans site' }}</p>
</div> </div>
<div v-for="daily in row.daily" :key="daily.date" class="text-left leading-4"> <div
v-for="daily in row.daily"
:key="daily.date"
class="text-left leading-4 rounded-md px-2 py-1"
:class="daily.hasAbsence ? 'text-white' : ''"
:style="getDailyCellStyle(daily)"
:title="daily.absenceLabel ?? ''"
>
<template v-if="row.trackingMode === 'PRESENCE'">{{ daily.present ?? 0 }}</template> <template v-if="row.trackingMode === 'PRESENCE'">{{ daily.present ?? 0 }}</template>
<template v-else> <template v-else>
<div>J {{ formatMinutes(daily.dayMinutes) }}</div> <div>J {{ formatMinutes(daily.dayMinutes) }}</div>
@@ -47,10 +56,16 @@
{{ row.trackingMode === 'PRESENCE' ? (row.weeklyPresenceCount ?? 0) : formatMinutes(row.weeklyTotalMinutes) }} {{ row.trackingMode === 'PRESENCE' ? (row.weeklyPresenceCount ?? 0) : formatMinutes(row.weeklyTotalMinutes) }}
</div> </div>
<div class="font-semibold"> <div class="font-semibold">
{{ row.trackingMode === 'PRESENCE' ? '-' : formatMinutes(row.weeklyOvertime25Minutes ?? 0) }} {{ row.trackingMode === 'PRESENCE' ? '-' : formatMinutes(row.weeklyOvertimeTotalMinutes ?? 0) }}
</div> </div>
<div class="font-semibold"> <div class="font-semibold">
{{ row.trackingMode === 'PRESENCE' ? '-' : formatMinutes(row.weeklyOvertime50Minutes ?? 0) }} {{ row.trackingMode === 'PRESENCE' || isInterimContract(row.contractType) ? '-' : formatMinutes(row.weeklyOvertime25Minutes ?? 0) }}
</div>
<div class="font-semibold">
{{ row.trackingMode === 'PRESENCE' || isInterimContract(row.contractType) ? '-' : formatMinutes(row.weeklyOvertime50Minutes ?? 0) }}
</div>
<div class="font-semibold">
{{ row.trackingMode === 'PRESENCE' || isInterimContract(row.contractType) ? '-' : formatMinutes(row.weeklyRecoveryMinutes ?? 0) }}
</div> </div>
</div> </div>
</div> </div>
@@ -59,6 +74,19 @@
<script setup lang="ts"> <script setup lang="ts">
import type { WeeklyWorkHourSummary } from '~/services/dto/work-hour' import type { WeeklyWorkHourSummary } from '~/services/dto/work-hour'
import { CONTRACT_TYPES, type ContractType } from '~/services/dto/contract'
const isInterimContract = (contractType?: ContractType | null) => {
return contractType === CONTRACT_TYPES.INTERIM
}
const getDailyCellStyle = (daily: {
hasAbsence?: boolean
absenceColor?: string | null
}) => {
if (!daily.hasAbsence) return undefined
return { backgroundColor: daily.absenceColor || '#dc2626' }
}
defineProps<{ defineProps<{
isWeekLoading: boolean isWeekLoading: boolean
+1
View File
@@ -8,5 +8,6 @@ export type HourRow = {
eveningTo: string eveningTo: string
isPresentMorning: boolean isPresentMorning: boolean
isPresentAfternoon: boolean isPresentAfternoon: boolean
isSiteValid: boolean
isValid: boolean isValid: boolean
} }
+116 -15
View File
@@ -1,15 +1,36 @@
<template> <template>
<div ref="root" class="relative w-full"> <div ref="root" class="relative w-full">
<button <div
ref="trigger" ref="trigger"
type="button" class="w-full flex items-center rounded-md border border-neutral-300 px-2 text-sm text-neutral-900 focus-within:border-primary-500"
class="w-full flex justify-between rounded-md border border-neutral-300 bg-white px-3 py-2 text-left text-sm text-neutral-900 focus:outline-none focus:border-primary-500 disabled:cursor-not-allowed disabled:bg-neutral-100 disabled:text-neutral-500" :class="props.disabled ? 'cursor-not-allowed border-neutral-300 bg-neutral-200 text-neutral-500' : 'bg-white'"
:disabled="props.disabled"
@click="toggleOpen"
> >
{{ displayValue }} <input
<Icon name="mdi:chevron-down" class="self-center"/> ref="inputRef"
</button> v-model="inputValue"
type="text"
inputmode="numeric"
:placeholder="placeholder"
:disabled="props.disabled"
class="h-9 w-full bg-transparent px-1 outline-none disabled:cursor-not-allowed disabled:bg-neutral-200 disabled:text-neutral-500"
@focus="openMenu"
@keydown.down.prevent="openMenuAndFocusFirst"
@keydown.enter.prevent="commitInput"
@keydown.esc.prevent="closeMenu"
@input="onInput($event)"
@blur="onInputBlur"
/>
<button
type="button"
tabindex="-1"
class="inline-flex h-8 w-8 items-center justify-center rounded text-neutral-600 hover:bg-tertiary-500 disabled:cursor-not-allowed disabled:bg-neutral-200 disabled:text-neutral-500"
:disabled="props.disabled"
@mousedown.prevent
@click="toggleOpen"
>
<Icon name="mdi:chevron-down" />
</button>
</div>
</div> </div>
<Teleport to="body"> <Teleport to="body">
<div <div
@@ -18,15 +39,11 @@
class="fixed z-[120] overflow-y-auto rounded-md border border-neutral-300 bg-white shadow-sm" class="fixed z-[120] overflow-y-auto rounded-md border border-neutral-300 bg-white shadow-sm"
:style="menuStyle" :style="menuStyle"
> >
<button <button type="button" class="block w-full px-2 py-2 text-left text-sm hover:bg-tertiary-500" @click="selectValue('')">
type="button"
class="block w-full px-2 py-2 text-left text-sm hover:bg-tertiary-500"
@click="selectValue('')"
>
{{ placeholder }} {{ placeholder }}
</button> </button>
<button <button
v-for="slot in timeSlots" v-for="slot in filteredTimeSlots"
:key="slot" :key="slot"
type="button" type="button"
class="block w-full px-2 py-2 text-left text-sm hover:bg-tertiary-500" class="block w-full px-2 py-2 text-left text-sm hover:bg-tertiary-500"
@@ -34,6 +51,9 @@
> >
{{ slot }} {{ slot }}
</button> </button>
<p v-if="filteredTimeSlots.length === 0" class="px-2 py-2 text-sm text-neutral-500">
Aucun résultat
</p>
</div> </div>
</Teleport> </Teleport>
</template> </template>
@@ -55,7 +75,9 @@ const emit = defineEmits<{
const root = ref<HTMLElement | null>(null) const root = ref<HTMLElement | null>(null)
const trigger = ref<HTMLElement | null>(null) const trigger = ref<HTMLElement | null>(null)
const menu = ref<HTMLElement | null>(null) const menu = ref<HTMLElement | null>(null)
const inputRef = ref<HTMLInputElement | null>(null)
const isOpen = ref(false) const isOpen = ref(false)
const inputValue = ref('')
const menuStyle = ref<Record<string, string>>({ const menuStyle = ref<Record<string, string>>({
top: '0px', top: '0px',
left: '0px', left: '0px',
@@ -73,7 +95,31 @@ const timeSlots = computed(() => {
return slots return slots
}) })
const displayValue = computed(() => props.modelValue || props.placeholder) const filteredTimeSlots = computed(() => {
const query = inputValue.value.trim()
if (!query) return timeSlots.value
return timeSlots.value.filter((slot) => slot.includes(query))
})
const applyTimeMask = (value: string): string => {
const digits = value.replace(/\D/g, '').slice(0, 4)
if (digits.length <= 2) return digits
return `${digits.slice(0, 2)}:${digits.slice(2)}`
}
const normalizeTypedTime = (value: string): string | null => {
const trimmed = value.trim()
if (trimmed === '') return ''
// Accepte HH:MM ou H:MM puis normalise en HH:MM.
const match = trimmed.match(/^(\d{1,2}):(\d{2})$/)
if (!match) return null
const hours = Number(match[1])
const minutes = Number(match[2])
if (!Number.isInteger(hours) || !Number.isInteger(minutes)) return null
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
}
const updateMenuPosition = () => { const updateMenuPosition = () => {
const triggerEl = trigger.value const triggerEl = trigger.value
@@ -103,10 +149,57 @@ const toggleOpen = () => {
} }
} }
const openMenu = () => {
if (props.disabled) return
if (!isOpen.value) {
isOpen.value = true
nextTick(updateMenuPosition)
}
}
const openMenuAndFocusFirst = () => {
openMenu()
}
const closeMenu = () => {
isOpen.value = false
}
const commitInput = () => {
const normalized = normalizeTypedTime(inputValue.value)
if (normalized === null) {
inputValue.value = props.modelValue
closeMenu()
return
}
emit('update:modelValue', normalized)
inputValue.value = normalized
closeMenu()
}
const onInput = (event: Event) => {
const target = event.target as HTMLInputElement
const masked = applyTimeMask(target.value)
if (masked !== inputValue.value) {
inputValue.value = masked
}
openMenu()
}
const onInputBlur = () => {
// Laisse le temps au click menu de passer avant fermeture.
setTimeout(() => {
if (menu.value?.contains(document.activeElement)) return
commitInput()
}, 50)
}
const selectValue = (value: string) => { const selectValue = (value: string) => {
if (props.disabled) return if (props.disabled) return
emit('update:modelValue', value) emit('update:modelValue', value)
inputValue.value = value
isOpen.value = false isOpen.value = false
nextTick(() => inputRef.value?.focus())
} }
const onDocumentClick = (event: MouseEvent) => { const onDocumentClick = (event: MouseEvent) => {
@@ -139,6 +232,14 @@ watch(() => props.disabled, (disabled) => {
} }
}) })
watch(
() => props.modelValue,
(value) => {
inputValue.value = value
},
{ immediate: true }
)
onMounted(() => { onMounted(() => {
document.addEventListener('click', onDocumentClick) document.addEventListener('click', onDocumentClick)
}) })
+25 -2
View File
@@ -21,6 +21,7 @@ export type ApiClient = {
export type ApiFetchOptions<ResponseType extends 'json' | 'blob'> = export type ApiFetchOptions<ResponseType extends 'json' | 'blob'> =
FetchOptions<ResponseType> & { FetchOptions<ResponseType> & {
toast?: boolean toast?: boolean
toastOn401?: boolean
toastTitle?: string toastTitle?: string
toastErrorMessage?: string toastErrorMessage?: string
toastSuccessMessage?: string toastSuccessMessage?: string
@@ -102,9 +103,31 @@ export const useApi = (): ApiClient => {
} }
}, },
async onResponseError({ response, error, options }) { async onResponseError({ response, error, options }) {
const apiOptions = options as ApiFetchOptions<'json'>
if (response?.status === 401) { if (response?.status === 401) {
const requestUrl = typeof options?.url === 'string' ? options.url : '' const requestUrl = typeof options?.url === 'string' ? options.url : ''
if (!requestUrl.includes('/login_check') && !requestUrl.includes('/logout')) { const isLoginCheck = requestUrl.includes('/login_check')
const isLogout = requestUrl.includes('/logout')
const shouldToast401 = apiOptions?.toastOn401 === true && apiOptions?.toast !== false
if (shouldToast401) {
const errorKey = apiOptions?.toastErrorKey
const errorMessage =
errorKey ? (te(errorKey) ? t(errorKey) : errorKey) : ''
const extractedMessage = extractErrorMessage(error, response?._data)
const message =
apiOptions?.toastErrorMessage ||
errorMessage ||
extractedMessage ||
'Une erreur est survenue.'
toast.error({
title: apiOptions?.toastTitle ?? 'Erreur',
message
})
}
if (!isLoginCheck && !isLogout) {
if (!isHandlingUnauthorized) { if (!isHandlingUnauthorized) {
isHandlingUnauthorized = true isHandlingUnauthorized = true
auth.clearSession() auth.clearSession()
@@ -115,10 +138,10 @@ export const useApi = (): ApiClient => {
isHandlingUnauthorized = false isHandlingUnauthorized = false
} }
} }
return return
} }
const apiOptions = options as ApiFetchOptions<'json'>
if (apiOptions?.toast === false) { if (apiOptions?.toast === false) {
return return
} }
+560 -17
View File
@@ -1,20 +1,31 @@
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import type { Employee } from '~/services/dto/employee' import type { Employee } from '~/services/dto/employee'
import type { Site } from '~/services/dto/site' import type { Site } from '~/services/dto/site'
import type { WorkHour, WeeklyWorkHourSummary } from '~/services/dto/work-hour' import type { WorkHour, WorkHourDayContext, WeeklyWorkHourSummary } from '~/services/dto/work-hour'
import type { AbsenceType } from '~/services/dto/absence-type'
import type { Absence } from '~/services/dto/absence'
import type { HalfDay } from '~/services/dto/half-day'
import { CONTRACT_TYPES, TRACKING_MODES } from '~/services/dto/contract'
import type { HourRow } from '~/components/hours/types' import type { HourRow } from '~/components/hours/types'
import { listScopedEmployees } from '~/services/employees' import { listScopedEmployees } from '~/services/employees'
import { listAbsenceTypes } from '~/services/absence-types'
import { createAbsence, deleteAbsence, listAbsences, updateAbsence } from '~/services/absences'
import { listPublicHolidays } from '~/services/public-holidays'
import { import {
bulkUpsertWorkHours, bulkUpsertWorkHours,
getWorkHourDayContext,
getWeeklyWorkHourSummary, getWeeklyWorkHourSummary,
listWorkHoursByDate, listWorkHoursByDate,
updateWorkHourSiteValidation,
updateWorkHourValidation updateWorkHourValidation
} from '~/services/work-hours' } from '~/services/work-hours'
import { import {
formatDateLongFr, formatDateLongFr,
formatWeekDayHeaderFr, formatWeekDayHeaderFr,
formatWeekRangeFr, formatWeekRangeFr,
getIsoWeekNumber,
getOffsetFromTodayYmd, getOffsetFromTodayYmd,
getWeekStartDate,
getTodayYmd, getTodayYmd,
parseYmd, parseYmd,
shiftYmd shiftYmd
@@ -23,7 +34,10 @@ import { sortEmployeesBySiteAndOrder } from '~/utils/employee'
export const useHoursPage = () => { export const useHoursPage = () => {
const auth = useAuthStore() const auth = useAuthStore()
const toast = useToast()
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false) const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
const isSelfUser = computed(() => auth.user?.roles?.includes('ROLE_SELF') ?? false)
const isSiteManager = computed(() => !isAdmin.value && !isSelfUser.value)
const viewMode = ref<'day' | 'week'>('day') const viewMode = ref<'day' | 'week'>('day')
const selectedDate = ref(getTodayYmd()) const selectedDate = ref(getTodayYmd())
@@ -32,19 +46,36 @@ export const useHoursPage = () => {
const selectedSiteIds = ref<number[]>([]) const selectedSiteIds = ref<number[]>([])
const sitesInitialized = ref(false) const sitesInitialized = ref(false)
const rows = ref<Record<number, HourRow>>({}) const rows = ref<Record<number, HourRow>>({})
const dayContext = ref<WorkHourDayContext | null>(null)
const weeklySummary = ref<WeeklyWorkHourSummary | null>(null) const weeklySummary = ref<WeeklyWorkHourSummary | null>(null)
const absenceTypes = ref<AbsenceType[]>([])
const absences = ref<Absence[]>([])
const publicHolidaysByYear = ref<Record<number, Record<string, string>>>({})
const isAbsenceDrawerOpen = ref(false)
const isAbsenceSubmitting = ref(false)
const editingAbsence = ref<Absence | null>(null)
const absenceForm = ref({
employeeId: '' as number | '',
typeId: '' as number | '',
startDate: '',
startHalf: 'AM' as HalfDay,
endDate: '',
endHalf: 'PM' as HalfDay,
comment: ''
})
const isLoading = ref(false) const isLoading = ref(false)
const isWeekLoading = ref(false) const isWeekLoading = ref(false)
const isSubmitting = ref(false) const isSubmitting = ref(false)
const validatingRowIds = ref<number[]>([]) const validatingRowIds = ref<number[]>([])
const siteValidatingRowIds = ref<number[]>([])
const dayGridCols = computed(() => { const dayGridCols = computed(() => {
const metricCol = '0.5fr' const metricCol = '0.4fr'
const cols = `1.2fr repeat(6, 1fr) ${metricCol} ${metricCol} ${metricCol} ${metricCol}` const validationCols = isAdmin.value ? `${metricCol}` : `${metricCol} ${metricCol}`
return isAdmin.value ? `${cols} ${metricCol}` : cols return `1.2fr 0.6fr repeat(6, 0.8fr) ${metricCol} ${metricCol} ${metricCol} ${validationCols}`
}) })
const weekGridCols = '1.6fr repeat(7, 1fr) 1fr 0.8fr 0.8fr 0.8fr' const weekGridCols = '1.6fr repeat(7, 1fr) repeat(6, 0.6fr)'
const sites = computed<Site[]>(() => { const sites = computed<Site[]>(() => {
const siteMap = new Map<number, Site>() const siteMap = new Map<number, Site>()
@@ -94,6 +125,43 @@ export const useHoursPage = () => {
}) })
const isValidationPending = (employeeId: number) => validatingRowIds.value.includes(employeeId) const isValidationPending = (employeeId: number) => validatingRowIds.value.includes(employeeId)
const isSiteValidationPending = (employeeId: number) => siteValidatingRowIds.value.includes(employeeId)
const canToggleValidation = (employeeId: number) => !!rows.value[employeeId]?.workHourId
const canToggleSiteValidation = (employeeId: number) => {
if (!isSiteManager.value) return false
const row = rows.value[employeeId]
if (!row?.workHourId) return false
// Une validation RH fige la ligne côté chef de site.
if (row.isValid) return false
return true
}
const validatableEmployeeIds = computed(() => {
return employees.value
.map((employee) => employee.id)
.filter((employeeId) => canToggleValidation(employeeId))
})
const isBulkValidationChecked = computed(() => {
const ids = validatableEmployeeIds.value
if (ids.length === 0) return false
return ids.every((employeeId) => rows.value[employeeId]?.isValid ?? false)
})
const isBulkValidationIndeterminate = computed(() => {
const ids = validatableEmployeeIds.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 dayContextByEmployeeId = computed(() => {
const map = new Map<number, WorkHourDayContext['rows'][number]>()
for (const row of dayContext.value?.rows ?? []) {
map.set(row.employeeId, row)
}
return map
})
const shortcutButtonClass = (target: 'yesterday' | 'today' | 'tomorrow') => { const shortcutButtonClass = (target: 'yesterday' | 'today' | 'tomorrow') => {
const targetDate = target === 'yesterday' const targetDate = target === 'yesterday'
@@ -109,6 +177,37 @@ export const useHoursPage = () => {
return 'bg-white text-primary-500 hover:bg-tertiary-500' return 'bg-white text-primary-500 hover:bg-tertiary-500'
} }
const weekShortcutButtonClass = (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => {
const selected = parseYmd(selectedDate.value)
if (!selected) {
return 'bg-white text-primary-500 hover:bg-tertiary-500'
}
const today = new Date()
const targetDate = new Date(today)
if (target === 'previousWeek') targetDate.setDate(today.getDate() - 7)
if (target === 'nextWeek') targetDate.setDate(today.getDate() + 7)
const selectedWeekStart = getWeekStartDate(selected)
const targetWeekStart = getWeekStartDate(targetDate)
const isActive = selectedWeekStart.getTime() === targetWeekStart.getTime()
if (isActive) {
return 'bg-primary-500 text-white'
}
return 'bg-white text-primary-500 hover:bg-tertiary-500'
}
const getWeekShortcutLabel = (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => {
const today = new Date()
if (target === 'previousWeek') today.setDate(today.getDate() - 7)
if (target === 'nextWeek') today.setDate(today.getDate() + 7)
const weekNumber = getIsoWeekNumber(today)
return `Sem. S${weekNumber}`
}
const formattedSelectedDate = computed(() => { const formattedSelectedDate = computed(() => {
const parsed = parseYmd(selectedDate.value) const parsed = parseYmd(selectedDate.value)
if (!parsed) return selectedDate.value if (!parsed) return selectedDate.value
@@ -120,6 +219,19 @@ export const useHoursPage = () => {
return formatDateLongFr(parsed) return formatDateLongFr(parsed)
}) })
const selectedYear = computed(() => {
const parsed = parseYmd(selectedDate.value)
return parsed ? parsed.getFullYear() : null
})
const selectedHolidayLabel = computed(() => {
const year = selectedYear.value
if (!year) return ''
return publicHolidaysByYear.value[year]?.[selectedDate.value] ?? ''
})
const isSelectedDateHoliday = computed(() => selectedHolidayLabel.value !== '')
const weekDayHeaders = computed(() => { const weekDayHeaders = computed(() => {
const days = weeklySummary.value?.days ?? [] const days = weeklySummary.value?.days ?? []
return days.map((date) => ({ date, label: formatWeekDayHeaderFr(date) })) return days.map((date) => ({ date, label: formatWeekDayHeaderFr(date) }))
@@ -146,6 +258,40 @@ export const useHoursPage = () => {
shiftDate(1) shiftDate(1)
} }
const setThisWeek = () => {
selectedDate.value = getTodayYmd()
}
const setPreviousWeek = () => {
const previousWeek = shiftYmd(getTodayYmd(), -7)
if (!previousWeek) return
selectedDate.value = previousWeek
}
const setNextWeek = () => {
const nextWeek = shiftYmd(getTodayYmd(), 7)
if (!nextWeek) return
selectedDate.value = nextWeek
}
const resetAbsenceForm = () => {
absenceForm.value = {
employeeId: '',
typeId: '',
startDate: '',
startHalf: 'AM',
endDate: '',
endHalf: 'PM',
comment: ''
}
}
const closeAbsenceDrawer = () => {
isAbsenceDrawerOpen.value = false
editingAbsence.value = null
resetAbsenceForm()
}
const emptyRow = (): HourRow => ({ const emptyRow = (): HourRow => ({
workHourId: null, workHourId: null,
morningFrom: '', morningFrom: '',
@@ -156,17 +302,27 @@ export const useHoursPage = () => {
eveningTo: '', eveningTo: '',
isPresentMorning: false, isPresentMorning: false,
isPresentAfternoon: false, isPresentAfternoon: false,
isSiteValid: false,
isValid: false isValid: false
}) })
const isPresenceTracking = (employee: Employee) => employee.contract?.trackingMode === 'PRESENCE' const isPresenceTracking = (employee: Employee) => employee.contract?.trackingMode === TRACKING_MODES.PRESENCE
const isTimeTracking = (employee: Employee) => !isPresenceTracking(employee) const isTimeTracking = (employee: Employee) => !isPresenceTracking(employee)
const isRowLocked = (employeeId: number) => rows.value[employeeId]?.isValid ?? false const isRowLocked = (employeeId: number) => {
const row = rows.value[employeeId]
if (!row) return false
if (row.isValid) return true
if (!isAdmin.value && row.isSiteValid) return true
return false
}
const contractLabel = (employee: Employee) => { const contractLabel = (employee: Employee) => {
const contract = employee.contract const contract = employee.contract
if (!contract) return '-' if (!contract) return '-'
if (contract.weeklyHours !== null && contract.weeklyHours !== undefined && contract.trackingMode === 'TIME') { if (contract.type === CONTRACT_TYPES.INTERIM) {
return contract.name
}
if (contract.weeklyHours !== null && contract.weeklyHours !== undefined && contract.trackingMode === TRACKING_MODES.TIME) {
return `${contract.weeklyHours}h` return `${contract.weeklyHours}h`
} }
return contract.name return contract.name
@@ -243,10 +399,63 @@ export const useHoursPage = () => {
nightMinutes += nightIntervalMinutes(from, to) nightMinutes += nightIntervalMinutes(from, to)
} }
const creditedMinutes = dayContextByEmployeeId.value.get(employeeId)?.creditedMinutes ?? 0
totalMinutes += creditedMinutes
const dayMinutes = Math.max(0, totalMinutes - nightMinutes) const dayMinutes = Math.max(0, totalMinutes - nightMinutes)
return { dayMinutes, nightMinutes, totalMinutes } return { dayMinutes, nightMinutes, totalMinutes }
} }
const getRowAbsenceLabel = (employeeId: number) => {
const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (dayRow && dayRow.hasContractAtDate === false) {
return 'Contrat non démarré'
}
if (isSelectedDateHoliday.value) return 'Férié'
if (!dayRow?.absenceLabel) return ''
if (dayRow.absenceHalf === 'AM' || dayRow.absenceHalf === 'PM') {
const halfLabel = dayRow.absenceHalf === 'AM' ? 'Matin' : 'ap.-m.'
return `${dayRow.absenceLabel} (${halfLabel})`
}
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 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)
}
const hasContractAtSelectedDate = (employeeId: number) => {
const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (!dayRow) return true
return dayRow.hasContractAtDate !== false
}
const isHalfLockedByAbsence = (employeeId: number, half: 'AM' | 'PM') => {
if (!hasContractAtSelectedDate(employeeId)) return true
const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (!dayRow) return false
return half === 'AM' ? dayRow.absentMorning : dayRow.absentAfternoon
}
const isEveningLockedByAbsence = (employeeId: number) => {
if (!hasContractAtSelectedDate(employeeId)) return true
const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (!dayRow) return false
return dayRow.absentAfternoon
}
const formatMinutes = (minutes: number) => { const formatMinutes = (minutes: number) => {
const safeMinutes = Math.max(0, minutes) const safeMinutes = Math.max(0, minutes)
const hours = Math.floor(safeMinutes / 60) const hours = Math.floor(safeMinutes / 60)
@@ -273,6 +482,7 @@ export const useHoursPage = () => {
eveningTo: workHour?.eveningTo ?? '', eveningTo: workHour?.eveningTo ?? '',
isPresentMorning: workHour?.isPresentMorning ?? false, isPresentMorning: workHour?.isPresentMorning ?? false,
isPresentAfternoon: workHour?.isPresentAfternoon ?? false, isPresentAfternoon: workHour?.isPresentAfternoon ?? false,
isSiteValid: workHour?.isSiteValid ?? false,
isValid: workHour?.isValid ?? false isValid: workHour?.isValid ?? false
} }
} }
@@ -280,19 +490,304 @@ export const useHoursPage = () => {
rows.value = nextRows rows.value = nextRows
} }
const toggleValidation = async (employeeId: number, checked: boolean) => { const loadAbsenceTypes = async () => {
absenceTypes.value = await listAbsenceTypes()
}
const loadPublicHolidaysForSelectedYear = async () => {
const year = selectedYear.value
if (!year) return
if (publicHolidaysByYear.value[year]) return
const holidays = await listPublicHolidays('metropole', year)
publicHolidaysByYear.value = {
...publicHolidaysByYear.value,
[year]: holidays
}
}
const loadAbsences = async () => {
absences.value = await listAbsences({
from: selectedDate.value,
to: selectedDate.value,
siteIds: isAdmin.value ? selectedSiteIds.value : undefined
})
}
const openAbsenceDrawer = (employeeId: number) => {
if (!hasContractAtSelectedDate(employeeId)) return
if (isSelectedDateHoliday.value) return
const existing = absences.value.find((absence) => {
if (absence.employee?.id !== employeeId) return false
const start = absence.startDate.slice(0, 10)
const end = absence.endDate.slice(0, 10)
return selectedDate.value >= start && selectedDate.value <= end
}) ?? null
if (existing) {
editingAbsence.value = existing
absenceForm.value = {
employeeId,
typeId: existing.type?.id ?? '',
startDate: existing.startDate.slice(0, 10),
startHalf: existing.startHalf ?? 'AM',
endDate: existing.endDate.slice(0, 10),
endHalf: existing.endHalf ?? 'PM',
comment: existing.comment ?? ''
}
} else {
editingAbsence.value = null
absenceForm.value = {
employeeId,
typeId: '',
startDate: selectedDate.value,
startHalf: 'AM',
endDate: selectedDate.value,
endHalf: 'PM',
comment: ''
}
}
isAbsenceDrawerOpen.value = true
}
const applyLocalClearFromAbsence = (employeeId: number, startHalf: HalfDay, endHalf: HalfDay) => {
const row = rows.value[employeeId] const row = rows.value[employeeId]
if (!row?.workHourId || isValidationPending(employeeId)) return if (!row) return
if (startHalf === 'AM' && endHalf === 'AM') {
row.morningFrom = ''
row.morningTo = ''
return
}
if (startHalf === 'PM' && endHalf === 'PM') {
row.afternoonFrom = ''
row.afternoonTo = ''
row.eveningFrom = ''
row.eveningTo = ''
return
}
row.morningFrom = ''
row.morningTo = ''
row.afternoonFrom = ''
row.afternoonTo = ''
row.eveningFrom = ''
row.eveningTo = ''
}
const refreshAfterAbsenceChange = async () => {
if (isAdmin.value) {
await Promise.all([loadWeeklySummary(), loadDayContext(), loadAbsences()])
return
}
weeklySummary.value = null
await Promise.all([loadDayContext(), loadAbsences()])
}
const submitAbsence = async () => {
const form = absenceForm.value
if (isAbsenceSubmitting.value || form.employeeId === '' || form.typeId === '') return
isAbsenceSubmitting.value = true
try {
if (editingAbsence.value) {
await updateAbsence({
id: editingAbsence.value.id,
employeeId: Number(form.employeeId),
typeId: Number(form.typeId),
startDate: form.startDate,
startHalf: form.startHalf,
endDate: form.endDate,
endHalf: form.endHalf,
comment: editingAbsence.value.comment ?? ''
})
} else {
await createAbsence({
employeeId: Number(form.employeeId),
typeId: Number(form.typeId),
startDate: form.startDate,
startHalf: form.startHalf,
endDate: form.endDate,
endHalf: form.endHalf,
comment: ''
})
}
applyLocalClearFromAbsence(Number(form.employeeId), form.startHalf, form.endHalf)
closeAbsenceDrawer()
await refreshAfterAbsenceChange()
} finally {
isAbsenceSubmitting.value = false
}
}
const deleteAbsenceFromDrawer = async () => {
if (!editingAbsence.value || isAbsenceSubmitting.value) return
isAbsenceSubmitting.value = true
try {
await deleteAbsence(editingAbsence.value.id)
closeAbsenceDrawer()
await refreshAfterAbsenceChange()
} finally {
isAbsenceSubmitting.value = false
}
}
const toggleValidation = async (
employeeId: number,
checked: boolean,
options: { toast?: boolean } = {}
) => {
const row = rows.value[employeeId]
const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (!row?.workHourId && checked) {
const employee = employees.value.find((item) => item.id === employeeId)
const hasAbsence = !!dayRow?.absenceLabel
const canCreateFromAbsence = !!employee && hasAbsence && hasContractAtSelectedDate(employeeId)
if (canCreateFromAbsence) {
await bulkUpsertWorkHours({
workDate: selectedDate.value,
entries: [{
employeeId,
morningFrom: null,
morningTo: null,
afternoonFrom: null,
afternoonTo: null,
eveningFrom: null,
eveningTo: null,
isPresentMorning: false,
isPresentAfternoon: false
}]
}, { toast: false })
await loadWorkHours()
}
}
const updatedRow = rows.value[employeeId]
if (!updatedRow?.workHourId) {
if (options.toast !== false) {
toast.error({
title: 'Validation impossible',
message: 'La ligne doit contenir des heures ou une absence.'
})
}
return
}
if (isValidationPending(employeeId)) return
validatingRowIds.value = [...validatingRowIds.value, employeeId] validatingRowIds.value = [...validatingRowIds.value, employeeId]
try { try {
await updateWorkHourValidation(row.workHourId, checked) await updateWorkHourValidation(updatedRow.workHourId, checked, { toast: options.toast })
row.isValid = checked updatedRow.isValid = checked
} finally { } finally {
validatingRowIds.value = validatingRowIds.value.filter((id) => id !== employeeId) validatingRowIds.value = validatingRowIds.value.filter((id) => id !== employeeId)
} }
} }
const toggleSiteValidation = async (
employeeId: number,
checked: boolean,
options: { toast?: boolean } = {}
) => {
const row = rows.value[employeeId]
const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (!row?.workHourId && checked) {
const employee = employees.value.find((item) => item.id === employeeId)
const hasAbsence = !!dayRow?.absenceLabel
const canCreateFromAbsence = !!employee && hasAbsence && hasContractAtSelectedDate(employeeId)
if (canCreateFromAbsence) {
await bulkUpsertWorkHours({
workDate: selectedDate.value,
entries: [{
employeeId,
morningFrom: null,
morningTo: null,
afternoonFrom: null,
afternoonTo: null,
eveningFrom: null,
eveningTo: null,
isPresentMorning: false,
isPresentAfternoon: false
}]
}, { toast: false })
await loadWorkHours()
}
}
const updatedRow = rows.value[employeeId]
if (!updatedRow?.workHourId) {
if (options.toast !== false) {
toast.error({
title: 'Validation impossible',
message: 'La ligne doit contenir des heures ou une absence.'
})
}
return
}
if (isSiteValidationPending(employeeId)) return
if (!canToggleSiteValidation(employeeId)) return
siteValidatingRowIds.value = [...siteValidatingRowIds.value, employeeId]
try {
await updateWorkHourSiteValidation(updatedRow.workHourId, checked, { toast: options.toast })
updatedRow.isSiteValid = checked
} finally {
siteValidatingRowIds.value = siteValidatingRowIds.value.filter((id) => id !== employeeId)
}
}
const toggleValidationBulk = async (checked: boolean) => {
const employeeIds = validatableEmployeeIds.value
if (employeeIds.length === 0) return
let successCount = 0
let failedCount = 0
for (const employeeId of employeeIds) {
if (isValidationPending(employeeId)) continue
try {
await toggleValidation(employeeId, checked, { toast: false })
successCount += 1
} catch {
failedCount += 1
}
}
if (failedCount === 0) {
toast.success({
title: 'Succès',
message: checked
? `${successCount} ligne(s) validée(s).`
: `${successCount} validation(s) retirée(s).`
})
return
}
if (successCount === 0) {
toast.error({
title: 'Erreur',
message: 'Impossible de mettre à jour les validations.'
})
return
}
toast.error({
title: 'Erreur',
message: `${successCount} mise(s) à jour, ${failedCount} en échec.`
})
}
const loadEmployees = async () => { const loadEmployees = async () => {
const scopedEmployees = await listScopedEmployees() const scopedEmployees = await listScopedEmployees()
employees.value = sortEmployeesBySiteAndOrder(scopedEmployees) employees.value = sortEmployeesBySiteAndOrder(scopedEmployees)
@@ -312,20 +807,26 @@ export const useHoursPage = () => {
} }
} }
const loadDayContext = async () => {
dayContext.value = await getWorkHourDayContext(selectedDate.value)
}
const refreshByDate = async () => { const refreshByDate = async () => {
if (isAdmin.value) { if (isAdmin.value) {
await Promise.all([loadWorkHours(), loadWeeklySummary()]) await Promise.all([loadWorkHours(), loadWeeklySummary(), loadDayContext(), loadAbsences()])
return return
} }
weeklySummary.value = null weeklySummary.value = null
await loadWorkHours() await Promise.all([loadWorkHours(), loadDayContext(), loadAbsences()])
} }
const loadPage = async () => { const loadPage = async () => {
isLoading.value = true isLoading.value = true
try { try {
await loadPublicHolidaysForSelectedYear()
await loadEmployees() await loadEmployees()
await loadAbsenceTypes()
await refreshByDate() await refreshByDate()
} finally { } finally {
isLoading.value = false isLoading.value = false
@@ -347,14 +848,19 @@ export const useHoursPage = () => {
selectedSiteIds.value = selectedSiteIds.value.filter((siteId) => currentSiteIds.includes(siteId)) selectedSiteIds.value = selectedSiteIds.value.filter((siteId) => currentSiteIds.includes(siteId))
}, { immediate: true }) }, { immediate: true })
watch(isAdmin, (admin) => { watch(isAdmin, async (admin) => {
if (!admin) { if (!admin) {
viewMode.value = 'day' viewMode.value = 'day'
weeklySummary.value = null weeklySummary.value = null
await Promise.all([loadAbsenceTypes(), loadAbsences()])
return
} }
await loadAbsenceTypes()
await loadAbsences()
}, { immediate: true }) }, { immediate: true })
watch(selectedDate, async () => { watch(selectedDate, async () => {
await loadPublicHolidaysForSelectedYear()
await refreshByDate() await refreshByDate()
}) })
@@ -363,7 +869,9 @@ export const useHoursPage = () => {
isSubmitting.value = true isSubmitting.value = true
try { try {
const entries = employees.value.map((employee) => { const entries = employees.value
.filter((employee) => hasContractAtSelectedDate(employee.id))
.map((employee) => {
const employeeId = employee.id const employeeId = employee.id
const row = rows.value[employeeId] ?? emptyRow() const row = rows.value[employeeId] ?? emptyRow()
if (isPresenceTracking(employee)) { if (isPresenceTracking(employee)) {
@@ -391,7 +899,11 @@ export const useHoursPage = () => {
isPresentMorning: false, isPresentMorning: false,
isPresentAfternoon: false isPresentAfternoon: false
} }
}) })
if (entries.length === 0) {
return
}
await bulkUpsertWorkHours({ await bulkUpsertWorkHours({
workDate: selectedDate.value, workDate: selectedDate.value,
@@ -406,6 +918,8 @@ export const useHoursPage = () => {
return { return {
isAdmin, isAdmin,
isSelfUser,
isSiteManager,
viewMode, viewMode,
selectedDate, selectedDate,
employeeFilter, employeeFilter,
@@ -414,6 +928,11 @@ export const useHoursPage = () => {
employees, employees,
visibleEmployees, visibleEmployees,
rows, rows,
absenceTypes,
absenceForm,
isAbsenceDrawerOpen,
isAbsenceSubmitting,
editingAbsence,
weeklySummary, weeklySummary,
filteredWeeklySummary, filteredWeeklySummary,
isLoading, isLoading,
@@ -423,19 +942,43 @@ export const useHoursPage = () => {
weekGridCols, weekGridCols,
saveButtonClass, saveButtonClass,
formattedSelectedDate, formattedSelectedDate,
isSelectedDateHoliday,
weekDayHeaders, weekDayHeaders,
shortcutButtonClass, shortcutButtonClass,
weekShortcutButtonClass,
getWeekShortcutLabel,
setToday, setToday,
setYesterday, setYesterday,
setTomorrow, setTomorrow,
setThisWeek,
setPreviousWeek,
setNextWeek,
shiftDate, shiftDate,
contractLabel, contractLabel,
isTimeTracking, isTimeTracking,
isPresenceTracking, isPresenceTracking,
isRowLocked, isRowLocked,
isHalfLockedByAbsence,
isEveningLockedByAbsence,
hasContractAtSelectedDate,
isValidationPending, isValidationPending,
isSiteValidationPending,
canToggleValidation,
canToggleSiteValidation,
validatableEmployeeIds,
isBulkValidationChecked,
isBulkValidationIndeterminate,
toggleValidation, toggleValidation,
toggleSiteValidation,
toggleValidationBulk,
getRowMetrics, getRowMetrics,
getRowAbsenceLabel,
getRowAbsenceStyle,
getPresenceDayValue,
openAbsenceDrawer,
submitAbsence,
deleteAbsenceFromDrawer,
closeAbsenceDrawer,
formatMinutes, formatMinutes,
handleSave handleSave
} }
+10 -8
View File
@@ -6,17 +6,10 @@
<img src="/malio.png" alt="Logo" class="w-auto"/> <img src="/malio.png" alt="Logo" class="w-auto"/>
</div> </div>
<nav class="flex-1 px-4 pb-6"> <nav class="flex-1 px-4 pb-6">
<NuxtLink
to="/hours"
class="flex items-center gap-3 px-4 pb-3 pt-6 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500 border-t border-secondary-500"
active-class="bg-tertiary-500 text-primary-500"
>
Heures
</NuxtLink>
<template v-if="isAdmin"> <template v-if="isAdmin">
<NuxtLink <NuxtLink
to="/" to="/"
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500" class="flex items-center gap-3 px-4 pb-3 pt-6 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500 border-t border-secondary-500"
active-class="bg-tertiary-500 text-primary-500" active-class="bg-tertiary-500 text-primary-500"
> >
Tableau de bord Tableau de bord
@@ -28,6 +21,15 @@
> >
Calendrier Calendrier
</NuxtLink> </NuxtLink>
</template>
<NuxtLink
to="/hours"
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500"
active-class="bg-tertiary-500 text-primary-500"
>
Heures
</NuxtLink>
<template v-if="isAdmin">
<NuxtLink <NuxtLink
to="/employees" to="/employees"
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500" class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500"
+4 -2
View File
@@ -3,7 +3,9 @@ export default defineNuxtConfig({
devtools: {enabled: false}, devtools: {enabled: false},
ssr: false, ssr: false,
app: { app: {
baseURL: process.env.NUXT_PUBLIC_APP_BASE || '/' baseURL: process.env.NODE_ENV === 'production'
? (process.env.NUXT_PUBLIC_APP_BASE || '/')
: '/'
}, },
modules: [ modules: [
'@nuxtjs/tailwindcss', '@nuxtjs/tailwindcss',
@@ -37,4 +39,4 @@ export default defineNuxtConfig({
typescript: { typescript: {
strict: true strict: true
} }
}) })
+48 -5
View File
@@ -19,10 +19,11 @@
</div> </div>
<div v-else class="overflow-hidden rounded-lg border border-neutral-200 bg-white"> <div v-else class="overflow-hidden rounded-lg border border-neutral-200 bg-white">
<div class="grid grid-cols-[120px_120px_1fr_200px] gap-4 border-b border-neutral-200 bg-tertiary-500 px-6 py-3 text-md font-semibold text-neutral-700"> <div class="grid grid-cols-[120px_160px_1fr_220px_200px] gap-4 border-b border-neutral-200 bg-tertiary-500 px-6 py-3 text-md font-semibold text-neutral-700">
<span class="text-left">Code</span> <span class="text-left">Code</span>
<span class="text-left">Libellé</span> <span class="text-left">Libellé</span>
<span class="text-left">Couleur</span> <span class="text-left">Couleur</span>
<span class="text-left">Compte en heures</span>
<span class="text-right">Actions</span> <span class="text-right">Actions</span>
</div> </div>
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500"> <div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
@@ -32,7 +33,7 @@
<div <div
v-for="type in absenceTypes" v-for="type in absenceTypes"
:key="type.id" :key="type.id"
class="grid grid-cols-[120px_120px_1fr_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_160px_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"
> >
<span class="font-semibold text-left">{{ type.code }}</span> <span class="font-semibold text-left">{{ type.code }}</span>
<span class="text-left">{{ type.label }}</span> <span class="text-left">{{ type.label }}</span>
@@ -43,6 +44,14 @@
/> />
<span class="text-md uppercase text-neutral-500">{{ type.color }}</span> <span class="text-md uppercase text-neutral-500">{{ type.color }}</span>
</div> </div>
<div class="text-left">
<span
class="inline-flex rounded-md px-2 py-1 text-sm font-semibold"
:class="type.countAsWorkedHours ? 'bg-emerald-100 text-emerald-700' : 'bg-neutral-100 text-neutral-700'"
>
{{ type.countAsWorkedHours ? 'Oui' : 'Non' }}
</span>
</div>
<div class="flex items-center justify-end gap-2"> <div class="flex items-center justify-end gap-2">
<button <button
type="button" type="button"
@@ -94,6 +103,31 @@
Le libellé est obligatoire. Le libellé est obligatoire.
</p> </p>
</div> </div>
<div>
<label class="text-md font-semibold text-neutral-700">
Compté comme travaillé
</label>
<div class="mt-2 flex items-center gap-6">
<label class="inline-flex items-center gap-2 text-md text-neutral-800">
<input
v-model="form.countAsWorkedHours"
type="radio"
class="h-4 w-4"
:value="true"
/>
Oui
</label>
<label class="inline-flex items-center gap-2 text-md text-neutral-800">
<input
v-model="form.countAsWorkedHours"
type="radio"
class="h-4 w-4"
:value="false"
/>
Non
</label>
</div>
</div>
<div> <div>
<label class="text-md font-semibold text-neutral-700" for="color"> <label class="text-md font-semibold text-neutral-700" for="color">
Couleur <span class="text-red-600">*</span> Couleur <span class="text-red-600">*</span>
@@ -136,6 +170,10 @@
import type { AbsenceType } from '~/services/dto/absence-type' import type { AbsenceType } from '~/services/dto/absence-type'
import { createAbsenceType, deleteAbsenceType, listAbsenceTypes, updateAbsenceType } from '~/services/absence-types' import { createAbsenceType, deleteAbsenceType, listAbsenceTypes, updateAbsenceType } from '~/services/absence-types'
useHead({
title: 'Types d\'absences'
})
const isDrawerOpen = ref(false) const isDrawerOpen = ref(false)
const isSubmitting = ref(false) const isSubmitting = ref(false)
const isLoading = ref(false) const isLoading = ref(false)
@@ -150,7 +188,8 @@ const drawerTitle = computed(() =>
const form = reactive({ const form = reactive({
code: '', code: '',
label: '', label: '',
color: '#222783' color: '#222783',
countAsWorkedHours: true
}) })
const validationTouched = reactive({ const validationTouched = reactive({
@@ -214,6 +253,7 @@ const resetForm = () => {
form.code = '' form.code = ''
form.label = '' form.label = ''
form.color = '#222783' form.color = '#222783'
form.countAsWorkedHours = true
} }
const openCreate = () => { const openCreate = () => {
@@ -227,6 +267,7 @@ const openEdit = (type: AbsenceType) => {
form.code = type.code form.code = type.code
form.label = type.label form.label = type.label
form.color = type.color form.color = type.color
form.countAsWorkedHours = type.countAsWorkedHours
isDrawerOpen.value = true isDrawerOpen.value = true
} }
@@ -249,13 +290,15 @@ const handleSubmit = async () => {
await updateAbsenceType(editingType.value.id, { await updateAbsenceType(editingType.value.id, {
code: form.code, code: form.code,
label: form.label, label: form.label,
color: form.color color: form.color,
countAsWorkedHours: form.countAsWorkedHours
}) })
} else { } else {
await createAbsenceType({ await createAbsenceType({
code: form.code, code: form.code,
label: form.label, label: form.label,
color: form.color color: form.color,
countAsWorkedHours: form.countAsWorkedHours
}) })
} }
+66 -20
View File
@@ -27,23 +27,20 @@
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<EmployeeNameFilterInput v-model="employeeFilter"/> <div class="w-80">
<select <EmployeeNameFilterInput v-model="employeeFilter"/>
v-model="selectedMonth" </div>
class="h-10 rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900" <PeriodStepperPicker
> width-class="w-[260px]"
<option v-for="month in months" :key="month.value" :value="month.value"> :label="selectedMonthLabel"
{{ month.label }} picker-type="month"
</option> :picker-value="monthPickerValue"
</select> prev-aria-label="Mois précédent"
<select next-aria-label="Mois suivant"
v-model="selectedYear" @prev="shiftMonth(-1)"
class="h-10 rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900" @next="shiftMonth(1)"
> @pick="onMonthPickerValue"
<option v-for="year in years" :key="year" :value="year"> />
{{ year }}
</option>
</select>
</div> </div>
</div> </div>
<div class="flex flex-wrap items-center gap-6 py-2"> <div class="flex flex-wrap items-center gap-6 py-2">
@@ -84,6 +81,8 @@
<AbsencePrintDrawer <AbsencePrintDrawer
v-model="isPrintOpen" v-model="isPrintOpen"
:sites="sites" :sites="sites"
:contract-natures="contractNatureOptions"
:work-contracts="workContractOptions"
:print-form="printForm" :print-form="printForm"
@submit="handlePrint" @submit="handlePrint"
@cancel="closePrint" @cancel="closePrint"
@@ -107,8 +106,13 @@ import CalendarGrid from '~/components/CalendarGrid.vue'
import AbsenceFormDrawer from '~/components/AbsenceFormDrawer.vue' import AbsenceFormDrawer from '~/components/AbsenceFormDrawer.vue'
import AbsencePrintDrawer from '~/components/AbsencePrintDrawer.vue' import AbsencePrintDrawer from '~/components/AbsencePrintDrawer.vue'
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue' import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
import PeriodStepperPicker from '~/components/PeriodStepperPicker.vue'
import SiteFilterSelector from '~/components/SiteFilterSelector.vue' import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
useHead({
title: 'Calendrier'
})
// Données principales affichées dans la grille. // Données principales affichées dans la grille.
const employees = ref<Employee[]>([]) const employees = ref<Employee[]>([])
const sites = computed(() => { const sites = computed(() => {
@@ -187,8 +191,8 @@ const months = [
{value: 11, label: 'Décembre'} {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. // Infos de calendrier calculées.
const daysInMonth = computed(() => getDaysInMonth(selectedYear.value, selectedMonth.value)) const daysInMonth = computed(() => getDaysInMonth(selectedYear.value, selectedMonth.value))
@@ -215,7 +219,25 @@ const form = reactive({
const printForm = reactive({ const printForm = reactive({
from: '', from: '',
to: '', to: '',
siteIds: [] as number[] siteIds: [] as number[],
contractNatures: [] as Array<'CDI' | 'CDD' | 'INTERIM'>,
workContractIds: [] as number[]
})
const contractNatureOptions = [
{ value: 'CDI' as const, label: 'CDI' },
{ value: 'CDD' as const, label: 'CDD' },
{ value: 'INTERIM' as const, label: 'Intérim' }
]
const workContractOptions = computed(() => {
const byId = new Map<number, { id: number; name: string }>()
for (const employee of employees.value) {
const contract = employee.contract
if (!contract?.id) continue
byId.set(contract.id, { id: contract.id, name: contract.name })
}
return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name, 'fr'))
}) })
// Remet le formulaire à zéro. // Remet le formulaire à zéro.
@@ -243,6 +265,8 @@ const openPrint = () => {
printForm.from = monthStart printForm.from = monthStart
printForm.to = monthEnd printForm.to = monthEnd
printForm.siteIds = [...selectedSiteIds.value] printForm.siteIds = [...selectedSiteIds.value]
printForm.contractNatures = contractNatureOptions.map((item) => item.value)
printForm.workContractIds = workContractOptions.value.map((item) => item.id)
isPrintOpen.value = true isPrintOpen.value = true
} }
@@ -288,6 +312,22 @@ const addMonths = (date: Date, months: number) => {
return next 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. // Limite l'intervalle d'impression à 2 mois max.
const enforcePrintRange = () => { const enforcePrintRange = () => {
if (!printForm.from) return if (!printForm.from) return
@@ -651,6 +691,12 @@ const handlePrint = async () => {
if (printForm.siteIds.length > 0) { if (printForm.siteIds.length > 0) {
params.set('sites', printForm.siteIds.join(',')) params.set('sites', printForm.siteIds.join(','))
} }
if (printForm.contractNatures.length > 0) {
params.set('contractNatures', printForm.contractNatures.join(','))
}
if (printForm.workContractIds.length > 0) {
params.set('workContracts', printForm.workContractIds.join(','))
}
await printPdf(`/absences/print?${params.toString()}`) await printPdf(`/absences/print?${params.toString()}`)
isPrintOpen.value = false isPrintOpen.value = false
} }
+271 -71
View File
@@ -1,59 +1,82 @@
<template> <template>
<div> <div class="h-full overflow-hidden flex flex-col">
<div class="flex items-center justify-between pb-12"> <div class="shrink-0">
<h1 class="text-4xl font-bold text-primary-500">Employés</h1> <div class="flex items-center justify-between">
<button <h1 class="text-4xl font-bold text-primary-500">Employés</h1>
type="button" </div>
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
@click="isDrawerOpen = true" <div class="flex flex-col gap-3 py-6">
> <div class="flex justify-between">
Ajouter un employé <SiteFilterSelector v-if="sites.length > 0" v-model="selectedSiteIds" :sites="sites" />
</button> <button
type="button"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
@click="openCreate"
>
Ajouter un employé
</button>
</div>
<div class="w-80">
<EmployeeNameFilterInput v-model="employeeFilter" />
</div>
</div>
</div> </div>
<div <div
v-if="!isLoading && employees.length === 0" v-if="!isLoading && filteredEmployees.length === 0"
class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600"
> >
Aucun employé pour le moment. Aucun employé pour le moment.
</div> </div>
<div v-else class="max-h-[80vh] overflow-auto rounded-lg border border-neutral-200 bg-white"> <div v-else class="flex-1 min-h-0 rounded-lg border border-neutral-200 bg-white overflow-hidden">
<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"> <div class="h-full overflow-auto">
<span class="text-left">Prénom</span> <div class="min-w-[900px]">
<span class="text-left">Nom</span> <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">Site</span> <span class="text-left">Prénom</span>
<span class="text-left">Contrat</span> <span class="text-left">Nom</span>
<span class="text-right">Actions</span> <span class="text-left">Site</span>
</div> <span class="text-left">Nature</span>
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500"> <span class="text-left">Contrat</span>
Chargement... <span class="text-right">Actions</span>
</div> </div>
<div v-else> <div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
<div Chargement...
v-for="employee in employees" </div>
:key="employee.id" <div v-else>
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" <div
> v-for="employee in filteredEmployees"
<span>{{ employee.firstName }}</span> :key="employee.id"
<span>{{ employee.lastName }}</span> 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.site?.name ?? '-' }}</span>
<span>{{ employee.contract?.name ?? '-' }}</span>
<div class="flex items-center justify-end gap-2">
<button
type="button"
class="rounded-md border border-neutral-200 px-2 py-1 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
@click="openEdit(employee)"
> >
Modifier <span>{{ employee.firstName }}</span>
</button> <span>{{ employee.lastName }}</span>
<button <span
type="button" class="inline-flex w-fit max-w-full rounded-md px-2 py-1 text-sm font-semibold"
class="rounded-md border border-red-200 px-2 py-1 text-md font-semibold text-red-600 hover:bg-red-50" :style="employee.site ? { backgroundColor: employee.site.color, color: '#0f172a' } : {}"
@click="confirmDelete(employee)" :class="employee.site ? '' : 'bg-neutral-100 text-neutral-600'"
> >
Supprimer {{ employee.site?.name ?? '-' }}
</button> </span>
<span>{{ contractNatureLabel(employee.currentContractNature) }}</span>
<span>{{ employee.contract?.name ?? '-' }}</span>
<div class="flex items-center justify-end gap-2">
<button
type="button"
class="rounded-md border border-neutral-200 px-2 py-1 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
@click="openEdit(employee)"
>
Modifier
</button>
<button
type="button"
class="rounded-md border border-red-200 px-2 py-1 text-md font-semibold text-red-600 hover:bg-red-50"
@click="confirmDelete(employee)"
>
Supprimer
</button>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -107,24 +130,72 @@
Le site est obligatoire. Le site est obligatoire.
</p> </p>
</div> </div>
<div> <template v-if="!editingEmployee">
<label class="text-md font-semibold text-neutral-700" for="contract"> <div>
Contrat <span class="text-red-600">*</span> <label class="text-md font-semibold text-neutral-700" for="contract-nature">
</label> Type de contrat <span class="text-red-600">*</span>
<select </label>
id="contract" <select
v-model="form.contractId" id="contract-nature"
:class="contractFieldClass" v-model="form.contractNature"
> :class="contractNatureFieldClass"
<option value="">Sélectionner un contrat</option> >
<option v-for="contract in contracts" :key="contract.id" :value="contract.id"> <option value="CDI">CDI</option>
{{ contract.name }} <option value="CDD">CDD</option>
</option> <option value="INTERIM">Intérim</option>
</select> </select>
<p v-if="showContractError" class="mt-1 text-sm text-red-600"> <p v-if="showContractNatureError" class="mt-1 text-sm text-red-600">
Le contrat est obligatoire. Le type de contrat est obligatoire.
</p> </p>
</div> </div>
<div>
<label class="text-md font-semibold text-neutral-700" for="contract">
Temps de travail <span class="text-red-600">*</span>
</label>
<select
id="contract"
v-model="form.contractId"
:class="contractFieldClass"
>
<option value="">Sélectionner un contrat</option>
<option v-for="contract in contracts" :key="contract.id" :value="contract.id">
{{ contract.name }}
</option>
</select>
<p v-if="showContractError" class="mt-1 text-sm text-red-600">
Le temps de travail est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="contract-start-date">
Début contrat <span class="text-red-600">*</span>
</label>
<input
id="contract-start-date"
v-model="form.contractStartDate"
type="date"
:class="contractStartDateFieldClass"
/>
<p v-if="showContractStartDateError" class="mt-1 text-sm text-red-600">
La date de début est obligatoire.
</p>
</div>
<div v-if="requiresContractEndDate">
<label class="text-md font-semibold text-neutral-700" for="contract-end-date">
Fin contrat
<span v-if="requiresContractEndDate" class="text-red-600">*</span>
</label>
<input
id="contract-end-date"
v-model="form.contractEndDate"
type="date"
:class="contractEndDateFieldClass"
/>
<p v-if="showContractEndDateError" class="mt-1 text-sm text-red-600">
La date de fin est obligatoire pour un CDD ou un intérim.
</p>
</div>
</template>
<div class="flex justify-end gap-3 pt-2"> <div class="flex justify-end gap-3 pt-2">
<button <button
type="button" type="button"
@@ -153,10 +224,15 @@ import type { Site } from '~/services/dto/site'
import { listContracts } from '~/services/contracts' import { listContracts } from '~/services/contracts'
import { createEmployee, deleteEmployee, listEmployees, updateEmployee } from '~/services/employees' import { createEmployee, deleteEmployee, listEmployees, updateEmployee } from '~/services/employees'
import { listSites } from '~/services/sites' import { listSites } from '~/services/sites'
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
useHead({
title: 'Employés'
})
const isDrawerOpen = ref(false) const isDrawerOpen = ref(false)
const isSubmitting = ref(false) const isSubmitting = ref(false)
const isLoading = ref(false) const isLoading = ref(false)
const sitesInitialized = ref(false)
const editingEmployee = ref<Employee | null>(null) const editingEmployee = ref<Employee | null>(null)
const drawerTitle = computed(() => const drawerTitle = computed(() =>
editingEmployee.value ? 'Modifier un employé' : 'Ajouter un employé' editingEmployee.value ? 'Modifier un employé' : 'Ajouter un employé'
@@ -165,27 +241,75 @@ const drawerTitle = computed(() =>
const employees = ref<Employee[]>([]) const employees = ref<Employee[]>([])
const sites = ref<Site[]>([]) const sites = ref<Site[]>([])
const contracts = ref<Contract[]>([]) const contracts = ref<Contract[]>([])
const employeeFilter = ref('')
const selectedSiteIds = ref<number[]>([])
const filteredEmployees = computed(() => {
if (selectedSiteIds.value.length === 0) return []
const filter = employeeFilter.value.trim().toLowerCase()
const bySite = employees.value.filter((employee) => {
const siteId = employee.site?.id
return !!siteId && selectedSiteIds.value.includes(siteId)
})
if (!filter) return bySite
return bySite.filter((employee) => {
const firstName = employee.firstName?.toLowerCase() ?? ''
const lastName = employee.lastName?.toLowerCase() ?? ''
return firstName.includes(filter) || lastName.includes(filter)
})
})
const contractNatureLabel = (value?: 'CDI' | 'CDD' | 'INTERIM') => {
if (value === 'CDD') return 'CDD'
if (value === 'INTERIM') return 'Intérim'
return 'CDI'
}
const form = reactive({ const form = reactive({
firstName: '', firstName: '',
lastName: '', lastName: '',
siteId: '' as number | '', siteId: '' as number | '',
contractId: '' as number | '' contractId: '' as number | '',
contractNature: 'CDI' as 'CDI' | 'CDD' | 'INTERIM',
contractStartDate: '',
contractEndDate: ''
}) })
const validationTouched = reactive({ const validationTouched = reactive({
firstName: false, firstName: false,
lastName: false, lastName: false,
siteId: false, siteId: false,
contractId: false contractId: false,
contractNature: false,
contractStartDate: false,
contractEndDate: false
}) })
const isFirstNameValid = computed(() => form.firstName.trim() !== '') const isFirstNameValid = computed(() => form.firstName.trim() !== '')
const isLastNameValid = computed(() => form.lastName.trim() !== '') const isLastNameValid = computed(() => form.lastName.trim() !== '')
const isSiteValid = computed(() => form.siteId !== '') const isSiteValid = computed(() => form.siteId !== '')
const isContractValid = computed(() => form.contractId !== '') const isContractValid = computed(() => form.contractId !== '')
const isContractNatureValid = computed(() => ['CDI', 'CDD', 'INTERIM'].includes(form.contractNature))
const isContractStartDateValid = computed(() => form.contractStartDate !== '')
const requiresContractEndDate = computed(() => form.contractNature === 'CDD' || form.contractNature === 'INTERIM')
const isContractEndDateValid = computed(() => {
if (!requiresContractEndDate.value) return true
return form.contractEndDate !== ''
})
const isFormValid = computed( const isFormValid = computed(
() => isFirstNameValid.value && isLastNameValid.value && isSiteValid.value && isContractValid.value () =>
isFirstNameValid.value &&
isLastNameValid.value &&
isSiteValid.value &&
(editingEmployee.value
? true
: (isContractValid.value &&
isContractNatureValid.value &&
isContractStartDateValid.value &&
isContractEndDateValid.value))
) )
const showFirstNameError = computed( const showFirstNameError = computed(
@@ -200,6 +324,15 @@ const showSiteError = computed(
const showContractError = computed( const showContractError = computed(
() => validationTouched.contractId && !isContractValid.value () => validationTouched.contractId && !isContractValid.value
) )
const showContractNatureError = computed(
() => !editingEmployee.value && validationTouched.contractNature && !isContractNatureValid.value
)
const showContractStartDateError = computed(
() => !editingEmployee.value && validationTouched.contractStartDate && !isContractStartDateValid.value
)
const showContractEndDateError = computed(
() => !editingEmployee.value && validationTouched.contractEndDate && !isContractEndDateValid.value
)
const baseInputClass = const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20' 'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
@@ -231,6 +364,26 @@ const contractFieldClass = computed(() => {
} }
return `${baseClass} border-neutral-300` return `${baseClass} border-neutral-300`
}) })
const contractNatureFieldClass = computed(() => {
const baseClass =
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
if (showContractNatureError.value) {
return `${baseClass} border-red-500`
}
return `${baseClass} border-neutral-300`
})
const contractStartDateFieldClass = computed(() => {
if (showContractStartDateError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const contractEndDateFieldClass = computed(() => {
if (showContractEndDateError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const submitButtonClass = computed(() => { const submitButtonClass = computed(() => {
if (isSubmitting.value || !isFormValid.value) { if (isSubmitting.value || !isFormValid.value) {
@@ -258,14 +411,35 @@ const loadContracts = async () => {
onMounted(async () => { onMounted(async () => {
await Promise.all([loadEmployees(), loadSites(), loadContracts()]) await Promise.all([loadEmployees(), loadSites(), loadContracts()])
if (form.contractStartDate === '') {
form.contractStartDate = new Date().toISOString().slice(0, 10)
}
}) })
watch(sites, (nextSites) => {
const currentSiteIds = nextSites.map((site) => site.id)
if (!sitesInitialized.value) {
if (currentSiteIds.length === 0) return
selectedSiteIds.value = currentSiteIds
sitesInitialized.value = true
return
}
selectedSiteIds.value = selectedSiteIds.value.filter((siteId) => currentSiteIds.includes(siteId))
}, { immediate: true })
const handleSubmit = async () => { const handleSubmit = async () => {
if (isSubmitting.value) return if (isSubmitting.value) return
validationTouched.firstName = true validationTouched.firstName = true
validationTouched.lastName = true validationTouched.lastName = true
validationTouched.siteId = true validationTouched.siteId = true
validationTouched.contractId = true if (!editingEmployee.value) {
validationTouched.contractId = true
validationTouched.contractNature = true
validationTouched.contractStartDate = true
validationTouched.contractEndDate = true
}
if (!isFormValid.value) return if (!isFormValid.value) return
isSubmitting.value = true isSubmitting.value = true
@@ -275,14 +449,17 @@ const handleSubmit = async () => {
firstName: form.firstName, firstName: form.firstName,
lastName: form.lastName, lastName: form.lastName,
siteId: form.siteId === '' ? null : Number(form.siteId), siteId: form.siteId === '' ? null : Number(form.siteId),
contractId: Number(form.contractId) contractId: editingEmployee.value.contract?.id ?? Number(form.contractId)
}) })
} else { } else {
await createEmployee({ await createEmployee({
firstName: form.firstName, firstName: form.firstName,
lastName: form.lastName, lastName: form.lastName,
siteId: form.siteId === '' ? null : Number(form.siteId), siteId: form.siteId === '' ? null : Number(form.siteId),
contractId: Number(form.contractId) contractId: Number(form.contractId),
contractNature: form.contractNature,
contractStartDate: form.contractStartDate,
contractEndDate: requiresContractEndDate.value ? form.contractEndDate : null
}) })
} }
@@ -290,6 +467,9 @@ const handleSubmit = async () => {
form.lastName = '' form.lastName = ''
form.siteId = '' form.siteId = ''
form.contractId = '' form.contractId = ''
form.contractNature = 'CDI'
form.contractStartDate = new Date().toISOString().slice(0, 10)
form.contractEndDate = ''
editingEmployee.value = null editingEmployee.value = null
isDrawerOpen.value = false isDrawerOpen.value = false
await loadEmployees() await loadEmployees()
@@ -304,6 +484,15 @@ watch(isDrawerOpen, (isOpen) => {
validationTouched.lastName = false validationTouched.lastName = false
validationTouched.siteId = false validationTouched.siteId = false
validationTouched.contractId = false validationTouched.contractId = false
validationTouched.contractNature = false
validationTouched.contractStartDate = false
validationTouched.contractEndDate = false
}
})
watch(requiresContractEndDate, (required) => {
if (!required) {
form.contractEndDate = ''
} }
}) })
@@ -312,7 +501,18 @@ const openEdit = (employee: Employee) => {
form.firstName = employee.firstName form.firstName = employee.firstName
form.lastName = employee.lastName form.lastName = employee.lastName
form.siteId = employee.site?.id ?? '' form.siteId = employee.site?.id ?? ''
form.contractId = employee.contract?.id ?? '' isDrawerOpen.value = true
}
const openCreate = () => {
editingEmployee.value = null
form.firstName = ''
form.lastName = ''
form.siteId = ''
form.contractId = ''
form.contractNature = 'CDI'
form.contractStartDate = new Date().toISOString().slice(0, 10)
form.contractEndDate = ''
isDrawerOpen.value = true isDrawerOpen.value = true
} }
+91 -17
View File
@@ -11,11 +11,17 @@
v-model:employee-filter="employeeFilter" v-model:employee-filter="employeeFilter"
:is-admin="isAdmin" :is-admin="isAdmin"
:sites="sites" :sites="sites"
:absence-types="absenceTypes"
:formatted-selected-date="formattedSelectedDate" :formatted-selected-date="formattedSelectedDate"
:shortcut-button-class="shortcutButtonClass" :shortcut-button-class="shortcutButtonClass"
:week-shortcut-button-class="weekShortcutButtonClass"
:get-week-shortcut-label="getWeekShortcutLabel"
@set-yesterday="setYesterday" @set-yesterday="setYesterday"
@set-today="setToday" @set-today="setToday"
@set-tomorrow="setTomorrow" @set-tomorrow="setTomorrow"
@set-previous-week="setPreviousWeek"
@set-this-week="setThisWeek"
@set-next-week="setNextWeek"
@shift-date="shiftDate" @shift-date="shiftDate"
/> />
@@ -27,31 +33,51 @@
Aucun employé accessible. Aucun employé accessible.
</div> </div>
<div v-else class="flex flex-1 min-h-0 flex-col gap-4"> <div v-else class="flex min-h-0 flex-col gap-4">
<HoursDayView <div class="min-h-0 flex flex-col max-h-[calc(100vh-300px)]">
v-if="viewMode === 'day'" <HoursDayView
v-model:rows="rows" v-if="viewMode === 'day'"
:employees="visibleEmployees" v-model:rows="rows"
:is-admin="isAdmin" :employees="visibleEmployees"
:day-grid-cols="dayGridCols" :is-admin="isAdmin"
:contract-label="contractLabel" :is-site-manager="isSiteManager"
:is-time-tracking="isTimeTracking" :day-grid-cols="dayGridCols"
:is-holiday="isSelectedDateHoliday"
:contract-label="contractLabel"
:is-time-tracking="isTimeTracking"
:is-presence-tracking="isPresenceTracking" :is-presence-tracking="isPresenceTracking"
:is-row-locked="isRowLocked" :is-row-locked="isRowLocked"
:is-half-locked-by-absence="isHalfLockedByAbsence"
:is-evening-locked-by-absence="isEveningLockedByAbsence"
:has-contract-at-selected-date="hasContractAtSelectedDate"
:is-validation-pending="isValidationPending" :is-validation-pending="isValidationPending"
:is-site-validation-pending="isSiteValidationPending"
:can-toggle-validation="canToggleValidation"
:can-toggle-site-validation="canToggleSiteValidation"
:is-bulk-validation-checked="isBulkValidationChecked"
:is-bulk-validation-indeterminate="isBulkValidationIndeterminate"
:on-toggle-validation="toggleValidation" :on-toggle-validation="toggleValidation"
:on-toggle-site-validation="toggleSiteValidation"
:on-toggle-validation-bulk="toggleValidationBulk"
:get-row-metrics="getRowMetrics" :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" :format-minutes="formatMinutes"
class="max-h-[calc(100vh-300px)]"
/> />
<HoursWeekView <HoursWeekView
v-else-if="isAdmin && viewMode === 'week'" v-else-if="isAdmin && viewMode === 'week'"
:is-week-loading="isWeekLoading" :is-week-loading="isWeekLoading"
:week-grid-cols="weekGridCols" :week-grid-cols="weekGridCols"
:weekly-summary="filteredWeeklySummary" :weekly-summary="filteredWeeklySummary"
:week-day-headers="weekDayHeaders" :week-day-headers="weekDayHeaders"
:format-minutes="formatMinutes" :format-minutes="formatMinutes"
/> class="max-h-[calc(100vh-300px)]"
/>
</div>
<div v-if="viewMode === 'day'" class="shrink-0 px-4 pt-4 flex justify-center"> <div v-if="viewMode === 'day'" class="shrink-0 px-4 pt-4 flex justify-center">
<button <button
@@ -65,12 +91,28 @@
</button> </button>
</div> </div>
</div> </div>
<AbsenceFormDrawer
v-model="isAbsenceDrawerOpen"
:employees="employees"
:absence-types="absenceTypes"
:form="absenceForm"
:editing-absence="editingAbsence"
:is-submitting="isAbsenceSubmitting"
:lock-employee="true"
:lock-dates="true"
:show-comment="false"
@submit="submitAbsence"
@delete="deleteAbsenceFromDrawer"
@cancel="closeAbsenceDrawer"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const { const {
isAdmin, isAdmin,
isSiteManager,
viewMode, viewMode,
selectedDate, selectedDate,
employeeFilter, employeeFilter,
@@ -79,28 +121,60 @@ const {
employees, employees,
visibleEmployees, visibleEmployees,
rows, rows,
absenceTypes,
absenceForm,
isAbsenceDrawerOpen,
isAbsenceSubmitting,
editingAbsence,
filteredWeeklySummary, filteredWeeklySummary,
isLoading, isLoading,
isWeekLoading, isWeekLoading,
isSubmitting, isSubmitting,
dayGridCols, dayGridCols,
isSelectedDateHoliday,
weekGridCols, weekGridCols,
saveButtonClass, saveButtonClass,
formattedSelectedDate, formattedSelectedDate,
weekDayHeaders, weekDayHeaders,
shortcutButtonClass, shortcutButtonClass,
weekShortcutButtonClass,
getWeekShortcutLabel,
setToday, setToday,
setYesterday, setYesterday,
setTomorrow, setTomorrow,
setThisWeek,
setPreviousWeek,
setNextWeek,
shiftDate, shiftDate,
contractLabel, contractLabel,
isTimeTracking, isTimeTracking,
isPresenceTracking, isPresenceTracking,
isRowLocked, isRowLocked,
isHalfLockedByAbsence,
isEveningLockedByAbsence,
hasContractAtSelectedDate,
isValidationPending, isValidationPending,
isSiteValidationPending,
canToggleValidation,
canToggleSiteValidation,
isBulkValidationChecked,
isBulkValidationIndeterminate,
toggleValidation, toggleValidation,
toggleSiteValidation,
toggleValidationBulk,
getRowMetrics, getRowMetrics,
getRowAbsenceLabel,
getRowAbsenceStyle,
getPresenceDayValue,
openAbsenceDrawer,
submitAbsence,
deleteAbsenceFromDrawer,
closeAbsenceDrawer,
formatMinutes, formatMinutes,
handleSave handleSave
} = useHoursPage() } = useHoursPage()
useHead({
title: 'Heures'
})
</script> </script>
+3 -1
View File
@@ -3,5 +3,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
useHead({
title: 'Tableau de bord'
})
</script> </script>
+3
View File
@@ -49,6 +49,9 @@
<script setup lang="ts"> <script setup lang="ts">
definePageMeta({layout: 'auth'}) definePageMeta({layout: 'auth'})
useHead({
title: 'Connexion'
})
const router = useRouter() const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
+4
View File
@@ -123,6 +123,10 @@
import type { Site } from '~/services/dto/site' import type { Site } from '~/services/dto/site'
import { createSite, deleteSite, listSites, updateSite, updateSiteOrder } from '~/services/sites' import { createSite, deleteSite, listSites, updateSite, updateSiteOrder } from '~/services/sites'
useHead({
title: 'Sites'
})
const isDrawerOpen = ref(false) const isDrawerOpen = ref(false)
const isSubmitting = ref(false) const isSubmitting = ref(false)
const isLoading = ref(false) const isLoading = ref(false)
+3
View File
@@ -209,6 +209,9 @@ import { createUser, listUsers, updateUser } from '~/services/users'
import { createUserSiteRole, deleteUserSiteRole, listUserSiteRoles } from '~/services/user-site-roles' import { createUserSiteRole, deleteUserSiteRole, listUserSiteRoles } from '~/services/user-site-roles'
definePageMeta({ middleware: ['admin'] }) definePageMeta({ middleware: ['admin'] })
useHead({
title: 'Utilisateurs'
})
const users = ref<User[]>([]) const users = ref<User[]>([])
const employees = ref<Employee[]>([]) const employees = ref<Employee[]>([])
+2 -2
View File
@@ -12,7 +12,7 @@ export const listAbsenceTypes = async () => {
} }
export const createAbsenceType = async ( export const createAbsenceType = async (
payload: Pick<AbsenceType, 'code' | 'label' | 'color'> payload: Pick<AbsenceType, 'code' | 'label' | 'color' | 'countAsWorkedHours'>
) => { ) => {
const api = useApi() const api = useApi()
return api.post<AbsenceType>('/absence_types', payload, { return api.post<AbsenceType>('/absence_types', payload, {
@@ -23,7 +23,7 @@ export const createAbsenceType = async (
export const updateAbsenceType = async ( export const updateAbsenceType = async (
id: number, id: number,
payload: Pick<AbsenceType, 'code' | 'label' | 'color'> payload: Pick<AbsenceType, 'code' | 'label' | 'color' | 'countAsWorkedHours'>
) => { ) => {
const api = useApi() const api = useApi()
return api.patch<AbsenceType>(`/absence_types/${id}`, payload, { return api.patch<AbsenceType>(`/absence_types/${id}`, payload, {
+1
View File
@@ -8,6 +8,7 @@ export const getCurrentUser = () => {
export const login = (username: string, password: string) => { export const login = (username: string, password: string) => {
const api = useApi() const api = useApi()
return api.post('/login_check', { username, password }, { return api.post('/login_check', { username, password }, {
toastOn401: true,
toastErrorKey: 'errors.auth.login' toastErrorKey: 'errors.auth.login'
}) })
} }
+1
View File
@@ -3,4 +3,5 @@ export type AbsenceType = {
code: string code: string
label: string label: string
color: string color: string
countAsWorkedHours: boolean
} }
+19 -1
View File
@@ -1,7 +1,25 @@
export const TRACKING_MODES = {
TIME: 'TIME',
PRESENCE: 'PRESENCE'
} as const
export type TrackingMode = (typeof TRACKING_MODES)[keyof typeof TRACKING_MODES]
export const CONTRACT_TYPES = {
FORFAIT: 'FORFAIT',
H35: '35H',
H39: '39H',
INTERIM: 'INTERIM',
CUSTOM: 'CUSTOM'
} as const
export type ContractType = (typeof CONTRACT_TYPES)[keyof typeof CONTRACT_TYPES]
export type Contract = { export type Contract = {
id: number id: number
name: string name: string
trackingMode: 'TIME' | 'PRESENCE' trackingMode: TrackingMode
type: ContractType
weeklyHours?: number | null weeklyHours?: number | null
isActive?: boolean isActive?: boolean
} }
+3
View File
@@ -7,5 +7,8 @@ export type Employee = {
lastName: string lastName: string
site: Site site: Site
contract?: Contract | null contract?: Contract | null
currentContractNature?: 'CDI' | 'CDD' | 'INTERIM'
currentContractStartDate?: string | null
currentContractEndDate?: string | null
displayOrder?: number displayOrder?: number
} }
+26 -1
View File
@@ -1,4 +1,5 @@
import type { Employee } from './employee' import type { Employee } from './employee'
import type { ContractType, TrackingMode } from './contract'
export type WorkHour = { export type WorkHour = {
id: number id: number
@@ -12,6 +13,7 @@ export type WorkHour = {
eveningTo?: string | null eveningTo?: string | null
isPresentMorning?: boolean isPresentMorning?: boolean
isPresentAfternoon?: boolean isPresentAfternoon?: boolean
isSiteValid?: boolean
isValid?: boolean isValid?: boolean
} }
@@ -33,6 +35,9 @@ export type WeeklyWorkHourDailySummary = {
nightMinutes: number nightMinutes: number
totalMinutes: number totalMinutes: number
present?: number | null present?: number | null
hasAbsence?: boolean
absenceLabel?: string | null
absenceColor?: string | null
} }
export type WeeklyWorkHourRowSummary = { export type WeeklyWorkHourRowSummary = {
@@ -41,14 +46,17 @@ export type WeeklyWorkHourRowSummary = {
lastName: string lastName: string
siteName?: string | null siteName?: string | null
contractName?: string | null contractName?: string | null
trackingMode?: 'TIME' | 'PRESENCE' | null contractType?: ContractType | null
trackingMode?: TrackingMode | null
daily: WeeklyWorkHourDailySummary[] daily: WeeklyWorkHourDailySummary[]
weeklyDayMinutes: number weeklyDayMinutes: number
weeklyNightMinutes: number weeklyNightMinutes: number
weeklyTotalMinutes: number weeklyTotalMinutes: number
weeklyPresenceCount?: number weeklyPresenceCount?: number
weeklyOvertimeTotalMinutes?: number
weeklyOvertime25Minutes?: number weeklyOvertime25Minutes?: number
weeklyOvertime50Minutes?: number weeklyOvertime50Minutes?: number
weeklyRecoveryMinutes?: number
} }
export type WeeklyWorkHourSummary = { export type WeeklyWorkHourSummary = {
@@ -57,3 +65,20 @@ export type WeeklyWorkHourSummary = {
days: string[] days: string[]
rows: WeeklyWorkHourRowSummary[] rows: WeeklyWorkHourRowSummary[]
} }
export type WorkHourDayContextRow = {
employeeId: number
hasContractAtDate: boolean
absenceLabel?: string | null
absenceColor?: string | null
absenceHalf?: 'AM' | 'PM' | null
absentMorning: boolean
absentAfternoon: boolean
creditedMinutes: number
creditedPresenceUnits: number
}
export type WorkHourDayContext = {
workDate: string
rows: WorkHourDayContextRow[]
}
+13 -1
View File
@@ -26,13 +26,19 @@ export const createEmployee = async (payload: {
lastName: string lastName: string
siteId?: number | null siteId?: number | null
contractId: number contractId: number
contractNature?: 'CDI' | 'CDD' | 'INTERIM'
contractStartDate?: string
contractEndDate?: string | null
}) => { }) => {
const api = useApi() const api = useApi()
return api.post<Employee>('/employees', { return api.post<Employee>('/employees', {
firstName: payload.firstName, firstName: payload.firstName,
lastName: payload.lastName, lastName: payload.lastName,
site: payload.siteId ? `/api/sites/${payload.siteId}` : null, site: payload.siteId ? `/api/sites/${payload.siteId}` : null,
contract: `/api/contracts/${payload.contractId}` contract: `/api/contracts/${payload.contractId}`,
contractNature: payload.contractNature,
contractStartDate: payload.contractStartDate,
contractEndDate: payload.contractEndDate ?? null
}, { }, {
toastSuccessKey: 'success.employee.create', toastSuccessKey: 'success.employee.create',
toastErrorKey: 'errors.employee.create' toastErrorKey: 'errors.employee.create'
@@ -46,6 +52,9 @@ export const updateEmployee = async (
lastName: string lastName: string
siteId?: number | null siteId?: number | null
contractId: number contractId: number
contractNature?: 'CDI' | 'CDD' | 'INTERIM'
contractStartDate?: string
contractEndDate?: string | null
displayOrder?: number displayOrder?: number
} }
) => { ) => {
@@ -55,6 +64,9 @@ export const updateEmployee = async (
lastName: payload.lastName, lastName: payload.lastName,
site: payload.siteId ? `/api/sites/${payload.siteId}` : null, site: payload.siteId ? `/api/sites/${payload.siteId}` : null,
contract: `/api/contracts/${payload.contractId}`, contract: `/api/contracts/${payload.contractId}`,
contractNature: payload.contractNature,
contractStartDate: payload.contractStartDate,
contractEndDate: payload.contractEndDate ?? null,
displayOrder: payload.displayOrder displayOrder: payload.displayOrder
}, { }, {
toastSuccessKey: 'success.employee.update', toastSuccessKey: 'success.employee.update',
+35 -2
View File
@@ -1,4 +1,5 @@
import type { import type {
WorkHourDayContext,
WorkHour, WorkHour,
WorkHourEntryPayload, WorkHourEntryPayload,
WeeklyWorkHourSummary WeeklyWorkHourSummary
@@ -22,7 +23,7 @@ export const listWorkHoursByDate = async (workDate: string) => {
export const bulkUpsertWorkHours = async (payload: { export const bulkUpsertWorkHours = async (payload: {
workDate: string workDate: string
entries: WorkHourEntryPayload[] entries: WorkHourEntryPayload[]
}) => { }, options?: { toast?: boolean }) => {
const api = useApi() const api = useApi()
return api.post<{ return api.post<{
processed: number processed: number
@@ -33,24 +34,47 @@ export const bulkUpsertWorkHours = async (payload: {
'/work-hours/bulk-upsert', '/work-hours/bulk-upsert',
payload, payload,
{ {
toast: options?.toast ?? true,
toastSuccessMessage: 'Horaires enregistrés.', toastSuccessMessage: 'Horaires enregistrés.',
toastErrorMessage: "Impossible d'enregistrer les horaires." toastErrorMessage: "Impossible d'enregistrer les horaires."
} }
) )
} }
export const updateWorkHourValidation = async (id: number, isValid: boolean) => { export const updateWorkHourValidation = async (
id: number,
isValid: boolean,
options?: { toast?: boolean }
) => {
const api = useApi() const api = useApi()
return api.patch<WorkHour>( return api.patch<WorkHour>(
`/work_hours/${id}`, `/work_hours/${id}`,
{ isValid }, { isValid },
{ {
toast: options?.toast ?? true,
toastSuccessMessage: isValid ? 'Ligne validée.' : 'Validation retirée.', toastSuccessMessage: isValid ? 'Ligne validée.' : 'Validation retirée.',
toastErrorMessage: 'Impossible de mettre à jour la validation.' toastErrorMessage: 'Impossible de mettre à jour la validation.'
} }
) )
} }
export const updateWorkHourSiteValidation = async (
id: number,
isSiteValid: boolean,
options?: { toast?: boolean }
) => {
const api = useApi()
return api.patch<WorkHour>(
`/work_hours/${id}/site-validation`,
{ isSiteValid },
{
toast: options?.toast ?? true,
toastSuccessMessage: isSiteValid ? 'Validation site enregistrée.' : 'Validation site retirée.',
toastErrorMessage: "Impossible de mettre à jour la validation site."
}
)
}
export const getWeeklyWorkHourSummary = async (weekStart: string) => { export const getWeeklyWorkHourSummary = async (weekStart: string) => {
const api = useApi() const api = useApi()
return api.get<WeeklyWorkHourSummary>( return api.get<WeeklyWorkHourSummary>(
@@ -59,3 +83,12 @@ export const getWeeklyWorkHourSummary = async (weekStart: string) => {
{ toast: false } { toast: false }
) )
} }
export const getWorkHourDayContext = async (workDate: string) => {
const api = useApi()
return api.get<WorkHourDayContext>(
'/work-hours/day-context',
{ workDate },
{ toast: false }
)
}
+42 -1
View File
@@ -42,6 +42,46 @@ export const getWeekStartDate = (date: Date) => {
return copy return copy
} }
export const getIsoWeekNumber = (date: Date) => {
const utc = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))
const day = utc.getUTCDay() || 7
utc.setUTCDate(utc.getUTCDate() + 4 - day)
const yearStart = new Date(Date.UTC(utc.getUTCFullYear(), 0, 1))
return Math.ceil((((utc.getTime() - yearStart.getTime()) / 86400000) + 1) / 7)
}
export const getIsoWeekYear = (date: Date) => {
const utc = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()))
const day = utc.getUTCDay() || 7
utc.setUTCDate(utc.getUTCDate() + 4 - day)
return utc.getUTCFullYear()
}
export const ymdToWeekInputValue = (dateYmd: string) => {
const parsed = parseYmd(dateYmd)
if (!parsed) return ''
const weekDate = getWeekStartDate(parsed)
const weekNumber = getIsoWeekNumber(weekDate)
const weekYear = getIsoWeekYear(weekDate)
return `${weekYear}-W${String(weekNumber).padStart(2, '0')}`
}
export const weekInputValueToYmd = (weekValue: string) => {
const match = /^(\d{4})-W(\d{2})$/.exec(weekValue)
if (!match) return null
const year = Number(match[1])
const week = Number(match[2])
if (!Number.isInteger(year) || !Number.isInteger(week) || week < 1 || week > 53) return null
const jan4 = new Date(year, 0, 4)
const week1Monday = getWeekStartDate(jan4)
const monday = new Date(week1Monday)
monday.setDate(week1Monday.getDate() + ((week - 1) * 7))
return toYmd(monday.getFullYear(), monday.getMonth(), monday.getDate())
}
export const getTodayYmd = () => { export const getTodayYmd = () => {
const date = new Date() const date = new Date()
return toYmd(date.getFullYear(), date.getMonth(), date.getDate()) return toYmd(date.getFullYear(), date.getMonth(), date.getDate())
@@ -64,6 +104,7 @@ export const formatWeekRangeFr = (date: Date) => {
const start = getWeekStartDate(date) const start = getWeekStartDate(date)
const end = new Date(start) const end = new Date(start)
end.setDate(start.getDate() + 6) end.setDate(start.getDate() + 6)
const weekNumber = getIsoWeekNumber(start)
const formatter = new Intl.DateTimeFormat('fr-FR', { const formatter = new Intl.DateTimeFormat('fr-FR', {
day: '2-digit', day: '2-digit',
@@ -71,7 +112,7 @@ export const formatWeekRangeFr = (date: Date) => {
year: 'numeric' year: 'numeric'
}) })
return `Semaine du ${formatter.format(start)} au ${formatter.format(end)}` return `S${weekNumber} du ${formatter.format(start)} au ${formatter.format(end)}`
} }
export const getDaysInMonth = (year: number, month: number) => { export const getDaysInMonth = (year: number, month: number) => {
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260218190000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add count_as_worked_hours on absence_types';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE absence_types ADD count_as_worked_hours BOOLEAN DEFAULT FALSE NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE absence_types DROP count_as_worked_hours');
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use DateInterval;
use DatePeriod;
use DateTimeImmutable;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
final class Version20260219180000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Decoupe les absences multi-jours en lignes journalieres.';
}
public function up(Schema $schema): void
{
$rows = $this->connection->fetchAllAssociative(
'SELECT id, employee_id, type_id, start_date, end_date, start_half, end_half, comment
FROM absences
WHERE start_date < end_date
ORDER BY id ASC'
);
foreach ($rows as $row) {
$start = DateTimeImmutable::createFromFormat('Y-m-d', (string) $row['start_date']);
$end = DateTimeImmutable::createFromFormat('Y-m-d', (string) $row['end_date']);
if (!$start instanceof DateTimeImmutable || !$end instanceof DateTimeImmutable) {
continue;
}
$startHalf = (string) $row['start_half'];
$endHalf = (string) $row['end_half'];
$days = new DatePeriod($start, new DateInterval('P1D'), $end->modify('+1 day'));
foreach ($days as $day) {
$isFirst = $day->format('Y-m-d') === $start->format('Y-m-d');
$isLast = $day->format('Y-m-d') === $end->format('Y-m-d');
if ($isFirst && 'PM' === $startHalf) {
$segmentStartHalf = 'PM';
$segmentEndHalf = 'PM';
} elseif ($isLast && 'AM' === $endHalf) {
$segmentStartHalf = 'AM';
$segmentEndHalf = 'AM';
} else {
$segmentStartHalf = 'AM';
$segmentEndHalf = 'PM';
}
$this->connection->insert('absences', [
'employee_id' => (int) $row['employee_id'],
'type_id' => (int) $row['type_id'],
'start_date' => $day,
'end_date' => $day,
'start_half' => $segmentStartHalf,
'end_half' => $segmentEndHalf,
'comment' => $row['comment'],
], [
'employee_id' => Types::INTEGER,
'type_id' => Types::INTEGER,
'start_date' => Types::DATE_IMMUTABLE,
'end_date' => Types::DATE_IMMUTABLE,
'start_half' => Types::STRING,
'end_half' => Types::STRING,
'comment' => Types::TEXT,
]);
}
$this->connection->delete('absences', ['id' => (int) $row['id']], ['id' => Types::INTEGER]);
}
}
public function down(Schema $schema): void
{
$this->throwIrreversibleMigrationException('Cette migration de decoupage est irreversible.');
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260220133000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add employee contract periods history table and seed current contracts';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE employee_contract_periods (id SERIAL NOT NULL, employee_id INT NOT NULL, contract_id INT NOT NULL, start_date DATE NOT NULL, end_date DATE DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX idx_emp_contract_period_employee_start ON employee_contract_periods (employee_id, start_date)');
$this->addSql('CREATE INDEX idx_emp_contract_period_employee_end ON employee_contract_periods (employee_id, end_date)');
$this->addSql('CREATE INDEX IDX_831EED7A8C03F15C ON employee_contract_periods (employee_id)');
$this->addSql('CREATE INDEX IDX_831EED7A2576E0FD ON employee_contract_periods (contract_id)');
$this->addSql('ALTER TABLE employee_contract_periods ADD CONSTRAINT FK_831EED7A8C03F15C FOREIGN KEY (employee_id) REFERENCES employees (id) ON DELETE CASCADE NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE employee_contract_periods ADD CONSTRAINT FK_831EED7A2576E0FD FOREIGN KEY (contract_id) REFERENCES contracts (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
// Initialise l\'historique avec le contrat actuel de chaque employé.
$this->addSql("INSERT INTO employee_contract_periods (employee_id, contract_id, start_date, end_date, created_at)
SELECT id, contract_id, DATE '1970-01-01', NULL, NOW()
FROM employees
WHERE contract_id IS NOT NULL");
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE employee_contract_periods DROP CONSTRAINT FK_831EED7A8C03F15C');
$this->addSql('ALTER TABLE employee_contract_periods DROP CONSTRAINT FK_831EED7A2576E0FD');
$this->addSql('DROP TABLE employee_contract_periods');
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260226183000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add site validation flag to work hours';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE work_hours ADD is_site_valid BOOLEAN DEFAULT FALSE NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE work_hours DROP is_site_valid');
}
}
+33
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');
}
}
+82
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.');
}
}
+2
View File
@@ -18,6 +18,8 @@ use App\State\AbsencePrintProvider;
new QueryParameter(key: 'from', required: true), new QueryParameter(key: 'from', required: true),
new QueryParameter(key: 'to', required: true), new QueryParameter(key: 'to', required: true),
new QueryParameter(key: 'sites', required: false), new QueryParameter(key: 'sites', required: false),
new QueryParameter(key: 'contractNatures', required: false),
new QueryParameter(key: 'workContracts', required: false),
], ],
security: "is_granted('ROLE_ADMIN')" security: "is_granted('ROLE_ADMIN')"
), ),
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\State\WorkHourDayContextProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/work-hours/day-context',
security: "is_granted('ROLE_USER')",
provider: WorkHourDayContextProvider::class
),
],
paginationEnabled: false
)]
final class WorkHourDayContext
{
public string $workDate = '';
/**
* @var list<array{
* employeeId:int,
* absenceLabel:?string,
* absenceColor:?string,
* absenceHalf:?string,
* absentMorning:bool,
* absentAfternoon:bool,
* creditedMinutes:int,
* creditedPresenceUnits:float
* }>
*/
public array $rows = [];
}
+2 -23
View File
@@ -6,6 +6,7 @@ namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
use App\Dto\WorkHours\WeeklySummaryRow;
use App\State\WorkHourWeeklySummaryProvider; use App\State\WorkHourWeeklySummaryProvider;
#[ApiResource( #[ApiResource(
@@ -26,28 +27,6 @@ final class WorkHourWeeklySummary
/** @var list<string> */ /** @var list<string> */
public array $days = []; public array $days = [];
/** /** @var list<WeeklySummaryRow> */
* @var list<array{
* employeeId:int,
* firstName:string,
* lastName:string,
* siteName:?string,
* contractName:?string,
* trackingMode:?string,
* daily:list<array{
* date:string,
* dayMinutes:int,
* nightMinutes:int,
* totalMinutes:int,
* present:?float
* }>,
* weeklyDayMinutes:int,
* weeklyNightMinutes:int,
* weeklyTotalMinutes:int,
* weeklyPresenceCount:float,
* weeklyOvertime25Minutes:int,
* weeklyOvertime50Minutes:int
* }>
*/
public array $rows = []; public array $rows = [];
} }
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Doctrine;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\Absence;
use App\Entity\User;
use App\Security\EmployeeScopeService;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bundle\SecurityBundle\Security;
final readonly class AbsenceCollectionExtension implements QueryCollectionExtensionInterface
{
public function __construct(
private Security $security,
private EmployeeScopeService $employeeScopeService,
) {}
public function applyToCollection(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = []
): void {
if (Absence::class !== $resourceClass) {
return;
}
$user = $this->security->getUser();
if (!$user instanceof User) {
$queryBuilder->andWhere('1 = 0');
return;
}
$rootAlias = $queryBuilder->getRootAliases()[0];
$employeeAlias = 'absence_employee_scope';
$queryBuilder->leftJoin(sprintf('%s.employee', $rootAlias), $employeeAlias)
->addSelect($employeeAlias)
;
$this->employeeScopeService->applyEmployeeScope($queryBuilder, $employeeAlias, 'absence_scope', $user);
}
}
+99
View File
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Dto\WorkHours;
final class DayContextRow
{
public function __construct(
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,
public int $creditedMinutes = 0,
public float $creditedPresenceUnits = 0.0,
) {}
public function addAbsence(
?string $label,
?string $color,
bool $morning,
bool $afternoon,
int $creditedMinutes,
float $creditedPresenceUnits
): void {
// Fusionne plusieurs absences du même jour sur la ligne salarié.
$this->absentMorning = $this->absentMorning || $morning;
$this->absentAfternoon = $this->absentAfternoon || $afternoon;
// Garde un libellé lisible: unique si possible, sinon "Absences multiples".
if (null === $this->absenceLabel) {
$this->absenceLabel = $label;
} elseif ($label !== $this->absenceLabel) {
$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".
$this->creditedMinutes += $creditedMinutes;
// Cumule les unités de présence créditées (0.5 par demi-journée).
$this->creditedPresenceUnits += $creditedPresenceUnits;
}
/**
* @return array{
* employeeId:int,
* hasContractAtDate:bool,
* absenceLabel:?string,
* absenceColor:?string,
* absenceHalf:?string,
* absentMorning:bool,
* absentAfternoon:bool,
* creditedMinutes:int,
* creditedPresenceUnits:float
* }
*/
public function toArray(): array
{
return [
'employeeId' => $this->employeeId,
'hasContractAtDate' => $this->hasContractAtDate,
'absenceLabel' => $this->absenceLabel,
'absenceColor' => $this->absenceColor,
'absenceHalf' => $this->absenceHalf,
'absentMorning' => $this->absentMorning,
'absentAfternoon' => $this->absentAfternoon,
'creditedMinutes' => $this->creditedMinutes,
'creditedPresenceUnits' => $this->creditedPresenceUnits,
];
}
private function resolveHalfLabel(bool $morning, bool $afternoon): ?string
{
// Matin + après-midi => journée complète, pas de libellé AM/PM.
if ($morning && $afternoon) {
return null;
}
if ($morning) {
return 'AM';
}
if ($afternoon) {
return 'PM';
}
return null;
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Dto\WorkHours;
final class WeeklyDaySummary
{
public function __construct(
public string $date,
public int $dayMinutes,
public int $nightMinutes,
public int $totalMinutes,
public ?float $present = null,
public bool $hasAbsence = false,
public ?string $absenceLabel = null,
public ?string $absenceColor = null,
) {}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Dto\WorkHours;
final class WeeklySummaryRow
{
/**
* @param list<WeeklyDaySummary> $daily
*/
public function __construct(
public int $employeeId,
public string $firstName,
public string $lastName,
public ?string $siteName,
public ?string $contractName,
public ?string $contractType,
public ?string $trackingMode,
public array $daily,
public int $weeklyDayMinutes,
public int $weeklyNightMinutes,
public int $weeklyTotalMinutes,
public float $weeklyPresenceCount,
public int $weeklyOvertimeTotalMinutes,
public int $weeklyOvertime25Minutes,
public int $weeklyOvertime50Minutes,
public int $weeklyRecoveryMinutes,
) {}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Dto\WorkHours;
final class WorkMetrics
{
public function __construct(
public int $dayMinutes = 0,
public int $nightMinutes = 0,
public int $totalMinutes = 0,
) {}
public function addCreditedMinutes(int $creditedMinutes): void
{
// Ignore les valeurs nulles ou négatives pour ne pas biaiser les totaux.
if ($creditedMinutes <= 0) {
return;
}
// Le crédit absence alimente les heures de jour et le total.
$this->dayMinutes += $creditedMinutes;
$this->totalMinutes += $creditedMinutes;
}
}
+35 -9
View File
@@ -9,12 +9,39 @@ use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Enum\HalfDay;
use App\Repository\AbsenceRepository; use App\Repository\AbsenceRepository;
use App\State\AbsenceWriteProcessor;
use DateTimeInterface; use DateTimeInterface;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource( #[ApiResource(
operations: [
new GetCollection(
security: "is_granted('ROLE_USER')"
),
new Get(
security: "is_granted('ABSENCE_VIEW', object)"
),
new Post(
securityPostDenormalize: "is_granted('ABSENCE_EDIT', object)",
processor: AbsenceWriteProcessor::class
),
new Patch(
security: "is_granted('ABSENCE_EDIT', object)",
processor: AbsenceWriteProcessor::class
),
new Delete(
security: "is_granted('ABSENCE_EDIT', object)",
processor: AbsenceWriteProcessor::class
),
],
normalizationContext: [ normalizationContext: [
'groups' => ['absence:read', 'employee:read', 'absence_type:read'], 'groups' => ['absence:read', 'employee:read', 'absence_type:read'],
'datetime_format' => 'Y-m-d', 'datetime_format' => 'Y-m-d',
@@ -23,7 +50,6 @@ use Symfony\Component\Serializer\Attribute\Groups;
'datetime_format' => 'Y-m-d', 'datetime_format' => 'Y-m-d',
], ],
paginationEnabled: false, paginationEnabled: false,
security: "is_granted('ROLE_ADMIN')",
)] )]
#[ApiFilter(DateFilter::class, properties: ['startDate', 'endDate'])] #[ApiFilter(DateFilter::class, properties: ['startDate', 'endDate'])]
#[ApiFilter(SearchFilter::class, properties: ['employee.site' => 'exact'])] #[ApiFilter(SearchFilter::class, properties: ['employee.site' => 'exact'])]
@@ -53,17 +79,17 @@ class Absence
#[Groups(['absence:read'])] #[Groups(['absence:read'])]
private DateTimeInterface $startDate; private DateTimeInterface $startDate;
#[ORM\Column(type: 'string', length: 2, options: ['default' => 'AM'])] #[ORM\Column(type: 'string', length: 2, enumType: HalfDay::class, options: ['default' => 'AM'])]
#[Groups(['absence:read'])] #[Groups(['absence:read'])]
private string $startHalf = 'AM'; private HalfDay $startHalf = HalfDay::AM;
#[ORM\Column(type: 'date')] #[ORM\Column(type: 'date')]
#[Groups(['absence:read'])] #[Groups(['absence:read'])]
private DateTimeInterface $endDate; private DateTimeInterface $endDate;
#[ORM\Column(type: 'string', length: 2, options: ['default' => 'PM'])] #[ORM\Column(type: 'string', length: 2, enumType: HalfDay::class, options: ['default' => 'PM'])]
#[Groups(['absence:read'])] #[Groups(['absence:read'])]
private string $endHalf = 'PM'; private HalfDay $endHalf = HalfDay::PM;
#[ORM\Column(type: 'text', nullable: true)] #[ORM\Column(type: 'text', nullable: true)]
#[Groups(['absence:read'])] #[Groups(['absence:read'])]
@@ -122,24 +148,24 @@ class Absence
return $this; return $this;
} }
public function getStartHalf(): string public function getStartHalf(): HalfDay
{ {
return $this->startHalf; return $this->startHalf;
} }
public function setStartHalf(string $startHalf): self public function setStartHalf(HalfDay $startHalf): self
{ {
$this->startHalf = $startHalf; $this->startHalf = $startHalf;
return $this; return $this;
} }
public function getEndHalf(): string public function getEndHalf(): HalfDay
{ {
return $this->endHalf; return $this->endHalf;
} }
public function setEndHalf(string $endHalf): self public function setEndHalf(HalfDay $endHalf): self
{ {
$this->endHalf = $endHalf; $this->endHalf = $endHalf;
+43 -1
View File
@@ -5,13 +5,34 @@ declare(strict_types=1);
namespace App\Entity; namespace App\Entity;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource( #[ApiResource(
operations: [
new GetCollection(
security: "is_granted('ROLE_USER')"
),
new Get(
security: "is_granted('ROLE_USER')"
),
new Post(
security: "is_granted('ROLE_ADMIN')"
),
new Patch(
security: "is_granted('ROLE_ADMIN')"
),
new Delete(
security: "is_granted('ROLE_ADMIN')"
),
],
normalizationContext: ['groups' => ['absence_type:read']], normalizationContext: ['groups' => ['absence_type:read']],
paginationEnabled: false, paginationEnabled: false,
security: "is_granted('ROLE_ADMIN')"
)] )]
#[ORM\Entity] #[ORM\Entity]
#[ORM\Table(name: 'absence_types')] #[ORM\Table(name: 'absence_types')]
@@ -35,6 +56,10 @@ class AbsenceType
#[Groups(['absence:read', 'absence_type:read'])] #[Groups(['absence:read', 'absence_type:read'])]
private string $color = ''; private string $color = '';
#[ORM\Column(type: 'boolean', options: ['default' => false])]
#[Groups(['absence:read', 'absence_type:read'])]
private bool $countAsWorkedHours = false;
public function getId(): ?int public function getId(): ?int
{ {
return $this->id; return $this->id;
@@ -75,4 +100,21 @@ class AbsenceType
return $this; return $this;
} }
public function isCountAsWorkedHours(): bool
{
return $this->countAsWorkedHours;
}
public function getCountAsWorkedHours(): bool
{
return $this->countAsWorkedHours;
}
public function setCountAsWorkedHours(bool $countAsWorkedHours): self
{
$this->countAsWorkedHours = $countAsWorkedHours;
return $this;
}
} }
+23 -4
View File
@@ -5,7 +5,10 @@ declare(strict_types=1);
namespace App\Entity; namespace App\Entity;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use App\Enum\ContractType;
use App\Enum\TrackingMode;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use InvalidArgumentException;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource( #[ApiResource(
@@ -18,8 +21,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Table(name: 'contracts')] #[ORM\Table(name: 'contracts')]
class Contract class Contract
{ {
public const string TRACKING_TIME = 'TIME'; public const string TRACKING_TIME = TrackingMode::TIME->value;
public const string TRACKING_PRESENCE = 'PRESENCE'; public const string TRACKING_PRESENCE = TrackingMode::PRESENCE->value;
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
@@ -65,13 +68,29 @@ class Contract
return $this->trackingMode; return $this->trackingMode;
} }
public function setTrackingMode(string $trackingMode): self public function getTrackingModeEnum(): TrackingMode
{ {
$this->trackingMode = $trackingMode; return TrackingMode::tryFrom($this->trackingMode) ?? TrackingMode::TIME;
}
public function setTrackingMode(string|TrackingMode $trackingMode): self
{
$value = $trackingMode instanceof TrackingMode ? $trackingMode->value : $trackingMode;
if (null === TrackingMode::tryFrom($value)) {
throw new InvalidArgumentException(sprintf('Invalid tracking mode "%s".', $value));
}
$this->trackingMode = $value;
return $this; return $this;
} }
#[Groups(['contract:read', 'employee:read'])]
public function getType(): ContractType
{
return ContractType::resolve($this->name, $this->trackingMode, $this->weeklyHours);
}
public function getWeeklyHours(): ?int public function getWeeklyHours(): ?int
{ {
return $this->weeklyHours; return $this->weeklyHours;
+100 -2
View File
@@ -6,8 +6,12 @@ namespace App\Entity;
use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use App\Enum\ContractNature;
use App\Repository\EmployeeRepository; use App\Repository\EmployeeRepository;
use App\State\EmployeeWriteProcessor;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
@@ -15,7 +19,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
normalizationContext: ['groups' => ['employee:read', 'site:read']], normalizationContext: ['groups' => ['employee:read', 'site:read']],
denormalizationContext: ['groups' => ['employee:write']], denormalizationContext: ['groups' => ['employee:write']],
paginationEnabled: false, paginationEnabled: false,
security: "is_granted('ROLE_ADMIN')" security: "is_granted('ROLE_ADMIN')",
processor: EmployeeWriteProcessor::class,
)] )]
#[ORM\Entity(repositoryClass: EmployeeRepository::class)] #[ORM\Entity(repositoryClass: EmployeeRepository::class)]
#[ORM\Table(name: 'employees')] #[ORM\Table(name: 'employees')]
@@ -54,9 +59,25 @@ class Employee
#[ORM\Column(type: 'datetime_immutable')] #[ORM\Column(type: 'datetime_immutable')]
private DateTimeImmutable $createdAt; private DateTimeImmutable $createdAt;
/**
* @var Collection<int, EmployeeContractPeriod>
*/
#[ORM\OneToMany(mappedBy: 'employee', targetEntity: EmployeeContractPeriod::class)]
private Collection $contractPeriods;
#[Groups(['employee:write'])]
private ?string $contractNature = null;
#[Groups(['employee:write'])]
private ?string $contractStartDate = null;
#[Groups(['employee:write'])]
private ?string $contractEndDate = null;
public function __construct() public function __construct()
{ {
$this->createdAt = new DateTimeImmutable(); $this->createdAt = new DateTimeImmutable();
$this->contractPeriods = new ArrayCollection();
} }
public function getId(): ?int public function getId(): ?int
@@ -128,4 +149,81 @@ class Employee
return $this; return $this;
} }
public function getContractNature(): ?string
{
return $this->contractNature;
}
public function setContractNature(?string $contractNature): self
{
$this->contractNature = $contractNature;
return $this;
}
public function getContractStartDate(): ?string
{
return $this->contractStartDate;
}
public function setContractStartDate(?string $contractStartDate): self
{
$this->contractStartDate = $contractStartDate;
return $this;
}
public function getContractEndDate(): ?string
{
return $this->contractEndDate;
}
public function setContractEndDate(?string $contractEndDate): self
{
$this->contractEndDate = $contractEndDate;
return $this;
}
#[Groups(['employee:read'])]
public function getCurrentContractNature(): string
{
return $this->resolveCurrentContractPeriod()?->getContractNatureEnum()->value ?? ContractNature::CDI->value;
}
#[Groups(['employee:read'])]
public function getCurrentContractStartDate(): ?string
{
return $this->resolveCurrentContractPeriod()?->getStartDate()->format('Y-m-d');
}
#[Groups(['employee:read'])]
public function getCurrentContractEndDate(): ?string
{
return $this->resolveCurrentContractPeriod()?->getEndDate()?->format('Y-m-d');
}
private function resolveCurrentContractPeriod(): ?EmployeeContractPeriod
{
$today = new DateTimeImmutable('today');
$current = null;
foreach ($this->contractPeriods as $period) {
if ($period->getStartDate() > $today) {
continue;
}
$endDate = $period->getEndDate();
if (null !== $endDate && $endDate < $today) {
continue;
}
if (null === $current || $period->getStartDate() > $current->getStartDate()) {
$current = $period;
}
}
return $current;
}
} }
+124
View File
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use App\Enum\ContractNature;
use App\Repository\EmployeeContractPeriodRepository;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: EmployeeContractPeriodRepository::class)]
#[ORM\Table(name: 'employee_contract_periods')]
#[ORM\Index(columns: ['employee_id', 'start_date'], name: 'idx_emp_contract_period_employee_start')]
#[ORM\Index(columns: ['employee_id', 'end_date'], name: 'idx_emp_contract_period_employee_end')]
class EmployeeContractPeriod
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Employee::class, inversedBy: 'contractPeriods')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private ?Employee $employee = null;
#[ORM\ManyToOne(targetEntity: Contract::class)]
#[ORM\JoinColumn(nullable: false)]
private ?Contract $contract = null;
#[ORM\Column(type: 'date_immutable')]
private DateTimeImmutable $startDate;
#[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;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->startDate = new DateTimeImmutable('today');
}
public function getId(): ?int
{
return $this->id;
}
public function getEmployee(): ?Employee
{
return $this->employee;
}
public function setEmployee(?Employee $employee): self
{
$this->employee = $employee;
return $this;
}
public function getContract(): ?Contract
{
return $this->contract;
}
public function setContract(?Contract $contract): self
{
$this->contract = $contract;
return $this;
}
public function getStartDate(): DateTimeImmutable
{
return $this->startDate;
}
public function setStartDate(DateTimeImmutable $startDate): self
{
$this->startDate = $startDate;
return $this;
}
public function getEndDate(): ?DateTimeImmutable
{
return $this->endDate;
}
public function setEndDate(?DateTimeImmutable $endDate): self
{
$this->endDate = $endDate;
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;
}
}
+29
View File
@@ -13,6 +13,7 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Patch;
use App\Repository\WorkHourRepository; use App\Repository\WorkHourRepository;
use App\State\WorkHourSiteValidationProcessor;
use DateTimeInterface; use DateTimeInterface;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
@@ -33,6 +34,13 @@ use Symfony\Component\Serializer\Attribute\Groups;
denormalizationContext: ['groups' => ['work_hour:validate']], denormalizationContext: ['groups' => ['work_hour:validate']],
security: "is_granted('ROLE_ADMIN')" security: "is_granted('ROLE_ADMIN')"
), ),
new Patch(
uriTemplate: '/work_hours/{id}/site-validation',
normalizationContext: ['groups' => ['work_hour:read', 'employee:read', 'site:read']],
denormalizationContext: ['groups' => ['work_hour:site_validate']],
security: "is_granted('ROLE_USER')",
processor: WorkHourSiteValidationProcessor::class
),
], ],
)] )]
#[ApiFilter(DateFilter::class, properties: ['workDate'])] #[ApiFilter(DateFilter::class, properties: ['workDate'])]
@@ -94,6 +102,10 @@ class WorkHour
#[Groups(['work_hour:read', 'work_hour:validate'])] #[Groups(['work_hour:read', 'work_hour:validate'])]
private bool $isValid = false; private bool $isValid = false;
#[ORM\Column(type: 'boolean', options: ['default' => false])]
#[Groups(['work_hour:read', 'work_hour:site_validate'])]
private bool $isSiteValid = false;
public function getId(): ?int public function getId(): ?int
{ {
return $this->id; return $this->id;
@@ -245,4 +257,21 @@ class WorkHour
return $this; return $this;
} }
public function isSiteValid(): bool
{
return $this->isSiteValid;
}
public function getIsSiteValid(): bool
{
return $this->isSiteValid;
}
public function setIsSiteValid(bool $isSiteValid): self
{
$this->isSiteValid = $isSiteValid;
return $this;
}
} }
+17
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;
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Enum;
enum ContractType: string
{
case FORFAIT = 'FORFAIT';
case H35 = '35H';
case H39 = '39H';
case INTERIM = 'INTERIM';
case CUSTOM = 'CUSTOM';
public static function resolve(?string $name, ?string $trackingMode, ?int $weeklyHours): self
{
if (TrackingMode::PRESENCE->value === $trackingMode) {
return self::FORFAIT;
}
$normalizedName = self::normalize($name);
if ('interim' === $normalizedName) {
return self::INTERIM;
}
if (35 === $weeklyHours) {
return self::H35;
}
if (39 === $weeklyHours) {
return self::H39;
}
return self::CUSTOM;
}
private static function normalize(?string $value): string
{
if (null === $value) {
return '';
}
$normalized = mb_strtolower(trim($value));
return str_replace('é', 'e', $normalized);
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Enum;
enum HalfDay: string
{
case AM = 'AM';
case PM = 'PM';
}
+11
View File
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Enum;
enum TrackingMode: string
{
case TIME = 'TIME';
case PRESENCE = 'PRESENCE';
}
+55 -2
View File
@@ -6,14 +6,16 @@ namespace App\Repository;
use App\Entity\Absence; use App\Entity\Absence;
use App\Entity\Employee; use App\Entity\Employee;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use DateTimeImmutable; use DateTimeImmutable;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
/** /**
* @extends ServiceEntityRepository<Absence> * @extends ServiceEntityRepository<Absence>
*/ */
final class AbsenceRepository extends ServiceEntityRepository final class AbsenceRepository extends ServiceEntityRepository implements AbsenceReadRepositoryInterface
{ {
public function __construct(ManagerRegistry $registry) public function __construct(ManagerRegistry $registry)
{ {
@@ -43,7 +45,58 @@ final class AbsenceRepository extends ServiceEntityRepository
->setParameter('employees', $employees) ->setParameter('employees', $employees)
; ;
/** @var list<Absence> $absences */ // @var list<Absence> $absences
return $qb->getQuery()->getResult();
}
/**
* @param list<Employee> $employees
*
* @return list<Absence>
*/
public function findByDateAndEmployees(DateTimeImmutable $date, array $employees): array
{
if ([] === $employees) {
return [];
}
$qb = $this->createQueryBuilder('a')
->leftJoin('a.employee', 'e')
->leftJoin('a.type', 't')
->addSelect('e', 't')
->andWhere('a.startDate <= :date')
->andWhere('a.endDate >= :date')
->andWhere('a.employee IN (:employees)')
->setParameter('date', $date)
->setParameter('employees', $employees)
;
// @var list<Absence> $absences
return $qb->getQuery()->getResult();
}
/**
* @return list<Absence>
*/
public function findByEmployeeAndDateRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): array
{
$fromDate = DateTimeImmutable::createFromInterface($from);
$toDate = DateTimeImmutable::createFromInterface($to);
$qb = $this->createQueryBuilder('a')
->leftJoin('a.employee', 'e')
->leftJoin('a.type', 't')
->addSelect('e', 't')
->andWhere('a.employee = :employee')
->andWhere('a.startDate >= :from')
->andWhere('a.startDate <= :to')
->setParameter('employee', $employee)
->setParameter('from', $fromDate)
->setParameter('to', $toDate)
->orderBy('a.startDate', 'ASC')
;
// @var list<Absence> $absences
return $qb->getQuery()->getResult(); return $qb->getQuery()->getResult();
} }
} }
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Repository\Contract;
use App\Entity\Absence;
use App\Entity\Employee;
use DateTimeImmutable;
use DateTimeInterface;
interface AbsenceReadRepositoryInterface
{
/**
* @param list<Employee> $employees
*
* @return list<Absence>
*/
public function findForPrint(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array;
/**
* @param list<Employee> $employees
*
* @return list<Absence>
*/
public function findByDateAndEmployees(DateTimeImmutable $date, array $employees): array;
/**
* @return list<Absence>
*/
public function findByEmployeeAndDateRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): array;
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Repository\Contract;
use App\Entity\Employee;
use App\Entity\User;
interface EmployeeScopedRepositoryInterface
{
/**
* @return list<Employee>
*/
public function findScoped(User $user): array;
}
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Repository\Contract;
use App\Entity\Employee;
use App\Entity\WorkHour;
use DateTimeImmutable;
use DateTimeInterface;
interface WorkHourReadRepositoryInterface
{
/**
* @param list<Employee> $employees
*
* @return list<WorkHour>
*/
public function findByDateRangeAndEmployees(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array;
public function findOneByEmployeeAndDate(Employee $employee, DateTimeInterface $date): ?WorkHour;
public function hasValidatedInRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): bool;
public function hasSiteValidatedInRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): bool;
}
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Employee;
use App\Entity\EmployeeContractPeriod;
use DateTimeImmutable;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<EmployeeContractPeriod>
*/
final class EmployeeContractPeriodRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, EmployeeContractPeriod::class);
}
/**
* @param list<Employee> $employees
*
* @return list<EmployeeContractPeriod>
*/
public function findByEmployeesAndDateRange(array $employees, DateTimeImmutable $from, DateTimeImmutable $to): array
{
if ([] === $employees) {
return [];
}
return $this->createQueryBuilder('p')
->andWhere('p.employee IN (:employees)')
->andWhere('p.startDate <= :to')
->andWhere('p.endDate IS NULL OR p.endDate >= :from')
->setParameter('employees', $employees)
->setParameter('from', $from)
->setParameter('to', $to)
->orderBy('p.startDate', 'ASC')
->getQuery()
->getResult()
;
}
public function findOneCoveringDate(Employee $employee, DateTimeImmutable $date): ?EmployeeContractPeriod
{
return $this->createQueryBuilder('p')
->andWhere('p.employee = :employee')
->andWhere('p.startDate <= :date')
->andWhere('p.endDate IS NULL OR p.endDate >= :date')
->setParameter('employee', $employee)
->setParameter('date', $date)
->orderBy('p.startDate', 'DESC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult()
;
}
public function closeOpenPeriods(Employee $employee, DateTimeImmutable $endDate): int
{
return $this->createQueryBuilder('p')
->update()
->set('p.endDate', ':endDate')
->andWhere('p.employee = :employee')
->andWhere('p.endDate IS NULL')
->setParameter('employee', $employee)
->setParameter('endDate', $endDate)
->getQuery()
->execute()
;
}
}
+6 -3
View File
@@ -6,6 +6,7 @@ namespace App\Repository;
use App\Entity\Employee; use App\Entity\Employee;
use App\Entity\User; use App\Entity\User;
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
use App\Security\EmployeeScopeService; use App\Security\EmployeeScopeService;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
@@ -13,7 +14,7 @@ use Doctrine\Persistence\ManagerRegistry;
/** /**
* @extends ServiceEntityRepository<Employee> * @extends ServiceEntityRepository<Employee>
*/ */
final class EmployeeRepository extends ServiceEntityRepository final class EmployeeRepository extends ServiceEntityRepository implements EmployeeScopedRepositoryInterface
{ {
public function __construct( public function __construct(
ManagerRegistry $registry, ManagerRegistry $registry,
@@ -70,7 +71,7 @@ final class EmployeeRepository extends ServiceEntityRepository
$this->employeeScopeService->applyEmployeeScope($qb, 'e', 'employee_scoped_list', $user); $this->employeeScopeService->applyEmployeeScope($qb, 'e', 'employee_scoped_list', $user);
/** @var list<Employee> $employees */ // @var list<Employee> $employees
return $qb->getQuery()->getResult(); return $qb->getQuery()->getResult();
} }
@@ -84,6 +85,8 @@ final class EmployeeRepository extends ServiceEntityRepository
$qb = $this->createQueryBuilder('e') $qb = $this->createQueryBuilder('e')
->leftJoin('e.site', 's') ->leftJoin('e.site', 's')
->addSelect('s') ->addSelect('s')
->leftJoin('e.contract', 'c')
->addSelect('c')
->orderBy('s.displayOrder', 'ASC') ->orderBy('s.displayOrder', 'ASC')
->addOrderBy('s.name', 'ASC') ->addOrderBy('s.name', 'ASC')
->addOrderBy('e.displayOrder', 'ASC') ->addOrderBy('e.displayOrder', 'ASC')
@@ -97,7 +100,7 @@ final class EmployeeRepository extends ServiceEntityRepository
; ;
} }
/** @var list<Employee> $employees */ // @var list<Employee> $employees
return $qb->getQuery()->getResult(); return $qb->getQuery()->getResult();
} }
} }
+60 -2
View File
@@ -6,14 +6,16 @@ namespace App\Repository;
use App\Entity\Employee; use App\Entity\Employee;
use App\Entity\WorkHour; use App\Entity\WorkHour;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
use DateTimeImmutable; use DateTimeImmutable;
use DateTimeInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
/** /**
* @extends ServiceEntityRepository<WorkHour> * @extends ServiceEntityRepository<WorkHour>
*/ */
final class WorkHourRepository extends ServiceEntityRepository final class WorkHourRepository extends ServiceEntityRepository implements WorkHourReadRepositoryInterface
{ {
public function __construct(ManagerRegistry $registry) public function __construct(ManagerRegistry $registry)
{ {
@@ -76,7 +78,63 @@ final class WorkHourRepository extends ServiceEntityRepository
->setParameter('employees', $employees) ->setParameter('employees', $employees)
; ;
/** @var list<WorkHour> $workHours */ // @var list<WorkHour> $workHours
return $qb->getQuery()->getResult(); return $qb->getQuery()->getResult();
} }
public function hasValidatedInRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): bool
{
$fromDate = DateTimeImmutable::createFromInterface($from);
$toDate = DateTimeImmutable::createFromInterface($to);
$qb = $this->createQueryBuilder('w')
->select('COUNT(w.id)')
->andWhere('w.employee = :employee')
->andWhere('w.workDate >= :from')
->andWhere('w.workDate <= :to')
->andWhere('w.isValid = :isValid')
->setParameter('employee', $employee)
->setParameter('from', $fromDate)
->setParameter('to', $toDate)
->setParameter('isValid', true)
;
return ((int) $qb->getQuery()->getSingleScalarResult()) > 0;
}
public function hasSiteValidatedInRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): bool
{
$fromDate = DateTimeImmutable::createFromInterface($from);
$toDate = DateTimeImmutable::createFromInterface($to);
$qb = $this->createQueryBuilder('w')
->select('COUNT(w.id)')
->andWhere('w.employee = :employee')
->andWhere('w.workDate >= :from')
->andWhere('w.workDate <= :to')
->andWhere('w.isSiteValid = :isSiteValid')
->setParameter('employee', $employee)
->setParameter('from', $fromDate)
->setParameter('to', $toDate)
->setParameter('isSiteValid', true)
;
return ((int) $qb->getQuery()->getSingleScalarResult()) > 0;
}
public function findOneByEmployeeAndDate(Employee $employee, DateTimeInterface $date): ?WorkHour
{
$workDate = DateTimeImmutable::createFromInterface($date);
$qb = $this->createQueryBuilder('w')
->andWhere('w.employee = :employee')
->andWhere('w.workDate = :workDate')
->setParameter('employee', $employee)
->setParameter('workDate', $workDate)
->setMaxResults(1)
;
// @var null|WorkHour $workHour
return $qb->getQuery()->getOneOrNullResult();
}
} }
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Security\Voter;
use App\Entity\Absence;
use App\Entity\User;
use App\Security\EmployeeScopeService;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
final class AbsenceVoter extends Voter
{
public const string VIEW = 'ABSENCE_VIEW';
public const string EDIT = 'ABSENCE_EDIT';
public function __construct(
private readonly Security $security,
private readonly EmployeeScopeService $employeeScopeService,
) {}
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, [self::VIEW, self::EDIT], true) && $subject instanceof Absence;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$user = $this->security->getUser();
if (!$user instanceof User) {
return false;
}
if (!$subject instanceof Absence) {
return false;
}
$employee = $subject->getEmployee();
if (null === $employee) {
return false;
}
return $this->employeeScopeService->canAccessEmployee($user, $employee);
}
}
@@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace App\Service\Contracts;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Enum\ContractNature;
use App\Repository\EmployeeContractPeriodRepository;
use DateTimeImmutable;
readonly class EmployeeContractResolver
{
public function __construct(
private EmployeeContractPeriodRepository $periodRepository,
) {}
public function resolveForEmployeeAndDate(Employee $employee, DateTimeImmutable $date): ?Contract
{
$period = $this->periodRepository->findOneCoveringDate($employee, $date);
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
*
* @return array<int, array<string, ?Contract>>
*/
public function resolveForEmployeesAndDays(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] = null;
}
}
$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();
$contract = $period->getContract();
if (!$employeeId || null === $contract) {
continue;
}
$start = $period->getStartDate()->format('Y-m-d');
$end = $period->getEndDate()?->format('Y-m-d') ?? '9999-12-31';
foreach ($days as $day) {
if ($day < $start || $day > $end) {
continue;
}
$resolved[$employeeId][$day] = $contract;
}
}
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;
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Service\WorkHours;
use App\Entity\Absence;
use App\Enum\HalfDay;
final class AbsenceSegmentsResolver
{
/**
* @return array{bool, bool}
*/
public function resolveForDate(Absence $absence, string $dateYmd): array
{
$startDate = $absence->getStartDate()->format('Y-m-d');
$endDate = $absence->getEndDate()->format('Y-m-d');
$startHalf = $absence->getStartHalf();
$endHalf = $absence->getEndHalf();
// Cas d'une absence sur une seule date: on déduit matin/après-midi depuis les bornes.
if ($startDate === $endDate) {
if (HalfDay::AM === $startHalf && HalfDay::AM === $endHalf) {
// Uniquement le matin absent.
return [true, false];
}
if (HalfDay::PM === $startHalf && HalfDay::PM === $endHalf) {
// Uniquement l'après-midi absent.
return [false, true];
}
// Sinon, on considère la journée complète absente.
return [true, true];
}
// Premier jour d'une absence multi-jours qui commence l'après-midi.
if ($dateYmd === $startDate && HalfDay::PM === $startHalf) {
return [false, true];
}
// Dernier jour d'une absence multi-jours qui se termine le matin.
if ($dateYmd === $endDate && HalfDay::AM === $endHalf) {
return [true, false];
}
// Les jours intermédiaires sont entièrement absents.
return [true, true];
}
}
@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace App\Service\WorkHours;
use App\Entity\Absence;
use App\Enum\TrackingMode;
use App\Service\Contracts\EmployeeContractResolver;
use DateMalformedStringException;
use DateTimeImmutable;
final readonly class WorkedHoursCreditPolicy
{
public function __construct(
private EmployeeContractResolver $contractResolver,
) {}
/**
* @throws DateMalformedStringException
*/
public function computeCreditedMinutes(Absence $absence, string $dateYmd, bool $absentMorning, bool $absentAfternoon): int
{
$type = $absence->getType();
// Certaines absences ne doivent jamais générer d'heures créditées.
if (!$type?->getCountAsWorkedHours()) {
return 0;
}
$employee = $absence->getEmployee();
if (null === $employee) {
return 0;
}
$workDate = new DateTimeImmutable($dateYmd);
$contract = $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate);
// Les contrats suivis en "présence" ne cumulent pas d'heures en minutes.
if (TrackingMode::TIME->value !== $contract?->getTrackingMode()) {
return 0;
}
$weekday = (int) $workDate->format('N');
// On applique la règle de crédit dépendante du contrat (35h / 39h / fallback).
$dayMinutes = $this->resolveContractDayMinutes($contract?->getWeeklyHours(), $weekday);
if ($dayMinutes <= 0) {
return 0;
}
// Crédit en demi-journées: matin = 0.5, après-midi = 0.5.
$halfUnits = ($absentMorning ? 1 : 0) + ($absentAfternoon ? 1 : 0);
return (int) round(($dayMinutes / 2) * $halfUnits);
}
/**
* @throws DateMalformedStringException
*/
public function computeCreditedPresenceUnits(
Absence $absence,
string $dateYmd,
bool $absentMorning,
bool $absentAfternoon
): float {
$employee = $absence->getEmployee();
if (null === $employee) {
return 0.0;
}
$contract = $this->contractResolver->resolveForEmployeeAndDate($employee, new DateTimeImmutable($dateYmd));
if (TrackingMode::PRESENCE->value !== $contract?->getTrackingMode()) {
return 0.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 0.0;
}
public function resolveContractDayMinutes(?int $weeklyHours, int $isoWeekDay): int
{
// Week-end non travaillé dans cette politique.
if ($isoWeekDay >= 6) {
return 0;
}
// Règle fixe: 35h => 7h/jour.
if (35 === $weeklyHours) {
return 7 * 60;
}
// Règle fixe: 39h => 8h lundi-jeudi, 7h le vendredi.
if (39 === $weeklyHours) {
return 5 === $isoWeekDay ? 7 * 60 : 8 * 60;
}
// Cas spécifique métier: contrat 4h/semaine réparti sur 2 jours => 2h/jour.
if (4 === $weeklyHours) {
return 2 * 60;
}
// Contrat non renseigné/invalide: aucun crédit.
if (null === $weeklyHours || $weeklyHours <= 0) {
return 0;
}
// Fallback générique: répartition homogène sur 5 jours ouvrés.
return (int) round(($weeklyHours * 60) / 5);
}
}
+41 -7
View File
@@ -6,6 +6,8 @@ namespace App\State;
use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\ProviderInterface;
use App\Enum\ContractNature;
use App\Enum\HalfDay;
use App\Repository\AbsenceRepository; use App\Repository\AbsenceRepository;
use App\Repository\EmployeeRepository; use App\Repository\EmployeeRepository;
use App\Service\PublicHolidayServiceInterface; use App\Service\PublicHolidayServiceInterface;
@@ -52,9 +54,11 @@ class AbsencePrintProvider implements ProviderInterface
$fromDate = DateTimeImmutable::createFromFormat('Y-m-d', $from); $fromDate = DateTimeImmutable::createFromFormat('Y-m-d', $from);
$toDate = DateTimeImmutable::createFromFormat('Y-m-d', $to); $toDate = DateTimeImmutable::createFromFormat('Y-m-d', $to);
$siteIds = $this->parseIds($request->query->get('sites')); $siteIds = $this->parseIds($request->query->get('sites'));
$workContractIds = $this->parseIds($request->query->get('workContracts'));
$contractNatures = $this->parseContractNatures($request->query->get('contractNatures'));
$employees = $this->loadEmployees($siteIds); $employees = $this->loadEmployees($siteIds, $contractNatures, $workContractIds);
$absences = $this->loadAbsences($fromDate, $toDate, $employees); $absences = $this->loadAbsences($fromDate, $toDate, $employees);
$days = $this->buildDays($fromDate, $toDate); $days = $this->buildDays($fromDate, $toDate);
@@ -107,9 +111,19 @@ class AbsencePrintProvider implements ProviderInterface
return array_values(array_unique($ids)); return array_values(array_unique($ids));
} }
private function loadEmployees(array $siteIds): array private function loadEmployees(array $siteIds, array $contractNatures, array $workContractIds): array
{ {
return $this->employeeRepository->findForPrintBySiteIds($siteIds); $employees = $this->employeeRepository->findForPrintBySiteIds($siteIds);
return array_values(array_filter($employees, static function ($employee) use ($contractNatures, $workContractIds): bool {
$employeeNature = (string) $employee->getCurrentContractNature();
$employeeContractId = $employee->getContract()?->getId();
$natureMatches = [] === $contractNatures || in_array($employeeNature, $contractNatures, true);
$contractMatches = [] === $workContractIds || (null !== $employeeContractId && in_array($employeeContractId, $workContractIds, true));
return $natureMatches && $contractMatches;
}));
} }
private function loadAbsences(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array private function loadAbsences(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array
@@ -164,13 +178,13 @@ class AbsencePrintProvider implements ProviderInterface
if ($isSameDay) { if ($isSameDay) {
if ($startHalf === $endHalf) { if ($startHalf === $endHalf) {
$halfLabel = $startHalf; $halfLabel = $startHalf->value;
} }
} else { } else {
if ($isStartDay && 'PM' === $startHalf) { if ($isStartDay && HalfDay::PM === $startHalf) {
$halfLabel = 'PM'; $halfLabel = 'PM';
} }
if ($isEndDay && 'AM' === $endHalf) { if ($isEndDay && HalfDay::AM === $endHalf) {
$halfLabel = 'AM'; $halfLabel = 'AM';
} }
} }
@@ -208,4 +222,24 @@ class AbsencePrintProvider implements ProviderInterface
return $map; return $map;
} }
/**
* @return list<string>
*/
private function parseContractNatures(?string $value): array
{
if (null === $value || '' === trim($value)) {
return [];
}
$values = [];
foreach (explode(',', $value) as $part) {
$nature = strtoupper(trim($part));
if (null !== ContractNature::tryFrom($nature)) {
$values[] = $nature;
}
}
return array_values(array_unique($values));
}
} }
+249
View File
@@ -0,0 +1,249 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Absence;
use App\Entity\Employee;
use App\Entity\User;
use App\Enum\HalfDay;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
use DateInterval;
use DatePeriod;
use DateTime;
use DateTimeImmutable;
use DateTimeInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
final readonly class AbsenceWriteProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private AbsenceReadRepositoryInterface $absenceRepository,
private WorkHourReadRepositoryInterface $workHourRepository,
private Security $security,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof Absence) {
return $data;
}
$employee = $data->getEmployee();
if (null === $employee) {
return $data;
}
$user = $this->security->getUser();
$isAdmin = $user instanceof User && in_array('ROLE_ADMIN', $user->getRoles(), true);
if ($operation instanceof DeleteOperationInterface) {
if ($this->isLockedByValidation($employee, $data->getStartDate(), $data->getEndDate(), $isAdmin)) {
throw new ConflictHttpException('Impossible de modifier une absence sur une période validée.');
}
$this->entityManager->remove($data);
$this->entityManager->flush();
return null;
}
$segments = $this->expandAbsenceRange($data);
if ([] === $segments) {
throw new UnprocessableEntityHttpException('La période de l\'absence est invalide.');
}
$from = DateTimeImmutable::createFromInterface($segments[0]['date']);
$to = DateTimeImmutable::createFromInterface($segments[count($segments) - 1]['date']);
if ($this->isLockedByValidation($employee, $from, $to, $isAdmin)) {
throw new ConflictHttpException('Impossible de modifier une absence sur une période validée.');
}
$existing = $this->absenceRepository->findByEmployeeAndDateRange($employee, $from, $to);
foreach ($existing as $existingAbsence) {
if ($existingAbsence->getId() === $data->getId()) {
continue;
}
throw new ConflictHttpException('Cette période chevauche déjà une absence existante.');
}
$first = array_shift($segments);
if (null === $first) {
throw new UnprocessableEntityHttpException('La période de l\'absence est invalide.');
}
$data
->setStartDate($this->toMutableDate($first['date']))
->setEndDate($this->toMutableDate($first['date']))
->setStartHalf($first['startHalf'])
->setEndHalf($first['endHalf'])
;
$this->clearWorkHoursForSegment($employee, $first);
$this->entityManager->persist($data);
foreach ($segments as $segment) {
$absence = new Absence()
->setEmployee($employee)
->setType($data->getType())
->setComment($data->getComment())
->setStartDate($this->toMutableDate($segment['date']))
->setEndDate($this->toMutableDate($segment['date']))
->setStartHalf($segment['startHalf'])
->setEndHalf($segment['endHalf'])
;
$this->clearWorkHoursForSegment($employee, $segment);
$this->entityManager->persist($absence);
}
$this->entityManager->flush();
return $data;
}
/**
* @return list<array{date: DateTimeImmutable, startHalf: HalfDay, endHalf: HalfDay}>
*/
private function expandAbsenceRange(Absence $absence): array
{
$start = DateTimeImmutable::createFromInterface($absence->getStartDate());
$end = DateTimeImmutable::createFromInterface($absence->getEndDate());
if ($start > $end) {
throw new UnprocessableEntityHttpException('La date de fin ne peut pas être avant la date de début.');
}
if (
$start->format('Y-m-d') === $end->format('Y-m-d')
&& HalfDay::PM === $absence->getStartHalf()
&& HalfDay::AM === $absence->getEndHalf()
) {
throw new UnprocessableEntityHttpException('La demi-journée de fin ne peut pas être avant la demi-journée de début.');
}
$days = new DatePeriod($start, new DateInterval('P1D'), $end->modify('+1 day'));
$segments = [];
foreach ($days as $day) {
$isFirst = $day->format('Y-m-d') === $start->format('Y-m-d');
$isLast = $day->format('Y-m-d') === $end->format('Y-m-d');
$isSame = $isFirst && $isLast;
if ($isSame) {
$segments[] = [
'date' => $day,
'startHalf' => $absence->getStartHalf(),
'endHalf' => $absence->getEndHalf(),
];
continue;
}
if ($isFirst && HalfDay::PM === $absence->getStartHalf()) {
$segments[] = [
'date' => $day,
'startHalf' => HalfDay::PM,
'endHalf' => HalfDay::PM,
];
continue;
}
if ($isLast && HalfDay::AM === $absence->getEndHalf()) {
$segments[] = [
'date' => $day,
'startHalf' => HalfDay::AM,
'endHalf' => HalfDay::AM,
];
continue;
}
$segments[] = [
'date' => $day,
'startHalf' => HalfDay::AM,
'endHalf' => HalfDay::PM,
];
}
return $segments;
}
private function toMutableDate(DateTimeImmutable $date): DateTime
{
return DateTime::createFromImmutable($date);
}
private function isLockedByValidation(Employee $employee, DateTimeInterface $from, DateTimeInterface $to, bool $isAdmin): bool
{
if ($this->workHourRepository->hasValidatedInRange($employee, $from, $to)) {
return true;
}
if ($isAdmin) {
return false;
}
return $this->workHourRepository->hasSiteValidatedInRange($employee, $from, $to);
}
/**
* @param array{date: DateTimeImmutable, startHalf: HalfDay, endHalf: HalfDay} $segment
*/
private function clearWorkHoursForSegment(Employee $employee, array $segment): void
{
$workHour = $this->workHourRepository->findOneByEmployeeAndDate($employee, $segment['date']);
if (null === $workHour) {
return;
}
// Demi-journée matin: on efface uniquement la plage du matin.
if (HalfDay::AM === $segment['startHalf'] && HalfDay::AM === $segment['endHalf']) {
$workHour
->setMorningFrom(null)
->setMorningTo(null)
->setIsSiteValid(false)
->setIsValid(false)
;
return;
}
// Demi-journée après-midi: on efface après-midi + soirée.
if (HalfDay::PM === $segment['startHalf'] && HalfDay::PM === $segment['endHalf']) {
$workHour
->setAfternoonFrom(null)
->setAfternoonTo(null)
->setEveningFrom(null)
->setEveningTo(null)
->setIsSiteValid(false)
->setIsValid(false)
;
return;
}
// Journée complète: on efface toutes les plages horaires.
$workHour
->setMorningFrom(null)
->setMorningTo(null)
->setAfternoonFrom(null)
->setAfternoonTo(null)
->setEveningFrom(null)
->setEveningTo(null)
->setIsSiteValid(false)
->setIsValid(false)
;
}
}
+196
View File
@@ -0,0 +1,196 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
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
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private ProcessorInterface $persistProcessor,
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private ProcessorInterface $removeProcessor,
private EntityManagerInterface $entityManager,
private EmployeeContractPeriodRepository $periodRepository,
) {}
public function process(
mixed $data,
Operation $operation,
array $uriVariables = [],
array $context = []
): mixed {
if ($operation instanceof DeleteOperationInterface) {
return $this->removeProcessor->process($data, $operation, $uriVariables, $context);
}
if (!$data instanceof Employee) {
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
$isNew = null === $data->getId();
$previousContract = $this->resolvePreviousContract($data);
$result = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
$currentContract = $data->getContract();
if (!$currentContract instanceof Contract) {
return $result;
}
$today = new DateTimeImmutable('today');
$requestedContractNature = $this->resolveContractNature($data->getContractNature());
$requestedStartDate = $this->parseOptionalYmd($data->getContractStartDate(), 'contractStartDate');
$requestedEndDate = $this->parseOptionalYmd($data->getContractEndDate(), 'contractEndDate');
if ($isNew) {
$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;
}
$hasPeriodChangeRequest = null !== $requestedContractNature || null !== $requestedStartDate || null !== $requestedEndDate;
if ($this->isSameContract($previousContract, $currentContract) && !$hasPeriodChangeRequest) {
return $result;
}
$startDate = $requestedStartDate ?? $today;
$todayPeriod = $this->periodRepository->findOneCoveringDate($data, $today);
$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, $startDate->modify('-1 day'));
$this->createPeriod($data, $currentContract, $startDate, $endDate, $nature);
$this->entityManager->flush();
return $result;
}
private function resolvePreviousContract(Employee $employee): ?Contract
{
if (null === $employee->getId()) {
return null;
}
$originalData = $this->entityManager->getUnitOfWork()->getOriginalEntityData($employee);
$original = $originalData['contract'] ?? null;
return $original instanceof Contract ? $original : null;
}
private function isSameContract(?Contract $first, ?Contract $second): bool
{
if (null === $first || null === $second) {
return $first === $second;
}
return $first->getId() === $second->getId();
}
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, $endDate, $nature);
$this->entityManager->flush();
}
private function createPeriod(
Employee $employee,
Contract $contract,
DateTimeImmutable $startDate,
?DateTimeImmutable $endDate,
ContractNature $nature,
): void {
$period = new EmployeeContractPeriod()
->setEmployee($employee)
->setContract($contract)
->setStartDate($startDate)
->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.');
}
}
}
+65 -10
View File
@@ -8,11 +8,13 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\WorkHourBulkUpsert; use App\ApiResource\WorkHourBulkUpsert;
use App\ApiResource\WorkHourBulkUpsertResult; use App\ApiResource\WorkHourBulkUpsertResult;
use App\Entity\Contract;
use App\Entity\User; use App\Entity\User;
use App\Entity\WorkHour; use App\Entity\WorkHour;
use App\Enum\TrackingMode;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\EmployeeRepository; use App\Repository\EmployeeRepository;
use App\Repository\WorkHourRepository; use App\Repository\WorkHourRepository;
use App\Service\Contracts\EmployeeContractResolver;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
@@ -27,6 +29,8 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
private Security $security, private Security $security,
private EmployeeRepository $employeeRepository, private EmployeeRepository $employeeRepository,
private WorkHourRepository $workHourRepository, private WorkHourRepository $workHourRepository,
private AbsenceReadRepositoryInterface $absenceRepository,
private EmployeeContractResolver $contractResolver,
) {} ) {}
public function process( public function process(
@@ -65,6 +69,13 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
$existingByEmployeeId = $this->workHourRepository $existingByEmployeeId = $this->workHourRepository
->findByDateAndEmployeesIndexedByEmployeeId($workDate, array_values($employeesById)) ->findByDateAndEmployeesIndexedByEmployeeId($workDate, array_values($employeesById))
; ;
$absenceByEmployeeId = [];
foreach ($this->absenceRepository->findByDateAndEmployees($workDate, array_values($employeesById)) as $absence) {
$absenceEmployeeId = $absence->getEmployee()?->getId();
if ($absenceEmployeeId) {
$absenceByEmployeeId[$absenceEmployeeId] = true;
}
}
$result = new WorkHourBulkUpsertResult(); $result = new WorkHourBulkUpsertResult();
@@ -75,9 +86,18 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
throw new AccessDeniedHttpException(sprintf('Employee %d is outside your scope.', $employeeId)); throw new AccessDeniedHttpException(sprintf('Employee %d is outside your scope.', $employeeId));
} }
$isPresenceTracking = Contract::TRACKING_PRESENCE === $employee->getContract()?->getTrackingMode(); $contract = $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate);
if (null === $contract) {
throw new UnprocessableEntityHttpException(sprintf(
'Employee %d has no active contract on %s.',
$employeeId,
$data->workDate
));
}
$isPresenceTracking = TrackingMode::PRESENCE->value === $contract?->getTrackingMode();
$normalized = $this->normalizeEntry($entry, $employeeId, $isPresenceTracking); $normalized = $this->normalizeEntry($entry, $employeeId, $isPresenceTracking);
$existing = $existingByEmployeeId[$employeeId] ?? null; $existing = $existingByEmployeeId[$employeeId] ?? null;
$isAdmin = in_array('ROLE_ADMIN', $user->getRoles(), true);
if ($existing?->isValid()) { if ($existing?->isValid()) {
if (!$this->isSameAsExisting($existing, $normalized)) { if (!$this->isSameAsExisting($existing, $normalized)) {
@@ -92,11 +112,42 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
continue; continue;
} }
if (!$isAdmin && $existing?->isSiteValid()) {
if (!$this->isSameAsExisting($existing, $normalized)) {
throw new UnprocessableEntityHttpException(sprintf(
'Employee %d: site validated work hour cannot be modified.',
$employeeId
));
}
++$result->processed;
continue;
}
// Si aucune donnée n'a changé, on ne touche pas la ligne:
// cela évite de perdre les validations existantes (site/RH) sur un simple enregistrement.
if (null !== $existing && $this->isSameAsExisting($existing, $normalized)) {
++$result->processed;
continue;
}
if ($this->isEntryEmpty($normalized)) { if ($this->isEntryEmpty($normalized)) {
// Convention choisie: une ligne vide supprime l'enregistrement existant. // Convention choisie: une ligne vide supprime l'enregistrement existant.
if ($existing) { if ($existing) {
$this->entityManager->remove($existing); $this->entityManager->remove($existing);
++$result->deleted; ++$result->deleted;
} elseif (($absenceByEmployeeId[$employeeId] ?? false) === true) {
// Si une absence existe ce jour, on garde une ligne technique pour pouvoir valider la journée.
$workHour = new WorkHour()
->setEmployee($employee)
->setWorkDate($workDate)
;
$this->hydrateWorkHour($workHour, $normalized);
$this->entityManager->persist($workHour);
$existingByEmployeeId[$employeeId] = $workHour;
++$result->created;
} }
++$result->processed; ++$result->processed;
@@ -184,14 +235,16 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
} }
return [ return [
'morningFrom' => $this->normalizeTime($entry['morningFrom'] ?? null, $employeeId, 'morningFrom'), 'morningFrom' => $this->normalizeTime($entry['morningFrom'] ?? null, $employeeId, 'morningFrom'),
'morningTo' => $this->normalizeTime($entry['morningTo'] ?? null, $employeeId, 'morningTo'), 'morningTo' => $this->normalizeTime($entry['morningTo'] ?? null, $employeeId, 'morningTo'),
'afternoonFrom' => $this->normalizeTime($entry['afternoonFrom'] ?? null, $employeeId, 'afternoonFrom'), 'afternoonFrom' => $this->normalizeTime($entry['afternoonFrom'] ?? null, $employeeId, 'afternoonFrom'),
'afternoonTo' => $this->normalizeTime($entry['afternoonTo'] ?? null, $employeeId, 'afternoonTo'), 'afternoonTo' => $this->normalizeTime($entry['afternoonTo'] ?? null, $employeeId, 'afternoonTo'),
'eveningFrom' => $this->normalizeTime($entry['eveningFrom'] ?? null, $employeeId, 'eveningFrom'), 'eveningFrom' => $this->normalizeTime($entry['eveningFrom'] ?? null, $employeeId, 'eveningFrom'),
'eveningTo' => $this->normalizeTime($entry['eveningTo'] ?? null, $employeeId, 'eveningTo'), 'eveningTo' => $this->normalizeTime($entry['eveningTo'] ?? null, $employeeId, 'eveningTo'),
'isPresentMorning' => false, // On conserve aussi la présence si envoyée (cas forfait affiché côté UI),
'isPresentAfternoon' => false, // même si le contrat résolu ce jour est en suivi horaire.
'isPresentMorning' => $this->normalizePresence($entry['isPresentMorning'] ?? false, $employeeId, 'isPresentMorning'),
'isPresentAfternoon' => $this->normalizePresence($entry['isPresentAfternoon'] ?? false, $employeeId, 'isPresentAfternoon'),
]; ];
} }
@@ -281,6 +334,8 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
->setEveningTo($entry['eveningTo']) ->setEveningTo($entry['eveningTo'])
->setIsPresentMorning($entry['isPresentMorning']) ->setIsPresentMorning($entry['isPresentMorning'])
->setIsPresentAfternoon($entry['isPresentAfternoon']) ->setIsPresentAfternoon($entry['isPresentAfternoon'])
// Toute modification invalide la validation chef de site.
->setIsSiteValid(false)
// Toute modification utilisateur repasse la ligne en attente de validation RH. // Toute modification utilisateur repasse la ligne en attente de validation RH.
->setIsValid(false) ->setIsValid(false)
; ;
+116
View File
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\WorkHourDayContext;
use App\Dto\WorkHours\DayContextRow;
use App\Entity\User;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy;
use DateTimeImmutable;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
final readonly class WorkHourDayContextProvider implements ProviderInterface
{
public function __construct(
private Security $security,
private RequestStack $requestStack,
private EmployeeScopedRepositoryInterface $employeeRepository,
private AbsenceReadRepositoryInterface $absenceRepository,
private EmployeeContractResolver $contractResolver,
private AbsenceSegmentsResolver $absenceSegmentsResolver,
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourDayContext
{
$user = $this->security->getUser();
// Endpoint protégé: on exige un utilisateur authentifié.
if (!$user instanceof User) {
throw new AccessDeniedHttpException('Authentication required.');
}
$workDate = $this->resolveWorkDate();
$employees = $this->employeeRepository->findScoped($user);
$absences = $this->absenceRepository->findByDateAndEmployees($workDate, $employees);
$rowsByEmployeeId = [];
foreach ($employees as $employee) {
$employeeId = $employee->getId();
if (!$employeeId) {
continue;
}
// On initialise toutes les lignes, même sans absence ce jour-là.
$rowsByEmployeeId[$employeeId] = new DayContextRow(
employeeId: $employeeId,
hasContractAtDate: null !== $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate)
);
}
$dateKey = $workDate->format('Y-m-d');
foreach ($absences as $absence) {
$employeeId = $absence->getEmployee()?->getId();
// Ignore les absences orphelines ou hors scope utilisateur.
if (!$employeeId || !isset($rowsByEmployeeId[$employeeId])) {
continue;
}
[$absentMorning, $absentAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $dateKey);
// Pas de segment absent sur ce jour: rien à injecter dans la ligne.
if (!$absentMorning && !$absentAfternoon) {
continue;
}
// Calcule le crédit d'heures selon la politique métier (type d'absence + contrat).
$creditedMinutes = $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $dateKey, $absentMorning, $absentAfternoon);
$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,
creditedPresenceUnits: $creditedPresenceUnits
);
}
$response = new WorkHourDayContext();
$response->workDate = $dateKey;
$response->rows = array_map(
static fn (DayContextRow $row): array => $row->toArray(),
array_values($rowsByEmployeeId)
);
return $response;
}
private function resolveWorkDate(): DateTimeImmutable
{
$query = $this->requestStack->getCurrentRequest()?->query;
$raw = (string) ($query?->get('workDate') ?? '');
// Sans paramètre, on cible la date du jour.
if ('' === $raw) {
return new DateTimeImmutable('today');
}
$date = DateTimeImmutable::createFromFormat('Y-m-d', $raw);
// Validation stricte du format pour éviter les ambiguïtés de parsing.
if (!$date || $date->format('Y-m-d') !== $raw) {
throw new UnprocessableEntityHttpException('workDate must use Y-m-d format.');
}
return $date;
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\User;
use App\Entity\WorkHour;
use App\Security\EmployeeScopeService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
final readonly class WorkHourSiteValidationProcessor implements ProcessorInterface
{
public function __construct(
private Security $security,
private EmployeeScopeService $employeeScopeService,
private EntityManagerInterface $entityManager,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): WorkHour
{
if (!$data instanceof WorkHour) {
throw new AccessDeniedHttpException('Invalid payload.');
}
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException('Authentication required.');
}
// Réservé aux profils "Sites" (ni admin, ni self).
if (in_array('ROLE_ADMIN', $user->getRoles(), true) || in_array('ROLE_SELF', $user->getRoles(), true)) {
throw new AccessDeniedHttpException('Only site managers can update site validation.');
}
$siteId = $data->getEmployee()?->getSite()?->getId();
if (!$siteId) {
throw new AccessDeniedHttpException('Employee site is required.');
}
$allowedSiteIds = $this->employeeScopeService->getAllowedSiteIds($user);
if (!in_array($siteId, $allowedSiteIds, true)) {
throw new AccessDeniedHttpException('Employee is outside your site scope.');
}
$this->entityManager->flush();
return $data;
}
}
+236 -72
View File
@@ -7,11 +7,23 @@ namespace App\State;
use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\ProviderInterface;
use App\ApiResource\WorkHourWeeklySummary; use App\ApiResource\WorkHourWeeklySummary;
use App\Dto\WorkHours\WeeklyDaySummary;
use App\Dto\WorkHours\WeeklySummaryRow;
use App\Dto\WorkHours\WorkMetrics;
use App\Entity\Absence;
use App\Entity\Contract;
use App\Entity\Employee; use App\Entity\Employee;
use App\Entity\User; use App\Entity\User;
use App\Entity\WorkHour; use App\Entity\WorkHour;
use App\Repository\EmployeeRepository; use App\Enum\ContractNature;
use App\Repository\WorkHourRepository; use App\Enum\ContractType;
use App\Enum\TrackingMode;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy;
use DateTimeImmutable; use DateTimeImmutable;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\RequestStack;
@@ -23,13 +35,18 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
public function __construct( public function __construct(
private Security $security, private Security $security,
private RequestStack $requestStack, private RequestStack $requestStack,
private EmployeeRepository $employeeRepository, private EmployeeScopedRepositoryInterface $employeeRepository,
private WorkHourRepository $workHourRepository, private WorkHourReadRepositoryInterface $workHourRepository,
private AbsenceReadRepositoryInterface $absenceRepository,
private AbsenceSegmentsResolver $absenceSegmentsResolver,
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
private EmployeeContractResolver $contractResolver,
) {} ) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourWeeklySummary public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourWeeklySummary
{ {
$user = $this->security->getUser(); $user = $this->security->getUser();
// Endpoint protégé: résumé hebdo réservé aux utilisateurs authentifiés.
if (!$user instanceof User) { if (!$user instanceof User) {
throw new AccessDeniedHttpException('Authentication required.'); throw new AccessDeniedHttpException('Authentication required.');
} }
@@ -39,12 +56,13 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
$employees = $this->employeeRepository->findScoped($user); $employees = $this->employeeRepository->findScoped($user);
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($weekStart, $weekEnd, $employees); $workHours = $this->workHourRepository->findByDateRangeAndEmployees($weekStart, $weekEnd, $employees);
$absences = $this->absenceRepository->findForPrint($weekStart, $weekEnd, $employees);
$summary = new WorkHourWeeklySummary(); $summary = new WorkHourWeeklySummary();
$summary->weekStart = $weekStart->format('Y-m-d'); $summary->weekStart = $weekStart->format('Y-m-d');
$summary->weekEnd = $weekEnd->format('Y-m-d'); $summary->weekEnd = $weekEnd->format('Y-m-d');
$summary->days = $days; $summary->days = $days;
$summary->rows = $this->buildRows($employees, $workHours, $days); $summary->rows = $this->buildRows($employees, $workHours, $absences, $days, $anchorDate->format('Y-m-d'));
return $summary; return $summary;
} }
@@ -54,11 +72,13 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
$query = $this->requestStack->getCurrentRequest()?->query; $query = $this->requestStack->getCurrentRequest()?->query;
$raw = (string) ($query?->get('weekStart') ?? ''); $raw = (string) ($query?->get('weekStart') ?? '');
// Sans paramètre, on ancre la semaine sur aujourd'hui.
if ('' === $raw) { if ('' === $raw) {
return new DateTimeImmutable('today'); return new DateTimeImmutable('today');
} }
$date = DateTimeImmutable::createFromFormat('Y-m-d', $raw); $date = DateTimeImmutable::createFromFormat('Y-m-d', $raw);
// Validation stricte du format attendu.
if (!$date || $date->format('Y-m-d') !== $raw) { if (!$date || $date->format('Y-m-d') !== $raw) {
throw new UnprocessableEntityHttpException('weekStart must use Y-m-d format.'); throw new UnprocessableEntityHttpException('weekStart must use Y-m-d format.');
} }
@@ -71,6 +91,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
*/ */
private function resolveWeek(DateTimeImmutable $anchorDate): array private function resolveWeek(DateTimeImmutable $anchorDate): array
{ {
// Convention ISO: semaine de lundi (1) à dimanche (7).
$dayOfWeek = (int) $anchorDate->format('N'); $dayOfWeek = (int) $anchorDate->format('N');
$weekStart = $anchorDate->modify(sprintf('-%d days', $dayOfWeek - 1)); $weekStart = $anchorDate->modify(sprintf('-%d days', $dayOfWeek - 1));
$weekEnd = $weekStart->modify('+6 days'); $weekEnd = $weekStart->modify('+6 days');
@@ -86,33 +107,23 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
/** /**
* @param list<Employee> $employees * @param list<Employee> $employees
* @param list<WorkHour> $workHours * @param list<WorkHour> $workHours
* @param list<Absence> $absences
* @param list<string> $days * @param list<string> $days
* *
* @return list<array{ * @return list<WeeklySummaryRow>
* employeeId:int,
* firstName:string,
* lastName:string,
* siteName:?string,
* contractName:?string,
* trackingMode:?string,
* daily:list<array{date:string, dayMinutes:int, nightMinutes:int, totalMinutes:int, present:?float}>,
* weeklyDayMinutes:int,
* weeklyNightMinutes:int,
* weeklyTotalMinutes:int,
* weeklyPresenceCount:float,
* weeklyOvertime25Minutes:int,
* weeklyOvertime50Minutes:int
* }>
*/ */
private function buildRows(array $employees, array $workHours, array $days): array private function buildRows(array $employees, array $workHours, array $absences, array $days, string $anchorDateYmd): array
{ {
$metricsByEmployeeDate = []; $contractsByEmployeeDate = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
$contractNaturesByEmployeeDate = $this->contractResolver->resolveNaturesForEmployeesAndDays($employees, $days);
$metricsByEmployeeDate = [];
foreach ($workHours as $workHour) { foreach ($workHours as $workHour) {
$employeeId = $workHour->getEmployee()?->getId(); $employeeId = $workHour->getEmployee()?->getId();
if (!$employeeId) { if (!$employeeId) {
continue; continue;
} }
// Pré-calcul des métriques par salarié/date pour simplifier l'agrégation finale.
$dateKey = $workHour->getWorkDate()->format('Y-m-d'); $dateKey = $workHour->getWorkDate()->format('Y-m-d');
$metricsByEmployeeDate[$employeeId][$dateKey] = [ $metricsByEmployeeDate[$employeeId][$dateKey] = [
'metrics' => $this->computeMetrics($workHour), 'metrics' => $this->computeMetrics($workHour),
@@ -121,6 +132,46 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
]; ];
} }
$creditedByEmployeeDate = [];
$creditedPresenceByEmployeeDate = [];
$absenceByEmployeeDate = [];
$absentMorningByEmployeeDate = [];
$absentAfternoonByEmployeeDate = [];
$absenceLabelByEmployeeDate = [];
$absenceColorByEmployeeDate = [];
foreach ($absences as $absence) {
$employeeId = $absence->getEmployee()?->getId();
if (!$employeeId) {
continue;
}
$start = $absence->getStartDate()->format('Y-m-d');
$end = $absence->getEndDate()->format('Y-m-d');
foreach ($days as $date) {
// On ne crédite que les dates couvertes par l'intervalle d'absence.
if ($date < $start || $date > $end) {
continue;
}
[$absentMorning, $absentAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date);
if ($absentMorning || $absentAfternoon) {
$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();
}
if (!isset($absenceColorByEmployeeDate[$employeeId][$date])) {
$absenceColorByEmployeeDate[$employeeId][$date] = $absence->getType()?->getColor();
}
}
$creditedByEmployeeDate[$employeeId][$date] = ($creditedByEmployeeDate[$employeeId][$date] ?? 0)
+ $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $absentMorning, $absentAfternoon);
$creditedPresenceByEmployeeDate[$employeeId][$date] = ($creditedPresenceByEmployeeDate[$employeeId][$date] ?? 0.0)
+ $this->workedHoursCreditPolicy->computeCreditedPresenceUnits($absence, $date, $absentMorning, $absentAfternoon);
}
}
$rows = []; $rows = [];
foreach ($employees as $employee) { foreach ($employees as $employee) {
$employeeId = $employee->getId(); $employeeId = $employee->getId();
@@ -133,62 +184,96 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
$weeklyTotalMinutes = 0; $weeklyTotalMinutes = 0;
$weeklyPresenceCount = 0.0; $weeklyPresenceCount = 0.0;
$daily = []; $daily = [];
$isPresenceTracking = 'PRESENCE' === $employee->getContract()?->getTrackingMode(); // Les contrats au suivi "présence" ne manipulent pas les heures, mais des demi-journées.
$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;
}
foreach ($days as $date) { foreach ($days as $date) {
$entry = $metricsByEmployeeDate[$employeeId][$date] ?? null; $entry = $metricsByEmployeeDate[$employeeId][$date] ?? null;
$metrics = $entry['metrics'] ?? [ $metrics = $entry['metrics'] ?? new WorkMetrics();
'dayMinutes' => 0, $creditedMinutes = $creditedByEmployeeDate[$employeeId][$date] ?? 0;
'nightMinutes' => 0, $contractAtDate = $employeeContractsByDate[$date] ?? null;
'totalMinutes' => 0, $isPresenceTracking = TrackingMode::PRESENCE->value === $contractAtDate?->getTrackingMode();
]; // Les absences "comptées comme travaillées" alimentent le total du jour.
$metrics->addCreditedMinutes($creditedMinutes);
$present = null; $present = null;
if ($isPresenceTracking) { if ($isPresenceTracking) {
$morning = ($entry['isPresentMorning'] ?? false) ? 0.5 : 0.0; $absentMorning = $absentMorningByEmployeeDate[$employeeId][$date] ?? false;
$afternoon = ($entry['isPresentAfternoon'] ?? false) ? 0.5 : 0.0; $absentAfternoon = $absentAfternoonByEmployeeDate[$employeeId][$date] ?? false;
$present = $morning + $afternoon; $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);
} }
$weeklyDayMinutes += $metrics['dayMinutes']; $weeklyDayMinutes += $metrics->dayMinutes;
$weeklyNightMinutes += $metrics['nightMinutes']; $weeklyNightMinutes += $metrics->nightMinutes;
$weeklyTotalMinutes += $metrics['totalMinutes']; $weeklyTotalMinutes += $metrics->totalMinutes;
if (null !== $present) { if (null !== $present) {
$weeklyPresenceCount += $present; $weeklyPresenceCount += $present;
} }
$daily[] = [ $daily[] = new WeeklyDaySummary(
'date' => $date, date: $date,
'dayMinutes' => $metrics['dayMinutes'], dayMinutes: $metrics->dayMinutes,
'nightMinutes' => $metrics['nightMinutes'], nightMinutes: $metrics->nightMinutes,
'totalMinutes' => $metrics['totalMinutes'], totalMinutes: $metrics->totalMinutes,
'present' => $present, present: $present,
]; hasAbsence: $absenceByEmployeeDate[$employeeId][$date] ?? false,
absenceLabel: $absenceLabelByEmployeeDate[$employeeId][$date] ?? null,
absenceColor: $absenceColorByEmployeeDate[$employeeId][$date] ?? null,
);
} }
$rows[] = [ $isWeekPresenceTracking = TrackingMode::PRESENCE->value === $weekAnchorContract?->getTrackingMode();
'employeeId' => $employeeId, $disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($weekAnchorContract, $weekAnchorContractNature);
'firstName' => $employee->getFirstName(), $overtimeReferenceMinutes = $this->computeWeeklyOvertimeReferenceMinutes($days, $employeeContractsByDate);
'lastName' => $employee->getLastName(), $overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($days, $employeeContractsByDate);
'siteName' => $employee->getSite()?->getName(), $weeklyOvertimeTotalMinutes = $isWeekPresenceTracking
'contractName' => $employee->getContract()?->getName(), ? 0
'trackingMode' => $employee->getContract()?->getTrackingMode(), : max(0, $weeklyTotalMinutes - $overtimeReferenceMinutes);
'daily' => $daily, $weeklyOvertime25Minutes = ($isWeekPresenceTracking || $disableOvertimeBonuses)
'weeklyDayMinutes' => $weeklyDayMinutes, ? 0
'weeklyNightMinutes' => $weeklyNightMinutes, : $this->computeOvertime25BonusMinutes($weeklyTotalMinutes, $overtime25StartMinutes);
'weeklyTotalMinutes' => $weeklyTotalMinutes, $weeklyOvertime50Minutes = ($isWeekPresenceTracking || $disableOvertimeBonuses)
'weeklyPresenceCount' => $weeklyPresenceCount, ? 0
'weeklyOvertime25Minutes' => $isPresenceTracking ? 0 : $this->computeOvertime25Minutes($weeklyTotalMinutes), : $this->computeOvertime50BonusMinutes($weeklyTotalMinutes);
'weeklyOvertime50Minutes' => $isPresenceTracking ? 0 : $this->computeOvertime50Minutes($weeklyTotalMinutes), $weeklyRecoveryMinutes = ($isWeekPresenceTracking || $disableOvertimeBonuses)
]; ? 0
: $weeklyOvertimeTotalMinutes + $weeklyOvertime25Minutes + $weeklyOvertime50Minutes;
$rows[] = new WeeklySummaryRow(
employeeId: $employeeId,
firstName: $employee->getFirstName(),
lastName: $employee->getLastName(),
siteName: $employee->getSite()?->getName(),
contractName: $weekAnchorContract?->getName(),
contractType: $weekAnchorContract?->getType()->value,
trackingMode: $weekAnchorContract?->getTrackingMode(),
daily: $daily,
weeklyDayMinutes: $weeklyDayMinutes,
weeklyNightMinutes: $weeklyNightMinutes,
weeklyTotalMinutes: $weeklyTotalMinutes,
weeklyPresenceCount: $weeklyPresenceCount,
weeklyOvertimeTotalMinutes: $weeklyOvertimeTotalMinutes,
weeklyOvertime25Minutes: $weeklyOvertime25Minutes,
weeklyOvertime50Minutes: $weeklyOvertime50Minutes,
weeklyRecoveryMinutes: $weeklyRecoveryMinutes
);
} }
return $rows; return $rows;
} }
/** private function computeMetrics(WorkHour $workHour): WorkMetrics
* @return array{dayMinutes:int, nightMinutes:int, totalMinutes:int}
*/
private function computeMetrics(WorkHour $workHour): array
{ {
$ranges = [ $ranges = [
[$workHour->getMorningFrom(), $workHour->getMorningTo()], [$workHour->getMorningFrom(), $workHour->getMorningTo()],
@@ -206,11 +291,11 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
$dayMinutes = max(0, $totalMinutes - $nightMinutes); $dayMinutes = max(0, $totalMinutes - $nightMinutes);
return [ return new WorkMetrics(
'dayMinutes' => $dayMinutes, dayMinutes: $dayMinutes,
'nightMinutes' => $nightMinutes, nightMinutes: $nightMinutes,
'totalMinutes' => $totalMinutes, totalMinutes: $totalMinutes,
]; );
} }
/** /**
@@ -224,6 +309,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
return null; return null;
} }
// Si fin <= début, on considère un passage à minuit.
$end = $toMinutes <= $fromMinutes ? $toMinutes + 1440 : $toMinutes; $end = $toMinutes <= $fromMinutes ? $toMinutes + 1440 : $toMinutes;
return [$fromMinutes, $end]; return [$fromMinutes, $end];
@@ -260,9 +346,11 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
} }
[$start, $end] = $interval; [$start, $end] = $interval;
$windows = [[0, 360], [1260, 1440]]; // Fenêtres de nuit: 00:00-06:00 et 21:00-24:00.
$total = 0; $windows = [[0, 360], [1260, 1440]];
$total = 0;
// On projette aussi sur J+1 pour couvrir les shifts qui traversent minuit.
for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) { for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) {
$shift = $dayOffset * 1440; $shift = $dayOffset * 1440;
foreach ($windows as [$windowStart, $windowEnd]) { foreach ($windows as [$windowStart, $windowEnd]) {
@@ -281,13 +369,89 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
return max(0, $end - $start); return max(0, $end - $start);
} }
private function computeOvertime25Minutes(int $weeklyTotalMinutes): int /**
* @param array<string, ?Contract> $contractsByDate
*/
private function computeWeeklyOvertimeReferenceMinutes(array $days, array $contractsByDate): int
{ {
return max(0, min($weeklyTotalMinutes, 43 * 60) - (35 * 60)); $total = 0;
foreach ($days as $date) {
$isoDay = (int) new DateTimeImmutable($date)->format('N');
$contract = $contractsByDate[$date] ?? null;
$hours = $contract?->getWeeklyHours();
$referenceHours = (null !== $hours && $hours > 0) ? max(35, $hours) : null;
$total += $this->resolveDailyReferenceMinutes($referenceHours, $isoDay);
}
return $total;
} }
private function computeOvertime50Minutes(int $weeklyTotalMinutes): int /**
* @param array<string, ?Contract> $contractsByDate
*/
private function computeWeeklyOvertime25StartMinutes(array $days, array $contractsByDate): int
{ {
return max(0, $weeklyTotalMinutes - (43 * 60)); $total = 0;
foreach ($days as $date) {
$isoDay = (int) new DateTimeImmutable($date)->format('N');
$contract = $contractsByDate[$date] ?? null;
$hours = $contract?->getWeeklyHours();
$startHours = (null !== $hours && $hours >= 39) ? 39 : 35;
$total += $this->resolveDailyReferenceMinutes($startHours, $isoDay);
}
return $total;
}
private function computeOvertime25BonusMinutes(int $weeklyTotalMinutes, int $startMinutes): int
{
$trancheMinutes = max(0, min($weeklyTotalMinutes, 43 * 60) - $startMinutes);
return (int) round($trancheMinutes * 0.25);
}
private function computeOvertime50BonusMinutes(int $weeklyTotalMinutes): int
{
// Bonus 50% appliqué au-delà de 43h.
$trancheMinutes = max(0, $weeklyTotalMinutes - (43 * 60));
return (int) round($trancheMinutes * 0.5);
}
private function hasDisabledOvertimeBonuses(?Contract $contract, ContractNature $contractNature): bool
{
if (ContractNature::INTERIM === $contractNature) {
return true;
}
$type = ContractType::resolve(
$contract?->getName(),
$contract?->getTrackingMode(),
$contract?->getWeeklyHours()
);
return ContractType::INTERIM === $type;
}
private function resolveDailyReferenceMinutes(?int $weeklyHours, int $isoWeekDay): int
{
// Week-end hors base de référence.
if ($isoWeekDay >= 6) {
return 0;
}
if (null === $weeklyHours || $weeklyHours <= 0) {
return 0;
}
if (39 === $weeklyHours) {
return 5 === $isoWeekDay ? 7 * 60 : 8 * 60;
}
if (35 === $weeklyHours) {
return 7 * 60;
}
return (int) round(($weeklyHours * 60) / 5);
} }
} }
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\WorkHours;
use App\Entity\Absence;
use App\Enum\HalfDay;
use App\Service\WorkHours\AbsenceSegmentsResolver;
use DateTime;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class AbsenceSegmentsResolverTest extends TestCase
{
public function testResolveForSameDayMorningOnly(): void
{
$absence = new Absence()
->setStartDate(new DateTime('2026-02-16'))
->setEndDate(new DateTime('2026-02-16'))
->setStartHalf(HalfDay::AM)
->setEndHalf(HalfDay::AM)
;
$resolver = new AbsenceSegmentsResolver();
self::assertSame([true, false], $resolver->resolveForDate($absence, '2026-02-16'));
}
public function testResolveForSameDayAfternoonOnly(): void
{
$absence = new Absence()
->setStartDate(new DateTime('2026-02-16'))
->setEndDate(new DateTime('2026-02-16'))
->setStartHalf(HalfDay::PM)
->setEndHalf(HalfDay::PM)
;
$resolver = new AbsenceSegmentsResolver();
self::assertSame([false, true], $resolver->resolveForDate($absence, '2026-02-16'));
}
public function testResolveForMultiDayBoundaries(): void
{
$absence = new Absence()
->setStartDate(new DateTime('2026-02-16'))
->setEndDate(new DateTime('2026-02-18'))
->setStartHalf(HalfDay::PM)
->setEndHalf(HalfDay::AM)
;
$resolver = new AbsenceSegmentsResolver();
self::assertSame([false, true], $resolver->resolveForDate($absence, '2026-02-16'));
self::assertSame([true, true], $resolver->resolveForDate($absence, '2026-02-17'));
self::assertSame([true, false], $resolver->resolveForDate($absence, '2026-02-18'));
}
}
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service\WorkHours;
use App\Entity\Absence;
use App\Entity\AbsenceType;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy;
use DateTime;
use PHPUnit\Framework\TestCase;
/**
* @internal
*/
final class WorkedHoursCreditPolicyTest extends TestCase
{
public function testComputeCreditedMinutesFor35hHalfDay(): void
{
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub());
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 35, countAsWorked: true);
$minutes = $policy->computeCreditedMinutes($absence, '2026-02-16', true, false);
self::assertSame(210, $minutes);
}
public function testComputeCreditedMinutesFor4hContractFullDay(): void
{
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub());
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 4, countAsWorked: true);
$minutes = $policy->computeCreditedMinutes($absence, '2026-02-16', true, true);
self::assertSame(120, $minutes);
}
public function testComputeCreditedPresenceUnitsForPresenceContract(): void
{
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub());
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_PRESENCE, weeklyHours: null, countAsWorked: true);
$units = $policy->computeCreditedPresenceUnits($absence, '2026-02-16', true, false);
self::assertSame(0.5, $units);
}
public function testNoCreditWhenAbsenceTypeDoesNotCount(): void
{
$policy = new WorkedHoursCreditPolicy($this->buildResolverStub());
$absence = $this->buildAbsence(trackMode: Contract::TRACKING_TIME, weeklyHours: 35, countAsWorked: false);
self::assertSame(0, $policy->computeCreditedMinutes($absence, '2026-02-16', true, true));
self::assertSame(0.0, $policy->computeCreditedPresenceUnits($absence, '2026-02-16', true, true));
}
private function buildAbsence(string $trackMode, ?int $weeklyHours, bool $countAsWorked): Absence
{
$contract = new Contract()
->setName('Contrat test')
->setTrackingMode($trackMode)
->setWeeklyHours($weeklyHours)
;
$employee = new Employee()
->setFirstName('Alice')
->setLastName('Durand')
->setContract($contract)
;
$type = new AbsenceType()
->setCode('CP')
->setLabel('Congés')
->setColor('#000')
->setCountAsWorkedHours($countAsWorked)
;
return new Absence()
->setEmployee($employee)
->setType($type)
->setStartDate(new DateTime('2026-02-16'))
->setEndDate(new DateTime('2026-02-16'))
;
}
private function buildResolverStub(): EmployeeContractResolver
{
$resolver = $this->createStub(EmployeeContractResolver::class);
$resolver
->method('resolveForEmployeeAndDate')
->willReturnCallback(static fn (Employee $employee): ?Contract => $employee->getContract())
;
return $resolver;
}
}
+143
View File
@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace App\Tests\State;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Post;
use App\Entity\Absence;
use App\Entity\AbsenceType;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\User;
use App\Enum\HalfDay;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
use App\State\AbsenceWriteProcessor;
use DateTime;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
/**
* @internal
*/
final class AbsenceWriteProcessorTest extends TestCase
{
private AbsenceWriteProcessor $processor;
public function testPostSplitsRangeIntoDailyEntries(): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$absenceRepository = $this->createMock(AbsenceReadRepositoryInterface::class);
$workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class);
$security = $this->createAdminSecurityStub();
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security);
$absence = $this->buildAbsence('2026-02-16', '2026-02-18', HalfDay::AM, HalfDay::PM);
$workHourRepository->expects(self::once())
->method('hasValidatedInRange')
->willReturn(false)
;
$absenceRepository->expects(self::once())
->method('findByEmployeeAndDateRange')
->willReturn([])
;
$entityManager->expects(self::exactly(3))->method('persist');
$entityManager->expects(self::once())->method('flush');
$result = $this->processor->process($absence, new Post());
self::assertSame($absence, $result);
self::assertSame('2026-02-16', $absence->getStartDate()->format('Y-m-d'));
self::assertSame('2026-02-16', $absence->getEndDate()->format('Y-m-d'));
}
public function testDeleteThrowsWhenValidated(): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
$workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class);
$security = $this->createAdminSecurityStub();
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security);
$absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::AM, HalfDay::PM);
$workHourRepository->expects(self::once())
->method('hasValidatedInRange')
->willReturn(true)
;
$entityManager->expects(self::never())->method('remove');
$entityManager->expects(self::never())->method('flush');
$this->expectException(ConflictHttpException::class);
$this->processor->process($absence, new Delete());
}
public function testDeleteRemovesWhenNotValidated(): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
$workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class);
$security = $this->createAdminSecurityStub();
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security);
$absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::AM, HalfDay::PM);
$workHourRepository->expects(self::once())
->method('hasValidatedInRange')
->willReturn(false)
;
$entityManager->expects(self::once())->method('remove')->with($absence);
$entityManager->expects(self::once())->method('flush');
$result = $this->processor->process($absence, new Delete());
self::assertNull($result);
}
public function testPostThrowsOnInvalidHalfDayOrder(): void
{
$entityManager = $this->createStub(EntityManagerInterface::class);
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
$workHourRepository = $this->createStub(WorkHourReadRepositoryInterface::class);
$security = $this->createAdminSecurityStub();
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security);
$absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::PM, HalfDay::AM);
$this->expectException(UnprocessableEntityHttpException::class);
$this->processor->process($absence, new Post());
}
private function buildAbsence(string $startDate, string $endDate, HalfDay $startHalf, HalfDay $endHalf): Absence
{
$contract = new Contract()->setName('35h')->setTrackingMode(Contract::TRACKING_TIME)->setWeeklyHours(35);
$employee = new Employee()->setFirstName('Test')->setLastName('User')->setContract($contract);
$type = new AbsenceType()->setCode('CP')->setLabel('Congé')->setColor('#000')->setCountAsWorkedHours(true);
return new Absence()
->setEmployee($employee)
->setType($type)
->setComment('x')
->setStartDate(new DateTime($startDate))
->setEndDate(new DateTime($endDate))
->setStartHalf($startHalf)
->setEndHalf($endHalf)
;
}
private function createAdminSecurityStub(): Security
{
$security = $this->createStub(Security::class);
$security->method('getUser')->willReturn(
new User()->setUsername('admin')->setRoles(['ROLE_ADMIN'])
);
return $security;
}
}
@@ -0,0 +1,169 @@
<?php
declare(strict_types=1);
namespace App\Tests\State;
use ApiPlatform\Metadata\Get;
use App\Entity\Absence;
use App\Entity\AbsenceType;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\User;
use App\Enum\HalfDay;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy;
use App\State\WorkHourDayContextProvider;
use DateTime;
use PHPUnit\Framework\TestCase;
use ReflectionObject;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
/**
* @internal
*/
final class WorkHourDayContextProviderTest extends TestCase
{
private Security $security;
private EmployeeScopedRepositoryInterface $employeeRepository;
private AbsenceReadRepositoryInterface $absenceRepository;
private RequestStack $requestStack;
protected function setUp(): void
{
$this->security = $this->createStub(Security::class);
$this->employeeRepository = $this->createStub(EmployeeScopedRepositoryInterface::class);
$this->absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
$this->requestStack = new RequestStack();
}
public function testThrowsWhenAnonymous(): void
{
$this->security->method('getUser')->willReturn(null);
$provider = new WorkHourDayContextProvider(
$this->security,
$this->requestStack,
$this->employeeRepository,
$this->absenceRepository,
$this->buildResolverStub(),
new AbsenceSegmentsResolver(),
new WorkedHoursCreditPolicy($this->buildResolverStub())
);
$this->expectException(AccessDeniedHttpException::class);
$provider->provide(new Get());
}
public function testThrowsWhenDateFormatInvalid(): void
{
$this->requestStack->push(new Request(query: ['workDate' => '16-02-2026']));
$this->security->method('getUser')->willReturn(new User());
$provider = new WorkHourDayContextProvider(
$this->security,
$this->requestStack,
$this->employeeRepository,
$this->absenceRepository,
$this->buildResolverStub(),
new AbsenceSegmentsResolver(),
new WorkedHoursCreditPolicy($this->buildResolverStub())
);
$this->expectException(UnprocessableEntityHttpException::class);
$provider->provide(new Get());
}
public function testBuildsRowsWithAbsenceCredits(): void
{
$user = new User();
$employee = $this->buildEmployee(1, Contract::TRACKING_TIME, 35);
$absence = $this->buildAbsence($employee, '2026-02-16', '2026-02-16', true);
$this->requestStack->push(new Request(query: ['workDate' => '2026-02-16']));
$this->security->method('getUser')->willReturn($user);
$this->employeeRepository->method('findScoped')->with($user)->willReturn([$employee]);
$this->absenceRepository->method('findByDateAndEmployees')->willReturn([$absence]);
$provider = new WorkHourDayContextProvider(
$this->security,
$this->requestStack,
$this->employeeRepository,
$this->absenceRepository,
$this->buildResolverStub(),
new AbsenceSegmentsResolver(),
new WorkedHoursCreditPolicy($this->buildResolverStub())
);
$result = $provider->provide(new Get());
self::assertSame('2026-02-16', $result->workDate);
self::assertCount(1, $result->rows);
self::assertSame(1, $result->rows[0]['employeeId']);
self::assertSame('Maladie', $result->rows[0]['absenceLabel']);
self::assertSame('AM', $result->rows[0]['absenceHalf']);
self::assertSame(210, $result->rows[0]['creditedMinutes']);
}
private function buildEmployee(int $id, string $trackingMode, ?int $weeklyHours): Employee
{
$contract = new Contract()
->setName('Contrat')
->setTrackingMode($trackingMode)
->setWeeklyHours($weeklyHours)
;
$employee = new Employee()
->setFirstName('Jean')
->setLastName('Test')
->setContract($contract)
;
$this->setEntityId($employee, $id);
return $employee;
}
private function buildAbsence(Employee $employee, string $startDate, string $endDate, bool $countAsWorked): Absence
{
$type = new AbsenceType()
->setCode('MAL')
->setLabel('Maladie')
->setColor('#f00')
->setCountAsWorkedHours($countAsWorked)
;
return new Absence()
->setEmployee($employee)
->setType($type)
->setStartDate(new DateTime($startDate))
->setEndDate(new DateTime($endDate))
->setStartHalf(HalfDay::AM)
->setEndHalf(HalfDay::AM)
;
}
private function setEntityId(object $entity, int $id): void
{
$reflection = new ReflectionObject($entity);
$property = $reflection->getProperty('id');
$property->setAccessible(true);
$property->setValue($entity, $id);
}
private function buildResolverStub(): EmployeeContractResolver
{
$resolver = $this->createStub(EmployeeContractResolver::class);
$resolver
->method('resolveForEmployeeAndDate')
->willReturnCallback(static fn (Employee $employee): ?Contract => $employee->getContract())
;
return $resolver;
}
}
@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
namespace App\Tests\State;
use ApiPlatform\Metadata\Get;
use App\Entity\Absence;
use App\Entity\AbsenceType;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\User;
use App\Entity\WorkHour;
use App\Enum\HalfDay;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy;
use App\State\WorkHourWeeklySummaryProvider;
use DateTime;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use ReflectionObject;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/**
* @internal
*/
final class WorkHourWeeklySummaryProviderTest extends TestCase
{
private Security $security;
private EmployeeScopedRepositoryInterface $employeeRepository;
private WorkHourReadRepositoryInterface $workHourRepository;
private AbsenceReadRepositoryInterface $absenceRepository;
private RequestStack $requestStack;
protected function setUp(): void
{
$this->security = $this->createStub(Security::class);
$this->employeeRepository = $this->createStub(EmployeeScopedRepositoryInterface::class);
$this->workHourRepository = $this->createStub(WorkHourReadRepositoryInterface::class);
$this->absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
$this->requestStack = new RequestStack();
}
public function testThrowsWhenAnonymous(): void
{
$this->security->method('getUser')->willReturn(null);
$provider = new WorkHourWeeklySummaryProvider(
$this->security,
$this->requestStack,
$this->employeeRepository,
$this->workHourRepository,
$this->absenceRepository,
new AbsenceSegmentsResolver(),
new WorkedHoursCreditPolicy($this->buildResolverStub()),
$this->buildResolverStub()
);
$this->expectException(AccessDeniedHttpException::class);
$provider->provide(new Get());
}
public function testBuildsWeeklyRowsWithOvertimeAndPresence(): void
{
$user = new User();
$timeEmployee = $this->buildEmployee(1, 'TIME', 35, 'Alice');
$presenceEmployee = $this->buildEmployee(2, 'PRESENCE', null, 'Bob');
$interimEmployee = $this->buildEmployee(3, 'TIME', 35, 'Charly', 'Interim');
$employees = [$timeEmployee, $presenceEmployee, $interimEmployee];
$workHours = [];
foreach (['2026-02-16', '2026-02-17', '2026-02-18', '2026-02-19', '2026-02-20'] as $date) {
$workHours[] = new WorkHour()
->setEmployee($timeEmployee)
->setWorkDate(new DateTimeImmutable($date))
->setMorningFrom('09:00')
->setMorningTo('19:00')
;
$workHours[] = new WorkHour()
->setEmployee($interimEmployee)
->setWorkDate(new DateTimeImmutable($date))
->setMorningFrom('09:00')
->setMorningTo('19:00')
;
}
$absenceType = new AbsenceType()
->setCode('CP')
->setLabel('Congé')
->setColor('#000')
->setCountAsWorkedHours(true)
;
$presenceAbsence = new Absence()
->setEmployee($presenceEmployee)
->setType($absenceType)
->setStartDate(new DateTime('2026-02-16'))
->setEndDate(new DateTime('2026-02-16'))
->setStartHalf(HalfDay::AM)
->setEndHalf(HalfDay::PM)
;
$this->requestStack->push(new Request(query: ['weekStart' => '2026-02-16']));
$this->security->method('getUser')->willReturn($user);
$this->employeeRepository->method('findScoped')->with($user)->willReturn($employees);
$this->workHourRepository->method('findByDateRangeAndEmployees')->willReturn($workHours);
$this->absenceRepository->method('findForPrint')->willReturn([$presenceAbsence]);
$provider = new WorkHourWeeklySummaryProvider(
$this->security,
$this->requestStack,
$this->employeeRepository,
$this->workHourRepository,
$this->absenceRepository,
new AbsenceSegmentsResolver(),
new WorkedHoursCreditPolicy($this->buildResolverStub()),
$this->buildWeeklyResolverStub($employees)
);
$result = $provider->provide(new Get());
self::assertSame('2026-02-16', $result->weekStart);
self::assertSame('2026-02-22', $result->weekEnd);
self::assertCount(3, $result->rows);
self::assertSame(3000, $result->rows[0]->weeklyTotalMinutes);
self::assertSame(900, $result->rows[0]->weeklyOvertimeTotalMinutes);
self::assertSame(120, $result->rows[0]->weeklyOvertime25Minutes);
self::assertSame(210, $result->rows[0]->weeklyOvertime50Minutes);
self::assertSame(1230, $result->rows[0]->weeklyRecoveryMinutes);
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);
self::assertSame(0, $result->rows[1]->weeklyOvertimeTotalMinutes);
self::assertSame(0, $result->rows[2]->weeklyOvertime25Minutes);
self::assertSame(0, $result->rows[2]->weeklyOvertime50Minutes);
self::assertSame(0, $result->rows[2]->weeklyRecoveryMinutes);
self::assertSame(900, $result->rows[2]->weeklyOvertimeTotalMinutes);
}
private function buildEmployee(int $id, string $trackingMode, ?int $weeklyHours, string $firstName, ?string $contractName = null): Employee
{
$contract = new Contract()
->setName($contractName ?? $trackingMode)
->setTrackingMode($trackingMode)
->setWeeklyHours($weeklyHours)
;
$employee = new Employee()
->setFirstName($firstName)
->setLastName('Test')
->setContract($contract)
;
$this->setEntityId($employee, $id);
return $employee;
}
private function setEntityId(object $entity, int $id): void
{
$reflection = new ReflectionObject($entity);
$property = $reflection->getProperty('id');
$property->setAccessible(true);
$property->setValue($entity, $id);
}
private function buildResolverStub(): EmployeeContractResolver
{
$resolver = $this->createStub(EmployeeContractResolver::class);
$resolver
->method('resolveForEmployeeAndDate')
->willReturnCallback(static fn (Employee $employee): ?Contract => $employee->getContract())
;
$resolver
->method('resolveForEmployeesAndDays')
->willReturn([])
;
return $resolver;
}
/**
* @param list<Employee> $employees
*/
private function buildWeeklyResolverStub(array $employees): EmployeeContractResolver
{
$resolver = $this->createStub(EmployeeContractResolver::class);
$resolver
->method('resolveForEmployeeAndDate')
->willReturnCallback(static fn (Employee $employee): ?Contract => $employee->getContract())
;
$resolver
->method('resolveForEmployeesAndDays')
->willReturnCallback(static function (array $scopedEmployees, array $days): array {
$map = [];
foreach ($scopedEmployees as $employee) {
$employeeId = $employee->getId();
if (!$employeeId) {
continue;
}
foreach ($days as $day) {
$map[$employeeId][$day] = $employee->getContract();
}
}
return $map;
})
;
return $resolver;
}
}