Compare commits

...

11 Commits

Author SHA1 Message Date
gitea-actions
11331da6a1 chore: bump version to v0.1.84
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 33s
2026-04-14 09:25:55 +00:00
399fd7335e fix : exclusion de certain jour férié et affichage différent des jours férié dans la page d'heure
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-14 11:25:44 +02:00
gitea-actions
46cb7f1a16 chore: bump version to v0.1.83
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 35s
2026-04-14 06:38:09 +00:00
b934f4d81f Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-14 08:38:01 +02:00
77c1cdcbbd fix : on masque la validation chef site 2026-04-14 08:37:54 +02:00
gitea-actions
de302d9ded chore: bump version to v0.1.82
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 22s
2026-04-14 06:25:17 +00:00
ef18210bf7 fix : export du récap congés
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-14 08:24:43 +02:00
gitea-actions
055d92153b chore: bump version to v0.1.81
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 2m19s
2026-04-13 07:41:52 +00:00
4cd30de3e3 feat : ajout d'un onglet formation
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-13 09:41:36 +02:00
gitea-actions
b185accdbb chore: bump version to v0.1.80
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 26s
2026-04-08 06:47:00 +00:00
a4bda53f57 fix : split deficit weeks by weekdays count when no hours worked
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
When a week spans two months and has zero worked hours (e.g. RTT
all week), the proportional split by worked minutes gave 0 to both
months. Now falls back to splitting by weekday count.
2026-04-08 08:17:07 +02:00
40 changed files with 1377 additions and 115 deletions

3
.env
View File

@@ -38,6 +38,9 @@ DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&ch
###> app ###
RTT_START_DATE=2026-02-23
# Comma-separated list of public holiday labels to exclude from the government API response
# (typically the "journée de solidarité" worked in many companies)
EXCLUDED_PUBLIC_HOLIDAYS="Lundi de Pentecôte"
###< app ###
###> nelmio/cors-bundle ###

View File

@@ -35,6 +35,13 @@
- Absences with `countAsWorkedHours=true`: credit minutes (TIME) or nothing (PRESENCE)
- Driver periods (`isDriver=true` on `EmployeeContractPeriod`): separate screen `/driver-hours`, uses `dayHoursMinutes`/`nightHoursMinutes` + meal/overnight flags on `WorkHour`
## Fériés
- Source : API gouv via `PublicHolidayService` (cache 30j)
- Exclusions : env `EXCLUDED_PUBLIC_HOLIDAYS` (CSV de libellés), défaut `"Lundi de Pentecôte"`. Le filtre s'applique après le cache, côté service, donc frontend et calculs backend voient la même liste.
- Écrans Heures / Heures Conducteurs (vue jour) : le nom du férié est affiché en badge `#b3e5fc` avec icône `mdi:calendar-star` dans la colonne Absence (distinct du pill absence). Bouton "Modifier" absence masqué sur férié (comme pour les formations).
- Création/édition d'absence bloquée sur un férié
- Saisie d'heures (ou de jours de présence) autorisée sur un férié — nécessaire pour éviter un déficit hebdomadaire (la référence hebdo n'est pas réduite par les fériés)
## Validation Rules
- `isValid` (RH): locks line for everyone (admin can only untoggle validation)
- `isSiteValid` (site manager): locks for non-admin, admin can still edit
@@ -54,6 +61,17 @@
- Validation: mois obligatoire + au moins `kilometers > 0` ou `amount > 0`
- Les deux champs km et montant sont optionnels individuellement mais au moins un requis
## Formations
- Onglet "Formation" sur la fiche employé (admin uniquement)
- Champs : date début, date fin, justificatif PDF optionnel, commentaire
- Validation: dates obligatoires, `endDate >= startDate`, fichier PDF uniquement
- Justificatif stocké dans `var/uploads/formations/{année}/{mois}/{uuid}.pdf` (année/mois = startDate)
- Suppression et remplacement du justificatif nettoient l'ancien fichier disque
- Tri tableau par `startDate DESC`
- Affichage écran Heures (jour) : pill "Formation" (indigo) dans la colonne Absence. Quand une formation existe, le bouton "Modifier" de la colonne Absence est masqué (lockdown complet du jour pour la gestion d'absence)
- Affichage Calendrier : cellule "F" (indigo) si formation seule, ou icône école en coin si formation + absence. Cellules avec formation non cliquables. Légende dédiée. PDF export : code "F" indigo ou astérisque à côté du code d'absence
- Le CRUD formation est exclusivement géré depuis la fiche employé > onglet Formation
## Frontend Patterns
### Table styling (standard across all pages)

View File

@@ -25,6 +25,7 @@ services:
App\Service\PublicHolidayService:
arguments:
$holidayUrl: '%env(HOLIDAY_URL)%'
$excludedLabels: '%env(default::EXCLUDED_PUBLIC_HOLIDAYS)%'
App\Service\Rtt\RttRecoveryComputationService:
arguments:
@@ -37,6 +38,7 @@ services:
App\Repository\Contract\AbsenceReadRepositoryInterface: '@App\Repository\AbsenceRepository'
App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface: '@App\Repository\EmployeeContractPeriodRepository'
App\Repository\Contract\EmployeeScopedRepositoryInterface: '@App\Repository\EmployeeRepository'
App\Repository\Contract\FormationReadRepositoryInterface: '@App\Repository\FormationRepository'
App\Repository\Contract\WorkHourReadRepositoryInterface: '@App\Repository\WorkHourRepository'
App\Service\Contracts\EmployeeContractPeriodManagerInterface: '@App\Service\Contracts\EmployeeContractPeriodManager'

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.79'
app.version: '0.1.84'

View File

@@ -23,3 +23,4 @@ DEFAULT_URI=https://sirh.malio-dev.fr
APP_SHARE_DIR=var/share
RTT_START_DATE=2026-02-23
HOLIDAY_URL="https://calendrier.api.gouv.fr/jours-feries/"
EXCLUDED_PUBLIC_HOLIDAYS="Lundi de Pentecôte"

58
doc/formations.md Normal file
View File

@@ -0,0 +1,58 @@
# Formations
Onglet **Formation** accessible depuis la fiche employé. Permet de tracer les formations suivies par un salarié.
## Accès
- Réservé aux administrateurs (`ROLE_ADMIN`)
- Invisible pour les autres rôles
## Champs
| Champ | Type | Obligatoire |
| --- | --- | --- |
| Date de début | date | oui |
| Date de fin | date | oui |
| Justificatif | fichier PDF | non |
| Commentaire | texte libre | non |
## Règles de validation
- La date de fin doit être supérieure ou égale à la date de début
- Seuls les fichiers PDF sont acceptés pour le justificatif
- Un employé peut avoir plusieurs formations (aucune unicité imposée)
## Stockage
Les justificatifs PDF sont stockés dans `var/uploads/formations/{année}/{mois}/{uuid}.pdf`, où l'année et le mois sont ceux de la date de début de la formation. Le nom d'origine du fichier est conservé en base pour l'affichage et le téléchargement.
Lors de la suppression d'une formation, le fichier associé est automatiquement supprimé du disque. Lors du remplacement d'un justificatif, l'ancien fichier est également supprimé.
## Tri
Les formations sont affichées dans le tableau par **date de début décroissante**.
## Affichage sur les autres écrans
### Écran des heures (vue jour)
Dans la colonne "Absence", lorsqu'un salarié est en formation sur la date sélectionnée, une pastille indigo **Formation** est affichée sous la pastille d'absence éventuelle. Cette pastille est uniquement informative :
- Le bouton **Modifier** de la colonne Absence est masqué : aucune création/modification/suppression d'absence n'est possible sur un jour en formation
- La gestion CRUD d'une formation se fait exclusivement depuis la fiche employé, onglet **Formation**
### Calendrier
Dans le calendrier mensuel, les formations sont affichées de deux façons :
- **Jour avec formation uniquement** : la cellule est teintée en indigo avec le code `F`
- **Jour avec absence + formation** : la cellule garde la couleur de l'absence et une icône école est ajoutée en coin supérieur droit
Une entrée "Formation" est visible dans la légende du calendrier. Les cellules contenant une formation sont **non cliquables** (aucune création/édition d'absence possible). La gestion d'une formation se fait exclusivement depuis la fiche employé, onglet **Formation**.
### Export PDF du calendrier
L'impression du calendrier d'absences reprend le même principe :
- **Jour avec formation uniquement** : cellule indigo avec le code `F`
- **Jour avec absence + formation** : le code de l'absence est suivi d'un astérisque (`*`)

View File

@@ -161,10 +161,14 @@ Documents complementaires:
## 7) Fériés
- Les jours fériés sont identifiés et affichés
- Source: API `calendrier.api.gouv.fr/jours-feries/` via `PublicHolidayService` (cache 30j)
- Exclusions configurables: variable d'env `EXCLUDED_PUBLIC_HOLIDAYS` (liste de libellés séparés par virgules). Par défaut `"Lundi de Pentecôte"` — journée de solidarité généralement travaillée. Le filtre s'applique à tous les consommateurs (frontend + calculs backend) en amont du retour du service.
- Onglet congés: jours fériés affichés sur le calendrier avec fond `rgb(179, 229, 252)` et nom au survol
- Écran Heures et Heures Conducteurs (vue jour): le nom du férié est affiché dans la colonne Absence sous forme de pill (fond `#b3e5fc`, icône `mdi:calendar-star`), distinct du pill absence
- Règle courante:
- absences bloquées sur jour férié
- saisie d'heures autorisée
- absences bloquées sur jour férié (création/édition) — bouton "Modifier" masqué comme pour les formations
- saisie d'heures ou de jours de présence autorisée — les heures saisies comptent normalement dans le total hebdo et le calcul RTT
- la référence hebdomadaire n'est pas réduite par un férié: un salarié qui ne saisit rien sur un férié est en déficit de la journée correspondante
## 8) Impression absences (PDF)

View File

@@ -45,9 +45,9 @@
<button
type="button"
class="relative flex h-8 w-full items-center justify-center overflow-hidden rounded-md border border-neutral-200 text-[11px] font-semibold text-neutral-800"
:class="isHolidayDate(day.date) ? 'cursor-not-allowed opacity-80' : ''"
:class="isHolidayDate(day.date) || getCellInfo(employee.id, day.date)?.hasFormation ? 'cursor-not-allowed opacity-80' : ''"
:style="getCellStyle(employee.id, day.date)"
:disabled="isHolidayDate(day.date)"
:disabled="isHolidayDate(day.date) || getCellInfo(employee.id, day.date)?.hasFormation"
@click="handleCellClick(employee, day.date)"
>
<span v-if="!getCellInfo(employee.id, day.date)?.halfLabel">
@@ -67,6 +67,13 @@
{{ getCellInfo(employee.id, day.date)?.code }}
</span>
</template>
<Icon
v-if="getCellInfo(employee.id, day.date)?.hasFormation && getCellInfo(employee.id, day.date)?.code !== 'F'"
name="mdi:school"
size="12"
class="absolute top-0 right-0 text-indigo-600 bg-white rounded-bl-md p-0.5"
title="Formation"
/>
</button>
</template>
<template v-else>
@@ -107,7 +114,7 @@ const props = defineProps<{
visibleEmployees: Employee[]
gridStyle: Record<string, string>
getCellStyle: (employeeId: number, date: string) => Record<string, string> | undefined
getCellInfo: (employeeId: number, date: string) => { id: number; code: string; color: string; halfLabel?: HalfDay; textColor?: string } | null
getCellInfo: (employeeId: number, date: string) => { id: number; code: string; color: string; halfLabel?: HalfDay; textColor?: string; hasFormation?: boolean } | null
formatEmployeeName: (employee: Employee) => string
isHolidayDate: (date: string) => boolean
}>()

View File

@@ -25,19 +25,7 @@
@change="onBulkValidationChange"
/>
</span>
<span v-else-if="isSiteManager" class="inline-flex items-center gap-2">
<span>Site</span>
<input
ref="bulkSiteValidationInput"
:checked="isBulkSiteValidationChecked"
type="checkbox"
class="h-4 w-4"
:class="canBulkToggleSiteValidation ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'"
:disabled="!canBulkToggleSiteValidation"
@change="onBulkSiteValidationChange"
/>
</span>
<span v-else>Site <Icon name="mdi:check-bold" class="ml-1"/></span>
<span v-else-if="!isSiteManager">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>
@@ -68,19 +56,31 @@
</p>
</div>
<div class="pl-2 min-w-0 self-stretch flex flex-col gap-1 justify-between py-0.5">
<p
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate"
:class="getRowAbsenceLabel(employee.id) ? 'text-white' : 'invisible'"
:title="getRowAbsenceLabel(employee.id) || ''"
:style="getRowAbsenceStyle(employee.id)"
>
{{ getRowAbsenceLabel(employee.id) || '—' }}
</p>
<div class="flex flex-col gap-1 min-w-0">
<p
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate"
:class="getRowAbsenceLabel(employee.id) ? 'text-white' : 'invisible'"
:title="getRowAbsenceLabel(employee.id) || ''"
:style="getRowAbsenceStyle(employee.id)"
>
{{ getRowAbsenceLabel(employee.id) || '—' }}
</p>
<p
v-if="isHoliday"
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate text-sky-900 inline-flex items-center gap-1"
style="background-color: #b3e5fc"
:title="holidayLabel || 'Férié'"
>
<Icon name="mdi:calendar-star" size="14" class="shrink-0"/>
<span class="truncate">{{ holidayLabel || 'Férié' }}</span>
</p>
</div>
<button
v-if="!isHoliday"
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)"
:class="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
:disabled="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
@click="onAbsenceClick(employee.id)"
>
Modifier
@@ -147,16 +147,8 @@
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
/>
</div>
<div v-else class="text-right p-5">
<input
v-if="isSiteManager"
:checked="rows[employee.id]?.isSiteValid ?? false"
type="checkbox"
class="h-4 w-4 cursor-pointer"
:disabled="(!canToggleSiteValidation(employee.id) && !canCreateSiteValidationRowFromAbsence(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>
<div v-else-if="!isSiteManager" class="text-right p-5">
<span v-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">
@@ -184,6 +176,7 @@ const props = defineProps<{
isSiteManager: boolean
dayGridCols: string
isHoliday: boolean
holidayLabel: string
contractLabel: (employee: Employee) => string
isRowLocked: (employeeId: number) => boolean
hasContractAtSelectedDate: (employeeId: number) => boolean

View File

@@ -0,0 +1,251 @@
<template>
<section class="mt-8">
<div class="overflow-hidden bg-white">
<div
class="grid grid-cols-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md">
<p>Date de début</p>
<p>Date de fin</p>
<p>Justificatif</p>
<p>Commentaire</p>
</div>
<div v-if="formations.length === 0" class="px-6 py-4 text-[20px] font-bold text-primary-500 border-x border-b border-primary-500 rounded-b-md">
Aucune formation.
</div>
<div v-else class="border-x border-b border-primary-500 rounded-b-md">
<div
v-for="item in formations"
:key="item.id"
class="grid grid-cols-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 cursor-pointer hover:bg-tertiary-500"
@click="onOpenEditDrawer(item)"
>
<p>{{ formatDate(item.startDate) }}</p>
<p>{{ formatDate(item.endDate) }}</p>
<p class="min-w-0">
<a
v-if="item.justificatifPath"
:href="getFormationJustificatifUrl(props.apiBase, item.id)"
target="_blank"
class="text-primary-500 hover:text-secondary-500 flex gap-2 items-center"
@click.stop
>
<Icon name="mdi:file-download-outline" size="20" class="shrink-0"/>
<span class="truncate">{{ item.justificatifName ?? 'Télécharger' }}</span>
</a>
<span v-else>-</span>
</p>
<p class="truncate">{{ item.comment ?? '-' }}</p>
</div>
</div>
</div>
<div class="flex justify-center mb-4 mt-8">
<button
type="button"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md text-white disabled:cursor-not-allowed disabled:opacity-50"
@click="onOpenCreateDrawer"
>
+ Ajouter
</button>
</div>
<AppDrawer v-model="isDrawerOpen" title="Formation">
<form class="space-y-4" @submit.prevent="onSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="formation-start-date">
Date de début <span class="text-red-600">*</span>
</label>
<input
id="formation-start-date"
v-model="form.startDate"
type="date"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="formation-end-date">
Date de fin <span class="text-red-600">*</span>
</label>
<input
id="formation-end-date"
v-model="form.endDate"
type="date"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
<p v-if="isDateRangeInvalid" class="mt-1 text-sm text-red-600">La date de fin doit être postérieure ou égale à la date de début.</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="formation-justificatif">
Justificatif
</label>
<div v-if="isEditing && editingItem?.justificatifName" class="mt-1 text-sm text-neutral-500">
Fichier actuel : {{ editingItem.justificatifName }}
</div>
<input
id="formation-justificatif"
ref="justificatifInput"
type="file"
accept="application/pdf"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 file:mr-3 file:rounded file:border-0 file:bg-primary-500 file:px-3 file:py-1 file:text-sm file:text-white"
@change="onJustificatifChange"
/>
<p v-if="justificatifError" class="mt-1 text-sm text-red-600">{{ justificatifError }}</p>
<p v-else class="mt-1 text-sm text-neutral-500">Fichier au format pdf</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="formation-comment">
Commentaire
</label>
<textarea
id="formation-comment"
v-model="form.comment"
rows="3"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
placeholder="Commentaire..."
/>
</div>
<div v-if="isEditing" class="grid grid-cols-2 gap-3 pt-2">
<button
type="button"
class="flex items-center justify-center rounded-md bg-red-500 px-4 py-2 text-md font-semibold text-white hover:bg-red-600"
@click="onDelete"
>
Supprimer
</button>
<button
type="submit"
class="flex items-center justify-center rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!isFormValid"
>
Modifier
</button>
</div>
<div v-else class="flex justify-center pt-2">
<button
type="submit"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!isFormValid"
>
+ Ajouter
</button>
</div>
</form>
</AppDrawer>
</section>
</template>
<script setup lang="ts">
import type {Formation} from '~/services/dto/formation'
import {getFormationJustificatifUrl} from '~/services/formations'
import AppDrawer from '~/components/AppDrawer.vue'
const props = defineProps<{
formations: Formation[]
apiBase: string
}>()
const emit = defineEmits<{
(event: 'create', data: { startDate: string; endDate: string; comment?: string }, justificatifFile?: File): void
(event: 'update', id: number, data: { startDate: string; endDate: string; comment?: string }, justificatifFile?: File): void
(event: 'delete', id: number): void
}>()
const isDrawerOpen = ref(false)
const isEditing = ref(false)
const editingItem = ref<Formation | null>(null)
const selectedJustificatif = ref<File | undefined>(undefined)
const justificatifInput = ref<HTMLInputElement | null>(null)
const justificatifError = ref('')
const form = reactive({
startDate: '',
endDate: '',
comment: ''
})
const isDateRangeInvalid = computed(() => {
if (!form.startDate || !form.endDate) return false
return form.endDate < form.startDate
})
const isFormValid = computed(() => {
return Boolean(form.startDate) && Boolean(form.endDate) && !isDateRangeInvalid.value && !justificatifError.value
})
const formatDate = (dateStr: string): string => {
if (!dateStr) return '-'
const date = new Date(dateStr)
if (Number.isNaN(date.getTime())) return dateStr
return date.toLocaleDateString('fr-FR')
}
const resetForm = () => {
form.startDate = ''
form.endDate = ''
form.comment = ''
selectedJustificatif.value = undefined
justificatifError.value = ''
if (justificatifInput.value) {
justificatifInput.value.value = ''
}
}
const onOpenCreateDrawer = () => {
isEditing.value = false
editingItem.value = null
resetForm()
isDrawerOpen.value = true
}
const onOpenEditDrawer = (item: Formation) => {
isEditing.value = true
editingItem.value = item
form.startDate = item.startDate
form.endDate = item.endDate
form.comment = item.comment ?? ''
selectedJustificatif.value = undefined
justificatifError.value = ''
if (justificatifInput.value) {
justificatifInput.value.value = ''
}
isDrawerOpen.value = true
}
const onJustificatifChange = (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (file && file.type !== 'application/pdf') {
justificatifError.value = 'Seuls les fichiers PDF sont acceptés.'
selectedJustificatif.value = undefined
target.value = ''
return
}
justificatifError.value = ''
selectedJustificatif.value = file ?? undefined
}
const onSubmit = () => {
const data = {
startDate: form.startDate,
endDate: form.endDate,
comment: form.comment || undefined
}
if (isEditing.value && editingItem.value) {
emit('update', editingItem.value.id, data, selectedJustificatif.value)
} else {
emit('create', data, selectedJustificatif.value)
}
isDrawerOpen.value = false
}
const onDelete = () => {
if (!editingItem.value) return
const ok = window.confirm('Supprimer cette formation ?')
if (!ok) return
emit('delete', editingItem.value.id)
isDrawerOpen.value = false
}
</script>

View File

@@ -26,19 +26,7 @@
@change="onBulkValidationChange"
/>
</span>
<span v-else-if="isSiteManager" class="inline-flex items-center gap-2">
<span>Site</span>
<input
ref="bulkSiteValidationInput"
:checked="isBulkSiteValidationChecked"
type="checkbox"
class="h-4 w-4"
:class="canBulkToggleSiteValidation ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'"
:disabled="!canBulkToggleSiteValidation"
@change="onBulkSiteValidationChange"
/>
</span>
<span v-else>Site <Icon name="mdi:check-bold" class="ml-1"/></span>
<span v-else-if="!isSiteManager">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>
@@ -69,19 +57,39 @@
</p>
</div>
<div class="pl-2 min-w-0 self-stretch flex flex-col gap-1 justify-between py-0.5">
<p
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate"
:class="getRowAbsenceLabel(employee.id) ? 'text-white' : 'invisible'"
:title="getRowAbsenceLabel(employee.id) || ''"
:style="getRowAbsenceStyle(employee.id)"
>
{{ getRowAbsenceLabel(employee.id) || '—' }}
</p>
<div class="flex flex-col gap-1 min-w-0">
<p
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate"
:class="getRowAbsenceLabel(employee.id) ? 'text-white' : 'invisible'"
:title="getRowAbsenceLabel(employee.id) || ''"
:style="getRowAbsenceStyle(employee.id)"
>
{{ getRowAbsenceLabel(employee.id) || '—' }}
</p>
<p
v-if="isHoliday"
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate text-sky-900 inline-flex items-center gap-1"
style="background-color: #b3e5fc"
:title="holidayLabel || 'Férié'"
>
<Icon name="mdi:calendar-star" size="14" class="shrink-0"/>
<span class="truncate">{{ holidayLabel || 'Férié' }}</span>
</p>
<p
v-if="hasRowFormation(employee.id)"
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate text-white bg-indigo-500 inline-flex items-center gap-1"
:title="getRowFormationLabel(employee.id)"
>
<Icon name="mdi:school-outline" size="14" class="shrink-0"/>
<span class="truncate">{{ getRowFormationLabel(employee.id) }}</span>
</p>
</div>
<button
v-if="!hasRowFormation(employee.id) && !isHoliday"
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)"
:class="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
:disabled="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
@click="onAbsenceClick(employee.id)"
>
Modifier
@@ -170,16 +178,8 @@
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
/>
</div>
<div v-else class="text-right p-5">
<input
v-if="isSiteManager"
:checked="rows[employee.id]?.isSiteValid ?? false"
type="checkbox"
class="h-4 w-4 cursor-pointer"
:disabled="(!canToggleSiteValidation(employee.id) && !canCreateSiteValidationRowFromAbsence(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>
<div v-else-if="!isSiteManager" class="text-right p-5">
<span v-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">
@@ -207,6 +207,7 @@ const props = defineProps<{
isSiteManager: boolean
dayGridCols: string
isHoliday: boolean
holidayLabel: string
contractLabel: (employee: Employee) => string
isTimeTracking: (employee: Employee) => boolean
isPresenceTracking: (employee: Employee) => boolean
@@ -231,6 +232,8 @@ const props = defineProps<{
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number }
getRowAbsenceLabel: (employeeId: number) => string
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
hasRowFormation: (employeeId: number) => boolean
getRowFormationLabel: (employeeId: number) => string
getRowUpdatedAt: (employeeId: number) => string
getPresenceDayValue: (employeeId: number) => string
onAbsenceClick: (employeeId: number) => void

View File

@@ -71,7 +71,7 @@ export const useDriverHoursPage = () => {
const dayGridCols = computed(() => {
const metricCol = '0.4fr'
const validationCols = isAdmin.value ? `${metricCol}` : `${metricCol} ${metricCol}`
const validationCols = isAdmin.value || isSiteManager.value ? `${metricCol}` : `${metricCol} ${metricCol}`
return `1.2fr 0.6fr 0.8fr 0.8fr 0.8fr ${metricCol} ${metricCol} ${metricCol} ${metricCol} ${metricCol} ${validationCols}`
})
@@ -381,7 +381,6 @@ export const useDriverHoursPage = () => {
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.'
@@ -941,6 +940,7 @@ export const useDriverHoursPage = () => {
saveButtonClass,
formattedSelectedDate,
isSelectedDateHoliday,
selectedHolidayLabel,
weekDayHeaders,
shortcutButtonClass,
weekShortcutButtonClass,

View File

@@ -6,7 +6,7 @@ export const useEmployeeDetailPage = () => {
const route = useRoute()
const employee = ref<Employee | null>(null)
const isLoading = ref(false)
const activeTab = ref<'contract' | 'leave' | 'rtt' | 'mileage' | 'bonus' | 'observation'>('contract')
const activeTab = ref<'contract' | 'leave' | 'rtt' | 'mileage' | 'formation' | 'bonus' | 'observation'>('contract')
const showLeaveTab = computed(() => employee.value?.currentContractNature !== 'INTERIM')
const showRttTab = computed(() => employee.value?.contract?.type !== CONTRACT_TYPES.FORFAIT)
@@ -39,6 +39,7 @@ export const useEmployeeDetailPage = () => {
leave.resetLoaded()
rtt.resetLoaded()
mileage.resetLoaded()
formation.resetLoaded()
bonus.resetLoaded()
observation.resetLoaded()
@@ -48,6 +49,8 @@ export const useEmployeeDetailPage = () => {
await rtt.loadRttData()
} else if (activeTab.value === 'mileage') {
await mileage.loadMileageData()
} else if (activeTab.value === 'formation') {
await formation.loadFormationData()
} else if (activeTab.value === 'bonus') {
await bonus.loadBonusData()
} else if (activeTab.value === 'observation') {
@@ -62,6 +65,7 @@ export const useEmployeeDetailPage = () => {
const leave = useEmployeeLeave(employee, loadEmployee)
const rtt = useEmployeeRtt(employee, loadEmployee)
const mileage = useEmployeeMileage(employee, loadEmployee)
const formation = useEmployeeFormation(employee, loadEmployee)
const bonus = useEmployeeBonus(employee, loadEmployee)
const observation = useEmployeeObservation(employee, loadEmployee)
@@ -72,6 +76,8 @@ export const useEmployeeDetailPage = () => {
rtt.loadRttData()
} else if (tab === 'mileage' && !mileage.mileageDataLoaded.value) {
mileage.loadMileageData()
} else if (tab === 'formation' && !formation.formationDataLoaded.value) {
formation.loadFormationData()
} else if (tab === 'bonus' && !bonus.bonusDataLoaded.value) {
bonus.loadBonusData()
} else if (tab === 'observation' && !observation.observationDataLoaded.value) {
@@ -95,6 +101,7 @@ export const useEmployeeDetailPage = () => {
...leave,
...rtt,
...mileage,
...formation,
...bonus,
...observation
}

View File

@@ -0,0 +1,73 @@
import type { Ref } from 'vue'
import type { Formation } from '~/services/dto/formation'
import type { Employee } from '~/services/dto/employee'
import {
listFormations,
createFormation,
updateFormation,
deleteFormation,
uploadFormationJustificatif
} from '~/services/formations'
export const useEmployeeFormation = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
const config = useRuntimeConfig()
const apiBase = (config.public.apiBase as string) ?? '/api'
const formations = ref<Formation[]>([])
const isFormationLoading = ref(false)
const formationDataLoaded = ref(false)
const loadFormationData = async () => {
if (!employee.value || isFormationLoading.value) return
isFormationLoading.value = true
try {
formations.value = await listFormations(employee.value.id)
formationDataLoaded.value = true
} finally {
isFormationLoading.value = false
}
}
const resetLoaded = () => {
formationDataLoaded.value = false
}
const submitCreateFormation = async (data: { startDate: string; endDate: string; comment?: string }, justificatifFile?: File) => {
if (!employee.value) return
const result = await createFormation({
employeeId: employee.value.id,
startDate: data.startDate,
endDate: data.endDate,
comment: data.comment
})
if (result?.id && justificatifFile) {
await uploadFormationJustificatif(apiBase, result.id, justificatifFile)
}
await reloadEmployee()
}
const submitUpdateFormation = async (id: number, data: { startDate: string; endDate: string; comment?: string }, justificatifFile?: File) => {
await updateFormation(id, data)
if (justificatifFile) {
await uploadFormationJustificatif(apiBase, id, justificatifFile)
}
await reloadEmployee()
}
const submitDeleteFormation = async (id: number) => {
await deleteFormation(id)
await reloadEmployee()
}
return {
formations,
isFormationLoading,
formationDataLoaded,
formationApiBase: apiBase,
loadFormationData,
resetLoaded,
submitCreateFormation,
submitUpdateFormation,
submitDeleteFormation
}
}

View File

@@ -73,7 +73,7 @@ export const useHoursPage = () => {
const dayGridCols = computed(() => {
const metricCol = '0.4fr'
const validationCols = isAdmin.value ? `${metricCol}` : `${metricCol} ${metricCol}`
const validationCols = isAdmin.value || isSiteManager.value ? `${metricCol}` : `${metricCol} ${metricCol}`
return `1.2fr 0.6fr repeat(6, 0.8fr) ${metricCol} ${metricCol} ${metricCol} ${validationCols}`
})
@@ -458,7 +458,6 @@ export const useHoursPage = () => {
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.'
@@ -476,6 +475,14 @@ export const useHoursPage = () => {
return { backgroundColor: dayRow.absenceColor || '#dc2626' }
}
const hasRowFormation = (employeeId: number): boolean => {
return dayContextByEmployeeId.value.get(employeeId)?.hasFormation === true
}
const getRowFormationLabel = (employeeId: number): string => {
return dayContextByEmployeeId.value.get(employeeId)?.formationLabel ?? ''
}
const getRowUpdatedAt = (employeeId: number): string => {
const raw = rows.value[employeeId]?.updatedAt
if (!raw) return ''
@@ -1119,6 +1126,7 @@ export const useHoursPage = () => {
saveButtonClass,
formattedSelectedDate,
isSelectedDateHoliday,
selectedHolidayLabel,
weekDayHeaders,
shortcutButtonClass,
weekShortcutButtonClass,
@@ -1154,6 +1162,8 @@ export const useHoursPage = () => {
getRowMetrics,
getRowAbsenceLabel,
getRowAbsenceStyle,
hasRowFormation,
getRowFormationLabel,
getRowUpdatedAt,
getPresenceDayValue,
openAbsenceDrawer,

View File

@@ -56,6 +56,7 @@ export const documentationSections: DocSection[] = [
{ type: 'list', content: 'Matin : heure de début et heure de fin\nAprès-midi : heure de début et heure de fin\nSoir : heure de début et heure de fin' },
{ type: 'paragraph', content: 'Le sélecteur de temps fonctionne par tranches de 15 minutes (00, 15, 30, 45). La saisie libre est possible mais sera corrigée automatiquement.' },
{ type: 'note', content: 'Les calculs sont mis à jour automatiquement : heures de jour (06:0021:00), heures de nuit (00:0006:00 et 21:0024:00) et total.' },
{ type: 'note', content: 'Jours fériés : le nom du férié apparaît en badge bleu dans la colonne Absence. La saisie d\'heures (ou de jours de présence) reste autorisée — elle est même nécessaire pour ne pas être en déficit sur la semaine concernée. La création d\'une absence sur un férié reste bloquée.' },
],
},
{
@@ -503,6 +504,17 @@ export const documentationSections: DocSection[] = [
{ type: 'note', content: 'Au moins un des deux champs (kilomètres ou montant) doit être supérieur à 0. Un seul enregistrement par mois par employé.' },
],
},
{
id: 'formation',
title: 'Onglet Formation',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'L\'onglet Formation sur la fiche employé permet de tracer les formations suivies par le salarié.' },
{ type: 'list', content: 'Date de début : obligatoire\nDate de fin : obligatoire (doit être postérieure ou égale à la date de début)\nJustificatif PDF : optionnel\nCommentaire : optionnel' },
{ type: 'note', content: 'Les formations sont triées par date de début décroissante. Cliquer sur une ligne permet de la modifier ou la supprimer.' },
{ type: 'paragraph', content: 'Les formations sont également affichées en consultation sur l\'écran des heures (pastille indigo "Formation" dans la colonne Absence, sans bouton Modifier) et dans le calendrier (cellule "F" indigo ou icône école si couplée à une absence, cellule non cliquable). La modification et la suppression d\'une formation se font exclusivement depuis cet onglet.' },
],
},
{
id: 'primes',
title: 'Onglet Prime',

View File

@@ -42,6 +42,11 @@
"update": "Impossible de mettre à jour le frais kilométrique.",
"delete": "Impossible de supprimer le frais kilométrique."
},
"formation": {
"create": "Impossible de créer la formation.",
"update": "Impossible de mettre à jour la formation.",
"delete": "Impossible de supprimer la formation."
},
"bonus": {
"create": "Impossible de créer la prime.",
"update": "Impossible de mettre à jour la prime.",
@@ -88,6 +93,11 @@
"update": "Frais kilométrique mis à jour.",
"delete": "Frais kilométrique supprimé."
},
"formation": {
"create": "Formation créée.",
"update": "Formation mise à jour.",
"delete": "Formation supprimée."
},
"bonus": {
"create": "Prime créée.",
"update": "Prime mise à jour.",

View File

@@ -49,6 +49,10 @@
<div :style="{ backgroundColor: type.color }" class="h-4 w-4 rounded"></div>
<p>{{ type.label }}</p>
</div>
<div class="flex items-center gap-2">
<div class="h-4 w-4 rounded bg-indigo-500"></div>
<p>FORMATION</p>
</div>
</div>
</div>
@@ -99,6 +103,8 @@ import {HALF_DAYS} from '~/services/dto/half-day'
import {listEmployees, updateEmployeeOrder} from '~/services/employees'
import {listAbsenceTypes} from '~/services/absence-types'
import {createAbsence, deleteAbsence, listAbsences, updateAbsence} from '~/services/absences'
import {listFormationsByDateRange} from '~/services/formations'
import type {Formation} from '~/services/dto/formation'
import {listPublicHolidays} from '~/services/public-holidays'
import {getDaysInMonth, normalizeDate, parseYmd, toYmd} from '~/utils/date'
import {compareEmployeesInSite, sortEmployeesBySiteAndOrder} from '~/utils/employee'
@@ -163,6 +169,7 @@ const visibleEmployees = computed(() => {
// Données de référence et absences du mois affiché.
const absenceTypes = ref<AbsenceType[]>([])
const absences = ref<Absence[]>([])
const formations = ref<Formation[]>([])
const publicHolidays = ref<Record<string, string>>({})
// États UI.
@@ -384,12 +391,18 @@ const loadAbsences = async () => {
})
}
const loadFormations = async () => {
const monthStart = toYmd(selectedYear.value, selectedMonth.value, 1)
const monthEnd = toYmd(selectedYear.value, selectedMonth.value + 1, 0)
formations.value = await listFormationsByDateRange(monthStart, monthEnd)
}
onMounted(async () => {
await Promise.all([loadEmployees(), loadAbsenceTypes(), loadPublicHolidays(), loadAbsences()])
await Promise.all([loadEmployees(), loadAbsenceTypes(), loadPublicHolidays(), loadAbsences(), loadFormations()])
})
watch([selectedMonth, selectedYear, selectedSiteIds], async () => {
await loadAbsences()
await Promise.all([loadAbsences(), loadFormations()])
})
watch(selectedYear, async () => {
@@ -441,6 +454,42 @@ const cellAbsenceMap = computed(() => {
return map
})
// Indexation des formations par cellule pour un lookup O(1).
const cellFormationMap = computed(() => {
const set = new Set<string>()
const monthStart = monthStartDate.value
const monthEnd = monthEndDate.value
for (const formation of formations.value) {
const employeeId = formation.employee?.id
if (!employeeId) continue
const startDate = normalizeDate(formation.startDate)
const endDate = normalizeDate(formation.endDate)
const start = parseYmd(startDate)
const end = parseYmd(endDate)
if (!start || !end) continue
const rangeStart = start < monthStart ? monthStart : start
const rangeEnd = end > monthEnd ? monthEnd : end
if (rangeEnd < rangeStart) continue
for (
let currentDate = new Date(rangeStart.getTime());
currentDate <= rangeEnd;
currentDate.setDate(currentDate.getDate() + 1)
) {
const dateKey = toYmd(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate())
set.add(`${employeeId}-${dateKey}`)
}
}
return set
})
const hasFormationOn = (employeeId: number, date: string): boolean => {
return cellFormationMap.value.has(`${employeeId}-${date}`)
}
// Jours fériés (interdit pour la création).
const isHolidayDate = (date: string) => {
return Boolean(publicHolidays.value[date])
@@ -457,7 +506,16 @@ const getCellAbsence = (employeeId: number, date: string) => {
}
}
const absence = cellAbsenceMap.value.get(`${employeeId}-${date}`)
if (absence) return absence
if (absence) return { ...absence, hasFormation: hasFormationOn(employeeId, date) }
if (hasFormationOn(employeeId, date)) {
return {
id: 0,
code: 'F',
color: '#6366f1',
textColor: '#fff',
hasFormation: true
}
}
return null
}

View File

@@ -43,6 +43,7 @@
:is-site-manager="isSiteManager"
:day-grid-cols="dayGridCols"
:is-holiday="isSelectedDateHoliday"
:holiday-label="selectedHolidayLabel"
:contract-label="contractLabel"
:is-row-locked="isRowLocked"
:has-contract-at-selected-date="hasContractAtSelectedDate"
@@ -174,6 +175,7 @@ const {
closeAbsenceDrawer,
formatMinutes,
isSelectedDateHoliday,
selectedHolidayLabel,
handleSave
} = useDriverHoursPage()

View File

@@ -74,6 +74,16 @@
<Icon name="mdi:account-cash-outline" size="24" class="align-self"/>
Frais
</button>
<button
class="pb-2 border-b-2 flex items-center gap-3"
:class="activeTab === 'formation'
? 'border-primary-500 text-primary-500'
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
@click="activeTab = 'formation'"
>
<Icon name="mdi:school-outline" size="24" class="align-self"/>
Formation
</button>
<button
class="pb-2 border-b-2 flex items-center gap-3"
:class="activeTab === 'bonus'
@@ -171,6 +181,20 @@
@delete="submitDeleteMileage"
/>
</div>
<div v-else-if="activeTab === 'formation'" class="h-full">
<div v-if="isFormationLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Chargement...
</div>
<EmployeesFormationTab
v-else
class="h-full"
:formations="formations"
:api-base="formationApiBase"
@create="submitCreateFormation"
@update="submitUpdateFormation"
@delete="submitDeleteFormation"
/>
</div>
<div v-else-if="activeTab === 'bonus'" class="h-full">
<div v-if="isBonusLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Chargement...
@@ -275,6 +299,12 @@ const {
submitCreateMileage,
submitUpdateMileage,
submitDeleteMileage,
formations,
isFormationLoading,
formationApiBase,
submitCreateFormation,
submitUpdateFormation,
submitDeleteFormation,
bonuses,
isBonusLoading,
submitCreateBonus,

View File

@@ -43,6 +43,7 @@
:is-site-manager="isSiteManager"
:day-grid-cols="dayGridCols"
:is-holiday="isSelectedDateHoliday"
:holiday-label="selectedHolidayLabel"
:contract-label="contractLabel"
:is-time-tracking="isTimeTracking"
:is-presence-tracking="isPresenceTracking"
@@ -67,6 +68,8 @@
:get-row-metrics="getRowMetrics"
:get-row-absence-label="getRowAbsenceLabel"
:get-row-absence-style="getRowAbsenceStyle"
:has-row-formation="hasRowFormation"
:get-row-formation-label="getRowFormationLabel"
:get-row-updated-at="getRowUpdatedAt"
:get-presence-day-value="getPresenceDayValue"
:on-absence-click="openAbsenceDrawer"
@@ -139,6 +142,7 @@ const {
isSubmitting,
dayGridCols,
isSelectedDateHoliday,
selectedHolidayLabel,
weekGridCols,
saveButtonClass,
formattedSelectedDate,
@@ -177,6 +181,8 @@ const {
getRowMetrics,
getRowAbsenceLabel,
getRowAbsenceStyle,
hasRowFormation,
getRowFormationLabel,
getRowUpdatedAt,
getPresenceDayValue,
openAbsenceDrawer,

View File

@@ -0,0 +1,12 @@
import type { Employee } from './employee'
export type Formation = {
id: number
startDate: string
endDate: string
comment: string | null
justificatifPath: string | null
justificatifName: string | null
createdAt: string
employee?: Employee
}

View File

@@ -106,6 +106,8 @@ export type WorkHourDayContextRow = {
creditedMinutes: number
creditedPresenceUnits: number
isDriverContract?: boolean
hasFormation?: boolean
formationLabel?: string | null
}
export type WorkHourDayContext = {

View File

@@ -0,0 +1,82 @@
import { $fetch } from 'ofetch'
import type { Formation } from './dto/formation'
import { extractItems } from '~/utils/api'
export const listFormations = async (employeeId: number) => {
const api = useApi()
const data = await api.get<Formation[] | { 'hydra:member'?: Formation[] }>(
'/formations',
{ employee: `/api/employees/${employeeId}` },
{ toast: false }
)
return extractItems<Formation>(data)
}
export const listFormationsByDateRange = async (from: string, to: string) => {
const api = useApi()
const data = await api.get<Formation[] | { 'hydra:member'?: Formation[] }>(
'/formations',
{
'startDate[before]': to,
'endDate[after]': from
},
{ toast: false }
)
return extractItems<Formation>(data)
}
export const createFormation = async (data: {
employeeId: number
startDate: string
endDate: string
comment?: string
}) => {
const api = useApi()
return api.post<Formation>('/formations', {
employee: `/api/employees/${data.employeeId}`,
startDate: data.startDate,
endDate: data.endDate,
comment: data.comment
}, {
toastSuccessKey: 'success.formation.create',
toastErrorKey: 'errors.formation.create'
})
}
export const updateFormation = async (id: number, data: {
startDate: string
endDate: string
comment?: string
}) => {
const api = useApi()
return api.patch<Formation>(`/formations/${id}`, {
startDate: data.startDate,
endDate: data.endDate,
comment: data.comment
}, {
toastSuccessKey: 'success.formation.update',
toastErrorKey: 'errors.formation.update'
})
}
export const deleteFormation = async (id: number) => {
const api = useApi()
return api.delete(`/formations/${id}`, {}, {
toastSuccessKey: 'success.formation.delete',
toastErrorKey: 'errors.formation.delete'
})
}
export const uploadFormationJustificatif = async (baseURL: string, id: number, file: File) => {
const formData = new FormData()
formData.append('file', file)
return $fetch(`${baseURL}/formations/${id}/justificatif`, {
method: 'POST',
body: formData,
credentials: 'include'
})
}
export const getFormationJustificatifUrl = (baseURL: string, id: number): string => {
return `${baseURL}/formations/${id}/justificatif`
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260413120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create formations table';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE formations (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, employee_id INT NOT NULL, start_date DATE NOT NULL, end_date DATE NOT NULL, comment TEXT DEFAULT NULL, justificatif_path VARCHAR(255) DEFAULT NULL, justificatif_name VARCHAR(255) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_FORMATION_EMPLOYEE ON formations (employee_id)');
$this->addSql('ALTER TABLE formations ADD CONSTRAINT FK_FORMATION_EMPLOYEE FOREIGN KEY (employee_id) REFERENCES employees (id) NOT DEFERRABLE');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE formations DROP CONSTRAINT FK_FORMATION_EMPLOYEE');
$this->addSql('DROP TABLE formations');
}
}

View File

@@ -17,8 +17,16 @@ final class DayContextRow
public int $creditedMinutes = 0,
public float $creditedPresenceUnits = 0.0,
public bool $isDriverContract = false,
public bool $hasFormation = false,
public ?string $formationLabel = null,
) {}
public function setFormation(string $label): void
{
$this->hasFormation = true;
$this->formationLabel = $label;
}
public function addAbsence(
?string $label,
?string $color,
@@ -64,7 +72,10 @@ final class DayContextRow
* absentMorning:bool,
* absentAfternoon:bool,
* creditedMinutes:int,
* creditedPresenceUnits:float
* creditedPresenceUnits:float,
* isDriverContract:bool,
* hasFormation:bool,
* formationLabel:?string
* }
*/
public function toArray(): array
@@ -80,6 +91,8 @@ final class DayContextRow
'creditedMinutes' => $this->creditedMinutes,
'creditedPresenceUnits' => $this->creditedPresenceUnits,
'isDriverContract' => $this->isDriverContract,
'hasFormation' => $this->hasFormation,
'formationLabel' => $this->formationLabel,
];
}

192
src/Entity/Formation.php Normal file
View File

@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
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\Repository\FormationRepository;
use App\State\FormationDeleteProcessor;
use App\State\FormationJustificatifDownloadProvider;
use App\State\FormationJustificatifUploadProcessor;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Get(
security: "is_granted('ROLE_ADMIN')"
),
new GetCollection(
security: "is_granted('ROLE_ADMIN')"
),
new Post(
security: "is_granted('ROLE_ADMIN')"
),
new Patch(
security: "is_granted('ROLE_ADMIN')"
),
new Delete(
security: "is_granted('ROLE_ADMIN')",
processor: FormationDeleteProcessor::class,
),
new Post(
uriTemplate: '/formations/{id}/justificatif',
security: "is_granted('ROLE_ADMIN')",
deserialize: false,
processor: FormationJustificatifUploadProcessor::class,
),
new Get(
uriTemplate: '/formations/{id}/justificatif',
security: "is_granted('ROLE_ADMIN')",
provider: FormationJustificatifDownloadProvider::class,
),
],
normalizationContext: [
'groups' => ['formation:read', 'employee:read'],
'datetime_format' => 'Y-m-d',
],
denormalizationContext: [
'groups' => ['formation:write'],
'datetime_format' => 'Y-m-d',
],
order: ['startDate' => 'DESC'],
paginationEnabled: false,
)]
#[ApiFilter(DateFilter::class, properties: ['startDate', 'endDate'])]
#[ApiFilter(SearchFilter::class, properties: ['employee' => 'exact'])]
#[ORM\Entity(repositoryClass: FormationRepository::class)]
#[ORM\Table(name: 'formations')]
class Formation
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
#[Groups(['formation:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Employee::class)]
#[ORM\JoinColumn(nullable: false)]
#[Groups(['formation:read', 'formation:write'])]
private ?Employee $employee = null;
#[ORM\Column(type: 'date_immutable')]
#[Groups(['formation:read', 'formation:write'])]
private ?DateTimeImmutable $startDate = null;
#[ORM\Column(type: 'date_immutable')]
#[Groups(['formation:read', 'formation:write'])]
private ?DateTimeImmutable $endDate = null;
#[ORM\Column(type: 'text', nullable: true)]
#[Groups(['formation:read', 'formation:write'])]
private ?string $comment = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
#[Groups(['formation:read'])]
private ?string $justificatifPath = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
#[Groups(['formation:read'])]
private ?string $justificatifName = null;
#[ORM\Column(type: 'datetime_immutable')]
#[Groups(['formation:read'])]
private DateTimeImmutable $createdAt;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
}
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 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 getComment(): ?string
{
return $this->comment;
}
public function setComment(?string $comment): self
{
$this->comment = $comment;
return $this;
}
public function getJustificatifPath(): ?string
{
return $this->justificatifPath;
}
public function setJustificatifPath(?string $justificatifPath): self
{
$this->justificatifPath = $justificatifPath;
return $this;
}
public function getJustificatifName(): ?string
{
return $this->justificatifName;
}
public function setJustificatifName(?string $justificatifName): self
{
$this->justificatifName = $justificatifName;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Repository\Contract;
use App\Entity\Employee;
use App\Entity\Formation;
use DateTimeImmutable;
interface FormationReadRepositoryInterface
{
/**
* @param list<Employee> $employees
*
* @return list<Formation>
*/
public function findByDateAndEmployees(DateTimeImmutable $date, array $employees): array;
/**
* @param list<Employee> $employees
*
* @return list<Formation>
*/
public function findByDateRangeAndEmployees(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array;
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Employee;
use App\Entity\Formation;
use App\Repository\Contract\FormationReadRepositoryInterface;
use DateTimeImmutable;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Formation>
*/
final class FormationRepository extends ServiceEntityRepository implements FormationReadRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Formation::class);
}
/**
* @param list<Employee> $employees
*
* @return list<Formation>
*/
public function findByDateAndEmployees(DateTimeImmutable $date, array $employees): array
{
if ([] === $employees) {
return [];
}
$qb = $this->createQueryBuilder('f')
->leftJoin('f.employee', 'e')
->addSelect('e')
->andWhere('f.startDate <= :date')
->andWhere('f.endDate >= :date')
->andWhere('f.employee IN (:employees)')
->setParameter('date', $date)
->setParameter('employees', $employees)
;
// @var list<Formation>
return $qb->getQuery()->getResult();
}
/**
* @param list<Employee> $employees
*
* @return list<Formation>
*/
public function findByDateRangeAndEmployees(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array
{
if ([] === $employees) {
return [];
}
$qb = $this->createQueryBuilder('f')
->leftJoin('f.employee', 'e')
->addSelect('e')
->andWhere('f.startDate <= :to')
->andWhere('f.endDate >= :from')
->andWhere('f.employee IN (:employees)')
->setParameter('from', $from)
->setParameter('to', $to)
->setParameter('employees', $employees)
;
// @var list<Formation>
return $qb->getQuery()->getResult();
}
}

View File

@@ -17,11 +17,22 @@ use Throwable;
final readonly class PublicHolidayService implements PublicHolidayServiceInterface
{
/**
* @var list<string>
*/
private array $excludedLabels;
public function __construct(
private HttpClientInterface $client,
private string $holidayUrl,
private CacheInterface $cache,
) {}
string $excludedLabels = '',
) {
$this->excludedLabels = array_values(array_filter(
array_map('trim', explode(',', $excludedLabels)),
static fn (string $label): bool => '' !== $label,
));
}
/**
* @throws TransportExceptionInterface
@@ -35,7 +46,7 @@ final readonly class PublicHolidayService implements PublicHolidayServiceInterfa
$zone = strtolower(trim($zone));
$key = "public_holidays_{$zone}_all";
return $this->cache->get($key, function (ItemInterface $item) use ($zone): array {
$holidays = $this->cache->get($key, function (ItemInterface $item) use ($zone): array {
$item->expiresAfter(30 * 86400);
$url = $this->holidayUrl."{$zone}.json";
@@ -56,6 +67,8 @@ final readonly class PublicHolidayService implements PublicHolidayServiceInterfa
return json_decode($response->getContent(), true);
});
return $this->applyExclusions($holidays);
}
/**
@@ -70,7 +83,7 @@ final readonly class PublicHolidayService implements PublicHolidayServiceInterfa
$years = trim($years);
$key = "public_holidays_{$zone}_{$years}";
return $this->cache->get($key, function (ItemInterface $item) use ($zone, $years): array {
$holidays = $this->cache->get($key, function (ItemInterface $item) use ($zone, $years): array {
$item->expiresAfter(30 * 86400);
$url = $this->holidayUrl."{$zone}/{$years}.json";
@@ -88,5 +101,24 @@ final readonly class PublicHolidayService implements PublicHolidayServiceInterfa
return json_decode($response->getContent(), true);
});
return $this->applyExclusions($holidays);
}
/**
* @param array<string, string> $holidays
*
* @return array<string, string>
*/
private function applyExclusions(array $holidays): array
{
if ([] === $this->excludedLabels) {
return $holidays;
}
return array_filter(
$holidays,
fn (string $label): bool => !in_array($label, $this->excludedLabels, true),
);
}
}

View File

@@ -6,9 +6,11 @@ namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Formation;
use App\Enum\ContractNature;
use App\Enum\HalfDay;
use App\Repository\AbsenceRepository;
use App\Repository\Contract\FormationReadRepositoryInterface;
use App\Repository\EmployeeRepository;
use App\Service\PublicHolidayServiceInterface;
use DateInterval;
@@ -30,6 +32,7 @@ class AbsencePrintProvider implements ProviderInterface
private readonly RequestStack $requestStack,
private EmployeeRepository $employeeRepository,
private AbsenceRepository $absenceRepository,
private FormationReadRepositoryInterface $formationRepository,
private PublicHolidayServiceInterface $publicHolidayService,
) {}
@@ -58,24 +61,27 @@ class AbsencePrintProvider implements ProviderInterface
$workContractIds = $this->parseIds($request->query->get('workContracts'));
$contractNatures = $this->parseContractNatures($request->query->get('contractNatures'));
$employees = $this->loadEmployees($siteIds, $contractNatures, $workContractIds);
$absences = $this->loadAbsences($fromDate, $toDate, $employees);
$employees = $this->loadEmployees($siteIds, $contractNatures, $workContractIds);
$absences = $this->loadAbsences($fromDate, $toDate, $employees);
$formations = $this->formationRepository->findByDateRangeAndEmployees($fromDate, $toDate, $employees);
$days = $this->buildDays($fromDate, $toDate);
$absenceMap = $this->buildAbsenceMap($absences, $fromDate, $toDate);
$holidayMap = $this->buildHolidayMap($fromDate, $toDate);
$days = $this->buildDays($fromDate, $toDate);
$absenceMap = $this->buildAbsenceMap($absences, $fromDate, $toDate);
$formationMap = $this->buildFormationMap($formations, $fromDate, $toDate);
$holidayMap = $this->buildHolidayMap($fromDate, $toDate);
$options = new Options();
$options->set('isRemoteEnabled', true);
$dompdf = new Dompdf($options);
$html = $this->twig->render('absence/print.html.twig', [
'from' => $fromDate,
'to' => $toDate,
'days' => $days,
'employees' => $employees,
'absenceMap' => $absenceMap,
'holidayMap' => $holidayMap,
'from' => $fromDate,
'to' => $toDate,
'days' => $days,
'employees' => $employees,
'absenceMap' => $absenceMap,
'formationMap' => $formationMap,
'holidayMap' => $holidayMap,
]);
$dompdf->loadHtml($html);
@@ -203,6 +209,37 @@ class AbsencePrintProvider implements ProviderInterface
return $map;
}
/**
* @param list<Formation> $formations
*
* @return array<int, array<string, bool>>
*/
private function buildFormationMap(array $formations, DateTimeImmutable $from, DateTimeImmutable $to): array
{
$map = [];
foreach ($formations as $formation) {
$employeeId = $formation->getEmployee()?->getId();
if (!$employeeId) {
continue;
}
$formationStart = DateTimeImmutable::createFromInterface($formation->getStartDate());
$formationEnd = DateTimeImmutable::createFromInterface($formation->getEndDate());
$start = max($formationStart, $from);
$end = min($formationEnd, $to);
$current = $start;
while ($current <= $end) {
$map[$employeeId][$current->format('Y-m-d')] = true;
$current = $current->add(new DateInterval('P1D'));
}
}
return $map;
}
private function buildHolidayMap(DateTimeImmutable $from, DateTimeImmutable $to): array
{
$map = [];

View File

@@ -330,6 +330,13 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
return $this->resolveCurrentLeaveYear($today);
}
public function resolvePaidLeaveDays(Employee $employee, string $ruleCode, int $year): float
{
$balance = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $year);
return null !== $balance ? $balance->getPaidLeaveDays() : 0.0;
}
private function resolveEffectivePeriodStart(
Employee $employee,
DateTimeImmutable $from,
@@ -778,13 +785,6 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
return null !== $balance ? $balance->getFractionedDays() : 0.0;
}
private function resolvePaidLeaveDays(Employee $employee, string $ruleCode, int $year): float
{
$balance = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $year);
return null !== $balance ? $balance->getPaidLeaveDays() : 0.0;
}
private function resolveCurrentLeaveYear(DateTimeImmutable $today): int
{
$year = (int) $today->format('Y');

View File

@@ -294,17 +294,28 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
}
// Week spans two months — split proportionally by daily worked minutes
$monthMinutes = [];
$monthMinutes = [];
$monthWeekdays = [];
foreach ($detail->dailyMinutes as $date => $mins) {
$m = (int) new DateTimeImmutable($date)->format('n');
$monthMinutes[$m] = ($monthMinutes[$m] ?? 0) + $mins;
$isoDay = (int) new DateTimeImmutable($date)->format('N');
if ($isoDay < 6) {
$monthWeekdays[$m] = ($monthWeekdays[$m] ?? 0) + 1;
}
}
$totalWorked = array_sum($monthMinutes);
$totalWorked = array_sum($monthMinutes);
$totalWeekdays = array_sum($monthWeekdays);
foreach ([$startMonth, $endMonth] as $month) {
$portion = $monthMinutes[$month] ?? 0;
$ratio = $totalWorked > 0 ? $portion / $totalWorked : 0.0;
if ($totalWorked > 0) {
$ratio = ($monthMinutes[$month] ?? 0) / $totalWorked;
} elseif ($totalWeekdays > 0) {
$ratio = ($monthWeekdays[$month] ?? 0) / $totalWeekdays;
} else {
$ratio = 0.0;
}
$result[] = new EmployeeRttWeekSummary(
month: $month,

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Formation;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final readonly class FormationDeleteProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
#[Autowire('%kernel.project_dir%/var/uploads')]
private string $uploadDir,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): null
{
if (!$data instanceof Formation) {
return null;
}
$justificatifPath = $data->getJustificatifPath();
if (null !== $justificatifPath) {
$absolutePath = sprintf('%s/%s', $this->uploadDir, $justificatifPath);
if (file_exists($absolutePath)) {
unlink($absolutePath);
}
}
$this->entityManager->remove($data);
$this->entityManager->flush();
return null;
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Formation;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final readonly class FormationJustificatifDownloadProvider implements ProviderInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
#[Autowire('%kernel.project_dir%/var/uploads')]
private string $uploadDir,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): BinaryFileResponse
{
$formation = $this->entityManager->find(Formation::class, $uriVariables['id']);
if (null === $formation) {
throw new NotFoundHttpException('Formation not found.');
}
$justificatifPath = $formation->getJustificatifPath();
if (null === $justificatifPath) {
throw new NotFoundHttpException('No justificatif found for this formation.');
}
$absolutePath = sprintf('%s/%s', $this->uploadDir, $justificatifPath);
if (!file_exists($absolutePath)) {
throw new NotFoundHttpException('Justificatif file not found.');
}
$response = new BinaryFileResponse($absolutePath);
$disposition = HeaderUtils::makeDisposition(
HeaderUtils::DISPOSITION_ATTACHMENT,
$formation->getJustificatifName() ?? 'justificatif.pdf'
);
$response->headers->set('Content-Disposition', $disposition);
return $response;
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Formation;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Uid\Uuid;
final readonly class FormationJustificatifUploadProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private RequestStack $requestStack,
#[Autowire('%kernel.project_dir%/var/uploads')]
private string $uploadDir,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): JsonResponse
{
if (!$data instanceof Formation) {
throw new BadRequestHttpException('Invalid entity.');
}
$request = $this->requestStack->getCurrentRequest();
$file = $request?->files->get('file');
if (null === $file) {
throw new BadRequestHttpException('No file uploaded.');
}
if ('application/pdf' !== $file->getMimeType()) {
throw new BadRequestHttpException('Only PDF files are accepted.');
}
$startDate = $data->getStartDate();
$year = $startDate?->format('Y') ?? date('Y');
$monthNumber = $startDate?->format('m') ?? date('m');
$relativePath = sprintf('formations/%s/%s', $year, $monthNumber);
$absoluteDir = sprintf('%s/%s', $this->uploadDir, $relativePath);
if (!is_dir($absoluteDir)) {
mkdir($absoluteDir, 0o755, true);
}
$filename = Uuid::v4()->toRfc4122().'.pdf';
$fullRelative = sprintf('%s/%s', $relativePath, $filename);
$originalName = $file->getClientOriginalName();
$previousPath = $data->getJustificatifPath();
$file->move($absoluteDir, $filename);
$data->setJustificatifPath($fullRelative);
$data->setJustificatifName($originalName);
$this->entityManager->flush();
if (null !== $previousPath) {
$previousAbsolute = sprintf('%s/%s', $this->uploadDir, $previousPath);
if (file_exists($previousAbsolute)) {
unlink($previousAbsolute);
}
}
return new JsonResponse(['path' => $fullRelative, 'name' => $originalName], Response::HTTP_OK);
}
}

View File

@@ -104,6 +104,13 @@ class LeaveRecapPrintProvider implements ProviderInterface
if (null !== $yearSummary) {
if ($isForfait) {
$paidLeaveDays = $this->leaveSummaryProvider->resolvePaidLeaveDays($employee, $yearSummary['ruleCode'], $leaveYear);
if ($paidLeaveDays > 0.0) {
$recomputed = $this->leaveSummaryProvider->computeYearSummary($employee, $leaveYear, $paidLeaveDays);
if (null !== $recomputed) {
$yearSummary = $recomputed;
}
}
$cpN1Remaining = round($yearSummary['previousYearRemainingDays'], 2);
$cpN = (string) round($yearSummary['acquiredDays'], 2);
$acquiredSaturdays = '-';

View File

@@ -11,6 +11,7 @@ use App\Dto\WorkHours\DayContextRow;
use App\Entity\User;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
use App\Repository\Contract\FormationReadRepositoryInterface;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy;
@@ -27,6 +28,7 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface
private RequestStack $requestStack,
private EmployeeScopedRepositoryInterface $employeeRepository,
private AbsenceReadRepositoryInterface $absenceRepository,
private FormationReadRepositoryInterface $formationRepository,
private EmployeeContractResolver $contractResolver,
private AbsenceSegmentsResolver $absenceSegmentsResolver,
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
@@ -40,9 +42,10 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface
throw new AccessDeniedHttpException('Authentication required.');
}
$workDate = $this->resolveWorkDate();
$employees = $this->employeeRepository->findScoped($user);
$absences = $this->absenceRepository->findByDateAndEmployees($workDate, $employees);
$workDate = $this->resolveWorkDate();
$employees = $this->employeeRepository->findScoped($user);
$absences = $this->absenceRepository->findByDateAndEmployees($workDate, $employees);
$formations = $this->formationRepository->findByDateAndEmployees($workDate, $employees);
$rowsByEmployeeId = [];
foreach ($employees as $employee) {
@@ -87,6 +90,14 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface
);
}
foreach ($formations as $formation) {
$employeeId = $formation->getEmployee()?->getId();
if (!$employeeId || !isset($rowsByEmployeeId[$employeeId])) {
continue;
}
$rowsByEmployeeId[$employeeId]->setFormation('Formation');
}
$response = new WorkHourDayContext();
$response->workDate = $dateKey;
$response->rows = array_map(

View File

@@ -84,6 +84,11 @@
background: #b3e5fc;
}
.formation {
background: #6366f1;
color: #fff;
}
.body-cell {
height: 6mm;
padding: 0 !important;
@@ -239,11 +244,15 @@
{% for day in days %}
{% set isHoliday = holidayMap[day.date] ?? null %}
{% set info = absenceMap[employee.id][day.date] ?? null %}
{% set hasFormation = formationMap[employee.id][day.date] ?? false %}
{% set isFormationOnly = hasFormation and not info and not isHoliday %}
{% set isMonthEnd = (not loop.last) and (days[loop.index].date|date('n') != day.date|date('n')) %}
{% set isWeekend = day.date|date('N') in [6, 7] %}
<td class="col-day body-cell{% if isMonthEnd %} month-separator{% endif %}{% if isWeekend %} weekend{% endif %}{% if isHoliday %} holiday{% endif %}" style="width: {{ dayColWidthMm }}mm;{% if info and not isHoliday and not info.half %} background-color: {{ info.color }};{% endif %}">
<td class="col-day body-cell{% if isMonthEnd %} month-separator{% endif %}{% if isWeekend %} weekend{% endif %}{% if isHoliday %} holiday{% endif %}{% if isFormationOnly %} formation{% endif %}" style="width: {{ dayColWidthMm }}mm;{% if info and not isHoliday and not info.half %} background-color: {{ info.color }};{% endif %}">
{% if isHoliday %}
<span class="full-cell code">Férié</span>
{% elseif isFormationOnly %}
<span class="full-cell code">F</span>
{% elseif info %}
{% if info.half %}
<table class="half-table">
@@ -259,7 +268,7 @@
</tr>
</table>
{% else %}
<span class="full-cell code">{{ info.code }}</span>
<span class="full-cell code">{{ info.code }}{% if hasFormation %}*{% endif %}</span>
{% endif %}
{% endif %}
</td>

View File

@@ -13,6 +13,7 @@ use App\Entity\User;
use App\Enum\HalfDay;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
use App\Repository\Contract\FormationReadRepositoryInterface;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy;
@@ -34,14 +35,17 @@ final class WorkHourDayContextProviderTest extends TestCase
private Security $security;
private EmployeeScopedRepositoryInterface $employeeRepository;
private AbsenceReadRepositoryInterface $absenceRepository;
private FormationReadRepositoryInterface $formationRepository;
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();
$this->security = $this->createStub(Security::class);
$this->employeeRepository = $this->createStub(EmployeeScopedRepositoryInterface::class);
$this->absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
$this->formationRepository = $this->createStub(FormationReadRepositoryInterface::class);
$this->formationRepository->method('findByDateAndEmployees')->willReturn([]);
$this->requestStack = new RequestStack();
}
public function testThrowsWhenAnonymous(): void
@@ -53,6 +57,7 @@ final class WorkHourDayContextProviderTest extends TestCase
$this->requestStack,
$this->employeeRepository,
$this->absenceRepository,
$this->formationRepository,
$this->buildResolverStub(),
new AbsenceSegmentsResolver(),
new WorkedHoursCreditPolicy($this->buildResolverStub())
@@ -72,6 +77,7 @@ final class WorkHourDayContextProviderTest extends TestCase
$this->requestStack,
$this->employeeRepository,
$this->absenceRepository,
$this->formationRepository,
$this->buildResolverStub(),
new AbsenceSegmentsResolver(),
new WorkedHoursCreditPolicy($this->buildResolverStub())
@@ -97,6 +103,7 @@ final class WorkHourDayContextProviderTest extends TestCase
$this->requestStack,
$this->employeeRepository,
$this->absenceRepository,
$this->formationRepository,
$this->buildResolverStub(),
new AbsenceSegmentsResolver(),
new WorkedHoursCreditPolicy($this->buildResolverStub())