Compare commits

...

19 Commits

Author SHA1 Message Date
gitea-actions
3ec0d4b074 chore: bump version to v0.1.100
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 40s
2026-04-29 15:45:14 +00:00
eaf8a11e2b feat: ajout des commentaires à la semaine (#15)
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

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

Reviewed-on: #15
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-29 15:45:02 +00:00
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
gitea-actions
90843dd997 chore: bump version to v0.1.95
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 31s
2026-04-20 14:19:05 +00:00
8a449cf81b feat : paiement RTT en centièmes d'heures + auto-calcul bonus
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
- Input step passé de 0.5 à 0.01 pour accepter les centièmes (xx,xx)
- Labels mis à jour "(centièmes)" au lieu de "(heures)"
- Auto-remplissage du bonus 25% (base × 0.25) et 50% (base × 0.50)
- Ligne "Payé" affiche désormais les centièmes en gris comme les autres lignes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 16:18:54 +02:00
gitea-actions
3926946a5f chore: bump version to v0.1.94
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build & Push Docker Image / build (push) Successful in 33s
2026-04-20 10:12:10 +00:00
b9c3a8a84f [#SIRH-25] Version mobile (#16)
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: #16
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-20 10:12:05 +00:00
gitea-actions
b2f6fdf222 chore: bump version to v0.1.93
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 18s
2026-04-20 06:25:18 +00:00
0fe82c63c5 Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-20 08:25:11 +02:00
849d19f124 fix : autoriser docker/php/config/php.ini dans .dockerignore pour le build prod
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 08:24:39 +02:00
62 changed files with 2105 additions and 1006 deletions

View File

@@ -3,6 +3,7 @@
.env.local
.env.test
docker/
!docker/php/config/php.ini
deploy/docker/docker-compose.prod.yml
deploy/docker/deploy.sh
deploy/docker/.env.example

View File

@@ -15,6 +15,7 @@
## Stack
- Backend: Symfony + API Platform + Doctrine ORM
- Frontend: Nuxt 4 + Vue 3 + TypeScript + Tailwind CSS
- UI library: `@malio/layer-ui` (Nuxt layer, `extends: ['@malio/layer-ui']` dans `nuxt.config.ts`). Composants auto-importés avec préfixe `Malio*` (ex. `MalioSelectCheckbox`, `MalioInputText`…). Doc d'usage dans `node_modules/@malio/layer-ui/COMPONENTS.md`. Tokens Tailwind `m-*` (primary/muted/danger/success/…) et variables CSS `--m-*` fournies par la couche.
## Project Structure
- `src/` — Symfony domain, API resources, state providers/processors, services
@@ -32,6 +33,8 @@
- Contract nature (per period): CDI, CDD, INTERIM
- **Agence d'intérim** (`InterimAgency` entity, table `interim_agencies`): optionnelle sur `EmployeeContractPeriod` quand nature = INTERIM. Pas de CRUD UI — gérée en BDD. API lecture seule `GET /interim_agencies`. Affichée "Intérim (NomAgence)" sur la liste employés et l'historique contrat.
- Employee contract history: `employee_contract_periods`, resolved by `EmployeeContractResolver`
- **Écrans Heures / Heures Conducteurs (vue jour)** : le libellé nature (CDI/CDD/Intérim) sous le nom de l'employé est résolu **à la date filtrée** via `WorkHourDayContext.contractNature` (alimenté par `EmployeeContractResolver::resolveNatureForEmployeeAndDate`), pas via `Employee.currentContractNature` (qui est résolu à aujourd'hui).
- **Écran Calendrier** : un employé est affiché uniquement si au moins une de ses périodes de contrat (`employee.contractHistory`) intersecte le mois affiché (`[1er ; dernier jour]`). Filtre côté frontend dans `visibleEmployees` (`pages/calendar.vue`).
- **Planning jours travaillés** (`EmployeeContractPeriod.workDaysHours` : JSON `{iso_day: minutes}`) : obligatoire pour tout contrat TIME **hors 35h/39h/INTERIM** (ex. 4h, 25h, 28h). Somme = `weeklyHours × 60`. Utilisé par `HolidayVirtualHoursResolver` (crédit férié) et `WorkedHoursCreditPolicy` (crédit absence) pour ne créditer que les jours effectivement travaillés. Validation : `EmployeeContractPeriodValidator::assertWorkDaysHours`.
- Absences: stored per day (auto-split), AM/PM/full day, clear corresponding hour slots
- Absences with `countAsWorkedHours=true`: credit minutes (TIME) or nothing (PRESENCE)
@@ -45,6 +48,12 @@
- Saisie d'heures (ou de jours de présence) autorisée sur un férié
- **Crédit automatique des heures contractuelles** sur un férié Lun-Ven pour tout contrat hors Forfait, **uniquement en l'absence d'absence déclarée** : le total journalier = `max(saisie + credited_absence, référence_contractuelle)`. Référence : 35h→7h, 39h→8h Lun-Jeu/7h Ven, CUSTOM→weeklyHours/5, INTERIM→idem 35h/39h/custom selon weeklyHours. Aucune ligne BDD créée (crédit virtuel). Drivers : crédité en `dayHoursMinutes`. Impacte directement le total hebdo RTT (tranches 25%/50%). Dès qu'une absence est posée sur le férié, le crédit virtuel saute — c'est le `countAsWorkedHours` du type d'absence qui pilote. Services : `App\Service\WorkHours\HolidayVirtualHoursResolver` + `DailyReferenceMinutesResolver`. Doc complète : `doc/holiday-virtual-hours.md`.
## Commentaires de semaine
- Entité `EmployeeWeekComment` : commentaire libre par employé et semaine ISO (unique `(employee_id, week_start_date)`). `week_start_date` = lundi.
- CRUD `/employee_week_comments` `ROLE_ADMIN`. Write processor audite via `AuditLogger`.
- Picto bulle vue semaine (HoursWeekView + DriverHoursWeekView) : fond bleu/rouge. Intégré dans `WeeklySummaryRow.comment/commentId`.
- Doc : `doc/week-comments.md`.
## Validation Rules
- `isValid` (RH): locks line for everyone (admin can only untoggle validation)
- `isSiteValid` (site manager): locks for non-admin, admin can still edit
@@ -58,6 +67,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.92'
app.version: '0.1.100'

View File

@@ -58,6 +58,9 @@ Documents complementaires:
- mise à jour uniquement quand un employé (`ROLE_SELF`) modifie ses propres heures
- non mise à jour lors de modifications admin ou chef de site
- affichée sous le nom de l'employé (visible admin uniquement)
- Libellé nature de contrat (CDI/CDD/Intérim) affiché sous le nom:
- résolu à la date filtrée (période de contrat couvrant ce jour), pas à aujourd'hui
- masqué si aucun contrat à cette date (cas rarissime en vue jour puisque l'employé est alors déjà filtré)
## 4) Absences
@@ -71,6 +74,10 @@ Documents complementaires:
- Calendrier congés: fond coloré selon la couleur du type d'absence (`AbsenceType.color`)
- demi-journée: dégradé diagonal
- journée complète: fond plein
- Visibilité des employés dans le Calendrier:
- un employé est affiché si au moins une de ses périodes de contrat intersecte le mois affiché
- un employé dont toutes les périodes se terminent avant le 1er du mois (ou commencent après la fin du mois) est masqué
- même logique que l'écran Heures : « pas de contrat sur la période → masqué »
### Effet absence sur les heures
@@ -166,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
@@ -306,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)
@@ -328,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)
@@ -371,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 |
@@ -435,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
@@ -453,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

1
frontend/.npmrc Normal file
View File

@@ -0,0 +1 @@
@malio:registry=https://gitea.malio.fr/api/packages/MALIO-DEV/npm/

View File

@@ -1,44 +1,26 @@
<template>
<AppDrawer v-model="drawerOpen" title="Nouvelle absence">
<form class="space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="employee">
Employé <span class="text-red-600">*</span>
</label>
<select
id="employee"
v-model="absenceForm.employeeId"
:class="employeeFieldClass"
:disabled="props.lockEmployee"
>
<option value="" disabled>Choisir un employé</option>
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
{{ employee.firstName }} {{ employee.lastName }}
</option>
</select>
<p v-if="showEmployeeError" class="mt-1 text-sm text-red-600">
L'employé est obligatoire.
</p>
</div>
<MalioSelect
:model-value="absenceForm.employeeId === '' ? null : absenceForm.employeeId"
:options="employeeOptions"
label="Employé *"
empty-option-label="Choisir un employé"
min-width=""
:disabled="props.lockEmployee"
:error="showEmployeeError ? `L'employé est obligatoire.` : ''"
@update:model-value="onEmployeeChange"
/>
<div>
<label class="text-md font-semibold text-neutral-700" for="type">
Type d'absence <span class="text-red-600">*</span>
</label>
<select
id="type"
v-model="absenceForm.typeId"
:class="typeFieldClass"
>
<option value="" disabled>Choisir un type</option>
<option v-for="type in absenceTypes" :key="type.id" :value="type.id">
{{ type.label }} ({{ type.code }})
</option>
</select>
<p v-if="showTypeError" class="mt-1 text-sm text-red-600">
Le type d'absence est obligatoire.
</p>
</div>
<MalioSelect
:model-value="absenceForm.typeId === '' ? null : absenceForm.typeId"
:options="typeOptions"
label="Type d'absence *"
empty-option-label="Choisir un type"
min-width=""
:error="showTypeError ? `Le type d'absence est obligatoire.` : ''"
@update:model-value="onTypeChange"
/>
<div class="space-y-4">
<div>
@@ -48,17 +30,15 @@
id="start-date"
v-model="absenceForm.startDate"
type="date"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
:class="[dateInputBaseClass, absenceForm.startDate ? 'border-black' : 'border-m-muted']"
:disabled="props.lockDates"
/>
<select
v-model="absenceForm.startHalf"
class="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
>
<option v-for="half in HALF_DAYS" :key="half.value" :value="half.value">
{{ half.label }}
</option>
</select>
<MalioSelect
:model-value="absenceForm.startHalf"
:options="halfDayOptions"
min-width=""
@update:model-value="(v) => { if (v !== null) absenceForm.startHalf = v as HalfDay }"
/>
</div>
</div>
<div>
@@ -68,17 +48,15 @@
id="end-date"
v-model="absenceForm.endDate"
type="date"
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
:class="[dateInputBaseClass, absenceForm.endDate ? 'border-black' : 'border-m-muted']"
:disabled="props.lockDates"
/>
<select
v-model="absenceForm.endHalf"
class="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
>
<option v-for="half in HALF_DAYS" :key="half.value" :value="half.value">
{{ half.label }}
</option>
</select>
<MalioSelect
:model-value="absenceForm.endHalf"
:options="halfDayOptions"
min-width=""
@update:model-value="(v) => { if (v !== null) absenceForm.endHalf = v as HalfDay }"
/>
</div>
</div>
</div>
@@ -110,13 +88,12 @@
</button>
</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="props.isSubmitting || !isFormValid"
/>
</div>
</form>
</AppDrawer>
@@ -189,20 +166,23 @@ const submitButtonClass = computed(() => {
return ''
})
const baseSelectClass =
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
const employeeFieldClass = computed(() => {
if (showEmployeeError.value) {
return `${baseSelectClass} border-red-500`
}
return `${baseSelectClass} border-neutral-300`
})
const typeFieldClass = computed(() => {
if (showTypeError.value) {
return `${baseSelectClass} border-red-500`
}
return `${baseSelectClass} border-neutral-300`
})
const employeeOptions = computed(() =>
props.employees.map((e) => ({ label: `${e.firstName} ${e.lastName}`, value: e.id }))
)
const typeOptions = computed(() =>
props.absenceTypes.map((t) => ({ label: `${t.label} (${t.code})`, value: t.id }))
)
const halfDayOptions = HALF_DAYS.map((h) => ({ label: h.label, value: h.value }))
const dateInputBaseClass =
'h-10 w-full rounded-md border px-3 text-md text-black outline-none focus:border-2 focus:border-m-primary'
const onEmployeeChange = (value: string | number | null) => {
absenceForm.value.employeeId = value === null ? '' : Number(value)
}
const onTypeChange = (value: string | number | null) => {
absenceForm.value.typeId = value === null ? '' : Number(value)
}
watch(
() => props.modelValue,

View File

@@ -9,8 +9,15 @@
<h2 class="text-[32px] font-semibold text-primary-500">
{{ title }}
</h2>
<button
type="button"
class="rounded-md p-1 text-primary-500 hover:text-secondary-500"
@click="close"
>
<Icon name="mdi:close" size="24"/>
</button>
</div>
<div class="min-h-0 flex-1 overflow-y-auto px-[20px] pb-4">
<div class="min-h-0 flex-1 overflow-y-auto px-[20px] pb-4 pt-1">
<slot />
</div>
</div>

View File

@@ -1,7 +1,14 @@
<template>
<header ref="headerRef" class="border-b border-neutral-200 bg-primary-500 p-5 text-white">
<div class="flex h-full items-center justify-end">
<div class="flex gap-6 text-xl text-white">
<header ref="headerRef" class="border-b border-neutral-200 bg-primary-500 px-4 py-3 text-white lg:p-5">
<div class="flex h-full items-center justify-between lg:justify-end">
<button
type="button"
class="rounded-md p-1 text-white hover:text-neutral-200 lg:hidden"
@click="$emit('toggleSidebar')"
>
<Icon name="mdi:menu" size="28"/>
</button>
<div class="flex gap-4 text-xl text-white lg:gap-6">
<div v-if="isAdmin" ref="bellRoot" class="relative">
<button type="button" class="relative self-center cursor-pointer flex items-center" @click="toggleNotifications">
<Icon name="mdi:bell-plus" size="36"/>
@@ -15,8 +22,8 @@
<div
v-if="isNotificationsOpen"
class="fixed right-[20px] z-30 w-[400px] rounded-md border border-neutral-200 bg-white text-neutral-800 shadow-lg"
:style="{ top: `${navbarBottom + 20}px` }"
class="fixed right-2 z-30 w-[calc(100vw-1rem)] max-w-[400px] rounded-md border border-neutral-200 bg-white text-neutral-800 shadow-lg lg:right-[20px]"
:style="{ top: `${navbarBottom + 10}px` }"
>
<div class="px-3 pt-3 pb-6 text-xl font-semibold">
Notifications
@@ -66,7 +73,7 @@
<div ref="userMenuRoot" class="relative flex gap-4">
<button type="button" class="flex items-center gap-4 cursor-pointer" @click="toggleUserMenu">
<Icon name="mdi:account-circle-outline" class="self-center" size="36"/>
<p class="self-center">{{ user?.username }}</p>
<p class="hidden self-center sm:block">{{ user?.username }}</p>
</button>
<div
v-if="isUserMenuOpen"
@@ -103,6 +110,10 @@ defineProps<{
user?: User
}>()
defineEmits<{
(event: 'toggleSidebar'): void
}>()
const formatTimeAgo = (dateString: string): string => {
const date = new Date(dateString)
const now = new Date()

View File

@@ -1,26 +0,0 @@
<template>
<div class="relative w-full max-w-[340px]">
<input
id="employee-search"
v-model="model"
type="text"
:placeholder="placeholder"
class="h-10 w-full rounded-md border border-neutral-300 bg-white pl-3 pr-10 text-md text-neutral-900"
/>
<Icon
name="mdi:magnify"
size="18"
class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500"
/>
</div>
</template>
<script setup lang="ts">
const model = defineModel<string>({required: true})
withDefaults(defineProps<{
placeholder?: string
}>(), {
placeholder: "Recherche d'un employé"
})
</script>

View File

@@ -1,69 +0,0 @@
<template>
<div ref="root" class="relative inline-block w-fit max-w-full">
<button
type="button"
class="inline-flex w-[320px] min-h-10 items-center justify-between gap-2 rounded-md border border-primary-500 bg-white px-3 py-2 text-md font-semibold text-primary-500 hover:bg-tertiary-500"
@click="isOpen = !isOpen"
>
<span>Sites</span>
<span class="inline-flex items-center gap-2">
<span class="text-sm font-medium text-neutral-600">{{ selectedCount }}/{{ sites.length }}</span>
<Icon :name="isOpen ? 'mdi:chevron-up' : 'mdi:chevron-down'" size="18" />
</span>
</button>
<div
v-if="isOpen"
class="z-50 absolute left-0 top-full z-20 mt-2 max-h-80 w-full overflow-auto rounded-md border border-neutral-200 bg-white p-3 shadow-lg"
>
<div class="flex flex-col gap-2">
<label
v-for="site in sites"
:key="site.id"
:for="`site-${site.id}`"
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-tertiary-500"
>
<input
:id="`site-${site.id}`"
v-model="selectedSiteIds"
:value="site.id"
type="checkbox"
class="h-4 w-4"
/>
<span class="text-md text-neutral-800">{{ site.name }}</span>
</label>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import type { Site } from '~/services/dto/site'
const selectedSiteIds = defineModel<number[]>({ required: true })
const isOpen = ref(false)
const root = ref<HTMLElement | null>(null)
defineProps<{
sites: Site[]
}>()
const selectedCount = computed(() => selectedSiteIds.value.length)
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node | null
if (!root.value || !target) return
if (!root.value.contains(target)) {
isOpen.value = false
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>

View File

@@ -43,7 +43,7 @@
</p>
<p class="text-neutral-500 truncate inline-flex items-center gap-2">
<span>
{{ employee.site?.name ?? 'Sans site' }}<span v-if="employee.currentContractNature"> {{ contractNatureLabel(employee.currentContractNature) }}</span>
{{ employee.site?.name ?? 'Sans site' }}<span v-if="getRowContractNature(employee.id)"> {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }}</span>
</span>
<span
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
@@ -205,6 +205,7 @@ const props = defineProps<{
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; workshopMinutes: number; totalMinutes: number; virtualHolidayMinutes: number }
getRowAbsenceLabel: (employeeId: number) => string
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
getRowContractNature: (employeeId: number) => 'CDI' | 'CDD' | 'INTERIM' | null
getRowUpdatedAt: (employeeId: number) => string
onAbsenceClick: (employeeId: number) => void
formatMinutes: (minutes: number) => string

View File

@@ -33,8 +33,11 @@
{{ row.firstName }} {{ row.lastName }}
<span class="font-normal text-neutral-600">({{ row.contractName ?? '-' }})</span>
</p>
<p class="text-[11px] text-neutral-500 truncate">
{{ row.siteName ?? 'Sans site' }}<span v-if="row.contractNature"> {{ contractNatureLabel(row.contractNature) }}</span>
<p class="text-[11px] text-neutral-500 truncate inline-flex items-center gap-2">
<span>{{ row.siteName ?? 'Sans site' }}<span v-if="row.contractNature"> {{ contractNatureLabel(row.contractNature) }}</span></span>
<button v-if="isAdmin" type="button" class="inline-flex items-center justify-center rounded-md p-1 text-white transition-colors" :class="row.comment ? 'bg-red-500 hover:bg-red-600' : 'bg-primary-500 hover:bg-secondary-500'" :title="row.comment ?? 'Ajouter un commentaire'" @click="$emit('open-comment', row)">
<Icon name="mdi:comment-text-outline" size="12"/>
</button>
</p>
</div>
@@ -44,7 +47,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,19 +96,37 @@
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<{
isWeekLoading: boolean
isAdmin: boolean
weekGridCols: string
weeklySummary: WeeklyWorkHourSummary | null
weekDayHeaders: Array<{ date: string; weekday: string; dayDate: string }>
formatMinutes: (minutes: number) => string
}>()
defineEmits<{ (e: 'open-comment', row: WeeklyWorkHourSummary['rows'][number]): void }>()
</script>

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,20 +150,22 @@
<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 -->
<tr>
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">Payé</td>
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-r-2">-</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBase25Minutes) : '0 h' }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBonus25Minutes) : '0 h' }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ currentPayment ? formatMinutes(-(currentPayment.paidBase25Minutes + currentPayment.paidBonus25Minutes)) : '0 h' }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBase50Minutes) : '0 h' }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBonus50Minutes) : '0 h' }}</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' }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(paidTotal) }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBase25Minutes) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -currentPayment.paidBase25Minutes : 0) }}</span></td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBonus25Minutes) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -currentPayment.paidBonus25Minutes : 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.paidBase25Minutes + currentPayment.paidBonus25Minutes)) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -(currentPayment.paidBase25Minutes + currentPayment.paidBonus25Minutes) : 0) }}</span></td>
<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 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>
@@ -187,41 +198,41 @@
</select>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-neutral-700">Base 25% (heures)</label>
<label class="block text-sm font-medium text-neutral-700">Base 25% (centièmes)</label>
<input
v-model.number="paymentForm.base25Hours"
type="number"
step="0.5"
step="0.01"
min="0"
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 class="mb-4">
<label class="block text-sm font-medium text-neutral-700">Heures 25% (heures)</label>
<label class="block text-sm font-medium text-neutral-700">Heures 25% (centièmes)</label>
<input
v-model.number="paymentForm.bonus25Hours"
type="number"
step="0.5"
step="0.01"
min="0"
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 class="mb-4">
<label class="block text-sm font-medium text-neutral-700">Base 50% (heures)</label>
<label class="block text-sm font-medium text-neutral-700">Base 50% (centièmes)</label>
<input
v-model.number="paymentForm.base50Hours"
type="number"
step="0.5"
step="0.01"
min="0"
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 class="mb-6">
<label class="block text-sm font-medium text-neutral-700">Heures 50% (heures)</label>
<label class="block text-sm font-medium text-neutral-700">Heures 50% (centièmes)</label>
<input
v-model.number="paymentForm.bonus50Hours"
type="number"
step="0.5"
step="0.01"
min="0"
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"
/>
@@ -500,10 +511,10 @@ const paymentForm = reactive({
const prefillFromExistingPayment = (month: number) => {
const existing = props.summary?.monthPayments.find((p) => p.month === month) ?? null
if (existing) {
paymentForm.base25Hours = existing.paidBase25Minutes / 60
paymentForm.bonus25Hours = existing.paidBonus25Minutes / 60
paymentForm.base50Hours = existing.paidBase50Minutes / 60
paymentForm.bonus50Hours = existing.paidBonus50Minutes / 60
paymentForm.base25Hours = Math.round(existing.paidBase25Minutes / 60 * 100) / 100
paymentForm.bonus25Hours = Math.round(existing.paidBonus25Minutes / 60 * 100) / 100
paymentForm.base50Hours = Math.round(existing.paidBase50Minutes / 60 * 100) / 100
paymentForm.bonus50Hours = Math.round(existing.paidBonus50Minutes / 60 * 100) / 100
} else {
paymentForm.base25Hours = 0
paymentForm.bonus25Hours = 0
@@ -516,6 +527,14 @@ watch(() => paymentForm.month, (newMonth) => {
prefillFromExistingPayment(newMonth)
})
watch(() => paymentForm.base25Hours, (value) => {
paymentForm.bonus25Hours = Math.round(value * 0.25 * 100) / 100
})
watch(() => paymentForm.base50Hours, (value) => {
paymentForm.bonus50Hours = Math.round(value * 0.50 * 100) / 100
})
const openPaymentDrawer = () => {
paymentForm.month = currentMonth.value
prefillFromExistingPayment(currentMonth.value)

View File

@@ -1,6 +1,174 @@
<template>
<div class="bg-white overflow-hidden flex min-h-0 flex-col">
<div class="overflow-y-auto min-h-0">
<!-- Mobile card layout -->
<div class="overflow-y-auto min-h-0 space-y-3 lg:hidden">
<div
v-for="employee in employees"
:key="'m-' + employee.id"
class="rounded-md border border-primary-500 bg-white p-4"
>
<!-- Employee name + site -->
<div class="mb-3">
<p class="text-md font-bold text-primary-500 truncate">
{{ employee.firstName }} {{ employee.lastName }}
<span class="font-normal text-neutral-600 text-sm">({{ contractLabel(employee) }})</span>
</p>
<p class="text-sm text-neutral-500 truncate">
{{ employee.site?.name ?? 'Sans site' }}<span v-if="getRowContractNature(employee.id)"> {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }}</span>
</p>
</div>
<!-- Absence / Holiday / Formation pills -->
<div class="mb-3 flex flex-col gap-1">
<p
v-if="getRowAbsenceLabel(employee.id)"
class="rounded-md px-2 py-1 text-xs text-white truncate"
:style="getRowAbsenceStyle(employee.id)"
>
{{ getRowAbsenceLabel(employee.id) }}
</p>
<p
v-else
class="text-xs text-neutral-400"
>
Aucune absence
</p>
<p
v-if="isHoliday"
class="rounded-md px-2 py-1 text-xs text-sky-900 inline-flex items-center gap-1"
style="background-color: #b3e5fc"
>
<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="rounded-md px-2 py-1 text-xs text-white bg-indigo-500 inline-flex items-center gap-1"
>
<Icon name="mdi:school-outline" size="14" class="shrink-0"/>
<span class="truncate">{{ getRowFormationLabel(employee.id) }}</span>
</p>
<button
v-if="!hasRowFormation(employee.id)"
type="button"
class="self-start text-xs font-semibold underline"
: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
</button>
</div>
<!-- Time inputs (TIME tracking) -->
<div v-if="isTimeTracking(employee)" class="space-y-2">
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-xs text-neutral-500">Début matin</label>
<TimeSelect
v-model="rows[employee.id].morningFrom"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
/>
</div>
<div>
<label class="text-xs text-neutral-500">Fin matin</label>
<TimeSelect
v-model="rows[employee.id].morningTo"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-xs text-neutral-500">Début après-midi</label>
<TimeSelect
v-model="rows[employee.id].afternoonFrom"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
/>
</div>
<div>
<label class="text-xs text-neutral-500">Fin après-midi</label>
<TimeSelect
v-model="rows[employee.id].afternoonTo"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="text-xs text-neutral-500">Début soir</label>
<TimeSelect
v-model="rows[employee.id].eveningFrom"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
/>
</div>
<div>
<label class="text-xs text-neutral-500">Fin soir</label>
<TimeSelect
v-model="rows[employee.id].eveningTo"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
/>
</div>
</div>
<div class="flex gap-4 pt-1 text-sm font-semibold text-primary-500">
<span>Jour : {{ formatMinutes(getRowMetrics(employee.id).dayMinutes) }}</span>
<span>Nuit : {{ formatMinutes(getRowMetrics(employee.id).nightMinutes) }}</span>
<span>Total : {{ formatMinutes(getRowMetrics(employee.id).totalMinutes) }}</span>
</div>
</div>
<!-- Presence tracking -->
<div v-else-if="isPresenceTracking(employee)" class="space-y-2">
<div class="flex items-center gap-4">
<label class="flex items-center gap-2 text-sm">
<input
v-model="rows[employee.id].isPresentMorning"
type="checkbox"
class="h-4 w-4 cursor-pointer"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
/>
Matin
</label>
<label class="flex items-center gap-2 text-sm">
<input
v-model="rows[employee.id].isPresentAfternoon"
type="checkbox"
class="h-4 w-4 cursor-pointer"
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
/>
Après-midi
</label>
</div>
<p class="text-sm font-semibold text-primary-500">Total : {{ getPresenceDayValue(employee.id) }}</p>
</div>
<!-- Validation status (non-admin) -->
<div v-if="!isAdmin" class="mt-3 flex gap-4 text-xs border-t border-neutral-200 pt-2">
<span v-if="!isSiteManager" class="flex items-center gap-1">
<Icon name="mdi:check-circle-outline" size="14" :class="rows[employee.id]?.isSiteValid ? 'text-green-600' : 'text-neutral-400'"/>
Validation site : <span :class="rows[employee.id]?.isSiteValid ? 'font-semibold text-green-600' : 'text-neutral-500'">{{ rows[employee.id]?.isSiteValid ? 'Validé' : 'En attente' }}</span>
</span>
<span class="flex items-center gap-1">
<Icon name="mdi:check-circle-outline" size="14" :class="rows[employee.id]?.isValid ? 'text-green-600' : 'text-neutral-400'"/>
Validation RH : <span :class="rows[employee.id]?.isValid ? 'font-semibold text-green-600' : 'text-neutral-500'">{{ rows[employee.id]?.isValid ? 'Validé' : 'En attente' }}</span>
</span>
</div>
<!-- Validation checkbox (admin) -->
<div v-if="isAdmin" class="mt-3 flex items-center gap-2 text-sm">
<input
:checked="rows[employee.id]?.isValid ?? false"
type="checkbox"
class="h-4 w-4 cursor-pointer"
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
/>
<span class="text-neutral-700 font-semibold">Valider</span>
</div>
</div>
</div>
<!-- Desktop table layout -->
<div class="overflow-y-auto min-h-0 hidden lg:block">
<div
class="grid w-full min-w-0 gap-1 border border-black bg-tertiary-500 px-4 py-3 text-sm font-semibold text-black rounded-t-md sticky top-0 z-10"
:style="{ gridTemplateColumns: dayGridCols }"
@@ -44,7 +212,7 @@
</p>
<p class="text-neutral-500 truncate inline-flex items-center gap-2">
<span>
{{ employee.site?.name ?? 'Sans site' }}<span v-if="employee.currentContractNature"> {{ contractNatureLabel(employee.currentContractNature) }}</span>
{{ employee.site?.name ?? 'Sans site' }}<span v-if="getRowContractNature(employee.id)"> {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }}</span>
</span>
<span
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
@@ -237,6 +405,7 @@ const props = defineProps<{
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
hasRowFormation: (employeeId: number) => boolean
getRowFormationLabel: (employeeId: number) => string
getRowContractNature: (employeeId: number) => 'CDI' | 'CDD' | 'INTERIM' | null
getRowUpdatedAt: (employeeId: number) => string
getPresenceDayValue: (employeeId: number) => string
onAbsenceClick: (employeeId: number) => void

View File

@@ -1,17 +1,90 @@
<template>
<div class="py-6 flex flex-col gap-3">
<div class="flex gap-4">
<SiteFilterSelector v-if="sites.length > 0 && isAdmin" v-model="selectedSiteIds" :sites="sites" />
<div v-if="isAdmin" class="w-80 max-w-full">
<EmployeeNameFilterInput v-model="employeeFilter" />
<div class="py-4 flex flex-col gap-3 lg:py-6">
<!-- Desktop: filters row -->
<div class="hidden lg:flex lg:items-center lg:gap-4">
<div v-if="sites.length > 0 && isAdmin" class="relative z-50 w-80">
<MalioSelectCheckbox
v-model="selectedSiteIds"
:options="siteOptions"
groupClass="w-80"
label="Sites"
display-select-all
/>
</div>
<div v-if="isAdmin" class="w-80">
<MalioInputText
v-model="employeeFilter"
label="Recherche d'un employé"
icon-name="mdi:magnify"
/>
</div>
</div>
<div class="flex justify-between items-center gap-4">
<div class="flex gap-4 flex-wrap">
<!-- Mobile: search + filter button -->
<div v-if="isAdmin" class="flex items-center gap-2 lg:hidden">
<div class="flex-1 min-w-0">
<MalioInputText
v-model="employeeFilter"
label="Recherche d'un employé"
icon-name="mdi:magnify"
/>
</div>
<button
type="button"
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-primary-500 bg-white text-primary-500"
@click="filtersDrawerOpen = true"
>
<Icon name="mdi:filter-variant" size="22"/>
</button>
</div>
<!-- Mobile filters drawer -->
<AppDrawer v-model="filtersDrawerOpen" title="Filtres">
<div class="space-y-6">
<div v-if="sites.length > 0 && isAdmin">
<label class="text-md font-semibold text-neutral-700">Sites</label>
<div class="mt-2">
<MalioSelectCheckbox
v-model="selectedSiteIds"
:options="siteOptions"
groupClass="w-80"
label="Sites"
display-select-all
/>
</div>
</div>
<div v-if="isAdmin">
<label class="text-md font-semibold text-neutral-700">Vue</label>
<div class="mt-2 inline-flex h-10 w-full overflow-hidden rounded-md border border-primary-500 bg-white">
<button
type="button"
class="flex-1 inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-semibold"
:class="viewModeButtonClass('day')"
@click="viewMode = 'day'; filtersDrawerOpen = false"
>
<Icon name="mdi:calendar-clock" />
Jour
</button>
<button
type="button"
class="flex-1 inline-flex items-center justify-center gap-2 border-l border-primary-500 px-4 py-2 text-sm font-semibold"
:class="viewModeButtonClass('week')"
@click="viewMode = 'week'; filtersDrawerOpen = false"
>
<Icon name="mdi:calendar-week" />
Semaine
</button>
</div>
</div>
</div>
</AppDrawer>
<!-- Date navigation -->
<div class="flex flex-col gap-3 lg:flex-row lg:justify-between lg:items-center lg:gap-4">
<div class="flex flex-col gap-3 lg:flex-row lg:flex-wrap lg:gap-4">
<div
v-if="viewMode === 'day'"
class="inline-flex h-10 w-[320px] overflow-hidden rounded-md border border-primary-500 bg-white"
class="inline-flex h-10 w-full overflow-hidden rounded-md border border-primary-500 bg-white lg:w-[320px]"
>
<button
type="button"
@@ -41,7 +114,7 @@
<div
v-else
class="inline-flex h-10 w-[320px] overflow-hidden rounded-md border border-primary-500 bg-white"
class="inline-flex h-10 w-full overflow-hidden rounded-md border border-primary-500 bg-white lg:w-[320px]"
>
<button
type="button"
@@ -70,7 +143,7 @@
</div>
<PeriodStepperPicker
width-class="w-[320px]"
width-class="w-full lg:w-[320px]"
:label="formattedSelectedDate"
:picker-type="viewMode === 'week' ? 'week' : 'date'"
:picker-value="pickerValue"
@@ -82,7 +155,8 @@
/>
</div>
<div v-if="isAdmin" class="inline-flex h-10 overflow-hidden rounded-md border border-primary-500 bg-white">
<!-- Desktop: view mode toggle -->
<div v-if="isAdmin" class="hidden lg:inline-flex h-10 overflow-hidden rounded-md border border-primary-500 bg-white">
<button
type="button"
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-semibold active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
@@ -106,7 +180,7 @@
<div
v-if="isAdmin && viewMode === 'week' && absenceTypes.length > 0"
class="flex flex-wrap items-center gap-6"
class="hidden lg:flex flex-wrap items-center gap-6"
>
<p class="font-bold">Légende :</p>
<div v-for="type in absenceTypes" :key="type.id" class="flex items-center gap-2">
@@ -120,9 +194,8 @@
<script setup lang="ts">
import type { Site } from '~/services/dto/site'
import type { AbsenceType } from '~/services/dto/absence-type'
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
import PeriodStepperPicker from '~/components/PeriodStepperPicker.vue'
import AppDrawer from '~/components/AppDrawer.vue'
import { weekInputValueToYmd, ymdToWeekInputValue } from '~/utils/date'
const selectedDate = defineModel<string>('selectedDate', { required: true })
@@ -130,7 +203,7 @@ const viewMode = defineModel<'day' | 'week'>('viewMode', { required: true })
const selectedSiteIds = defineModel<number[]>('selectedSiteIds', { required: true })
const employeeFilter = defineModel<string>('employeeFilter', { required: true })
defineProps<{
const props = defineProps<{
isAdmin: boolean
sites: Site[]
absenceTypes: AbsenceType[]
@@ -140,6 +213,8 @@ defineProps<{
getWeekShortcutLabel: (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => string
}>()
const siteOptions = computed(() => props.sites.map((site) => ({ label: site.name, value: site.id })))
const emit = defineEmits<{
(e: 'set-yesterday'): void
(e: 'set-today'): void
@@ -150,6 +225,8 @@ const emit = defineEmits<{
(e: 'shift-date', value: number): void
}>()
const filtersDrawerOpen = ref(false)
const pickerValue = computed(() => {
if (viewMode.value === 'week') return ymdToWeekInputValue(selectedDate.value)
return selectedDate.value

View File

@@ -1,7 +1,72 @@
<template>
<div class="bg-white overflow-hidden flex min-h-0 flex-col">
<div v-if="isWeekLoading" class="p-6 text-md text-neutral-600">Chargement de la semaine...</div>
<div v-else class="overflow-y-auto min-h-0">
<!-- Mobile cards -->
<div v-else class="overflow-y-auto min-h-0 space-y-3 lg:hidden">
<div
v-for="row in weeklySummary?.rows ?? []"
:key="'m-' + row.employeeId"
class="rounded-md border border-primary-500 bg-white p-4"
>
<div class="mb-3">
<p class="text-md font-bold text-primary-500 truncate">
{{ row.firstName }} {{ row.lastName }}
<span class="font-normal text-neutral-600 text-sm">({{ row.contractName ?? '-' }})</span>
</p>
<p class="text-xs text-neutral-500 truncate">
{{ row.siteName ?? 'Sans site' }}<span v-if="row.contractNature"> {{ contractNatureLabel(row.contractNature) }}</span>
</p>
</div>
<!-- Daily breakdown -->
<div class="mb-3 space-y-1">
<div
v-for="(daily, i) in row.daily"
:key="daily.date"
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>
<span v-else>J {{ formatMinutes(daily.dayMinutes) }} / N {{ formatMinutes(daily.nightMinutes) }}</span>
</div>
</div>
<!-- Weekly totals -->
<div class="border-t border-neutral-200 pt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
<div class="flex justify-between">
<span class="text-neutral-500">Total sem.</span>
<span class="font-bold text-primary-500">{{ row.trackingMode === 'PRESENCE' ? (row.weeklyPresenceCount ?? 0) : formatMinutes(row.weeklyTotalMinutes) }}</span>
</div>
<div class="flex justify-between">
<span class="text-neutral-500">H. supp.</span>
<span class="font-bold text-primary-500">{{ row.trackingMode === 'PRESENCE' ? '-' : formatMinutes(row.weeklyOvertimeTotalMinutes ?? 0) }}</span>
</div>
<div v-if="row.trackingMode !== 'PRESENCE' && !isInterimContract(row.contractType)" class="flex justify-between">
<span class="text-neutral-500">+25%</span>
<span class="font-bold text-primary-500">{{ formatMinutes(row.weeklyOvertime25Minutes ?? 0) }}</span>
</div>
<div v-if="row.trackingMode !== 'PRESENCE' && !isInterimContract(row.contractType)" class="flex justify-between">
<span class="text-neutral-500">+50%</span>
<span class="font-bold text-primary-500">{{ formatMinutes(row.weeklyOvertime50Minutes ?? 0) }}</span>
</div>
<div v-if="row.trackingMode !== 'PRESENCE' && !isInterimContract(row.contractType)" class="flex justify-between">
<span class="text-neutral-500">Récup.</span>
<span class="font-bold text-primary-500">{{ formatMinutes(row.weeklyRecoveryMinutes ?? 0) }}</span>
</div>
<div v-if="(row.weeklyNightBasketCount ?? 0) > 0" class="flex justify-between">
<span class="text-neutral-500">Panier nuit</span>
<span class="font-bold text-primary-500">{{ row.weeklyNightBasketCount }}</span>
</div>
</div>
</div>
</div>
<!-- Desktop table -->
<div v-if="!isWeekLoading" class="overflow-y-auto min-h-0 hidden lg:block">
<div
class="grid w-full min-w-0 gap-1 border border-black bg-tertiary-500 px-4 py-3 text-sm font-semibold text-black rounded-t-md sticky top-0 z-10"
:style="{ gridTemplateColumns: weekGridCols }"
@@ -29,8 +94,11 @@
{{ row.firstName }} {{ row.lastName }}
<span class="font-normal text-neutral-600">({{ row.contractName ?? '-' }})</span>
</p>
<p class="text-[11px] text-neutral-500 truncate">
{{ row.siteName ?? 'Sans site' }}<span v-if="row.contractNature"> {{ contractNatureLabel(row.contractNature) }}</span>
<p class="text-[11px] text-neutral-500 truncate inline-flex items-center gap-2">
<span>{{ row.siteName ?? 'Sans site' }}<span v-if="row.contractNature"> {{ contractNatureLabel(row.contractNature) }}</span></span>
<button v-if="isAdmin" type="button" class="flex items-center text-white p-1" :class="row.comment ? 'bg-red-500 hover:bg-red-600' : 'bg-primary-500 hover:bg-secondary-500'" :title="row.comment ?? 'Ajouter un commentaire'" @click="$emit('open-comment', row)">
<Icon name="mdi:comment-text-outline" size="12"/>
</button>
</p>
</div>
@@ -40,7 +108,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>
@@ -89,19 +157,37 @@ 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<{
isWeekLoading: boolean
isAdmin: boolean
weekGridCols: string
weeklySummary: WeeklyWorkHourSummary | null
weekDayHeaders: Array<{ date: string; label: string }>
formatMinutes: (minutes: number) => string
}>()
defineEmits<{ (e: 'open-comment', row: WeeklyWorkHourSummary['rows'][number]): void }>()
</script>

View File

@@ -0,0 +1,81 @@
<template>
<MalioDrawer v-model="drawerOpen" title="Commentaire">
<form class="space-y-4" @submit.prevent="onSave">
<div v-if="employeeLabel" class="text-md font-semibold text-neutral-700">{{ employeeLabel }}</div>
<div class="text-md font-semibold text-neutral-700">{{ formatWeekRange }}</div>
<MalioInputTextArea
v-model="content"
label="Commentaire"
:size="8"
:max-length="5000"
:show-counter="true"
resize="vertical"
/>
<div class="sticky bottom-0 -mx-[20px] bg-white px-[20px] py-3 flex justify-between gap-3">
<MalioButton
v-if="commentId"
label="Supprimer"
variant="danger"
:disabled="isSubmitting"
@click="onDelete"
/>
<MalioButton
label="Enregistrer"
button-class="ml-auto"
:disabled="isSubmitting || !canSubmit"
@click="onSave"
/>
</div>
</form>
</MalioDrawer>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { createWeekComment, deleteWeekComment, updateWeekComment } from '~/services/employee-week-comments'
import { getIsoWeekNumber, parseYmd } from '~/utils/date'
const props = defineProps<{
modelValue: boolean
employeeId: number | null
weekStart: string
weekEnd: string
initialContent: string
commentId: number | null
employeeLabel?: string
}>()
const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void; (e: 'saved'): void }>()
const drawerOpen = computed({ get: () => props.modelValue, set: (v) => emit('update:modelValue', v) })
const content = ref('')
const isSubmitting = ref(false)
watch(() => [props.modelValue, props.initialContent] as const, ([open, init]) => { if (open) content.value = init ?? '' }, { immediate: true })
const formatWeekRange = computed(() => {
const fmt = (ymd: string) => { const p = ymd.split('-'); return p.length === 3 ? `${p[2]}/${p[1]}/${p[0]}` : ymd }
const start = parseYmd(props.weekStart)
const weekLabel = start ? `S${getIsoWeekNumber(start)}` : ''
return weekLabel ? `${weekLabel} du ${fmt(props.weekStart)} au ${fmt(props.weekEnd)}` : `${fmt(props.weekStart)}${fmt(props.weekEnd)}`
})
const canSubmit = computed(() => content.value.trim().length > 0 || props.commentId !== null)
const onSave = async () => {
if (!props.employeeId || isSubmitting.value) return
const trimmed = content.value.trim()
isSubmitting.value = true
try {
if (trimmed === '' && props.commentId) await deleteWeekComment(props.commentId)
else if (trimmed !== '' && props.commentId) await updateWeekComment(props.commentId, trimmed)
else if (trimmed !== '') await createWeekComment({ employeeId: props.employeeId, weekStartDate: props.weekStart, content: trimmed })
emit('saved'); drawerOpen.value = false
} finally { isSubmitting.value = false }
}
const onDelete = async () => {
if (!props.commentId || isSubmitting.value) return
isSubmitting.value = true
try { await deleteWeekComment(props.commentId); emit('saved'); drawerOpen.value = false } finally { isSubmitting.value = false }
}
</script>

View File

@@ -23,7 +23,7 @@
<button
type="button"
tabindex="-1"
class="inline-flex h-8 w-8 items-center justify-center rounded text-neutral-600 hover:bg-tertiary-500 disabled:cursor-not-allowed disabled:bg-neutral-200 disabled:text-neutral-500"
class="hidden lg:inline-flex h-8 w-8 items-center justify-center rounded text-neutral-600 hover:bg-tertiary-500 disabled:cursor-not-allowed disabled:bg-neutral-200 disabled:text-neutral-500"
:disabled="props.disabled"
@mousedown.prevent
@click="toggleOpen"
@@ -149,8 +149,11 @@ const toggleOpen = () => {
}
}
const isMobile = () => window.innerWidth < 1024
const openMenu = () => {
if (props.disabled) return
if (isMobile()) return
if (!isOpen.value) {
isOpen.value = true
nextTick(updateMenuPosition)
@@ -165,8 +168,28 @@ const closeMenu = () => {
isOpen.value = false
}
const snapToNearest15 = (time: string): string => {
const [h, m] = time.split(':').map(Number)
const snapped = Math.round(m / 15) * 15
if (snapped === 60) {
const newH = h + 1
if (newH > 23) return '23:45'
return `${String(newH).padStart(2, '0')}:00`
}
return `${String(h).padStart(2, '0')}:${String(snapped).padStart(2, '0')}`
}
const commitInput = () => {
const normalized = normalizeTypedTime(inputValue.value)
let value = inputValue.value
if (isMobile()) {
value = clampTime(value)
const normalized = normalizeTypedTime(value)
if (normalized !== null && normalized !== '') {
value = snapToNearest15(normalized)
}
inputValue.value = value
}
const normalized = normalizeTypedTime(value)
if (normalized === null || (normalized !== '' && !timeSlots.value.includes(normalized))) {
emit('update:modelValue', '')
inputValue.value = ''
@@ -184,13 +207,26 @@ const onInput = (event: Event) => {
if (masked !== inputValue.value) {
inputValue.value = masked
}
openMenu()
if (!isMobile()) {
openMenu()
}
}
const clampTime = (value: string): string => {
const normalized = normalizeTypedTime(value)
if (normalized === null || normalized === '') return value
const [h, m] = normalized.split(':').map(Number)
if (h > 23 || (h === 23 && m > 45)) return '23:45'
return normalized
}
const onInputBlur = () => {
// Laisse le temps au click menu de passer avant fermeture.
setTimeout(() => {
if (menu.value?.contains(document.activeElement)) return
if (isMobile()) {
inputValue.value = clampTime(inputValue.value)
}
commitInput()
}, 50)
}

View File

@@ -417,6 +417,10 @@ export const useDriverHoursPage = () => {
return date.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
}
const getRowContractNature = (employeeId: number): 'CDI' | 'CDD' | 'INTERIM' | null => {
return dayContextByEmployeeId.value.get(employeeId)?.contractNature ?? null
}
const hasContractAtSelectedDate = (employeeId: number) => {
const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (!dayRow) return true
@@ -922,6 +926,15 @@ export const useDriverHoursPage = () => {
}
}
const isWeekCommentDrawerOpen = ref(false)
const weekCommentContext = ref<{ employeeId: number; employeeLabel: string; weekStart: string; weekEnd: string; content: string; commentId: number | null } | null>(null)
const openWeekCommentDrawer = (row: { employeeId: number; firstName: string; lastName: string; comment?: string | null; commentId?: number | null }) => {
if (!weeklySummary.value) return
weekCommentContext.value = { employeeId: row.employeeId, employeeLabel: `${row.firstName} ${row.lastName}`.trim(), weekStart: weeklySummary.value.weekStart, weekEnd: weeklySummary.value.weekEnd, content: row.comment ?? '', commentId: row.commentId ?? null }
isWeekCommentDrawerOpen.value = true
}
const reloadWeeklySummary = async () => { await loadWeeklySummary() }
return {
isAdmin,
isSelfUser,
@@ -982,12 +995,17 @@ export const useDriverHoursPage = () => {
getRowMetrics,
getRowAbsenceLabel,
getRowAbsenceStyle,
getRowContractNature,
getRowUpdatedAt,
openAbsenceDrawer,
submitAbsence,
deleteAbsenceFromDrawer,
closeAbsenceDrawer,
formatMinutes,
handleSave
handleSave,
isWeekCommentDrawerOpen,
weekCommentContext,
openWeekCommentDrawer,
reloadWeeklySummary
}
}

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

@@ -494,6 +494,10 @@ export const useHoursPage = () => {
return dayContextByEmployeeId.value.get(employeeId)?.formationLabel ?? ''
}
const getRowContractNature = (employeeId: number): 'CDI' | 'CDD' | 'INTERIM' | null => {
return dayContextByEmployeeId.value.get(employeeId)?.contractNature ?? null
}
const getRowUpdatedAt = (employeeId: number): string => {
const raw = rows.value[employeeId]?.updatedAt
if (!raw) return ''
@@ -1108,6 +1112,15 @@ export const useHoursPage = () => {
}
}
const isWeekCommentDrawerOpen = ref(false)
const weekCommentContext = ref<{ employeeId: number; employeeLabel: string; weekStart: string; weekEnd: string; content: string; commentId: number | null } | null>(null)
const openWeekCommentDrawer = (row: { employeeId: number; firstName: string; lastName: string; comment?: string | null; commentId?: number | null }) => {
if (!weeklySummary.value) return
weekCommentContext.value = { employeeId: row.employeeId, employeeLabel: `${row.firstName} ${row.lastName}`.trim(), weekStart: weeklySummary.value.weekStart, weekEnd: weeklySummary.value.weekEnd, content: row.comment ?? '', commentId: row.commentId ?? null }
isWeekCommentDrawerOpen.value = true
}
const reloadWeeklySummary = async () => { await loadWeeklySummary() }
return {
isAdmin,
isSelfUser,
@@ -1174,6 +1187,7 @@ export const useHoursPage = () => {
getRowAbsenceStyle,
hasRowFormation,
getRowFormationLabel,
getRowContractNature,
getRowUpdatedAt,
getPresenceDayValue,
openAbsenceDrawer,
@@ -1181,6 +1195,10 @@ export const useHoursPage = () => {
deleteAbsenceFromDrawer,
closeAbsenceDrawer,
formatMinutes,
handleSave
handleSave,
isWeekCommentDrawerOpen,
weekCommentContext,
openWeekCommentDrawer,
reloadWeeklySummary
}
}

View File

@@ -28,6 +28,7 @@ export const documentationSections: DocSection[] = [
{ type: 'paragraph', content: 'La vue jour est votre écran principal. Elle affiche les heures de travail pour une date donnée.' },
{ type: 'list', content: 'Boutons "Hier" / "Aujourd\'hui" / "Demain" pour naviguer rapidement\nSélecteur de date pour choisir une date spécifique\nFiltrage par site si vous avez accès à plusieurs sites' },
{ type: 'paragraph', content: 'Seuls les employés ayant un contrat actif à la date sélectionnée sont affichés.' },
{ type: 'note', content: 'Sous le nom de l\'employé, la nature du contrat (CDI / CDD / Intérim) affichée correspond à la période couvrant la date filtrée, et non à aujourd\'hui.' },
],
},
{
@@ -79,6 +80,16 @@ export const documentationSections: DocSection[] = [
{ type: 'list', content: 'Jour : total des heures dans la plage 06:0021:00\nNuit : total des heures dans les plages 00:0006:00 et 21:0024:00\nTotal : somme des heures de jour et de nuit' },
],
},
{
id: 'commentaire-semaine',
title: 'Commentaires de semaine (admin)',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Sur la vue semaine, un picto bulle permet d\'attacher un commentaire libre sur la semaine d\'un employé.' },
{ type: 'list', content: 'Bulle bleue : pas de commentaire\nBulle rouge : un commentaire existe\nClic : ouvre le drawer avec textarea' },
{ type: 'note', content: 'Les commentaires n\'affectent aucun calcul. Pour supprimer, videz la textarea puis Enregistrer, ou bouton Supprimer.' },
],
},
],
},
{
@@ -331,7 +342,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' },
],
},
{
@@ -388,6 +399,7 @@ export const documentationSections: DocSection[] = [
blocks: [
{ type: 'paragraph', content: 'Le calendrier offre une vue d\'ensemble mensuelle des absences de tous les employés.' },
{ type: 'list', content: 'Code couleur par type d\'absence\nDemi-journée : affichage en dégradé diagonal\nJournée complète : fond plein\nJours fériés : fond bleu clair (cliquable pour créer une absence)\nFiltres par site et par employé\nNavigation par mois (précédent / suivant)' },
{ type: 'note', content: 'Seuls les employés ayant au moins un jour de contrat sur le mois affiché apparaissent. Un employé dont le contrat s\'est terminé avant le 1er du mois (ou qui commence après la fin du mois) est masqué.' },
{ type: 'note', content: 'Les absences peuvent être créées sur les jours fériés. Quand une absence est posée sur un férié, elle remplace l\'affichage « Férié » dans la cellule.' },
],
},
@@ -480,6 +492,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).' },
],
},
{
@@ -568,7 +581,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)' },
],
},
{
@@ -586,7 +599,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

@@ -59,6 +59,10 @@
},
"leaveRecap": {
"load": "Impossible de charger le récap des congés."
},
"weekComment": {
"save": "Impossible d'enregistrer le commentaire de semaine.",
"delete": "Impossible de supprimer le commentaire de semaine."
}
},
"success": {
@@ -110,6 +114,10 @@
"create": "Observation créée.",
"update": "Observation mise à jour.",
"delete": "Observation supprimée."
},
"weekComment": {
"save": "Commentaire enregistré.",
"delete": "Commentaire supprimé."
}
}
}

View File

@@ -1,11 +1,40 @@
<template>
<div class="h-screen overflow-hidden">
<div class="flex h-full">
<aside class="flex h-full w-64 flex-shrink-0 flex-col border-r border-neutral-200 bg-tertiary-500">
<div class="h-[75px]">
<!-- Mobile overlay -->
<Transition
enter-active-class="transition-opacity duration-300"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-300"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="sidebarOpen"
class="fixed inset-0 z-40 bg-black/50 lg:hidden"
@click="sidebarOpen = false"
/>
</Transition>
<!-- Sidebar -->
<aside
:class="[
'fixed inset-y-0 left-0 z-50 flex w-64 flex-col border-r border-neutral-200 bg-tertiary-500 transition-transform duration-300 lg:static lg:translate-x-0 lg:flex-shrink-0',
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
]"
>
<div class="flex h-[75px] items-center justify-between">
<img src="/malio.png" alt="Logo" class="w-auto"/>
<button
type="button"
class="mr-3 rounded-md p-1 text-primary-500 hover:text-secondary-500 lg:hidden"
@click="sidebarOpen = false"
>
<Icon name="mdi:close" size="24"/>
</button>
</div>
<nav class="flex-1 px-4 pb-6">
<nav class="flex-1 overflow-y-auto px-4 pb-6">
<template v-if="isAdmin">
<NuxtLink
to="/calendar"
@@ -13,6 +42,7 @@
:class="route.path.startsWith('/calendar')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
@click="closeSidebarOnMobile"
>
<Icon name="mdi:calendar-blank" size="24"/>
<p>Calendrier</p>
@@ -26,6 +56,7 @@
route.path.startsWith('/hours') ? 'bg-tertiary-500 text-primary-500 font-bold' : '',
!isAdmin ? 'border-t border-secondary-500 pt-3' : ''
]"
@click="closeSidebarOnMobile"
>
<Icon name="mdi:clock-time-four-outline" size="24"/>
<p>Heures</p>
@@ -38,6 +69,7 @@
route.path.startsWith('/driver-hours') ? 'bg-tertiary-500 text-primary-500 font-bold' : '',
!isAdmin && isDriver ? 'border-t border-secondary-500 pt-3' : ''
]"
@click="closeSidebarOnMobile"
>
<Icon name="mdi:truck-outline" size="24"/>
<p>Heures Conducteurs</p>
@@ -49,6 +81,7 @@
:class="route.path.startsWith('/employees')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
@click="closeSidebarOnMobile"
>
<Icon name="mdi:account-group-outline" size="24"/>
<p>Employés</p>
@@ -60,6 +93,7 @@
:class="route.path.startsWith('/leave-recap')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
@click="closeSidebarOnMobile"
>
<Icon name="mdi:beach" size="24"/>
<p>Récap. congés</p>
@@ -70,6 +104,7 @@
:class="route.path.startsWith('/sites')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
@click="closeSidebarOnMobile"
>
<Icon name="mdi:business" size="24"/>
<p>Sites</p>
@@ -80,6 +115,7 @@
:class="route.path.startsWith('/absence-types')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
@click="closeSidebarOnMobile"
>
<Icon name="mdi:umbrella-beach-outline" size="24"/>
<p>Types de statut</p>
@@ -90,6 +126,7 @@
:class="route.path.startsWith('/users')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
@click="closeSidebarOnMobile"
>
<Icon name="mdi:account-outline" size="24"/>
<p>Utilisateurs</p>
@@ -100,6 +137,7 @@
to="/leave-recap"
class="flex items-center gap-2 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500 pt-3"
:class="route.path.startsWith('/leave-recap') ? 'bg-tertiary-500 text-primary-500 font-bold' : ''"
@click="closeSidebarOnMobile"
>
<Icon name="mdi:beach" size="24"/>
<p>Récap. congés</p>
@@ -111,6 +149,7 @@
:class="route.path.startsWith('/audit-logs')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
@click="closeSidebarOnMobile"
>
<Icon name="mdi:clipboard-text-clock-outline" size="24"/>
<p>Journal</p>
@@ -121,6 +160,7 @@
:class="route.path.startsWith('/documentation')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
@click="closeSidebarOnMobile"
>
<Icon name="mdi:book-open-page-variant-outline" size="24"/>
<p>Documentation</p>
@@ -132,9 +172,9 @@
</div>
</aside>
<div class="h-full flex-1 overflow-hidden flex flex-col">
<AppTopNav :user="auth.user" />
<main class="flex-1 overflow-y-auto px-8 py-12">
<div class="h-full flex-1 overflow-hidden flex flex-col min-w-0">
<AppTopNav :user="auth.user" @toggle-sidebar="sidebarOpen = !sidebarOpen" />
<main class="flex-1 overflow-y-auto [scrollbar-gutter:stable] px-4 py-6 lg:px-8 lg:py-12">
<slot/>
</main>
</div>
@@ -150,4 +190,9 @@ const isSuperAdmin = computed(() => auth.user?.roles?.includes('ROLE_SUPER_ADMIN
const isDriver = computed(() => auth.user?.isDriver ?? false)
const hasLeaveRecapAccess = computed(() => auth.user?.hasLeaveRecapAccess ?? false)
const route = useRoute()
const sidebarOpen = ref(false)
const closeSidebarOnMobile = () => {
sidebarOpen.value = false
}
</script>

View File

@@ -2,6 +2,7 @@ export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: {enabled: false},
ssr: false,
extends: ['@malio/layer-ui'],
app: {
baseURL: process.env.NODE_ENV === 'production'
? (process.env.NUXT_PUBLIC_APP_BASE || '/')

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@
"dependencies": {
"@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.1",
"@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,30 +5,37 @@
</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">
<SiteFilterSelector v-model="selectedSiteIds" :sites="sites"/>
</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">
<button
type="button"
class="h-10 rounded-lg bg-primary-500 px-4 text-md font-semibold text-white hover:bg-secondary-500"
<MalioButton
label="Ajouter une absence"
icon-name="mdi:plus"
icon-position="left"
@click="openCreateFromToday"
>
+ Ajouter une absence
</button>
<button
type="button"
class="h-10 rounded-lg bg-primary-500 px-4 text-md font-semibold text-white hover:bg-secondary-500"
/>
<MalioButton
label="Imprimer"
variant="secondary"
icon-name="mdi:printer"
icon-position="left"
@click="openPrint"
>
Imprimer
</button>
/>
</div>
</div>
<div class="flex justify-between">
<div class="flex items-center gap-4">
<div class="w-80">
<EmployeeNameFilterInput v-model="employeeFilter"/>
<MalioInputText
v-model="employeeFilter"
label="Recherche d'un employé"
icon-name="mdi:magnify"
/>
</div>
<PeriodStepperPicker
width-class="w-[260px]"
@@ -111,9 +118,7 @@ import {compareEmployeesInSite, sortEmployeesBySiteAndOrder} from '~/utils/emplo
import CalendarGrid from '~/components/CalendarGrid.vue'
import AbsenceFormDrawer from '~/components/AbsenceFormDrawer.vue'
import AbsencePrintDrawer from '~/components/AbsencePrintDrawer.vue'
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
import PeriodStepperPicker from '~/components/PeriodStepperPicker.vue'
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
useHead({
title: 'Calendrier'
@@ -136,6 +141,8 @@ const sites = computed(() => {
})
})
const siteOptions = computed(() => sites.value.map((site) => ({ label: site.name, value: site.id })))
// Filtres de sites (par défaut: tous sélectionnés à l'init).
const selectedSiteIds = ref<number[]>([])
const sitesInitialized = ref(false)
@@ -154,12 +161,27 @@ const sortedEmployees = computed(() => {
// Employés visibles selon le filtre de sites.
const employeeFilter = ref('')
// Un employé est considéré "présent" sur le mois affiché si au moins une de ses
// périodes de contrat intersecte [début du mois ; fin du mois]. Sinon il est masqué.
const hasContractInSelectedMonth = (employee: Employee): boolean => {
const monthStart = toYmd(selectedYear.value, selectedMonth.value, 1)
const monthEnd = toYmd(selectedYear.value, selectedMonth.value + 1, 0)
const history = employee.contractHistory ?? []
if (history.length === 0) return false
return history.some((period) => {
const start = period.startDate
const end = period.endDate ?? '9999-12-31'
return start <= monthEnd && end >= monthStart
})
}
const visibleEmployees = computed(() => {
if (selectedSiteIds.value.length === 0) return []
const filter = employeeFilter.value.trim().toLowerCase()
return sortedEmployees.value.filter((employee) => {
const siteOk = employee.site?.id && selectedSiteIds.value.includes(employee.site.id)
if (!siteOk) return false
if (!hasContractInSelectedMonth(employee)) return false
if (!filter) return true
const first = employee.firstName?.toLowerCase() ?? ''
const last = employee.lastName?.toLowerCase() ?? ''

View File

@@ -64,6 +64,7 @@
:get-row-metrics="getRowMetrics"
:get-row-absence-label="getRowAbsenceLabel"
:get-row-absence-style="getRowAbsenceStyle"
:get-row-contract-nature="getRowContractNature"
:get-row-updated-at="getRowUpdatedAt"
:on-absence-click="openAbsenceDrawer"
:format-minutes="formatMinutes"
@@ -73,11 +74,13 @@
<DriverHoursWeekView
v-else-if="isAdmin && viewMode === 'week'"
:is-week-loading="isWeekLoading"
:is-admin="isAdmin"
:week-grid-cols="weekGridCols"
:weekly-summary="filteredWeeklySummary"
:week-day-headers="weekDayHeaders"
:format-minutes="formatMinutes"
class="max-h-[calc(100vh-300px)]"
@open-comment="openWeekCommentDrawer"
/>
</div>
@@ -109,6 +112,17 @@
@cancel="closeAbsenceDrawer"
/>
<HoursWeekCommentDrawer
v-if="weekCommentContext"
v-model="isWeekCommentDrawerOpen"
:employee-id="weekCommentContext.employeeId"
:employee-label="weekCommentContext.employeeLabel"
:week-start="weekCommentContext.weekStart"
:week-end="weekCommentContext.weekEnd"
:initial-content="weekCommentContext.content"
:comment-id="weekCommentContext.commentId"
@saved="reloadWeeklySummary"
/>
</div>
</template>
@@ -169,6 +183,7 @@ const {
getRowMetrics,
getRowAbsenceLabel,
getRowAbsenceStyle,
getRowContractNature,
getRowUpdatedAt,
openAbsenceDrawer,
submitAbsence,
@@ -177,7 +192,11 @@ const {
formatMinutes,
isSelectedDateHoliday,
selectedHolidayLabel,
handleSave
handleSave,
isWeekCommentDrawerOpen,
weekCommentContext,
openWeekCommentDrawer,
reloadWeeklySummary
} = useDriverHoursPage()
useHead({

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,49 +4,45 @@
<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 gap-3 py-7">
<div class="flex items-center gap-3 py-7">
<div class="w-80">
<EmployeeNameFilterInput v-model="employeeFilter"/>
<MalioInputText
v-model="employeeFilter"
label="Recherche d'un employé"
icon-name="mdi:magnify"
/>
</div>
<SiteFilterSelector v-if="sites.length > 0" v-model="selectedSiteIds" :sites="sites"/>
<select
<div v-if="sites.length > 0" class="relative z-50 w-80">
<MalioSelectCheckbox
v-model="selectedSiteIds"
:options="siteOptions"
groupClass="w-80"
label="Sites"
display-select-all
/>
</div>
<MalioSelect
v-model="contractStatusFilter"
class="rounded-md border border-primary-500 bg-white px-3 py-2 text-md font-semibold text-primary-500 cursor-pointer"
>
<option value="active">Avec contrat</option>
<option value="inactive">Sans contrat</option>
<option value="all">Tous</option>
</select>
label="Statut contrat"
:options="contractStatusOptions"
group-class="w-40"
/>
</div>
</div>
@@ -88,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>
@@ -195,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.
@@ -210,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"
@@ -234,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>
@@ -275,9 +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 SiteFilterSelector from '~/components/SiteFilterSelector.vue'
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'
@@ -288,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)
@@ -304,7 +320,13 @@ const contracts = ref<Contract[]>([])
const interimAgencies = ref<InterimAgency[]>([])
const employeeFilter = ref('')
const contractStatusFilter = ref<'active' | 'inactive' | 'all'>('active')
const contractStatusOptions = [
{ label: 'Avec contrat', value: 'active' },
{ label: 'Sans contrat', value: 'inactive' },
{ label: 'Tous', value: 'all' }
]
const selectedSiteIds = ref<number[]>([])
const siteOptions = computed(() => sites.value.map((site) => ({ label: site.name, value: site.id })))
const filteredEmployees = computed<Employee[]>(() => {
if (selectedSiteIds.value.length === 0) return []
@@ -410,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
@@ -617,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

@@ -1,7 +1,7 @@
<template>
<div class="h-full overflow-hidden flex flex-col">
<div class="flex flex-wrap items-center justify-between gap-4">
<h1 class="text-4xl font-bold text-primary-500">Heures</h1>
<h1 class="text-2xl font-bold text-primary-500 lg:text-4xl">Heures</h1>
</div>
<HoursToolbar
@@ -70,6 +70,7 @@
:get-row-absence-style="getRowAbsenceStyle"
:has-row-formation="hasRowFormation"
:get-row-formation-label="getRowFormationLabel"
:get-row-contract-nature="getRowContractNature"
:get-row-updated-at="getRowUpdatedAt"
:get-presence-day-value="getPresenceDayValue"
:on-absence-click="openAbsenceDrawer"
@@ -80,11 +81,13 @@
<HoursWeekView
v-else-if="isAdmin && viewMode === 'week'"
:is-week-loading="isWeekLoading"
:is-admin="isAdmin"
:week-grid-cols="weekGridCols"
:weekly-summary="filteredWeeklySummary"
:week-day-headers="weekDayHeaders"
:format-minutes="formatMinutes"
class="max-h-[calc(100vh-300px)]"
@open-comment="openWeekCommentDrawer"
/>
</div>
@@ -116,6 +119,17 @@
@cancel="closeAbsenceDrawer"
/>
<HoursWeekCommentDrawer
v-if="weekCommentContext"
v-model="isWeekCommentDrawerOpen"
:employee-id="weekCommentContext.employeeId"
:employee-label="weekCommentContext.employeeLabel"
:week-start="weekCommentContext.weekStart"
:week-end="weekCommentContext.weekEnd"
:initial-content="weekCommentContext.content"
:comment-id="weekCommentContext.commentId"
@saved="reloadWeeklySummary"
/>
</div>
</template>
@@ -184,6 +198,7 @@ const {
getRowAbsenceStyle,
hasRowFormation,
getRowFormationLabel,
getRowContractNature,
getRowUpdatedAt,
getPresenceDayValue,
openAbsenceDrawer,
@@ -191,7 +206,11 @@ const {
deleteAbsenceFromDrawer,
closeAbsenceDrawer,
formatMinutes,
handleSave
handleSave,
isWeekCommentDrawerOpen,
weekCommentContext,
openWeekCommentDrawer,
reloadWeeklySummary
} = useHoursPage()
useHead({

View File

@@ -1,7 +1,7 @@
<template>
<div class="h-full flex flex-col overflow-hidden">
<div class="flex flex-wrap items-center justify-between gap-4 pb-8">
<h1 class="text-4xl font-bold text-primary-500">Récap. congés</h1>
<h1 class="text-2xl font-bold text-primary-500 lg:text-4xl">Récap. congés</h1>
<span
v-if="cutoffLabel"
class="inline-flex items-center gap-2 rounded-full bg-tertiary-500 px-4 py-1 text-sm font-semibold text-primary-500"
@@ -25,7 +25,8 @@
Aucun employé à afficher.
</div>
<div v-else class="min-h-0 overflow-auto rounded-md bg-white">
<!-- Desktop table -->
<div v-else class="min-h-0 overflow-auto rounded-md bg-white hidden lg:block">
<div
:class="`grid ${gridColsClass} gap-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10`"
>
@@ -64,6 +65,47 @@
</div>
</div>
</div>
<!-- Mobile cards -->
<div v-if="!isLoading && rows.length > 0" class="min-h-0 overflow-auto space-y-3 lg:hidden">
<div
v-for="row in rows"
:key="'m-' + row.employeeId"
class="rounded-md border border-primary-500 bg-white p-4"
>
<div class="mb-3 flex items-center justify-between gap-2">
<p class="text-md font-bold text-primary-500 truncate">
{{ row.lastName }} {{ row.firstName }}
</p>
<span
v-if="showSiteColumn && row.siteName"
class="inline-block shrink-0 rounded-full px-3 py-1 text-sm"
:style="{ backgroundColor: row.siteColor || '#ffd7d7', color: '#1a1a1a' }"
>
{{ row.siteName }}
</span>
</div>
<p v-if="row.contractName" class="mb-3 text-sm text-neutral-600">{{ row.contractName }}</p>
<div class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<div class="flex justify-between">
<span class="text-neutral-500">CP N-1</span>
<span class="font-bold text-primary-500 tabular-nums">{{ formatNumber(row.cpN1Remaining) }}</span>
</div>
<div class="flex justify-between">
<span class="text-neutral-500">Samedis</span>
<span class="font-bold text-primary-500 tabular-nums">{{ row.acquiredSaturdays }}</span>
</div>
<div class="flex justify-between">
<span class="text-neutral-500">CP N</span>
<span class="font-bold text-primary-500 tabular-nums">{{ formatNumber(row.cpN) }}</span>
</div>
<div class="flex justify-between">
<span class="text-neutral-500">RTT</span>
<span class="font-bold text-primary-500 tabular-nums">{{ row.rtt }}</span>
</div>
</div>
</div>
</div>
</div>
</template>

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

@@ -1,14 +1,13 @@
<template>
<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">Utilisateurs</h1>
<button
type="button"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
<h1 class="text-2xl font-bold text-primary-500 lg:text-4xl">Utilisateurs</h1>
<MalioButton
label="Ajouter"
icon-name="mdi:plus"
icon-position="left"
@click="openCreate"
>
+ Ajouter un utilisateur
</button>
/>
</div>
<div
@@ -18,7 +17,8 @@
Aucun utilisateur pour le moment.
</div>
<div v-else class="min-h-0 overflow-auto rounded-md bg-white">
<!-- Desktop table -->
<div v-else class="min-h-0 overflow-auto rounded-md bg-white hidden lg:block">
<div class="grid grid-cols-5 gap-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10">
<span class="text-left">Utilisateur</span>
<span class="text-left">Employé</span>
@@ -56,43 +56,61 @@
</div>
</div>
<AppDrawer
<!-- Mobile cards -->
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500 lg:hidden">
Chargement...
</div>
<div v-else-if="users.length > 0" class="min-h-0 overflow-auto space-y-3 lg:hidden">
<div
v-for="user in users"
:key="'m-' + user.id"
class="rounded-md border border-primary-500 bg-white p-4 cursor-pointer active:bg-tertiary-500"
@click="openEdit(user)"
>
<div class="flex items-center justify-between gap-2 mb-2">
<p class="text-md font-bold text-primary-500 truncate">{{ user.username }}</p>
<span
v-if="user.isLocked"
class="shrink-0 inline-block rounded-full bg-red-100 px-3 py-1 text-xs font-semibold text-red-700"
>Verrouillé</span>
<span
v-else
class="shrink-0 inline-block rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-700"
>Actif</span>
</div>
<div class="space-y-1 text-sm">
<p v-if="user.employee" class="text-neutral-600">
{{ user.employee.firstName }} {{ user.employee.lastName }}
</p>
<p class="text-neutral-500">
Accès : <span class="font-semibold text-primary-500">{{ getAccessLabel(user) }}</span>
</p>
<p v-if="getSiteLabels(user) !== '-'" class="text-neutral-500 truncate">
Sites : <span class="font-semibold text-primary-500">{{ getSiteLabels(user) }}</span>
</p>
</div>
</div>
</div>
<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>
@@ -135,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.
@@ -176,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>
@@ -311,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 = {
@@ -88,6 +89,8 @@ export type WeeklyWorkHourRowSummary = {
weeklyOvernightCount?: number
hasContractForWeek?: boolean
contractNature?: 'CDI' | 'CDD' | 'INTERIM' | null
comment?: string | null
commentId?: number | null
}
export type WeeklyWorkHourSummary = {
@@ -111,6 +114,7 @@ export type WorkHourDayContextRow = {
hasFormation?: boolean
formationLabel?: string | null
virtualHolidayMinutes?: number
contractNature?: 'CDI' | 'CDD' | 'INTERIM' | null
}
export type WorkHourDayContext = {

View File

@@ -0,0 +1,24 @@
export type EmployeeWeekComment = {
id: number
weekStartDate: string
content: string
}
export const createWeekComment = async (payload: { employeeId: number; weekStartDate: string; content: string }) => {
const api = useApi()
return api.post<EmployeeWeekComment>('/employee_week_comments', {
employee: `/api/employees/${payload.employeeId}`,
weekStartDate: payload.weekStartDate,
content: payload.content
}, { toastSuccessKey: 'success.weekComment.save', toastErrorKey: 'errors.weekComment.save' })
}
export const updateWeekComment = async (id: number, content: string) => {
const api = useApi()
return api.patch<EmployeeWeekComment>(`/employee_week_comments/${id}`, { content }, { toastSuccessKey: 'success.weekComment.save', toastErrorKey: 'errors.weekComment.save' })
}
export const deleteWeekComment = async (id: number) => {
const api = useApi()
await api.delete(`/employee_week_comments/${id}`, {}, { toastSuccessKey: 'success.weekComment.delete', toastErrorKey: 'errors.weekComment.delete' })
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260417100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create employee_week_comments table for per-week admin annotations on the hours weekly view';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE employee_week_comments (id SERIAL NOT NULL, employee_id INT NOT NULL, week_start_date DATE NOT NULL, content TEXT NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE UNIQUE INDEX uniq_employee_week_comment ON employee_week_comments (employee_id, week_start_date)');
$this->addSql('CREATE INDEX idx_ewc_week_start ON employee_week_comments (week_start_date)');
$this->addSql('ALTER TABLE employee_week_comments ADD CONSTRAINT fk_ewc_employee FOREIGN KEY (employee_id) REFERENCES employees(id) ON DELETE CASCADE');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE employee_week_comments');
}
}

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

@@ -20,6 +20,7 @@ final class DayContextRow
public bool $hasFormation = false,
public ?string $formationLabel = null,
public int $virtualHolidayMinutes = 0,
public ?string $contractNature = null,
) {}
public function setFormation(string $label): void
@@ -77,7 +78,8 @@ final class DayContextRow
* isDriverContract:bool,
* hasFormation:bool,
* formationLabel:?string,
* virtualHolidayMinutes:int
* virtualHolidayMinutes:int,
* contractNature:?string
* }
*/
public function toArray(): array
@@ -96,6 +98,7 @@ final class DayContextRow
'hasFormation' => $this->hasFormation,
'formationLabel' => $this->formationLabel,
'virtualHolidayMinutes' => $this->virtualHolidayMinutes,
'contractNature' => $this->contractNature,
];
}

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

@@ -35,5 +35,7 @@ final class WeeklySummaryRow
public int $weeklyOvernightCount = 0,
public bool $hasContractForWeek = true,
public ?string $contractNature = null,
public ?string $comment = null,
public ?int $commentId = null,
) {}
}

View File

@@ -0,0 +1,136 @@
<?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\EmployeeWeekCommentRepository;
use App\State\EmployeeWeekCommentWriteProcessor;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
operations: [
new Get(security: "is_granted('ROLE_ADMIN')"),
new GetCollection(security: "is_granted('ROLE_ADMIN')"),
new Post(security: "is_granted('ROLE_ADMIN')", processor: EmployeeWeekCommentWriteProcessor::class),
new Patch(security: "is_granted('ROLE_ADMIN')", processor: EmployeeWeekCommentWriteProcessor::class),
new Delete(security: "is_granted('ROLE_ADMIN')", processor: EmployeeWeekCommentWriteProcessor::class),
],
normalizationContext: ['groups' => ['week_comment:read'], 'datetime_format' => 'Y-m-d'],
denormalizationContext: ['groups' => ['week_comment:write'], 'datetime_format' => 'Y-m-d'],
order: ['weekStartDate' => 'DESC'],
paginationEnabled: false,
)]
#[ApiFilter(DateFilter::class, properties: ['weekStartDate'])]
#[ApiFilter(SearchFilter::class, properties: ['employee' => 'exact'])]
#[ORM\Entity(repositoryClass: EmployeeWeekCommentRepository::class)]
#[ORM\Table(name: 'employee_week_comments')]
#[ORM\UniqueConstraint(name: 'uniq_employee_week_comment', columns: ['employee_id', 'week_start_date'])]
class EmployeeWeekComment
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
#[Groups(['week_comment:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Employee::class)]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[Groups(['week_comment:read', 'week_comment:write'])]
#[Assert\NotNull]
private ?Employee $employee = null;
#[ORM\Column(type: 'date_immutable')]
#[Groups(['week_comment:read', 'week_comment:write'])]
#[Assert\NotNull]
private ?DateTimeImmutable $weekStartDate = null;
#[ORM\Column(type: 'text')]
#[Groups(['week_comment:read', 'week_comment:write'])]
#[Assert\NotBlank]
#[Assert\Length(max: 5000)]
private string $content = '';
#[ORM\Column(type: 'datetime_immutable')]
#[Groups(['week_comment:read'])]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: 'datetime_immutable')]
#[Groups(['week_comment:read'])]
private DateTimeImmutable $updatedAt;
public function __construct()
{
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
}
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 getWeekStartDate(): ?DateTimeImmutable
{
return $this->weekStartDate;
}
public function setWeekStartDate(?DateTimeImmutable $weekStartDate): self
{
$this->weekStartDate = $weekStartDate;
return $this;
}
public function getContent(): string
{
return $this->content;
}
public function setContent(string $content): self
{
$this->content = $content;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
public function getUpdatedAt(): DateTimeImmutable
{
return $this->updatedAt;
}
public function touchUpdatedAt(): void
{
$this->updatedAt = new DateTimeImmutable();
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Employee;
use App\Entity\EmployeeWeekComment;
use DateTimeImmutable;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<EmployeeWeekComment>
*/
class EmployeeWeekCommentRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, EmployeeWeekComment::class);
}
public function findOneByEmployeeAndWeek(Employee $employee, DateTimeImmutable $weekStart): ?EmployeeWeekComment
{
return $this->findOneBy(['employee' => $employee, 'weekStartDate' => $weekStart]);
}
/**
* @param list<Employee> $employees
*
* @return array<int, EmployeeWeekComment> employee_id → comment
*/
public function findByWeekAndEmployees(DateTimeImmutable $weekStart, array $employees): array
{
if ([] === $employees) {
return [];
}
$rows = $this->createQueryBuilder('c')
->andWhere('c.weekStartDate = :weekStart')
->andWhere('c.employee IN (:employees)')
->setParameter('weekStart', $weekStart)
->setParameter('employees', $employees)
->innerJoin('c.employee', 'e')->addSelect('e')
->getQuery()->getResult()
;
$map = [];
foreach ($rows as $row) {
$eid = $row->getEmployee()?->getId();
if (null !== $eid) {
$map[$eid] = $row;
}
}
return $map;
}
}

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

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Employee;
use App\Entity\EmployeeWeekComment;
use App\Service\AuditLogger;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
final readonly class EmployeeWeekCommentWriteProcessor implements ProcessorInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private ProcessorInterface $persistProcessor,
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private ProcessorInterface $removeProcessor,
private EntityManagerInterface $entityManager,
private AuditLogger $auditLogger,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof EmployeeWeekComment) {
return $data;
}
$employee = $data->getEmployee();
if ($operation instanceof DeleteOperationInterface) {
$this->auditLogger->log(
$employee,
'delete',
'week_comment',
$data->getId(),
sprintf('Commentaire semaine supprimé pour %s (semaine du %s)', $this->label($employee), $data->getWeekStartDate()?->format('d/m/Y') ?? '?'),
['old' => ['content' => $data->getContent()]],
$data->getWeekStartDate(),
);
$result = $this->removeProcessor->process($data, $operation, $uriVariables, $context);
$this->entityManager->flush();
return $result;
}
$weekStart = $data->getWeekStartDate();
if (null === $weekStart || '1' !== $weekStart->format('N')) {
throw new UnprocessableEntityHttpException('weekStartDate must be a Monday (ISO weekday 1).');
}
$prev = null;
if (null !== $data->getId()) {
$prev = $this->entityManager->getUnitOfWork()->getOriginalEntityData($data)['content'] ?? null;
$data->touchUpdatedAt();
}
$result = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
if (null === $prev) {
$this->auditLogger->log($employee, 'create', 'week_comment', $data->getId(), sprintf('Commentaire semaine créé pour %s (semaine du %s)', $this->label($employee), $weekStart->format('d/m/Y')), ['new' => ['content' => $data->getContent()]], $weekStart);
} elseif ($prev !== $data->getContent()) {
$this->auditLogger->log($employee, 'update', 'week_comment', $data->getId(), sprintf('Commentaire semaine modifié pour %s (semaine du %s)', $this->label($employee), $weekStart->format('d/m/Y')), ['old' => ['content' => $prev], 'new' => ['content' => $data->getContent()]], $weekStart);
}
$this->entityManager->flush();
return $result;
}
private function label(mixed $e): string
{
return $e instanceof Employee ? trim(($e->getLastName() ?? '').' '.($e->getFirstName() ?? '')) : '?';
}
}

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

@@ -57,13 +57,17 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface
}
// On initialise toutes les lignes, même sans absence ce jour-là.
$contract = $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate);
$workDaysMinutes = $this->contractResolver->resolveWorkDaysMinutesForEmployeeAndDate($employee, $workDate);
$contract = $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate);
$workDaysMinutes = $this->contractResolver->resolveWorkDaysMinutesForEmployeeAndDate($employee, $workDate);
$contractNature = null !== $contract
? $this->contractResolver->resolveNatureForEmployeeAndDate($employee, $workDate)->value
: null;
$rowsByEmployeeId[$employeeId] = new DayContextRow(
employeeId: $employeeId,
hasContractAtDate: null !== $contract,
isDriverContract: $this->contractResolver->resolveIsDriverForEmployeeAndDate($employee, $workDate),
virtualHolidayMinutes: $this->holidayVirtualHoursResolver->resolveVirtualCredit($contract, $workDate, false, $workDaysMinutes),
contractNature: $contractNature,
);
}

View File

@@ -13,6 +13,7 @@ use App\Dto\WorkHours\WorkMetrics;
use App\Entity\Absence;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\EmployeeWeekComment;
use App\Entity\User;
use App\Entity\WorkHour;
use App\Enum\ContractNature;
@@ -21,7 +22,9 @@ use App\Enum\TrackingMode;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
use App\Repository\EmployeeWeekCommentRepository;
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 +34,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 +49,8 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
private EmployeeContractResolver $contractResolver,
private DailyReferenceMinutesResolver $dailyReferenceResolver,
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
private PublicHolidayServiceInterface $publicHolidayService,
private EmployeeWeekCommentRepository $weekCommentRepository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourWeeklySummary
@@ -62,11 +68,13 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($weekStart, $weekEnd, $employees);
$absences = $this->absenceRepository->findForPrint($weekStart, $weekEnd, $employees);
$weekComments = $this->weekCommentRepository->findByWeekAndEmployees($weekStart, $employees);
$summary = new WorkHourWeeklySummary();
$summary->weekStart = $weekStart->format('Y-m-d');
$summary->weekEnd = $weekEnd->format('Y-m-d');
$summary->days = $days;
$summary->rows = $this->buildRows($employees, $workHours, $absences, $days, $anchorDate->format('Y-m-d'));
$summary->rows = $this->buildRows($employees, $workHours, $absences, $days, $anchorDate->format('Y-m-d'), $weekComments);
return $summary;
}
@@ -109,19 +117,21 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
}
/**
* @param list<Employee> $employees
* @param list<WorkHour> $workHours
* @param list<Absence> $absences
* @param list<string> $days
* @param list<Employee> $employees
* @param list<WorkHour> $workHours
* @param list<Absence> $absences
* @param list<string> $days
* @param array<int, EmployeeWeekComment> $weekComments
*
* @return list<WeeklySummaryRow>
*/
private function buildRows(array $employees, array $workHours, array $absences, array $days, string $anchorDateYmd): array
private function buildRows(array $employees, array $workHours, array $absences, array $days, string $anchorDateYmd, array $weekComments = []): array
{
$contractsByEmployeeDate = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
$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 +334,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
hasDinner: $hasDinner,
hasOvernight: $hasOvernight,
virtualHolidayMinutes: $virtualHolidayMinutes,
holidayLabel: $holidayLabelsByDate[$date] ?? null,
);
}
@@ -370,12 +381,46 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
weeklyOvernightCount: $weeklyOvernightCount,
hasContractForWeek: $hasContractForWeek,
contractNature: $weekAnchorContractNature->value,
comment: ($weekComments[$employeeId] ?? null)?->getContent(),
commentId: ($weekComments[$employeeId] ?? null)?->getId(),
);
}
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

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Tests\State;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Post;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Employee;
use App\Entity\EmployeeWeekComment;
use App\Service\AuditLogger;
use App\State\EmployeeWeekCommentWriteProcessor;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\UnitOfWork;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
/**
* @internal
*/
final class EmployeeWeekCommentWriteProcessorTest extends TestCase
{
public function testRejectsNonMondayWeekStart(): void
{
$processor = new EmployeeWeekCommentWriteProcessor(
$this->createStub(ProcessorInterface::class),
$this->createStub(ProcessorInterface::class),
$this->createStub(EntityManagerInterface::class),
$this->createStub(AuditLogger::class),
);
$comment = new EmployeeWeekComment()
->setEmployee(new Employee()->setFirstName('A')->setLastName('B'))
->setWeekStartDate(new DateTimeImmutable('2026-04-14'))
->setContent('test')
;
$this->expectException(UnprocessableEntityHttpException::class);
$processor->process($comment, new Post());
}
public function testAcceptsMondayAndAuditsCreate(): void
{
$persist = $this->createMock(ProcessorInterface::class);
$persist->expects(self::once())->method('process');
$em = $this->createMock(EntityManagerInterface::class);
$em->method('getUnitOfWork')->willReturn($this->createStub(UnitOfWork::class));
$em->expects(self::once())->method('flush');
$auditor = $this->createMock(AuditLogger::class);
$auditor->expects(self::once())->method('log')->with(self::anything(), 'create', 'week_comment');
$processor = new EmployeeWeekCommentWriteProcessor($persist, $this->createStub(ProcessorInterface::class), $em, $auditor);
$processor->process(
new EmployeeWeekComment()->setEmployee(new Employee()->setFirstName('A')->setLastName('B'))->setWeekStartDate(new DateTimeImmutable('2026-04-13'))->setContent('x'),
new Post()
);
}
public function testDeleteAudits(): void
{
$remove = $this->createMock(ProcessorInterface::class);
$remove->expects(self::once())->method('process');
$em = $this->createMock(EntityManagerInterface::class);
$em->expects(self::once())->method('flush');
$auditor = $this->createMock(AuditLogger::class);
$auditor->expects(self::once())->method('log')->with(self::anything(), 'delete', 'week_comment');
$processor = new EmployeeWeekCommentWriteProcessor($this->createStub(ProcessorInterface::class), $remove, $em, $auditor);
$processor->process(
new EmployeeWeekComment()->setEmployee(new Employee()->setFirstName('A')->setLastName('B'))->setWeekStartDate(new DateTimeImmutable('2026-04-13'))->setContent('x'),
new Delete()
);
}
}

View File

@@ -10,6 +10,7 @@ use App\Entity\AbsenceType;
use App\Entity\Contract;
use App\Entity\Employee;
use App\Entity\User;
use App\Enum\ContractNature;
use App\Enum\HalfDay;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
@@ -176,6 +177,10 @@ final class WorkHourDayContextProviderTest extends TestCase
->method('resolveForEmployeeAndDate')
->willReturnCallback(static fn (Employee $employee): ?Contract => $employee->getContract())
;
$resolver
->method('resolveNatureForEmployeeAndDate')
->willReturn(ContractNature::CDI)
;
return $resolver;
}

View File

@@ -15,6 +15,7 @@ use App\Enum\HalfDay;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
use App\Repository\EmployeeWeekCommentRepository;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\PublicHolidayServiceInterface;
use App\Service\WorkHours\AbsenceSegmentsResolver;
@@ -66,6 +67,8 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
$this->buildResolverStub(),
new DailyReferenceMinutesResolver(),
$this->buildHolidayResolver(),
$this->buildHolidayService(),
$this->buildWeekCommentRepoStub(),
);
$this->expectException(AccessDeniedHttpException::class);
@@ -128,6 +131,8 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
$this->buildWeeklyResolverStub($employees),
new DailyReferenceMinutesResolver(),
$this->buildHolidayResolver(),
$this->buildHolidayService(),
$this->buildWeekCommentRepoStub(),
);
$result = $provider->provide(new Get());
@@ -178,16 +183,29 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
$property->setValue($entity, $id);
}
private function buildWeekCommentRepoStub(): EmployeeWeekCommentRepository
{
$r = $this->createStub(EmployeeWeekCommentRepository::class);
$r->method('findByWeekAndEmployees')->willReturn([]);
return $r;
}
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