Compare commits

..

10 Commits

Author SHA1 Message Date
gitea-actions
02fc94fbed chore: bump version to v0.1.99
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 39s
2026-04-29 15:28:10 +00:00
eb5910dffe feat : surlignage des jours fériés sur la vue semaine des heures
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Quand un employé n'a pas d'absence sur un jour férié, la cellule prend le fond bleu clair (#b3e5fc) et affiche le nom du férié au survol — cohérent avec la vue jour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 17:27:46 +02:00
78f73ed2e9 feat : ajout des jours fériés sur l'export PDF des heures
Affiche désormais une ligne dédiée pour chaque jour férié (Lun-Ven) avec la mention "Férié : {nom}" et le total créditant les heures contractuelles, comme sur l'écran Heures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 17:21:59 +02:00
eacf52425a fix : récap salaire chauffeur, comptage des repas (déjeuner + dîner)
Un jour avec déjeuner ET dîner cochés ne comptait qu'1 repas (||) au lieu de 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 15:27:00 +02:00
gitea-actions
6f43c3356f chore: bump version to v0.1.98
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 44s
2026-04-29 09:43:56 +00:00
13eeeb9c86 feat : ajout colonne Cumul sur l'écran RTT (#18)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Affiche le solde RTT à la fin de chaque semaine (report N-1 + somme
totalMinutes des semaines − paiements des mois antérieurs). Permet la
comparaison ligne à ligne avec un suivi RH externe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

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

Reviewed-on: #18
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-29 09:43:46 +00:00
gitea-actions
973de2d094 chore: bump version to v0.1.97
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 56s
2026-04-27 13:02:01 +00:00
74c109713c fix : malio UI
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-27 15:01:51 +02:00
gitea-actions
06173e7225 chore: bump version to v0.1.96
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 2m59s
2026-04-27 12:08:31 +00:00
cc868a1e82 feat: ajout malio UI + décompte des jours de présence forfait (#17)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

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

Reviewed-on: #17
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-27 12:08:24 +00:00
33 changed files with 674 additions and 593 deletions

View File

@@ -61,6 +61,7 @@
- INTERIM: no overtime bonuses, no recovery time
- Driver contracts: RTT uses `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` instead of morning/afternoon/evening time ranges
- FORFAIT weekend/holiday bonus: each weekend or public holiday day worked gives bonus leave (full day if morning+afternoon, 0.5 if only one). Added to acquired days, no cap. PRESENCE mode only.
- **FORFAIT — jours de présence et N-1** : les congés posés et imputés sur le stock N-1 ne décrémentent **pas** les jours de présence affichés (`presenceDaysByMonth` et `presenceDaysToToday`). Implémenté dans `EmployeeLeaveSummaryProvider::computePresenceDaysByMonth` via un budget N-1 (= `previousYearTakenDays`) consommé chronologiquement avant comptage des absences. Pour les non-forfait, ce budget vaut toujours 0 → comportement inchangé.
## Récap. congés (écran)
- Accès via sidebar `Récap. congés`, conditionné au flag `User.hasLeaveRecapAccess` (défaut `false`) — activé au create/edit user. Le flag s'applique à tous les profils, y compris admin.

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.95'
app.version: '0.1.99'

View File

@@ -173,6 +173,7 @@ Documents complementaires:
- 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
- Écran Heures et Heures Conducteurs (vue semaine): la cellule du jour férié prend le fond `#b3e5fc` quand l'employé n'a pas d'absence ce jour-là, avec le nom du férié au survol (`title`). Si une absence est posée, la couleur de l'absence prime ; le `title` cumule les deux libellés (`Absence — Férié : Nom`).
- Règle courante:
- absences autorisées sur jour férié (création/édition depuis l'écran Heures et le Calendrier). Quand une absence est posée, le crédit virtuel férié est désactivé — c'est le `countAsWorkedHours` du type d'absence qui pilote
- saisie d'heures ou de jours de présence autorisée — les heures saisies comptent normalement dans le total hebdo et le calcul RTT
@@ -313,6 +314,7 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
- les heures payées sont soustraites du disponible RTT (`availableMinutes -= totalPaidMinutes`)
- affichage: 2 lignes par mois dans le tableau (25% et 50%)
- colonnes Total 25% et Total 50%: somme base + bonus de chaque tranche
- colonne Cumul (dernière colonne): solde RTT à la fin de chaque semaine = `report N-1 + somme totalMinutes des semaines jusqu'à celle-ci paiements RTT des mois antérieurs au mois de la semaine`. Le paiement d'un mois M n'est déduit qu'à partir des semaines du mois M+1 (cohérent avec la logique de la ligne "Report mois précédent"). Permet la comparaison ligne à ligne avec un suivi RH externe (Excel)
- ligne Report N-1 (carry rollover): affichée en juin uniquement si carry > 0
- ligne Report mois précédent: solde cumulé (carry N-1 + semaines antérieures paiements antérieurs), affichée à partir de juillet (masquée si nul)
- Reste = Report cumulé + Total du mois Payé du mois (balance courante en fin de mois)
@@ -335,7 +337,7 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
| Contrat | Contract.name |
| CP N-1 restant | CDI/CDD: acquis N-1 pris sur N-1. Forfait: report N-1 restant |
| Samedi restant | CDI/CDD: samedis acquis N-1 pris. Forfait: `-` |
| CP N | Forfait: jours acquis année civile. Non-forfait: en cours d'acquisition |
| CP N | Forfait: restant sur quota année civile (acquis pris depuis N, sans toucher au stock N-1). Non-forfait: en cours d'acquisition |
| RTT | Minutes disponibles (report N-1 + acquis N - payés). Format `X h Y m`. Forfait et INTERIM: `-` |
## 10bis) Écran Récap. congés (tableau)
@@ -378,7 +380,7 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
| Maladie - Nombre | Absence code 'M' ou 'AT' | Jours (demi-journées = 0.5) |
| Maladie - Date | Absence code 'M' ou 'AT' | Dates formatées dd/mm |
| CHAUFFEUR - PDJ | WorkHour.hasBreakfast | Comptage mois (chauffeurs uniquement) |
| CHAUFFEUR - REPAS | WorkHour.hasLunch + hasDinner | Comptage mois (chauffeurs uniquement) |
| CHAUFFEUR - REPAS | WorkHour.hasLunch + hasDinner | Somme sur le mois : +1 par déjeuner coché et +1 par dîner coché (un jour avec les deux compte 2 repas, chauffeurs uniquement) |
| CHAUFFEUR - NUITEE | WorkHour.hasOvernight | Comptage mois (chauffeurs uniquement) |
| CHAUFFEUR - samedi | WorkHour (samedi) | Samedis travaillés (chauffeurs uniquement) |
| Observations | — | Colonne vide pour saisie manuelle |
@@ -442,7 +444,8 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
- Accessible depuis la fiche employé (bouton imprimante à droite du nom)
- Ouvre un drawer pour choisir l'année (civile, Jan-Déc)
- Génère un PDF avec le détail jour par jour des heures de l'employé
- Seuls les jours avec heures saisies ou absence sont affichés
- Seuls les jours avec heures saisies, absence, week-end ou jour férié sont affichés
- Les jours fériés apparaissent toujours sur une ligne dédiée (fond bleu clair) avec la mention "Férié : {nom}" dans la colonne Absence (même si aucune saisie)
### Colonnes selon le mode de suivi
@@ -460,6 +463,7 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
- TIME non-chauffeur: somme des créneaux matin + après-midi + soir, plus minutes créditées des absences `countAsWorkedHours`
- Chauffeur: `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` + minutes créditées
- PRESENCE: 0.5 par demi-journée présente (matin/après-midi), max 1.0
- Jour férié Lun-Ven (hors Forfait, sans absence) : `total = max(saisie + crédit absence, référence contractuelle)` — même règle que l'écran Heures (cf. `HolidayVirtualHoursResolver`). Pour Forfait : pas de crédit virtuel, la ligne férié affiche juste l'éventuelle présence saisie.
### Nom du fichier

View File

@@ -44,7 +44,7 @@
class="text-left leading-4 rounded-md px-2 py-1"
:class="daily.hasAbsence ? 'text-white' : ''"
:style="getDailyCellStyle(daily)"
:title="daily.absenceLabel ?? ''"
:title="cellTitle(daily)"
>
<div>J {{ formatMinutes(daily.dayMinutes) }}</div>
<div>N {{ formatMinutes(daily.nightMinutes) }}</div>
@@ -93,12 +93,27 @@
import type { WeeklyWorkHourSummary } from '~/services/dto/work-hour'
import { contractNatureLabel } from '~/utils/contract'
const HOLIDAY_BG_COLOR = '#b3e5fc'
const getDailyCellStyle = (daily: {
hasAbsence?: boolean
absenceColor?: string | null
holidayLabel?: string | null
}) => {
if (!daily.hasAbsence) return undefined
return { backgroundColor: daily.absenceColor || '#dc2626' }
if (daily.hasAbsence) return { backgroundColor: daily.absenceColor || '#dc2626' }
if (daily.holidayLabel) return { backgroundColor: HOLIDAY_BG_COLOR }
return undefined
}
const cellTitle = (daily: {
hasAbsence?: boolean
absenceLabel?: string | null
holidayLabel?: string | null
}) => {
const parts: string[] = []
if (daily.absenceLabel) parts.push(daily.absenceLabel)
if (daily.holidayLabel) parts.push(`Férié : ${daily.holidayLabel}`)
return parts.join(' — ')
}
defineProps<{

View File

@@ -40,14 +40,15 @@
<table class="w-full table-fixed border-collapse text-[18px]">
<colgroup>
<col />
<col class="w-[11%]" />
<col class="w-[11%]" />
<col class="w-[11%]" />
<col class="w-[11%]" />
<col class="w-[11%]" />
<col class="w-[11%]" />
<col class="w-[11%]" />
<col class="w-[11%]" />
<col class="w-[10%]" />
<col class="w-[10%]" />
<col class="w-[10%]" />
<col class="w-[10%]" />
<col class="w-[10%]" />
<col class="w-[10%]" />
<col class="w-[10%]" />
<col class="w-[10%]" />
<col class="w-[10%]" />
</colgroup>
<thead>
<tr>
@@ -59,7 +60,8 @@
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">Base</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">50%</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">Total 50%</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">Total</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">Total</th>
<th class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">Cumul</th>
</tr>
</thead>
<tbody>
@@ -73,6 +75,7 @@
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBase50Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBase50Minutes) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBonus50Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBonus50Minutes) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(summary!.carryBase50Minutes + summary!.carryBonus50Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBase50Minutes + summary!.carryBonus50Minutes) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(summary!.carryFromPreviousYearMinutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryFromPreviousYearMinutes) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryFromPreviousYearMinutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryFromPreviousYearMinutes) }}</span></td>
</tr>
@@ -86,6 +89,7 @@
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.base50) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.base50) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.bonus50) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.bonus50) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(monthReport.total50) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.total50) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(monthReport.total) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.total) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.total) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.total) }}</span></td>
</tr>
@@ -126,10 +130,14 @@
<span v-if="week">{{ formatMinutes(week.base50Minutes + week.bonus50Minutes) }}</span>
<span v-else>0 h</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">
<span v-if="week">{{ formatMinutes(week.totalMinutes) }}</span>
<span v-else>0 h</span>
</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
<span v-if="week">{{ formatMinutes(week.cumulativeBalanceMinutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(week.cumulativeBalanceMinutes) }}</span></span>
<span v-else>&nbsp;</span>
</td>
</tr>
<!-- Total row -->
@@ -142,7 +150,8 @@
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-t-2">{{ formatMinutes(totals.base50) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-t-2">{{ formatMinutes(totals.bonus50) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2 border-t-2">{{ formatMinutes(totals.total50) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-t-2">{{ formatMinutes(totals.total) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2 border-t-2">{{ formatMinutes(totals.total) }}</td>
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-t-2">-</td>
</tr>
<!-- Payé row -->
@@ -155,7 +164,8 @@
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBase50Minutes) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -currentPayment.paidBase50Minutes : 0) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBonus50Minutes) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -currentPayment.paidBonus50Minutes : 0) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ currentPayment ? formatMinutes(-(currentPayment.paidBase50Minutes + currentPayment.paidBonus50Minutes)) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -(currentPayment.paidBase50Minutes + currentPayment.paidBonus50Minutes) : 0) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(paidTotal) }} <span class="text-neutral-400">/ {{ formatCentiemes(paidTotal) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(paidTotal) }} <span class="text-neutral-400">/ {{ formatCentiemes(paidTotal) }}</span></td>
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500">-</td>
</tr>
<!-- Reste row -->
@@ -168,7 +178,8 @@
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.base50) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.base50) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.bonus50) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.bonus50) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(reste.total50) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.total50) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.total) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.total) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(reste.total) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.total) }}</span></td>
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500">-</td>
</tr>
</tbody>
</table>

View File

@@ -6,6 +6,7 @@
<MalioSelectCheckbox
v-model="selectedSiteIds"
:options="siteOptions"
groupClass="w-80"
label="Sites"
display-select-all
/>
@@ -46,6 +47,7 @@
<MalioSelectCheckbox
v-model="selectedSiteIds"
:options="siteOptions"
groupClass="w-80"
label="Sites"
display-select-all
/>

View File

@@ -27,6 +27,7 @@
class="flex items-center justify-between rounded-md px-2 py-1 text-xs"
:class="daily.hasAbsence ? 'text-white' : 'text-primary-500'"
:style="getDailyCellStyle(daily)"
:title="cellTitle(daily)"
>
<span class="font-semibold">{{ weekDayHeaders[i]?.label ?? '' }}</span>
<span v-if="row.trackingMode === 'PRESENCE'">{{ daily.present ?? 0 }}</span>
@@ -104,7 +105,7 @@
class="text-left leading-4 rounded-md px-2 py-1"
:class="daily.hasAbsence ? 'text-white' : ''"
:style="getDailyCellStyle(daily)"
:title="daily.absenceLabel ?? ''"
:title="cellTitle(daily)"
>
<template v-if="row.trackingMode === 'PRESENCE'">{{ daily.present ?? 0 }}</template>
<template v-else>
@@ -153,12 +154,27 @@ const isInterimContract = (contractType?: ContractType | null) => {
return contractType === CONTRACT_TYPES.INTERIM
}
const HOLIDAY_BG_COLOR = '#b3e5fc'
const getDailyCellStyle = (daily: {
hasAbsence?: boolean
absenceColor?: string | null
holidayLabel?: string | null
}) => {
if (!daily.hasAbsence) return undefined
return { backgroundColor: daily.absenceColor || '#dc2626' }
if (daily.hasAbsence) return { backgroundColor: daily.absenceColor || '#dc2626' }
if (daily.holidayLabel) return { backgroundColor: HOLIDAY_BG_COLOR }
return undefined
}
const cellTitle = (daily: {
hasAbsence?: boolean
absenceLabel?: string | null
holidayLabel?: string | null
}) => {
const parts: string[] = []
if (daily.absenceLabel) parts.push(daily.absenceLabel)
if (daily.holidayLabel) parts.push(`Férié : ${daily.holidayLabel}`)
return parts.join(' — ')
}
defineProps<{

View File

@@ -10,10 +10,11 @@ export const useEmployeeDetailPage = () => {
const showLeaveTab = computed(() => employee.value?.currentContractNature !== 'INTERIM')
const showRttTab = computed(() => employee.value?.contract?.type !== CONTRACT_TYPES.FORFAIT)
const isForfait = computed(() => employee.value?.contract?.type === CONTRACT_TYPES.FORFAIT)
const employeeContractWorkLabel = computed(() => {
const contract = employee.value?.contract
if (!contract) return '-'
if (contract.type === CONTRACT_TYPES.FORFAIT) return 'Forfait'
if (contract.type === CONTRACT_TYPES.FORFAIT) return 'Forfait - 218 jours'
if (contract.weeklyHours !== null && contract.weeklyHours !== undefined) return `${contract.weeklyHours} heures`
return contract.name || '-'
})
@@ -55,6 +56,9 @@ export const useEmployeeDetailPage = () => {
await bonus.loadBonusData()
} else if (activeTab.value === 'observation') {
await observation.loadObservationData()
} else if (isForfait.value && showLeaveTab.value) {
// Eager load: needed for the "X jours restants" header label on forfait employees.
await leave.loadLeaveData()
}
} finally {
isLoading.value = false
@@ -63,6 +67,13 @@ export const useEmployeeDetailPage = () => {
const contract = useEmployeeContract(employee, loadEmployee)
const leave = useEmployeeLeave(employee, loadEmployee)
const forfaitRemainingDaysLabel = computed(() => {
if (!isForfait.value) return ''
const presence = leave.leaveSummary.value?.presenceDaysToToday
if (presence === undefined || presence === null) return ''
const remaining = 218 - presence
return ` (${remaining} restants)`
})
const rtt = useEmployeeRtt(employee, loadEmployee)
const mileage = useEmployeeMileage(employee, loadEmployee)
const formation = useEmployeeFormation(employee, loadEmployee)
@@ -97,6 +108,7 @@ export const useEmployeeDetailPage = () => {
showLeaveTab,
showRttTab,
employeeContractWorkLabel,
forfaitRemainingDaysLabel,
...contract,
...leave,
...rtt,

View File

@@ -332,7 +332,7 @@ export const documentationSections: DocSection[] = [
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'La vue semaine est réservée aux administrateurs. Elle affiche une synthèse hebdomadaire par employé avec les heures supplémentaires calculées.' },
{ type: 'list', content: 'Filtrage par site et par employé\nDétail par jour avec totaux hebdomadaires\nColonnes de calcul : base, heures sup 25%, heures sup 50%, total récupération' },
{ type: 'list', content: 'Filtrage par site et par employé\nDétail par jour avec totaux hebdomadaires\nColonnes de calcul : base, heures sup 25%, heures sup 50%, total récupération\nLes jours fériés sont signalés sur la cellule du jour : fond bleu clair quand pas d\'absence, nom du férié au survol' },
],
},
{
@@ -482,6 +482,7 @@ export const documentationSections: DocSection[] = [
blocks: [
{ type: 'list', content: 'Report N-1 : solde de l\'exercice précédent\nAcquis : cumul des heures supplémentaires de l\'exercice en cours\nDisponible : report + acquis payé\nPayé : RTT convertis en salaire (soustraits du disponible)' },
{ type: 'note', content: 'Les contrats INTERIM et le mode PRESENCE n\'accumulent pas de RTT (affiché à 0).' },
{ type: 'paragraph', content: 'La colonne "Cumul" affiche le solde RTT à la fin de chaque semaine : Report N-1 + somme des heures hebdomadaires jusqu\'à la semaine concernée paiements RTT des mois précédents. Un paiement enregistré sur le mois M n\'est déduit qu\'à partir des semaines du mois M+1. Permet la comparaison ligne à ligne avec un suivi RH externe (Excel).' },
],
},
{
@@ -570,7 +571,7 @@ export const documentationSections: DocSection[] = [
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Génère un PDF A4 paysage avec le détail mensuel pour la paie.' },
{ type: 'list', content: 'Sélecteur de mois (défaut = mois courant)\nDonnées groupées par site\nColonnes : nom, base contrat, jours de présence cadre, heures de nuit, panier de nuit, heures RTT payées, congés (nombre + dates), maladie/AT (nombre + dates), primes conducteur (PDJ, repas, nuitée, samedi), observations' },
{ type: 'list', content: 'Sélecteur de mois (défaut = mois courant)\nDonnées groupées par site\nColonnes : nom, base contrat, jours de présence cadre, heures de nuit, panier de nuit, heures RTT payées, congés (nombre + dates), maladie/AT (nombre + dates), primes conducteur (PDJ, repas, nuitée, samedi), observations\nColonne « Repas » chauffeur : somme déjeuner + dîner sur le mois (un jour avec les deux compte 2 repas)' },
],
},
{
@@ -588,7 +589,7 @@ export const documentationSections: DocSection[] = [
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Génère un PDF par employé avec le détail jour par jour de ses heures sur une année.' },
{ type: 'list', content: 'Accessible depuis la fiche employé (bouton imprimante)\nChoix de l\'année civile (janvier à décembre)\nColonnes adaptées au mode de suivi (TIME, PRESENCE, conducteur)\nSections séparées en cas de changement de contrat en cours d\'année' },
{ type: 'list', content: 'Accessible depuis la fiche employé (bouton imprimante)\nChoix de l\'année civile (janvier à décembre)\nColonnes adaptées au mode de suivi (TIME, PRESENCE, conducteur)\nSections séparées en cas de changement de contrat en cours d\'année\nLes jours fériés apparaissent toujours (ligne bleue) avec la mention « Férié : {nom} » dans la colonne Absence ; le total reprend les heures contractuelles créditées (hors Forfait)' },
],
},
],

View File

@@ -7,7 +7,7 @@
"name": "frontend",
"hasInstallScript": true,
"dependencies": {
"@malio/layer-ui": "^1.4.3",
"@malio/layer-ui": "^1.4.6",
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.1",
"@pinia/nuxt": "^0.11.3",
@@ -2222,9 +2222,9 @@
"license": "MIT"
},
"node_modules/@malio/layer-ui": {
"version": "1.4.3",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.4.3/layer-ui-1.4.3.tgz",
"integrity": "sha512-XGR0VteuRGGizl8ZP2ZRlyWsdSTAwOYR7z5687Gx/SFr5eTg+poOV2NupqOuWCksxEcXA54vzzC0vMG8PbSvxg==",
"version": "1.4.6",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.4.6/layer-ui-1.4.6.tgz",
"integrity": "sha512-stHqUAJ8E6a62Ka7QXlE177GhkIsjtmYNa/tNk1TVpbJ099okfLLivrlofEl7CCAqDeMaIepnW4q0vxJT+EFEA==",
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -13,7 +13,7 @@
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.1",
"@malio/layer-ui": "^1.4.3",
"@malio/layer-ui": "^1.4.6",
"@pinia/nuxt": "^0.11.3",
"nuxt": "^4.3.0",
"nuxt-toast": "^1.4.0",

View File

@@ -2,13 +2,12 @@
<div class="h-full flex flex-col overflow-hidden">
<div class="flex items-center justify-between pb-6">
<h1 class="text-4xl font-bold text-primary-500">Types de statut</h1>
<button
type="button"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
<MalioButton
label="Ajouter un type"
icon-name="mdi:plus"
icon-position="left"
@click="openCreate"
>
+ Ajouter un type
</button>
/>
</div>
<div
@@ -56,60 +55,40 @@
</div>
</div>
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle">
<MalioDrawer v-model="isDrawerOpen" :title="drawerTitle">
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="code">
Code <span class="text-red-600">*</span>
</label>
<input
id="code"
v-model="form.code"
type="text"
maxlength="10"
:class="codeFieldClass"
/>
<p v-if="showCodeError" class="mt-1 text-sm text-red-600">
Le code est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="label">
Libellé <span class="text-red-600">*</span>
</label>
<input
id="label"
v-model="form.label"
type="text"
:class="labelFieldClass"
/>
<p v-if="showLabelError" class="mt-1 text-sm text-red-600">
Le libellé est obligatoire.
</p>
</div>
<MalioInputText
v-model="form.code"
label="Code *"
group-class="mt-2"
:max-length="10"
:error="showCodeError ? 'Le code est obligatoire.' : ''"
/>
<MalioInputText
v-model="form.label"
label="Libellé *"
group-class="mt-2"
:error="showLabelError ? 'Le libellé est obligatoire.' : ''"
/>
<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>
<MalioRadioButton
v-model="form.countAsWorkedHours"
name="countAsWorkedHours"
:value="true"
label="Oui"
group-class="w-auto mt-0"
/>
<MalioRadioButton
v-model="form.countAsWorkedHours"
name="countAsWorkedHours"
:value="false"
label="Non"
group-class="w-auto mt-0"
/>
</div>
</div>
<div>
@@ -130,32 +109,29 @@
</p>
</div>
<div v-if="editingType" 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"
<MalioButton
label="Supprimer"
variant="danger"
button-class="w-full"
@click="confirmDelete(editingType)"
>
Supprimer
</button>
<button
/>
<MalioButton
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"
:class="submitButtonClass"
>
Modifier
</button>
label="Modifier"
button-class="w-full"
:disabled="isSubmitting || !isFormValid"
/>
</div>
<div v-else class="flex justify-center pt-2">
<button
<MalioButton
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"
:class="submitButtonClass"
>
+ Ajouter
</button>
label="Valider"
button-class="w-[200px]"
:disabled="isSubmitting || !isFormValid"
/>
</div>
</form>
</AppDrawer>
</MalioDrawer>
</div>
</template>
@@ -202,20 +178,6 @@ const showCodeError = computed(() => validationTouched.code && !isCodeValid.valu
const showLabelError = computed(() => validationTouched.label && !isLabelValid.value)
const showColorError = computed(() => validationTouched.color && !isColorValid.value)
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
const codeFieldClass = computed(() => {
if (showCodeError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const labelFieldClass = computed(() => {
if (showLabelError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const colorFieldClass = computed(() => {
const baseColorClass = 'h-10 w-16 cursor-pointer rounded-md border bg-white p-1'
if (showColorError.value) {
@@ -224,13 +186,6 @@ const colorFieldClass = computed(() => {
return `${baseColorClass} border-neutral-300`
})
const submitButtonClass = computed(() => {
if (isSubmitting.value || !isFormValid.value) {
return 'opacity-50 cursor-not-allowed'
}
return ''
})
const loadAbsenceTypes = async () => {
isLoading.value = true
try {

View File

@@ -5,16 +5,13 @@
</div>
<div class="flex flex-col gap-3 py-6">
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-4">
<div class="relative z-50 w-80">
<MalioSelectCheckbox
v-model="selectedSiteIds"
:options="siteOptions"
label="Sites"
display-select-all
/>
</div>
</div>
<MalioSelectCheckbox
v-model="selectedSiteIds"
:options="siteOptions"
label="Sites"
groupClass="relative z-50 w-80 h-10"
display-select-all
/>
<div class="flex gap-4">
<MalioButton
label="Ajouter une absence"

View File

@@ -26,7 +26,7 @@
<p>Date d'entrée : {{ employee.entryDate ? employee.entryDate.split('-').reverse().join('/') : '-' }}</p>
</div>
<div class="text-right">
<p class="font-bold text-[22px]">{{ contractNatureLabel(employee.currentContractNature) }} {{ employeeContractWorkLabel }}</p>
<p class="font-bold text-[22px]">{{ contractNatureLabel(employee.currentContractNature) }} {{ employeeContractWorkLabel }}{{ forfaitRemainingDaysLabel }}</p>
<p class="text-[18px]">{{ employee.site?.name ?? '-' }}</p>
</div>
</div>
@@ -257,6 +257,7 @@ const {
showRttTab,
contractHistory,
employeeContractWorkLabel,
forfaitRemainingDaysLabel,
contractForm,
createContractForm,
isContractDrawerOpen,

View File

@@ -4,34 +4,19 @@
<div class="flex items-center justify-between">
<h1 class="text-4xl font-bold text-primary-500">Employés</h1>
<div class="flex items-center gap-3">
<button
type="button"
class="flex items-center gap-2 rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
@click="handleLeaveRecapPrint"
>
Export récap. congés
</button>
<button
type="button"
class="flex items-center gap-2 rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
@click="isSalaryRecapOpen = true"
>
Export récap. salaire
</button>
<button
type="button"
class="flex items-center gap-2 rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
@click="isYearlyHoursBulkOpen = true"
>
Export heures
</button>
<button
type="button"
class="flex items-center gap-2 rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
<MalioButton
label="Export"
variant="secondary"
icon-name="mdi:download"
icon-position="left"
@click="openExportDrawer"
/>
<MalioButton
label="Ajouter un employé"
icon-name="mdi:plus"
icon-position="left"
@click="openCreate"
>
+ Ajouter un employé
</button>
/>
</div>
</div>
<div class="flex items-center gap-3 py-7">
@@ -46,6 +31,7 @@
<MalioSelectCheckbox
v-model="selectedSiteIds"
:options="siteOptions"
groupClass="w-80"
label="Sites"
display-select-all
/>
@@ -98,105 +84,53 @@
</NuxtLink>
</div>
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle">
<MalioDrawer v-model="isDrawerOpen" :title="drawerTitle">
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="first-name">
Prénom <span class="text-red-600">*</span>
</label>
<input
id="first-name"
v-model="form.firstName"
type="text"
:class="firstNameFieldClass"
/>
<p v-if="showFirstNameError" class="mt-1 text-sm text-red-600">
Le prénom est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="last-name">
Nom <span class="text-red-600">*</span>
</label>
<input
id="last-name"
v-model="form.lastName"
type="text"
:class="lastNameFieldClass"
/>
<p v-if="showLastNameError" class="mt-1 text-sm text-red-600">
Le nom est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="site">
Site <span class="text-red-600">*</span>
</label>
<select
id="site"
v-model="form.siteId"
:class="siteFieldClass"
>
<option value="">Aucun site</option>
<option v-for="site in sites" :key="site.id" :value="site.id">
{{ site.name }}
</option>
</select>
<p v-if="showSiteError" class="mt-1 text-sm text-red-600">
Le site est obligatoire.
</p>
</div>
<MalioInputText
v-model="form.firstName"
label="Prénom *"
group-class="mt-2"
:error="showFirstNameError ? 'Le prénom est obligatoire.' : ''"
/>
<MalioInputText
v-model="form.lastName"
label="Nom *"
group-class="mt-2"
:error="showLastNameError ? 'Le nom est obligatoire.' : ''"
/>
<MalioSelect
:model-value="form.siteId === '' ? null : form.siteId"
:options="formSiteOptions"
label="Site *"
min-width=""
:error="showSiteError ? 'Le site est obligatoire.' : ''"
@update:model-value="(v) => { form.siteId = v === null ? '' : Number(v) }"
/>
<template v-if="!editingEmployee">
<div>
<label class="text-md font-semibold text-neutral-700" for="contract-nature">
Type de contrat <span class="text-red-600">*</span>
</label>
<select
id="contract-nature"
v-model="form.contractNature"
:class="contractNatureFieldClass"
>
<option value="CDI">CDI</option>
<option value="CDD">CDD</option>
<option value="INTERIM">Intérim</option>
</select>
<p v-if="showContractNatureError" class="mt-1 text-sm text-red-600">
Le type de contrat est obligatoire.
</p>
</div>
<div v-if="form.contractNature === 'INTERIM'">
<label class="text-md font-semibold text-neutral-700" for="interim-agency">
Agence d'intérim
</label>
<select
id="interim-agency"
v-model="form.interimAgencyId"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
>
<option value="">Aucune</option>
<option v-for="agency in interimAgencies" :key="agency.id" :value="agency.id">
{{ agency.name }}
</option>
</select>
</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>
<MalioSelect
:model-value="form.contractNature"
:options="contractNatureFormOptions"
label="Type de contrat *"
min-width=""
:error="showContractNatureError ? 'Le type de contrat est obligatoire.' : ''"
@update:model-value="(v) => { if (v !== null) form.contractNature = v as 'CDI' | 'CDD' | 'INTERIM' }"
/>
<MalioSelect
v-if="form.contractNature === 'INTERIM'"
:model-value="form.interimAgencyId === '' ? null : form.interimAgencyId"
:options="interimAgencyOptions"
label="Agence d'intérim"
min-width=""
@update:model-value="(v) => { form.interimAgencyId = v === null ? '' : Number(v) }"
/>
<MalioSelect
:model-value="form.contractId === '' ? null : form.contractId"
:options="contractFormOptions"
label="Temps de travail *"
min-width=""
:error="showContractError ? 'Le temps de travail est obligatoire.' : ''"
@update:model-value="(v) => { form.contractId = v === null ? '' : Number(v) }"
/>
<div>
<label class="text-md font-semibold text-neutral-700" for="contract-start-date">
Début contrat <span class="text-red-600">*</span>
@@ -205,7 +139,7 @@
id="contract-start-date"
v-model="form.contractStartDate"
type="date"
:class="contractStartDateFieldClass"
:class="[dateInputBaseClass, form.contractStartDate ? 'border-black' : 'border-m-muted', showContractStartDateError ? '!border-m-danger' : '']"
/>
<p v-if="showContractStartDateError" class="mt-1 text-sm text-red-600">
La date de début est obligatoire.
@@ -220,22 +154,18 @@
id="contract-end-date"
v-model="form.contractEndDate"
type="date"
:class="contractEndDateFieldClass"
:class="[dateInputBaseClass, form.contractEndDate ? 'border-black' : 'border-m-muted', showContractEndDateError ? '!border-m-danger' : '']"
/>
<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>
<div class="rounded-md border border-neutral-200 bg-neutral-50 p-3">
<label class="inline-flex items-center gap-2 text-md font-semibold text-neutral-700" for="is-driver">
<input
id="is-driver"
v-model="form.isDriver"
type="checkbox"
class="h-4 w-4 rounded border-neutral-300 text-primary-500 focus:ring-primary-500"
/>
Chauffeur
</label>
<div class="flex h-10 items-center rounded-md border border-neutral-200 bg-neutral-50 px-3">
<MalioCheckbox
v-model="form.isDriver"
label="Chauffeur"
group-class="flex items-center"
/>
</div>
<WorkDaysHoursInput
v-if="requiresSchedule"
@@ -244,34 +174,72 @@
/>
</template>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
<MalioButton
label="Annuler"
variant="tertiary"
@click="isDrawerOpen = false"
>
Annuler
</button>
<button
/>
<MalioButton
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
:class="submitButtonClass"
>
Enregistrer
</button>
label="Enregistrer"
:disabled="isSubmitting || !isFormValid"
/>
</div>
</form>
</AppDrawer>
</MalioDrawer>
<SalaryRecapDrawer
v-model="isSalaryRecapOpen"
@submit="handleSalaryRecapPrint"
/>
<MalioDrawer v-model="isExportDrawerOpen" title="Export">
<div class="space-y-4">
<MalioSelect
:model-value="exportChoice === '' ? null : exportChoice"
:options="exportTypeOptions"
label="Type d'export"
empty-option-label="Choisir un export"
group-class="mt-2"
min-width=""
@update:model-value="onExportChoiceChange"
/>
<BulkYearlyHoursDrawer
v-model="isYearlyHoursBulkOpen"
:is-loading="isYearlyHoursBulkLoading"
@submit="handleBulkYearlyHoursPrint"
/>
<div v-if="exportChoice === 'salary-recap'">
<label class="text-md font-semibold text-neutral-700" for="export-salary-month">
Mois <span class="text-red-600">*</span>
</label>
<input
id="export-salary-month"
v-model="exportSalaryMonth"
type="month"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
/>
</div>
<template v-else-if="exportChoice === 'yearly-hours'">
<MalioSelect
:model-value="exportYear"
:options="exportYearOptions"
label="Année *"
min-width=""
@update:model-value="(v) => { if (v !== null) exportYear = Number(v) }"
/>
<MalioSelect
:model-value="exportMonth === '' ? null : exportMonth"
:options="exportMonthOptions"
label="Mois *"
empty-option-label="Choisir un mois"
min-width=""
@update:model-value="(v) => { exportMonth = v === null ? '' : Number(v) }"
/>
</template>
<div class="flex justify-center pt-2">
<MalioButton
label="Valider"
button-class="w-[200px]"
:disabled="!isExportValid"
@click="handleExportValidate"
/>
</div>
</div>
</MalioDrawer>
</div>
</template>
@@ -285,8 +253,6 @@ import {listContracts} from '~/services/contracts'
import {createEmployee, deleteEmployee, listEmployees, updateEmployee} from '~/services/employees'
import {listSites} from '~/services/sites'
import {listInterimAgencies, type InterimAgency} from '~/services/interim-agencies'
import SalaryRecapDrawer from '~/components/SalaryRecapDrawer.vue'
import BulkYearlyHoursDrawer from '~/components/BulkYearlyHoursDrawer.vue'
import {contractNatureLabel, isContractNature, requiresContractEndDate, showsContractEndDate} from '~/utils/contract'
import {usePdfPrinter} from '~/composables/usePdfPrinter'
@@ -297,9 +263,50 @@ useHead({
const isDrawerOpen = ref(false)
const isSubmitting = ref(false)
const isLoading = ref(false)
const isSalaryRecapOpen = ref(false)
const isYearlyHoursBulkOpen = ref(false)
const isYearlyHoursBulkLoading = ref(false)
const isExportDrawerOpen = ref(false)
const exportChoice = ref<'leave-recap' | 'salary-recap' | 'yearly-hours' | ''>('')
const exportYear = ref<number>(new Date().getFullYear())
const exportMonth = ref<number | ''>(new Date().getMonth() + 1)
const exportSalaryMonth = ref<string>(`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`)
const exportTypeOptions = [
{ label: 'Récap. congés', value: 'leave-recap' },
{ label: 'Récap. salaire', value: 'salary-recap' },
{ label: 'Heures annuelles', value: 'yearly-hours' }
]
const exportYearOptions = computed(() => {
const current = new Date().getFullYear()
return Array.from({ length: 6 }, (_, i) => ({ label: String(current - i), value: current - i }))
})
const exportMonthOptions = [
{ label: 'Janvier', value: 1 },
{ label: 'Février', value: 2 },
{ label: 'Mars', value: 3 },
{ label: 'Avril', value: 4 },
{ label: 'Mai', value: 5 },
{ label: 'Juin', value: 6 },
{ label: 'Juillet', value: 7 },
{ label: 'Août', value: 8 },
{ label: 'Septembre', value: 9 },
{ label: 'Octobre', value: 10 },
{ label: 'Novembre', value: 11 },
{ label: 'Décembre', value: 12 }
]
const isExportValid = computed(() => {
if (!exportChoice.value) return false
if (exportChoice.value === 'salary-recap') {
return exportSalaryMonth.value.trim() !== ''
}
if (exportChoice.value === 'yearly-hours') {
return exportYear.value > 0 && exportMonth.value !== ''
}
return true
})
const onExportChoiceChange = (value: string | number | null) => {
exportChoice.value = (value === null ? '' : String(value)) as 'leave-recap' | 'salary-recap' | 'yearly-hours' | ''
}
const { printPdf } = usePdfPrinter()
const sitesInitialized = ref(false)
const editingEmployee = ref<Employee | null>(null)
@@ -425,63 +432,23 @@ const showContractEndDateError = computed(
() => !editingEmployee.value && validationTouched.contractEndDate && !isContractEndDateValid.value
)
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
const firstNameFieldClass = computed(() => {
if (showFirstNameError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const lastNameFieldClass = computed(() => {
if (showLastNameError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const siteFieldClass = computed(() => {
const baseSelectClass =
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
if (showSiteError.value) {
return `${baseSelectClass} border-red-500`
}
return `${baseSelectClass} border-neutral-300`
})
const contractFieldClass = computed(() => {
const baseClass =
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
if (showContractError.value) {
return `${baseClass} border-red-500`
}
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 dateInputBaseClass =
'mt-2 h-10 w-full rounded-md border px-3 text-md text-black outline-none focus:border-2 focus:border-m-primary'
const submitButtonClass = computed(() => {
if (isSubmitting.value || !isFormValid.value) {
return 'opacity-50 cursor-not-allowed'
}
return ''
})
const formSiteOptions = computed(() =>
sites.value.map((site) => ({ label: site.name, value: site.id }))
)
const interimAgencyOptions = computed(() =>
interimAgencies.value.map((agency) => ({ label: agency.name, value: agency.id }))
)
const contractFormOptions = computed(() =>
contracts.value.map((contract) => ({ label: contract.name, value: contract.id }))
)
const contractNatureFormOptions = [
{ label: 'CDI', value: 'CDI' },
{ label: 'CDD', value: 'CDD' },
{ label: 'Intérim', value: 'INTERIM' }
]
const loadEmployees = async () => {
isLoading.value = true
@@ -632,26 +599,29 @@ const openCreate = () => {
isDrawerOpen.value = true
}
const handleLeaveRecapPrint = async () => {
await printPdf('/leave-recap/print')
const openExportDrawer = () => {
exportChoice.value = ''
const now = new Date()
exportYear.value = now.getFullYear()
exportMonth.value = now.getMonth() + 1
exportSalaryMonth.value = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
isExportDrawerOpen.value = true
}
const handleSalaryRecapPrint = async (month: string) => {
await printPdf(`/salary-recap/print?month=${month}`)
isSalaryRecapOpen.value = false
}
const handleBulkYearlyHoursPrint = async (payload: { year: number; month: number | null }) => {
isYearlyHoursBulkLoading.value = true
try {
const monthParam = null !== payload.month ? `&month=${payload.month}` : ''
await printPdf(`/yearly-hours/print-all?year=${payload.year}${monthParam}`)
isYearlyHoursBulkOpen.value = false
} finally {
isYearlyHoursBulkLoading.value = false
const handleExportValidate = async () => {
if (!isExportValid.value) return
const choice = exportChoice.value
isExportDrawerOpen.value = false
if (choice === 'leave-recap') {
await printPdf('/leave-recap/print')
} else if (choice === 'salary-recap') {
await printPdf(`/salary-recap/print?month=${exportSalaryMonth.value}`)
} else if (choice === 'yearly-hours') {
await printPdf(`/yearly-hours/print-all?year=${exportYear.value}&month=${exportMonth.value}`)
}
}
const confirmDelete = async (employee: Employee) => {
const ok = window.confirm(`Supprimer ${employee.firstName} ${employee.lastName} ?`)
if (!ok) return

View File

@@ -9,31 +9,18 @@
class="mt-8 space-y-6 rounded-lg border border-neutral-200 bg-white p-6 shadow-sm"
@submit.prevent="handleSubmit"
>
<div>
<label class="text-sm font-semibold text-neutral-700" for="username">
Nom d'utilisateur
</label>
<input
id="username"
v-model="username"
type="text"
autocomplete="username"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white 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>
<MalioInputText
v-model="username"
label="Nom d'utilisateur"
autocomplete="username"
group-class="mt-2"
/>
<div>
<label class="text-sm font-semibold text-neutral-700" for="password">
Mot de passe
</label>
<input
id="password"
v-model="password"
type="password"
autocomplete="current-password"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white 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>
<MalioInputPassword
v-model="password"
label="Mot de passe"
autocomplete="current-password"
/>
<button
type="submit"

View File

@@ -2,13 +2,12 @@
<div class="h-full flex flex-col overflow-hidden">
<div class="flex items-center justify-between pb-6">
<h1 class="text-4xl font-bold text-primary-500">Sites</h1>
<button
type="button"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
<MalioButton
label="Ajouter un site"
icon-name="mdi:plus"
icon-position="left"
@click="openCreate"
>
+ Ajouter un site
</button>
/>
</div>
<div
@@ -52,22 +51,14 @@
</div>
</div>
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle">
<MalioDrawer v-model="isDrawerOpen" :title="drawerTitle">
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="name">
Nom <span class="text-red-600">*</span>
</label>
<input
id="name"
v-model="form.name"
type="text"
:class="nameFieldClass"
/>
<p v-if="showNameError" class="mt-1 text-sm text-red-600">
Le nom du site est obligatoire.
</p>
</div>
<MalioInputText
v-model="form.name"
label="Nom *"
group-class="mt-2"
:error="showNameError ? 'Le nom du site est obligatoire.' : ''"
/>
<div>
<label class="text-md font-semibold text-neutral-700" for="color">
Couleur <span class="text-red-600">*</span>
@@ -83,32 +74,29 @@
</div>
</div>
<div v-if="editingSite" 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"
<MalioButton
label="Supprimer"
variant="danger"
button-class="w-full"
@click="confirmDelete(editingSite)"
>
Supprimer
</button>
<button
/>
<MalioButton
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"
:class="submitButtonClass"
>
Modifier
</button>
label="Modifier"
button-class="w-full"
:disabled="isSubmitting || !isFormValid"
/>
</div>
<div v-else class="flex justify-center pt-2">
<button
<MalioButton
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"
:class="submitButtonClass"
>
+ Ajouter
</button>
label="Valider"
button-class="w-[200px]"
:disabled="isSubmitting || !isFormValid"
/>
</div>
</form>
</AppDrawer>
</MalioDrawer>
</div>
</template>
@@ -146,22 +134,6 @@ const isFormValid = computed(() => isNameValid.value)
const showNameError = computed(() => validationTouched.name && !isNameValid.value)
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
const nameFieldClass = computed(() => {
if (showNameError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const submitButtonClass = computed(() => {
if (isSubmitting.value || !isFormValid.value) {
return 'opacity-50 cursor-not-allowed'
}
return ''
})
const loadSites = async () => {
isLoading.value = true
try {

View File

@@ -2,13 +2,12 @@
<div class="h-full flex flex-col overflow-hidden">
<div class="flex items-center justify-between pb-6">
<h1 class="text-2xl font-bold text-primary-500 lg:text-4xl">Utilisateurs</h1>
<button
type="button"
class="rounded-lg bg-primary-500 px-3 py-2 text-sm font-semibold text-white hover:bg-secondary-500 lg:px-4 lg:text-md"
<MalioButton
label="Ajouter"
icon-name="mdi:plus"
icon-position="left"
@click="openCreate"
>
+ Ajouter
</button>
/>
</div>
<div
@@ -93,43 +92,25 @@
</div>
</div>
<AppDrawer
<MalioDrawer
v-model="isDrawerOpen"
:title="editingUser ? 'Modifier un utilisateur' : 'Ajouter un utilisateur'"
>
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="username">
Nom d'utilisateur <span class="text-red-600">*</span>
</label>
<input
id="username"
v-model="form.username"
type="text"
:class="usernameFieldClass"
/>
<p v-if="showUsernameError" class="mt-1 text-sm text-red-600">
Le nom d'utilisateur est obligatoire.
</p>
</div>
<MalioInputText
v-model="form.username"
:label="editingUser ? `Nom d'utilisateur` : `Nom d'utilisateur *`"
group-class="mt-2"
:error="showUsernameError ? `Le nom d'utilisateur est obligatoire.` : ''"
/>
<div>
<label class="text-md font-semibold text-neutral-700" for="password">
Mot de passe
<span v-if="!editingUser" class="text-red-600">*</span>
</label>
<input
id="password"
<MalioInputPassword
v-model="form.password"
type="password"
:class="passwordFieldClass"
:label="editingUser ? 'Mot de passe' : 'Mot de passe *'"
:hint="editingUser ? 'Laisse vide pour ne pas changer le mot de passe.' : ''"
:error="!editingUser && showPasswordError ? 'Le mot de passe est obligatoire.' : ''"
/>
<p v-if="editingUser" class="mt-1 text-sm text-neutral-500">
Laisse vide pour ne pas changer le mot de passe.
</p>
<p v-else-if="showPasswordError" class="mt-1 text-sm text-red-600">
Le mot de passe est obligatoire.
</p>
</div>
<div>
@@ -172,40 +153,32 @@
</div>
<div v-if="form.accessMode === 'self'">
<label class="text-md font-semibold text-neutral-700" for="employee">
Employé lié
</label>
<select
id="employee"
v-model="form.employeeId"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
>
<option value="">Aucun</option>
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
{{ employee.firstName }} {{ employee.lastName }}
</option>
</select>
<p v-if="showSelfEmployeeError" class="mt-1 text-sm text-red-600">
Sélectionne un employé.
</p>
<MalioSelect
:model-value="form.employeeId === '' ? null : form.employeeId"
:options="employeeOptions"
label="Employé lié"
empty-option-label="Aucun"
min-width=""
:error="showSelfEmployeeError ? 'Sélectionne un employé.' : ''"
@update:model-value="onEmployeeChange"
/>
</div>
<div v-if="form.accessMode === 'sites'">
<p class="text-md font-semibold text-neutral-700">Sites autorisés</p>
<div class="mt-2 grid gap-2 sm:grid-cols-2">
<label
<div
v-for="site in sites"
:key="site.id"
class="flex items-center gap-2 rounded-md border border-neutral-200 px-3 py-2 text-sm text-neutral-700 cursor-pointer"
class="flex h-10 items-center rounded-md border border-neutral-200 px-3"
>
<input
type="checkbox"
class="cursor-pointer"
:checked="form.siteIds.includes(site.id)"
@change="toggleSite(site.id)"
<MalioCheckbox
:model-value="form.siteIds.includes(site.id)"
:label="site.name"
group-class="flex items-center"
@update:model-value="toggleSite(site.id)"
/>
<span>{{ site.name }}</span>
</label>
</div>
</div>
<p v-if="showSitesError" class="mt-1 text-sm text-red-600">
Sélectionne au moins un site.
@@ -213,44 +186,31 @@
</div>
<div>
<label class="flex items-center gap-2 cursor-pointer">
<input
v-model="form.isLocked"
type="checkbox"
class="cursor-pointer"
/>
<span class="text-md font-semibold text-neutral-700">Verrouiller le compte</span>
</label>
<p class="mt-1 text-sm text-neutral-500">
Un compte verrouillé ne peut plus se connecter.
</p>
<MalioCheckbox
v-model="form.isLocked"
label="Verrouiller le compte"
hint="Un compte verrouillé ne peut plus se connecter."
/>
</div>
<div>
<label class="flex items-center gap-2 cursor-pointer">
<input
v-model="form.hasLeaveRecapAccess"
type="checkbox"
class="cursor-pointer"
/>
<span class="text-md font-semibold text-neutral-700">Accès à l'écran Récap. congés</span>
</label>
<p class="mt-1 text-sm text-neutral-500">
Affiche l'onglet dans la sidebar et donne accès au tableau récap.
</p>
<MalioCheckbox
v-model="form.hasLeaveRecapAccess"
label="Accès à l'écran Récap. congés"
hint="Affiche l'onglet dans la sidebar et donne accès au tableau récap."
/>
</div>
<div class="flex justify-center pt-2">
<button
<MalioButton
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"
:class="submitButtonClass"
>
{{ editingUser ? 'Modifier' : '+ Ajouter' }}
</button>
:label="editingUser ? 'Modifier' : 'Valider'"
button-class="w-[200px]"
:disabled="isSubmitting || !isFormValid"
/>
</div>
</form>
</AppDrawer>
</MalioDrawer>
</div>
</template>
@@ -348,27 +308,13 @@ const getSiteLabels = (user: User) => {
return names.length > 0 ? names.join(', ') : 'Sites sélectionnés'
}
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'
const usernameFieldClass = computed(() => {
if (showUsernameError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const passwordFieldClass = computed(() => {
if (showPasswordError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const employeeOptions = computed(() =>
employees.value.map((e) => ({ label: `${e.firstName} ${e.lastName}`, value: e.id }))
)
const submitButtonClass = computed(() => {
if (isSubmitting.value || !isFormValid.value) {
return 'opacity-50 cursor-not-allowed'
}
return ''
})
const onEmployeeChange = (value: string | number | null) => {
form.employeeId = value === null ? '' : Number(value)
}
const loadData = async () => {
isLoading.value = true

View File

@@ -15,5 +15,6 @@ export type EmployeeLeaveSummary = {
previousYearRemainingDays: number
previousYearPaidDays: number
presenceDaysByMonth: Record<string, number>
presenceDaysToToday: number
}

View File

@@ -9,6 +9,7 @@ export type EmployeeRttWeekSummary = {
base50Minutes: number
bonus50Minutes: number
totalMinutes: number
cumulativeBalanceMinutes: number
}
export type RttMonthPayment = {

View File

@@ -60,6 +60,7 @@ export type WeeklyWorkHourDailySummary = {
hasDinner?: boolean
hasOvernight?: boolean
virtualHolidayMinutes?: number
holidayLabel?: string | null
}
export type WeeklyWorkHourRowSummary = {

View File

@@ -38,4 +38,7 @@ final class EmployeeLeaveSummary
/** @var array<string, float> YYYY-MM => count (0.5 for half-days) */
public array $presenceDaysByMonth = [];
/** Cumul des jours de présence depuis le début de l'année de congé jusqu'à aujourd'hui (forfait). */
public float $presenceDaysToToday = 0.0;
}

View File

@@ -17,5 +17,6 @@ final class EmployeeRttWeekSummary
public int $base50Minutes = 0,
public int $bonus50Minutes = 0,
public int $totalMinutes = 0,
public int $cumulativeBalanceMinutes = 0,
) {}
}

View File

@@ -22,5 +22,6 @@ final class WeeklyDaySummary
public bool $hasDinner = false,
public bool $hasOvernight = false,
public int $virtualHolidayMinutes = 0,
public ?string $holidayLabel = null,
) {}
}

View File

@@ -73,7 +73,7 @@ final readonly class LeaveRecapRowBuilder
}
}
$cpN1Remaining = round($yearSummary['previousYearRemainingDays'], 2);
$cpN = (string) round($yearSummary['acquiredDays'], 2);
$cpN = (string) round($yearSummary['remainingDays'], 2);
$acquiredSaturdays = '-';
} else {
$cpN1Remaining = round($yearSummary['remainingDays'], 2);

View File

@@ -14,8 +14,10 @@ use App\Enum\TrackingMode;
use App\Repository\AbsenceRepository;
use App\Repository\WorkHourRepository;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\PublicHolidayServiceInterface;
use DateInterval;
use DateTimeImmutable;
use Throwable;
class YearlyHoursExportBuilder
{
@@ -25,6 +27,8 @@ class YearlyHoursExportBuilder
private EmployeeContractResolver $contractResolver,
private AbsenceSegmentsResolver $absenceSegmentsResolver,
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
private PublicHolidayServiceInterface $publicHolidayService,
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
) {}
/**
@@ -56,6 +60,8 @@ class YearlyHoursExportBuilder
$absences = $this->absenceRepository->findForPrint($from, $to, $employees);
$contractMap = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
$driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days);
$workDaysMap = $this->contractResolver->resolveWorkDaysMinutesForEmployeesAndDays($employees, $days);
$holidayMap = $this->buildHolidayMap($from, $to);
$workHourMap = $this->buildWorkHourMap($workHours);
$absenceMap = $this->buildAbsenceMap($absences, $days);
@@ -71,6 +77,8 @@ class YearlyHoursExportBuilder
$driverMap[$employeeId] ?? [],
$workHourMap[$employeeId] ?? [],
$absenceData,
$workDaysMap[$employeeId] ?? [],
$holidayMap,
);
if ([] === $segments) {
@@ -205,6 +213,9 @@ class YearlyHoursExportBuilder
}
/**
* @param array<string, ?array<int, int>> $workDaysMinutesByDate
* @param array<string, string> $holidayMap
*
* @return list<array{mode: string, contractName: ?string, rows: list<array>}>
*/
private function buildSegments(
@@ -213,6 +224,8 @@ class YearlyHoursExportBuilder
array $driverByDate,
array $workHoursByDate,
array $absenceData,
array $workDaysMinutesByDate,
array $holidayMap,
): array {
$segments = [];
$currentMode = null;
@@ -222,7 +235,8 @@ class YearlyHoursExportBuilder
$firstDataDate = null;
foreach ($days as $date) {
$hasRow = null !== ($workHoursByDate[$date] ?? null)
|| ($absenceData['hasDayAbsence'][$date] ?? false);
|| ($absenceData['hasDayAbsence'][$date] ?? false)
|| isset($holidayMap[$date]);
if ($hasRow) {
$firstDataDate = $date;
@@ -241,14 +255,16 @@ class YearlyHoursExportBuilder
continue;
}
$contract = $contractsByDate[$date] ?? null;
$isDriver = $driverByDate[$date] ?? false;
$wh = $workHoursByDate[$date] ?? null;
$hasData = null !== $wh || ($absenceData['hasDayAbsence'][$date] ?? false);
$isoDay = (int) new DateTimeImmutable($date)->format('N');
$isWeekend = $isoDay >= 6;
$contract = $contractsByDate[$date] ?? null;
$isDriver = $driverByDate[$date] ?? false;
$wh = $workHoursByDate[$date] ?? null;
$hasData = null !== $wh || ($absenceData['hasDayAbsence'][$date] ?? false);
$holidayLabel = $holidayMap[$date] ?? null;
$isHoliday = null !== $holidayLabel;
$isoDay = (int) new DateTimeImmutable($date)->format('N');
$isWeekend = $isoDay >= 6;
if (!$hasData && !$isWeekend) {
if (!$hasData && !$isWeekend && !$isHoliday) {
continue;
}
@@ -275,10 +291,18 @@ class YearlyHoursExportBuilder
$creditedMinutes = $absenceData['credited'][$date] ?? 0;
$absenceLabel = $absenceData['labels'][$date] ?? null;
$hasAbsence = $absenceData['hasDayAbsence'][$date] ?? false;
$virtualMinutes = $this->holidayVirtualHoursResolver->resolveVirtualCredit(
$contract,
new DateTimeImmutable($date),
$hasAbsence,
$workDaysMinutesByDate[$date] ?? null,
);
$row = [
'date' => new DateTimeImmutable($date)->format('d/m/Y'),
'absenceLabel' => $absenceLabel,
'holidayLabel' => $holidayLabel,
'isWeekend' => $isWeekend,
];
@@ -297,6 +321,9 @@ class YearlyHoursExportBuilder
$nightMin = $wh?->getNightHoursMinutes() ?? 0;
$workshopMin = $wh?->getWorkshopHoursMinutes() ?? 0;
$totalMin = $dayMin + $nightMin + $workshopMin + $creditedMinutes;
if ($virtualMinutes > $totalMin) {
$totalMin = $virtualMinutes;
}
$row['dayHours'] = $this->formatMinutes($dayMin);
$row['nightHours'] = $this->formatMinutes($nightMin);
@@ -305,6 +332,10 @@ class YearlyHoursExportBuilder
} else {
$metrics = null !== $wh ? $this->computeMetrics($wh) : new WorkMetrics();
$metrics->addCreditedMinutes($creditedMinutes);
$totalMin = $metrics->totalMinutes;
if ($virtualMinutes > $totalMin) {
$totalMin = $virtualMinutes;
}
$row['morningFrom'] = $wh?->getMorningFrom() ?? '';
$row['morningTo'] = $wh?->getMorningTo() ?? '';
@@ -312,7 +343,7 @@ class YearlyHoursExportBuilder
$row['afternoonTo'] = $wh?->getAfternoonTo() ?? '';
$row['eveningFrom'] = $wh?->getEveningFrom() ?? '';
$row['eveningTo'] = $wh?->getEveningTo() ?? '';
$row['total'] = $this->formatMinutes($metrics->totalMinutes);
$row['total'] = $this->formatMinutes($totalMin);
}
$currentRows[] = $row;
@@ -329,6 +360,29 @@ class YearlyHoursExportBuilder
return $segments;
}
/**
* @return array<string, string> Y-m-d => label
*/
private function buildHolidayMap(DateTimeImmutable $from, DateTimeImmutable $to): array
{
$map = [];
$startYear = (int) $from->format('Y');
$endYear = (int) $to->format('Y');
try {
for ($year = $startYear; $year <= $endYear; ++$year) {
$holidays = $this->publicHolidayService->getHolidaysDayByYears('metropole', (string) $year);
foreach ($holidays as $date => $label) {
$map[(string) $date] = (string) $label;
}
}
} catch (Throwable) {
return [];
}
return $map;
}
private function resolveSegmentMode(string $trackingMode, bool $isDriver): string
{
if ($isDriver) {

View File

@@ -119,8 +119,29 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$summary->previousYearRemainingDays = $yearSummary['previousYearRemainingDays'];
$summary->previousYearPaidDays = $paidLeaveDays;
[$periodFrom, $periodTo] = $this->resolvePeriodBounds($employee, $year);
$summary->presenceDaysByMonth = $this->computePresenceDaysByMonth($employee, $periodFrom, $periodTo);
[$periodFrom, $periodTo] = $this->resolvePeriodBounds($employee, $year);
// Forfait-only: leaves taken from N-1 stock do NOT decrement presence days.
// For non-forfait, previousYearTakenDays is always 0, so the budget has no effect.
$n1AbsencesBudget = $yearSummary['previousYearTakenDays'];
$summary->presenceDaysByMonth = $this->computePresenceDaysByMonth(
$employee,
$periodFrom,
$periodTo,
$n1AbsencesBudget
);
// Same logic as presenceDaysByMonth but bounded at today: number of presence days
// accumulated from leave year start up to today (inclusive).
$today = new DateTimeImmutable('today');
$cappedTo = $today < $periodTo ? $today : $periodTo;
$summary->presenceDaysToToday = $today < $periodFrom
? 0.0
: array_sum($this->computePresenceDaysByMonth(
$employee,
$periodFrom,
$cappedTo,
$n1AbsencesBudget
));
return $summary;
}
@@ -686,8 +707,12 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
*
* @return array<string, float> YYYY-MM => presence day count
*/
private function computePresenceDaysByMonth(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): array
{
private function computePresenceDaysByMonth(
Employee $employee,
DateTimeImmutable $from,
DateTimeImmutable $to,
float $n1AbsencesBudget = 0.0
): array {
$publicHolidays = $this->buildPublicHolidayMap($from, $to);
$weekendWorkedDays = $this->workHourRepository->countWeekendWorkedDaysByMonth($employee, $from, $to);
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to);
@@ -697,10 +722,20 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
? $this->workHourRepository->findWorkedDatesAmong($employee, array_keys($publicHolidays))
: [];
// Sort absences chronologically so N-1 budget (forfait only) is consumed in date order:
// earliest absences attribute to N-1 first, later ones overflow to N and reduce presence.
$sortedAbsences = $absences;
usort(
$sortedAbsences,
static fn ($a, $b): int => $a->getStartDate() <=> $b->getStartDate()
);
$remainingN1Budget = $n1AbsencesBudget;
// Count absence days per month, iterating day by day to handle multi-day absences
// and properly distribute across months.
$absenceDaysByMonth = [];
foreach ($absences as $absence) {
foreach ($sortedAbsences as $absence) {
$start = DateTimeImmutable::createFromInterface($absence->getStartDate())->setTime(0, 0);
$end = DateTimeImmutable::createFromInterface($absence->getEndDate())->setTime(0, 0);
@@ -718,6 +753,17 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
continue;
}
// Forfait: leaves taken from N-1 stock do NOT decrement presence days.
// We chronologically consume the N-1 budget before counting any absence.
if ($remainingN1Budget > 0.0) {
$consumed = min($remainingN1Budget, $dayAmount);
$remainingN1Budget -= $consumed;
$dayAmount -= $consumed;
if ($dayAmount <= 0.0) {
continue;
}
}
$absenceDaysByMonth[$monthKey] = ($absenceDaysByMonth[$monthKey] ?? 0.0) + $dayAmount;
}
}

View File

@@ -164,6 +164,18 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
$monthBuckets[$m]['bonus50'] += $payment->getBonus50Minutes();
}
$runningCumul = $summary->carryFromPreviousYearMinutes;
$prevMonth = null;
foreach ($summary->weeks as $week) {
if (null !== $prevMonth && $week->month !== $prevMonth && isset($monthBuckets[$prevMonth])) {
$b = $monthBuckets[$prevMonth];
$runningCumul -= $b['base25'] + $b['bonus25'] + $b['base50'] + $b['bonus50'];
}
$runningCumul += $week->totalMinutes;
$week->cumulativeBalanceMinutes = $runningCumul;
$prevMonth = $week->month;
}
$monthPayments = [];
$totalPaidMinutes = 0;

View File

@@ -363,7 +363,10 @@ class SalaryRecapPrintProvider implements ProviderInterface
if ($wh->getHasBreakfast()) {
++$driverBreakfast;
}
if ($wh->getHasLunch() || $wh->getHasDinner()) {
if ($wh->getHasLunch()) {
++$driverMeals;
}
if ($wh->getHasDinner()) {
++$driverMeals;
}
if ($wh->getHasOvernight()) {

View File

@@ -22,6 +22,7 @@ use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\PublicHolidayServiceInterface;
use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\Service\WorkHours\DailyReferenceMinutesResolver;
use App\Service\WorkHours\HolidayVirtualHoursResolver;
@@ -31,6 +32,7 @@ use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Throwable;
final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
{
@@ -45,6 +47,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
private EmployeeContractResolver $contractResolver,
private DailyReferenceMinutesResolver $dailyReferenceResolver,
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
private PublicHolidayServiceInterface $publicHolidayService,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourWeeklySummary
@@ -122,6 +125,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
$contractNaturesByEmployeeDate = $this->contractResolver->resolveNaturesForEmployeesAndDays($employees, $days);
$isDriverByEmployeeDate = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days);
$workDaysByEmployeeDate = $this->contractResolver->resolveWorkDaysMinutesForEmployeesAndDays($employees, $days);
$holidayLabelsByDate = $this->buildHolidayLabelsForDays($days);
$metricsByEmployeeDate = [];
foreach ($workHours as $workHour) {
$employeeId = $workHour->getEmployee()?->getId();
@@ -324,6 +328,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
hasDinner: $hasDinner,
hasOvernight: $hasOvernight,
virtualHolidayMinutes: $virtualHolidayMinutes,
holidayLabel: $holidayLabelsByDate[$date] ?? null,
);
}
@@ -376,6 +381,38 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
return $rows;
}
/**
* @param list<string> $days
*
* @return array<string, string>
*/
private function buildHolidayLabelsForDays(array $days): array
{
if ([] === $days) {
return [];
}
$years = [];
foreach ($days as $day) {
$years[substr($day, 0, 4)] = true;
}
$map = [];
try {
foreach (array_keys($years) as $year) {
$holidays = $this->publicHolidayService->getHolidaysDayByYears('metropole', (string) $year);
foreach ($holidays as $date => $label) {
$map[(string) $date] = (string) $label;
}
}
} catch (Throwable) {
return [];
}
return $map;
}
private function computeMetrics(WorkHour $workHour): WorkMetrics
{
$ranges = [

View File

@@ -76,11 +76,14 @@
td { font-size: 9px; }
td.date { text-align: left; font-weight: bold; }
td.absence { text-align: left; color: #c00; }
td.absence .holiday { color: #0277bd; font-weight: 600; }
td.absence .holiday.with-absence { display: block; }
td.time { text-align: center; }
td.presence { text-align: center; }
td.total { text-align: center; font-weight: bold; }
tr.weekend td { background: #f3f3f3; color: #555; }
tr.weekend td.date { color: #333; }
tr.holiday td { background: #e1f5fe; }
.signature-footer {
page-break-inside: avoid;
@@ -165,9 +168,12 @@
</thead>
<tbody>
{% for row in segment.rows %}
<tr class="{{ row.isWeekend ? 'weekend' : '' }}">
<tr class="{{ row.isWeekend ? 'weekend' : (row.holidayLabel ? 'holiday' : '') }}">
<td class="date">{{ row.date }}</td>
<td class="absence">{{ row.absenceLabel ?? '' }}</td>
<td class="absence">
{{ row.absenceLabel ?? '' }}
{% if row.holidayLabel %}<span class="holiday {{ row.absenceLabel ? 'with-absence' : '' }}">Férié : {{ row.holidayLabel }}</span>{% endif %}
</td>
<td class="presence">{{ row.presentMorning ? 'X' : '' }}</td>
<td class="presence">{{ row.presentAfternoon ? 'X' : '' }}</td>
<td class="total">{{ row.total }}</td>
@@ -189,9 +195,12 @@
</thead>
<tbody>
{% for row in segment.rows %}
<tr class="{{ row.isWeekend ? 'weekend' : '' }}">
<tr class="{{ row.isWeekend ? 'weekend' : (row.holidayLabel ? 'holiday' : '') }}">
<td class="date">{{ row.date }}</td>
<td class="absence">{{ row.absenceLabel ?? '' }}</td>
<td class="absence">
{{ row.absenceLabel ?? '' }}
{% if row.holidayLabel %}<span class="holiday {{ row.absenceLabel ? 'with-absence' : '' }}">Férié : {{ row.holidayLabel }}</span>{% endif %}
</td>
<td class="time">{{ row.dayHours }}</td>
<td class="time">{{ row.nightHours }}</td>
<td class="time">{{ row.workshopHours }}</td>
@@ -217,9 +226,12 @@
</thead>
<tbody>
{% for row in segment.rows %}
<tr class="{{ row.isWeekend ? 'weekend' : '' }}">
<tr class="{{ row.isWeekend ? 'weekend' : (row.holidayLabel ? 'holiday' : '') }}">
<td class="date">{{ row.date }}</td>
<td class="absence">{{ row.absenceLabel ?? '' }}</td>
<td class="absence">
{{ row.absenceLabel ?? '' }}
{% if row.holidayLabel %}<span class="holiday {{ row.absenceLabel ? 'with-absence' : '' }}">Férié : {{ row.holidayLabel }}</span>{% endif %}
</td>
<td class="time">{{ row.morningFrom }}</td>
<td class="time">{{ row.morningTo }}</td>
<td class="time">{{ row.afternoonFrom }}</td>

View File

@@ -65,11 +65,14 @@
td { font-size: 9px; }
td.date { text-align: left; font-weight: bold; }
td.absence { text-align: left; color: #c00; }
td.absence .holiday { color: #0277bd; font-weight: 600; }
td.absence .holiday.with-absence { display: block; }
td.time { text-align: center; }
td.presence { text-align: center; }
td.total { text-align: center; font-weight: bold; }
tr.weekend td { background: #f3f3f3; color: #555; }
tr.weekend td.date { color: #333; }
tr.holiday td { background: #e1f5fe; }
.signature-footer {
page-break-inside: avoid;
@@ -151,9 +154,12 @@
</thead>
<tbody>
{% for row in segment.rows %}
<tr class="{{ row.isWeekend ? 'weekend' : '' }}">
<tr class="{{ row.isWeekend ? 'weekend' : (row.holidayLabel ? 'holiday' : '') }}">
<td class="date">{{ row.date }}</td>
<td class="absence">{{ row.absenceLabel ?? '' }}</td>
<td class="absence">
{{ row.absenceLabel ?? '' }}
{% if row.holidayLabel %}<span class="holiday {{ row.absenceLabel ? 'with-absence' : '' }}">Férié : {{ row.holidayLabel }}</span>{% endif %}
</td>
<td class="presence">{{ row.presentMorning ? 'X' : '' }}</td>
<td class="presence">{{ row.presentAfternoon ? 'X' : '' }}</td>
<td class="total">{{ row.total }}</td>
@@ -175,9 +181,12 @@
</thead>
<tbody>
{% for row in segment.rows %}
<tr class="{{ row.isWeekend ? 'weekend' : '' }}">
<tr class="{{ row.isWeekend ? 'weekend' : (row.holidayLabel ? 'holiday' : '') }}">
<td class="date">{{ row.date }}</td>
<td class="absence">{{ row.absenceLabel ?? '' }}</td>
<td class="absence">
{{ row.absenceLabel ?? '' }}
{% if row.holidayLabel %}<span class="holiday {{ row.absenceLabel ? 'with-absence' : '' }}">Férié : {{ row.holidayLabel }}</span>{% endif %}
</td>
<td class="time">{{ row.dayHours }}</td>
<td class="time">{{ row.nightHours }}</td>
<td class="time">{{ row.workshopHours }}</td>
@@ -203,9 +212,12 @@
</thead>
<tbody>
{% for row in segment.rows %}
<tr class="{{ row.isWeekend ? 'weekend' : '' }}">
<tr class="{{ row.isWeekend ? 'weekend' : (row.holidayLabel ? 'holiday' : '') }}">
<td class="date">{{ row.date }}</td>
<td class="absence">{{ row.absenceLabel ?? '' }}</td>
<td class="absence">
{{ row.absenceLabel ?? '' }}
{% if row.holidayLabel %}<span class="holiday {{ row.absenceLabel ? 'with-absence' : '' }}">Férié : {{ row.holidayLabel }}</span>{% endif %}
</td>
<td class="time">{{ row.morningFrom }}</td>
<td class="time">{{ row.morningTo }}</td>
<td class="time">{{ row.afternoonFrom }}</td>

View File

@@ -66,6 +66,7 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
$this->buildResolverStub(),
new DailyReferenceMinutesResolver(),
$this->buildHolidayResolver(),
$this->buildHolidayService(),
);
$this->expectException(AccessDeniedHttpException::class);
@@ -128,6 +129,7 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
$this->buildWeeklyResolverStub($employees),
new DailyReferenceMinutesResolver(),
$this->buildHolidayResolver(),
$this->buildHolidayService(),
);
$result = $provider->provide(new Get());
@@ -179,15 +181,20 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
}
private function buildHolidayResolver(array $holidayMap = []): HolidayVirtualHoursResolver
{
return new HolidayVirtualHoursResolver(
new DailyReferenceMinutesResolver(),
$this->buildHolidayService($holidayMap),
$this->createStub(EmployeeContractResolver::class),
);
}
private function buildHolidayService(array $holidayMap = []): PublicHolidayServiceInterface
{
$service = $this->createStub(PublicHolidayServiceInterface::class);
$service->method('getHolidaysDayByYears')->willReturn($holidayMap);
return new HolidayVirtualHoursResolver(
new DailyReferenceMinutesResolver(),
$service,
$this->createStub(EmployeeContractResolver::class),
);
return $service;
}
private function buildResolverStub(): EmployeeContractResolver