Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b541f9ded8 | ||
| 47f9bea57d | |||
| 7cadcfa362 | |||
|
|
3ec0d4b074 | ||
| eaf8a11e2b | |||
|
|
02fc94fbed | ||
| eb5910dffe | |||
| 78f73ed2e9 | |||
| eacf52425a | |||
|
|
6f43c3356f | ||
| 13eeeb9c86 |
20
CLAUDE.md
20
CLAUDE.md
@@ -48,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
|
||||
@@ -63,6 +69,20 @@
|
||||
- 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é.
|
||||
|
||||
## Onglet Congés (fiche employé)
|
||||
- Calendrier annuel des congés (`frontend/components/employees/LeaveTab.vue`) — période = Janvier→Décembre pour FORFAIT, Juin(N-1)→Mai(N) pour les autres contrats. Règle pilotée par le **contrat courant** (cf. `EmployeeLeaveSummaryProvider::resolveYear`), même quand on consulte une année passée.
|
||||
- **Sélecteur d'année** en pied de calendrier (zone scrollable, à gauche). Plage : de l'exercice courant jusqu'à `max(floor_contrat, floor_data_start_date)` — `floor_contrat` = premier exercice avec contrat ouvert (`employee.contractHistory[].startDate`) ; `floor_data_start_date` = exercice contenant `RTT_START_DATE` (env, ex. `2026-02-23` → exercice 2026). Le double plancher empêche de remonter avant la mise en service du logiciel. Format : `2026` pour FORFAIT, `Juin 2025 → Mai 2026` sinon.
|
||||
- Changement d'année → recharge complète de l'onglet via `useEmployeeLeave.setSelectedLeaveYear(year)` (reload de `getEmployeeLeaveSummary?year=YYYY` + `listAbsences` + `listPublicHolidays`). Backend : filtre `?year=YYYY` validé 2000-2100, et `EmployeeLeaveSummary` expose `dataStartDate` (env `RTT_START_DATE`, injecté via `services.yaml`).
|
||||
- Sur un exercice passé (`selectedYear !== currentYear`), les boutons crayon **Jours fractionnés** et **Année N-1 payés** sont **désactivés** : pas d'édition rétroactive des stocks de report.
|
||||
- Doc : `doc/leave-tab.md`.
|
||||
|
||||
## Onglet RTT (fiche employé)
|
||||
- Tableau hebdomadaire (`frontend/components/employees/RttTab.vue`) — exercice fixe Juin(N-1)→Mai(N). Onglet **masqué pour les FORFAIT** (`showRttTab`).
|
||||
- **Sélecteur d'année** sous le tableau dans la zone scrollable. Même mécanique que l'onglet Congés (double plancher) : `max(floor_contrat, floor_rttStartDate)`. Format unique : `Juin 2025 → Mai 2026`.
|
||||
- Changement d'année → recharge via `useEmployeeRtt.setSelectedRttYear(year)` (`getEmployeeRttSummary?year=YYYY`). `EmployeeRttSummary.rttStartDate` est déjà exposé (champ existant) — il sert à la fois au floor du sélecteur et au masquage des lignes Report avant la mise en service.
|
||||
- Sur un exercice passé, le bouton **+ Payer les RTT** est désactivé (pas de paiement rétroactif).
|
||||
- Doc : `doc/rtt-tab.md`.
|
||||
|
||||
## 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.
|
||||
- Scope : `ROLE_ADMIN` → tous les employés, `ROLE_USER` (chef de site) → employés de ses sites, `ROLE_SELF` → sa ligne
|
||||
|
||||
@@ -35,6 +35,10 @@ services:
|
||||
arguments:
|
||||
$rttStartDate: '%env(RTT_START_DATE)%'
|
||||
|
||||
App\State\EmployeeLeaveSummaryProvider:
|
||||
arguments:
|
||||
$dataStartDate: '%env(RTT_START_DATE)%'
|
||||
|
||||
App\Repository\Contract\AbsenceReadRepositoryInterface: '@App\Repository\AbsenceRepository'
|
||||
App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface: '@App\Repository\EmployeeContractPeriodRepository'
|
||||
App\Repository\Contract\EmployeeScopedRepositoryInterface: '@App\Repository\EmployeeRepository'
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.97'
|
||||
app.version: '0.1.101'
|
||||
|
||||
@@ -173,6 +173,7 @@ Documents complementaires:
|
||||
- Exclusions configurables: variable d'env `EXCLUDED_PUBLIC_HOLIDAYS` (liste de libellés séparés par virgules). Par défaut `"Lundi de Pentecôte"` — journée de solidarité généralement travaillée. Le filtre s'applique à tous les consommateurs (frontend + calculs backend) en amont du retour du service.
|
||||
- Onglet congés: jours fériés affichés sur le calendrier avec fond `rgb(179, 229, 252)` et nom au survol
|
||||
- Écran Heures et Heures Conducteurs (vue jour): le nom du férié est affiché dans la colonne Absence sous forme de pill (fond `#b3e5fc`, icône `mdi:calendar-star`), distinct du pill absence
|
||||
- Écran Heures et Heures Conducteurs (vue semaine): la cellule du jour férié prend le fond `#b3e5fc` quand l'employé n'a pas d'absence ce jour-là, avec le nom du férié au survol (`title`). Si une absence est posée, la couleur de l'absence prime ; le `title` cumule les deux libellés (`Absence — Férié : Nom`).
|
||||
- Règle courante:
|
||||
- absences autorisées sur jour férié (création/édition depuis l'écran Heures et le Calendrier). Quand une absence est posée, le crédit virtuel férié est désactivé — c'est le `countAsWorkedHours` du type d'absence qui pilote
|
||||
- saisie d'heures ou de jours de présence autorisée — les heures saisies comptent normalement dans le total hebdo et le calcul RTT
|
||||
@@ -313,6 +314,7 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
|
||||
- les heures payées sont soustraites du disponible RTT (`availableMinutes -= totalPaidMinutes`)
|
||||
- affichage: 2 lignes par mois dans le tableau (25% et 50%)
|
||||
- colonnes Total 25% et Total 50%: somme base + bonus de chaque tranche
|
||||
- colonne Cumul (dernière colonne): solde RTT à la fin de chaque semaine = `report N-1 + somme totalMinutes des semaines jusqu'à celle-ci − paiements RTT des mois antérieurs au mois de la semaine`. Le paiement d'un mois M n'est déduit qu'à partir des semaines du mois M+1 (cohérent avec la logique de la ligne "Report mois précédent"). Permet la comparaison ligne à ligne avec un suivi RH externe (Excel)
|
||||
- ligne Report N-1 (carry rollover): affichée en juin uniquement si carry > 0
|
||||
- ligne Report mois précédent: solde cumulé (carry N-1 + semaines antérieures − paiements antérieurs), affichée à partir de juillet (masquée si nul)
|
||||
- Reste = Report cumulé + Total du mois − Payé du mois (balance courante en fin de mois)
|
||||
@@ -378,7 +380,7 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
|
||||
| Maladie - Nombre | Absence code 'M' ou 'AT' | Jours (demi-journées = 0.5) |
|
||||
| Maladie - Date | Absence code 'M' ou 'AT' | Dates formatées dd/mm |
|
||||
| CHAUFFEUR - PDJ | WorkHour.hasBreakfast | Comptage mois (chauffeurs uniquement) |
|
||||
| CHAUFFEUR - REPAS | WorkHour.hasLunch + hasDinner | Comptage mois (chauffeurs uniquement) |
|
||||
| CHAUFFEUR - REPAS | WorkHour.hasLunch + hasDinner | Somme sur le mois : +1 par déjeuner coché et +1 par dîner coché (un jour avec les deux compte 2 repas, chauffeurs uniquement) |
|
||||
| CHAUFFEUR - NUITEE | WorkHour.hasOvernight | Comptage mois (chauffeurs uniquement) |
|
||||
| CHAUFFEUR - samedi | WorkHour (samedi) | Samedis travaillés (chauffeurs uniquement) |
|
||||
| Observations | — | Colonne vide pour saisie manuelle |
|
||||
@@ -442,7 +444,8 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
|
||||
- Accessible depuis la fiche employé (bouton imprimante à droite du nom)
|
||||
- Ouvre un drawer pour choisir l'année (civile, Jan-Déc)
|
||||
- Génère un PDF avec le détail jour par jour des heures de l'employé
|
||||
- Seuls les jours avec heures saisies ou absence sont affichés
|
||||
- Seuls les jours avec heures saisies, absence, week-end ou jour férié sont affichés
|
||||
- Les jours fériés apparaissent toujours sur une ligne dédiée (fond bleu clair) avec la mention "Férié : {nom}" dans la colonne Absence (même si aucune saisie)
|
||||
|
||||
### Colonnes selon le mode de suivi
|
||||
|
||||
@@ -460,6 +463,7 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
|
||||
- TIME non-chauffeur: somme des créneaux matin + après-midi + soir, plus minutes créditées des absences `countAsWorkedHours`
|
||||
- Chauffeur: `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` + minutes créditées
|
||||
- PRESENCE: 0.5 par demi-journée présente (matin/après-midi), max 1.0
|
||||
- Jour férié Lun-Ven (hors Forfait, sans absence) : `total = max(saisie + crédit absence, référence contractuelle)` — même règle que l'écran Heures (cf. `HolidayVirtualHoursResolver`). Pour Forfait : pas de crédit virtuel, la ligne férié affiche juste l'éventuelle présence saisie.
|
||||
|
||||
### Nom du fichier
|
||||
|
||||
|
||||
60
doc/leave-tab.md
Normal file
60
doc/leave-tab.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Onglet "Congés" — fiche employé
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
L'onglet **Congés** de la fiche employé (`frontend/components/employees/LeaveTab.vue`) affiche :
|
||||
- un bandeau de compteurs (acquis, pris, reste, en cours d'acquisition, N-1 ou samedis selon le contrat) ;
|
||||
- un calendrier annuel coloré des congés posés (12 mois en grille 4×3) ;
|
||||
- pour chaque mois, le nombre de jours de présence (`presenceDaysByMonth`) ;
|
||||
- un sélecteur d'année en pied de calendrier.
|
||||
|
||||
## Période affichée
|
||||
|
||||
La période dépend du **type de contrat actuel** de l'employé :
|
||||
|
||||
| Type de contrat | Période affichée |
|
||||
|-------------------|--------------------------------|
|
||||
| FORFAIT | Janvier → Décembre (année civile) |
|
||||
| Autres | Juin (Y-1) → Mai (Y) (exercice CP) |
|
||||
|
||||
Cette règle suit `EmployeeLeaveSummaryProvider::resolveYear()` côté backend : la sélection FORFAIT vs non-FORFAIT se fait toujours sur le contrat **courant**, pas sur celui qui était en vigueur à l'année consultée.
|
||||
|
||||
## Sélecteur d'année
|
||||
|
||||
Position : **en bas du calendrier**, à gauche, à l'intérieur de la zone scrollable. Il scrolle donc avec les mois et apparaît sous la grille.
|
||||
|
||||
Plage proposée :
|
||||
- du plus récent (= année courante) au plus ancien ;
|
||||
- **double plancher** : l'année minimum est `max(floor_historique_contrat, floor_data_start_date)`
|
||||
- **floor_historique_contrat** : dérivé de `employee.contractHistory[].startDate` — premier exercice où l'employé avait un contrat ouvert
|
||||
- **floor_data_start_date** : dérivé de l'env `RTT_START_DATE` (date de mise en service du logiciel, ex. `2026-02-23` → exercice 2026 / année forfait 2026). Aucune donnée historique n'existe avant cette date, donc on ne propose pas d'années antérieures même si le contrat de l'employé est plus ancien.
|
||||
- la valeur est exposée par l'API `GET /employees/{id}/leave-summary` via le champ `dataStartDate` (peuplé depuis l'env serveur).
|
||||
- en cas d'historique manquant **et** d'env absente, la plage se réduit à l'année courante.
|
||||
|
||||
Format des libellés :
|
||||
- FORFAIT : `2026`, `2025`, `2024`…
|
||||
- Autres : `Juin 2025 → Mai 2026`, `Juin 2024 → Mai 2025`…
|
||||
|
||||
Comportement :
|
||||
- changer d'année recharge l'intégralité de l'onglet (`getEmployeeLeaveSummary?year=YYYY` + `listAbsences` + `listPublicHolidays`) ;
|
||||
- les compteurs du bandeau reflètent l'année sélectionnée.
|
||||
|
||||
## Verrouillage des éditions sur années passées
|
||||
|
||||
Quand `selectedYear !== currentYear` (consultation d'une année antérieure) :
|
||||
- le bouton crayon **Jours fractionnés** (non-FORFAIT) est désactivé ;
|
||||
- le bouton crayon **Année N-1 payés** (FORFAIT) est désactivé.
|
||||
|
||||
Justification : modifier rétroactivement les stocks de report ou les jours fractionnés d'un exercice clos décalerait silencieusement les soldes de toutes les années postérieures. La consultation reste possible, l'édition non.
|
||||
|
||||
## Implémentation
|
||||
|
||||
- Composable : `frontend/composables/useEmployeeLeave.ts`
|
||||
- État : `selectedLeaveYear`, computed `currentLeaveYear`, `availableLeaveYears`
|
||||
- API : `setSelectedLeaveYear(year)`, `loadLeaveData()`, `resetLoaded()`
|
||||
- `resetLoaded()` (appelé au changement d'employé) remet `selectedLeaveYear = null` pour que la valeur par défaut soit recalculée à partir du nouveau contrat.
|
||||
- Composant : `frontend/components/employees/LeaveTab.vue`
|
||||
- Props : `selectedYear`, `availableYears`, `currentYear`
|
||||
- Event : `update-selected-year`
|
||||
- Page : `frontend/pages/employees/[id].vue` (câble le composable au composant)
|
||||
- Backend : `EmployeeLeaveSummaryProvider` reçoit `RTT_START_DATE` via `services.yaml` (argument `$dataStartDate`) et l'expose dans la réponse `EmployeeLeaveSummary.dataStartDate`. Le filtrage `?year=YYYY` était déjà accepté (validation 2000–2100).
|
||||
52
doc/rtt-tab.md
Normal file
52
doc/rtt-tab.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Onglet "RTT" — fiche employé
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
L'onglet **RTT** de la fiche employé (`frontend/components/employees/RttTab.vue`) affiche un tableau hebdomadaire détaillé des heures supplémentaires accumulées et payées sur un exercice :
|
||||
- bandeau de navigation par mois (chevrons gauche/droite) ;
|
||||
- table semaine par semaine : Heure / Base 25% / 25% / Total 25% / Base 50% / 50% / Total 50% / Total / Cumul ;
|
||||
- ligne Report (carry N-1 ou cumul mois précédents) ;
|
||||
- ligne Total mois, ligne Payé, ligne Reste ;
|
||||
- bouton « + Payer les RTT » dans le bandeau ;
|
||||
- sélecteur d'exercice en pied de tableau.
|
||||
|
||||
L'onglet est **masqué pour les contrats FORFAIT** (filtre `showRttTab` dans `useEmployeeDetailPage`). Les FORFAIT n'accumulent pas de RTT.
|
||||
|
||||
## Période affichée
|
||||
|
||||
Toujours **Juin (Y-1) → Mai (Y)**. Le champ `EmployeeRttSummary.year` correspond à `Y` (année de fin d'exercice) ; ex. `year=2026` = `01/06/2025 → 31/05/2026`.
|
||||
|
||||
## Sélecteur d'année
|
||||
|
||||
Position : sous la table, à l'intérieur de la zone scrollable, à gauche.
|
||||
|
||||
Plage proposée :
|
||||
- du plus récent (= exercice courant) au plus ancien ;
|
||||
- **double plancher** : `max(floor_historique_contrat, floor_data_start_date)`
|
||||
- **floor_historique_contrat** : dérivé de `employee.contractHistory[].startDate` — premier exercice où l'employé avait un contrat ouvert
|
||||
- **floor_data_start_date** : exercice contenant `RTT_START_DATE` (env, ex. `2026-02-23` → exercice 2026)
|
||||
- la valeur est exposée par l'API `GET /employees/{id}/rtt-summary` via le champ `rttStartDate` (déjà existant — mais peuplé uniquement quand la date tombe dans l'exercice retourné, donc le composable utilise la première réponse pour borner la plage).
|
||||
- format unique : `Juin 2025 → Mai 2026`, `Juin 2024 → Mai 2025`…
|
||||
|
||||
Comportement :
|
||||
- changer d'exercice recharge `getEmployeeRttSummary?year=YYYY` (le backend valide 2000–2100) ;
|
||||
- la table redéploie les semaines de l'exercice sélectionné, navigation par mois conservée.
|
||||
|
||||
## Verrouillage des édition sur exercices passés
|
||||
|
||||
Quand `selectedYear !== currentYear` (consultation d'un exercice antérieur), le bouton **+ Payer les RTT** est désactivé. Justification : un paiement rétroactif sur un exercice clos décalerait les soldes courants et le report N-1 calculé.
|
||||
|
||||
La consultation reste possible, l'édition non.
|
||||
|
||||
## Implémentation
|
||||
|
||||
- Composable : `frontend/composables/useEmployeeRtt.ts`
|
||||
- État : `selectedRttYear`, computed `currentRttYear`, `availableRttYears`
|
||||
- API : `setSelectedRttYear(year)`, `loadRttData()`, `resetLoaded()`
|
||||
- `resetLoaded()` (appelé au changement d'employé) remet `selectedRttYear = null`.
|
||||
- Composant : `frontend/components/employees/RttTab.vue`
|
||||
- Props : `selectedYear`, `availableYears`, `currentYear`
|
||||
- Event : `update-selected-year`
|
||||
- Renommage `currentYear` (computed local de l'année du mois affiché) → `displayedMonthYear` pour éviter la collision avec la nouvelle prop.
|
||||
- Page : `frontend/pages/employees/[id].vue`
|
||||
- Backend : aucun changement — `EmployeeRttSummaryProvider` accepte déjà `?year=YYYY` (validation 2000–2100) et expose `rttStartDate`.
|
||||
@@ -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>
|
||||
|
||||
@@ -39,6 +39,8 @@
|
||||
</div>
|
||||
<button
|
||||
class="flex items-center"
|
||||
:class="isHistoricalYear ? 'opacity-40 cursor-not-allowed' : ''"
|
||||
:disabled="isHistoricalYear"
|
||||
@click="openPaidLeaveDrawer"
|
||||
>
|
||||
<Icon name="mdi:edit-box" size="24"/>
|
||||
@@ -51,6 +53,8 @@
|
||||
</div>
|
||||
<button
|
||||
class="flex items-center"
|
||||
:class="isHistoricalYear ? 'opacity-40 cursor-not-allowed' : ''"
|
||||
:disabled="isHistoricalYear"
|
||||
@click="openFractionedDrawer"
|
||||
>
|
||||
<Icon name="mdi:edit-box" size="24"/>
|
||||
@@ -90,6 +94,22 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex items-center gap-3">
|
||||
<label for="leave-year-select" class="text-md font-semibold text-primary-500 uppercase">
|
||||
{{ isForfaitRule ? 'Année :' : 'Exercice :' }}
|
||||
</label>
|
||||
<select
|
||||
id="leave-year-select"
|
||||
:value="selectedYear ?? ''"
|
||||
:disabled="!availableYears.length"
|
||||
class="border border-primary-500 rounded-md px-3 py-1 text-md font-semibold text-primary-500 bg-white focus:outline-none focus:ring-2 focus:ring-secondary-500/20 disabled:opacity-50"
|
||||
@change="handleYearChange"
|
||||
>
|
||||
<option v-for="option in availableYears" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<AppDrawer v-model="isFractionedDrawerOpen" title="Jours fractionnés">
|
||||
<form class="space-y-4" @submit.prevent="handleSubmitFractioned">
|
||||
@@ -173,17 +193,39 @@ type DayLeaveState = {
|
||||
colors: string[]
|
||||
}
|
||||
|
||||
type LeaveYearOption = {
|
||||
value: number
|
||||
label: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
absences: Absence[]
|
||||
summary: EmployeeLeaveSummary | null
|
||||
publicHolidays: Record<string, string>
|
||||
selectedYear: number | null
|
||||
availableYears: LeaveYearOption[]
|
||||
currentYear: number | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update-fractioned-days', days: number): void
|
||||
(event: 'update-paid-leave-days', days: number): void
|
||||
(event: 'update-selected-year', year: number): void
|
||||
}>()
|
||||
|
||||
const isHistoricalYear = computed(() =>
|
||||
props.selectedYear !== null
|
||||
&& props.currentYear !== null
|
||||
&& props.selectedYear !== props.currentYear
|
||||
)
|
||||
|
||||
const handleYearChange = (event: Event) => {
|
||||
const target = event.target as HTMLSelectElement
|
||||
const value = Number(target.value)
|
||||
if (Number.isNaN(value)) return
|
||||
emit('update-selected-year', value)
|
||||
}
|
||||
|
||||
const isFractionedDrawerOpen = ref(false)
|
||||
const fractionedForm = reactive({days: 0})
|
||||
|
||||
@@ -239,6 +281,7 @@ const currentYearTakenDays = computed(() => {
|
||||
})
|
||||
|
||||
const displayedYear = computed(() => {
|
||||
if (props.selectedYear) return props.selectedYear
|
||||
if (props.summary?.year) return props.summary.year
|
||||
const today = new Date()
|
||||
const year = today.getFullYear()
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<Icon name="mdi:chevron-left" size="24"/>
|
||||
</button>
|
||||
<span class="text-lg font-bold tracking-wide min-w-[170px] text-center">
|
||||
{{ currentMonthLabel }} {{ currentYear }}
|
||||
{{ currentMonthLabel }} {{ displayedMonthYear }}
|
||||
</span>
|
||||
<button
|
||||
class="rounded px-2 py-1 font-bold hover:bg-primary-600 disabled:opacity-40 disabled:cursor-not-allowed flex items-center"
|
||||
@@ -27,7 +27,8 @@
|
||||
</p>
|
||||
<div class="flex justify-center">
|
||||
<button
|
||||
class="rounded-md bg-primary-500 px-8 py-2 font-bold text-white hover:bg-primary-600"
|
||||
class="rounded-md bg-primary-500 px-8 py-2 font-bold text-white hover:bg-primary-600 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
:disabled="isHistoricalYear"
|
||||
@click="openPaymentDrawer"
|
||||
>
|
||||
+ Payer les RTT
|
||||
@@ -40,14 +41,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 +61,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 +76,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 +90,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 +131,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> </span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Total row -->
|
||||
@@ -142,7 +151,8 @@
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-t-2">{{ formatMinutes(totals.base50) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-t-2">{{ formatMinutes(totals.bonus50) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2 border-t-2">{{ formatMinutes(totals.total50) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-t-2">{{ formatMinutes(totals.total) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2 border-t-2">{{ formatMinutes(totals.total) }}</td>
|
||||
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-t-2">-</td>
|
||||
</tr>
|
||||
|
||||
<!-- Payé row -->
|
||||
@@ -155,7 +165,8 @@
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBase50Minutes) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -currentPayment.paidBase50Minutes : 0) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBonus50Minutes) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -currentPayment.paidBonus50Minutes : 0) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ currentPayment ? formatMinutes(-(currentPayment.paidBase50Minutes + currentPayment.paidBonus50Minutes)) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -(currentPayment.paidBase50Minutes + currentPayment.paidBonus50Minutes) : 0) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(paidTotal) }} <span class="text-neutral-400">/ {{ formatCentiemes(paidTotal) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(paidTotal) }} <span class="text-neutral-400">/ {{ formatCentiemes(paidTotal) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500">-</td>
|
||||
</tr>
|
||||
|
||||
<!-- Reste row -->
|
||||
@@ -168,10 +179,27 @@
|
||||
<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>
|
||||
<div class="mt-6 flex items-center gap-3">
|
||||
<label for="rtt-year-select" class="text-md font-semibold text-primary-500 uppercase">
|
||||
Exercice :
|
||||
</label>
|
||||
<select
|
||||
id="rtt-year-select"
|
||||
:value="selectedYear ?? ''"
|
||||
:disabled="!availableYears.length"
|
||||
class="border border-primary-500 rounded-md px-3 py-1 text-md font-semibold text-primary-500 bg-white focus:outline-none focus:ring-2 focus:ring-secondary-500/20 disabled:opacity-50"
|
||||
@change="handleYearChange"
|
||||
>
|
||||
<option v-for="option in availableYears" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Drawer -->
|
||||
@@ -250,14 +278,36 @@
|
||||
import type { EmployeeRttSummary, EmployeeRttWeekSummary } from '~/services/dto/employee-rtt-summary'
|
||||
import AppDrawer from '~/components/AppDrawer.vue'
|
||||
|
||||
type RttYearOption = {
|
||||
value: number
|
||||
label: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
summary: EmployeeRttSummary | null
|
||||
selectedYear: number | null
|
||||
availableYears: RttYearOption[]
|
||||
currentYear: number | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'submit-rtt-payment', month: number, base25Minutes: number, bonus25Minutes: number, base50Minutes: number, bonus50Minutes: number): void
|
||||
(event: 'update-selected-year', year: number): void
|
||||
}>()
|
||||
|
||||
const isHistoricalYear = computed(() =>
|
||||
props.selectedYear !== null
|
||||
&& props.currentYear !== null
|
||||
&& props.selectedYear !== props.currentYear
|
||||
)
|
||||
|
||||
const handleYearChange = (event: Event) => {
|
||||
const target = event.target as HTMLSelectElement
|
||||
const value = Number(target.value)
|
||||
if (Number.isNaN(value)) return
|
||||
emit('update-selected-year', value)
|
||||
}
|
||||
|
||||
// --- Last complete week number ---
|
||||
|
||||
const lastCompleteWeek = computed(() => {
|
||||
@@ -313,7 +363,7 @@ const currentMonth = computed(() => orderedMonths[currentMonthIndex.value])
|
||||
|
||||
const currentMonthLabel = computed(() => monthLabels[currentMonth.value])
|
||||
|
||||
const currentYear = computed(() => {
|
||||
const displayedMonthYear = computed(() => {
|
||||
if (!props.summary) return ''
|
||||
return currentMonth.value >= 6 ? props.summary.year - 1 : props.summary.year
|
||||
})
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
class="flex items-center justify-between rounded-md px-2 py-1 text-xs"
|
||||
:class="daily.hasAbsence ? 'text-white' : 'text-primary-500'"
|
||||
:style="getDailyCellStyle(daily)"
|
||||
:title="cellTitle(daily)"
|
||||
>
|
||||
<span class="font-semibold">{{ weekDayHeaders[i]?.label ?? '' }}</span>
|
||||
<span v-if="row.trackingMode === 'PRESENCE'">{{ daily.present ?? 0 }}</span>
|
||||
@@ -93,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>
|
||||
|
||||
@@ -104,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>
|
||||
@@ -153,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>
|
||||
|
||||
81
frontend/components/hours/WeekCommentDrawer.vue
Normal file
81
frontend/components/hours/WeekCommentDrawer.vue
Normal 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>
|
||||
@@ -926,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,
|
||||
@@ -993,6 +1002,10 @@ export const useDriverHoursPage = () => {
|
||||
deleteAbsenceFromDrawer,
|
||||
closeAbsenceDrawer,
|
||||
formatMinutes,
|
||||
handleSave
|
||||
handleSave,
|
||||
isWeekCommentDrawerOpen,
|
||||
weekCommentContext,
|
||||
openWeekCommentDrawer,
|
||||
reloadWeeklySummary
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,27 +7,91 @@ import { listAbsences } from '~/services/absences'
|
||||
import { getEmployeeLeaveSummary, updateFractionedDays, updatePaidLeaveDays } from '~/services/employee-leave-summary'
|
||||
import { listPublicHolidays } from '~/services/public-holidays'
|
||||
|
||||
export type LeaveYearOption = {
|
||||
value: number
|
||||
label: string
|
||||
}
|
||||
|
||||
export const useEmployeeLeave = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
|
||||
const employeeAbsences = ref<Absence[]>([])
|
||||
const leaveSummary = ref<EmployeeLeaveSummary | null>(null)
|
||||
const publicHolidays = ref<Record<string, string>>({})
|
||||
const isLeaveLoading = ref(false)
|
||||
const leaveDataLoaded = ref(false)
|
||||
const selectedLeaveYear = ref<number | null>(null)
|
||||
|
||||
const getLeaveYear = () => {
|
||||
const now = new Date()
|
||||
const isForfait = employee.value?.contract?.type === CONTRACT_TYPES.FORFAIT
|
||||
return isForfait
|
||||
? now.getFullYear()
|
||||
: (now.getMonth() >= 5 ? now.getFullYear() + 1 : now.getFullYear())
|
||||
const isForfaitContract = (emp: Employee | null) =>
|
||||
emp?.contract?.type === CONTRACT_TYPES.FORFAIT
|
||||
|
||||
const computeLeaveYearForDate = (emp: Employee | null, date: Date): number => {
|
||||
if (isForfaitContract(emp)) return date.getFullYear()
|
||||
return date.getMonth() >= 5 ? date.getFullYear() + 1 : date.getFullYear()
|
||||
}
|
||||
|
||||
const currentLeaveYear = computed<number | null>(() => {
|
||||
if (!employee.value) return null
|
||||
return computeLeaveYearForDate(employee.value, new Date())
|
||||
})
|
||||
|
||||
const formatLeaveYearLabel = (year: number, isForfait: boolean): string => {
|
||||
if (isForfait) return String(year)
|
||||
return `Juin ${year - 1} → Mai ${year}`
|
||||
}
|
||||
|
||||
const availableLeaveYears = computed<LeaveYearOption[]>(() => {
|
||||
if (!employee.value || currentLeaveYear.value === null) return []
|
||||
const isForfait = isForfaitContract(employee.value)
|
||||
const current = currentLeaveYear.value
|
||||
|
||||
const startDates: string[] = []
|
||||
for (const period of employee.value.contractHistory ?? []) {
|
||||
if (period.startDate) startDates.push(period.startDate)
|
||||
}
|
||||
if (employee.value.entryDate) startDates.push(employee.value.entryDate)
|
||||
|
||||
let contractFloor = current
|
||||
for (const raw of startDates) {
|
||||
const date = new Date(`${raw.substring(0, 10)}T00:00:00`)
|
||||
if (Number.isNaN(date.getTime())) continue
|
||||
const leaveYear = computeLeaveYearForDate(employee.value, date)
|
||||
if (leaveYear < contractFloor) contractFloor = leaveYear
|
||||
}
|
||||
|
||||
// Hard floor : data-start-date (env RTT_START_DATE) — le logiciel n'a pas
|
||||
// d'historique avant cette date, inutile de proposer des années antérieures.
|
||||
let dataFloor: number | null = null
|
||||
const dataStart = leaveSummary.value?.dataStartDate
|
||||
if (dataStart) {
|
||||
const dataStartDate = new Date(`${dataStart.substring(0, 10)}T00:00:00`)
|
||||
if (!Number.isNaN(dataStartDate.getTime())) {
|
||||
dataFloor = computeLeaveYearForDate(employee.value, dataStartDate)
|
||||
}
|
||||
}
|
||||
|
||||
const minYear = dataFloor !== null ? Math.max(contractFloor, dataFloor) : contractFloor
|
||||
|
||||
const years: LeaveYearOption[] = []
|
||||
for (let y = current; y >= minYear; y -= 1) {
|
||||
years.push({ value: y, label: formatLeaveYearLabel(y, isForfait) })
|
||||
}
|
||||
return years
|
||||
})
|
||||
|
||||
const initSelectedLeaveYear = () => {
|
||||
if (selectedLeaveYear.value !== null) return
|
||||
if (currentLeaveYear.value !== null) {
|
||||
selectedLeaveYear.value = currentLeaveYear.value
|
||||
}
|
||||
}
|
||||
|
||||
const loadLeaveData = async () => {
|
||||
if (!employee.value || isLeaveLoading.value) return
|
||||
initSelectedLeaveYear()
|
||||
if (selectedLeaveYear.value === null) return
|
||||
isLeaveLoading.value = true
|
||||
try {
|
||||
const isForfait = employee.value.contract?.type === CONTRACT_TYPES.FORFAIT
|
||||
const leaveYear = getLeaveYear()
|
||||
const isForfait = isForfaitContract(employee.value)
|
||||
const leaveYear = selectedLeaveYear.value
|
||||
const from = isForfait ? `${leaveYear}-01-01` : `${leaveYear - 1}-06-01`
|
||||
const to = isForfait ? `${leaveYear}-12-31` : `${leaveYear}-05-31`
|
||||
const holidayYears = isForfait ? [leaveYear] : [leaveYear - 1, leaveYear]
|
||||
@@ -46,8 +110,16 @@ export const useEmployeeLeave = (employee: Ref<Employee | null>, reloadEmployee:
|
||||
}
|
||||
}
|
||||
|
||||
const setSelectedLeaveYear = async (year: number) => {
|
||||
if (selectedLeaveYear.value === year) return
|
||||
selectedLeaveYear.value = year
|
||||
leaveDataLoaded.value = false
|
||||
await loadLeaveData()
|
||||
}
|
||||
|
||||
const resetLoaded = () => {
|
||||
leaveDataLoaded.value = false
|
||||
selectedLeaveYear.value = null
|
||||
}
|
||||
|
||||
const submitFractionedDays = async (days: number) => {
|
||||
@@ -70,6 +142,10 @@ export const useEmployeeLeave = (employee: Ref<Employee | null>, reloadEmployee:
|
||||
publicHolidays,
|
||||
isLeaveLoading,
|
||||
leaveDataLoaded,
|
||||
selectedLeaveYear,
|
||||
currentLeaveYear,
|
||||
availableLeaveYears,
|
||||
setSelectedLeaveYear,
|
||||
loadLeaveData,
|
||||
resetLoaded,
|
||||
submitFractionedDays,
|
||||
|
||||
@@ -3,25 +3,94 @@ import type { EmployeeRttSummary } from '~/services/dto/employee-rtt-summary'
|
||||
import type { Employee } from '~/services/dto/employee'
|
||||
import { getEmployeeRttSummary, createRttPayment } from '~/services/employee-rtt-summary'
|
||||
|
||||
export type RttYearOption = {
|
||||
value: number
|
||||
label: string
|
||||
}
|
||||
|
||||
export const useEmployeeRtt = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
|
||||
const rttSummary = ref<EmployeeRttSummary | null>(null)
|
||||
const isRttLoading = ref(false)
|
||||
const rttDataLoaded = ref(false)
|
||||
const selectedRttYear = ref<number | null>(null)
|
||||
|
||||
// Exercice RTT : Juin (Y-1) → Mai (Y). Toujours, peu importe le type de contrat
|
||||
// (l'onglet RTT est masqué pour les FORFAIT côté page).
|
||||
const computeRttYearForDate = (date: Date): number =>
|
||||
date.getMonth() >= 5 ? date.getFullYear() + 1 : date.getFullYear()
|
||||
|
||||
const currentRttYear = computed<number | null>(() => {
|
||||
if (!employee.value) return null
|
||||
return computeRttYearForDate(new Date())
|
||||
})
|
||||
|
||||
const availableRttYears = computed<RttYearOption[]>(() => {
|
||||
if (!employee.value || currentRttYear.value === null) return []
|
||||
const current = currentRttYear.value
|
||||
|
||||
const startDates: string[] = []
|
||||
for (const period of employee.value.contractHistory ?? []) {
|
||||
if (period.startDate) startDates.push(period.startDate)
|
||||
}
|
||||
if (employee.value.entryDate) startDates.push(employee.value.entryDate)
|
||||
|
||||
let contractFloor = current
|
||||
for (const raw of startDates) {
|
||||
const date = new Date(`${raw.substring(0, 10)}T00:00:00`)
|
||||
if (Number.isNaN(date.getTime())) continue
|
||||
const rttYear = computeRttYearForDate(date)
|
||||
if (rttYear < contractFloor) contractFloor = rttYear
|
||||
}
|
||||
|
||||
// Hard floor : rttStartDate (env RTT_START_DATE) — pas d'historique avant.
|
||||
let dataFloor: number | null = null
|
||||
const dataStart = rttSummary.value?.rttStartDate
|
||||
if (dataStart) {
|
||||
const dataStartDate = new Date(`${dataStart.substring(0, 10)}T00:00:00`)
|
||||
if (!Number.isNaN(dataStartDate.getTime())) {
|
||||
dataFloor = computeRttYearForDate(dataStartDate)
|
||||
}
|
||||
}
|
||||
|
||||
const minYear = dataFloor !== null ? Math.max(contractFloor, dataFloor) : contractFloor
|
||||
|
||||
const years: RttYearOption[] = []
|
||||
for (let y = current; y >= minYear; y -= 1) {
|
||||
years.push({ value: y, label: `Juin ${y - 1} → Mai ${y}` })
|
||||
}
|
||||
return years
|
||||
})
|
||||
|
||||
const initSelectedRttYear = () => {
|
||||
if (selectedRttYear.value !== null) return
|
||||
if (currentRttYear.value !== null) {
|
||||
selectedRttYear.value = currentRttYear.value
|
||||
}
|
||||
}
|
||||
|
||||
const loadRttData = async () => {
|
||||
if (!employee.value || isRttLoading.value) return
|
||||
initSelectedRttYear()
|
||||
if (selectedRttYear.value === null) return
|
||||
isRttLoading.value = true
|
||||
try {
|
||||
const rttYear = new Date().getMonth() >= 5 ? new Date().getFullYear() + 1 : new Date().getFullYear()
|
||||
rttSummary.value = await getEmployeeRttSummary(employee.value.id, rttYear)
|
||||
rttSummary.value = await getEmployeeRttSummary(employee.value.id, selectedRttYear.value)
|
||||
rttDataLoaded.value = true
|
||||
} finally {
|
||||
isRttLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const setSelectedRttYear = async (year: number) => {
|
||||
if (selectedRttYear.value === year) return
|
||||
selectedRttYear.value = year
|
||||
rttDataLoaded.value = false
|
||||
await loadRttData()
|
||||
}
|
||||
|
||||
const resetLoaded = () => {
|
||||
rttDataLoaded.value = false
|
||||
selectedRttYear.value = null
|
||||
}
|
||||
|
||||
const submitRttPayment = async (month: number, base25Minutes: number, bonus25Minutes: number, base50Minutes: number, bonus50Minutes: number) => {
|
||||
@@ -35,6 +104,10 @@ export const useEmployeeRtt = (employee: Ref<Employee | null>, reloadEmployee: (
|
||||
rttSummary,
|
||||
isRttLoading,
|
||||
rttDataLoaded,
|
||||
selectedRttYear,
|
||||
currentRttYear,
|
||||
availableRttYears,
|
||||
setSelectedRttYear,
|
||||
loadRttData,
|
||||
resetLoaded,
|
||||
submitRttPayment
|
||||
|
||||
@@ -1112,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,
|
||||
@@ -1186,6 +1195,10 @@ export const useHoursPage = () => {
|
||||
deleteAbsenceFromDrawer,
|
||||
closeAbsenceDrawer,
|
||||
formatMinutes,
|
||||
handleSave
|
||||
handleSave,
|
||||
isWeekCommentDrawerOpen,
|
||||
weekCommentContext,
|
||||
openWeekCommentDrawer,
|
||||
reloadWeeklySummary
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,16 @@ export const documentationSections: DocSection[] = [
|
||||
{ type: 'list', content: 'Jour : total des heures dans la plage 06:00–21:00\nNuit : total des heures dans les plages 00:00–06:00 et 21:00–24: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.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -332,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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -447,6 +457,17 @@ export const documentationSections: DocSection[] = [
|
||||
{ type: 'paragraph', content: 'Compteurs visibles sur l\'onglet Congé de la fiche employé : acquis, en cours d\'acquisition, pris, restant.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'onglet-conges-fiche-employe',
|
||||
title: 'Onglet Congés (fiche employé)',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'L\'onglet "Congés" sur la fiche employé affiche un calendrier annuel des congés posés (12 mois en grille 4×3) ainsi que les compteurs (acquis, pris, reste, en cours d\'acquisition, N-1 ou samedis selon le contrat).' },
|
||||
{ type: 'paragraph', content: 'La période affichée dépend du type de contrat actuel : Janvier → Décembre pour FORFAIT, Juin (N-1) → Mai (N) pour les autres contrats.' },
|
||||
{ type: 'paragraph', content: 'Un sélecteur d\'année est disponible en bas du calendrier (zone scrollable, à gauche). Il permet de consulter les exercices passés. La plage proposée part de l\'exercice courant et remonte jusqu\'au plus récent entre (a) le premier exercice où l\'employé avait un contrat ouvert et (b) l\'exercice de mise en service du logiciel — il est inutile de remonter plus loin, aucune donnée n\'a été saisie avant.' },
|
||||
{ type: 'note', content: 'Sur un exercice passé, les boutons d\'édition "Jours fractionnés" et "Année N-1 payés" sont désactivés. La consultation reste possible, mais on n\'autorise pas la modification rétroactive d\'un exercice clos.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ecran-recap-conges',
|
||||
title: 'Écran Récap. congés',
|
||||
@@ -482,6 +503,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).' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -493,6 +515,15 @@ export const documentationSections: DocSection[] = [
|
||||
{ type: 'list', content: 'Saisie : mois, nombre de minutes, taux (25% ou 50%)\nPlusieurs paiements possibles par mois\nLes heures payées sont soustraites du solde disponible' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rtt-selecteur-exercice',
|
||||
title: 'Consulter un exercice passé',
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'Un sélecteur d\'exercice est disponible en bas du tableau RTT (zone scrollable, à gauche). Il permet de consulter les exercices passés (Juin → Mai). La plage proposée part de l\'exercice courant et remonte jusqu\'au plus récent entre (a) le premier exercice où l\'employé avait un contrat ouvert et (b) l\'exercice de mise en service du logiciel.' },
|
||||
{ type: 'note', content: 'Sur un exercice passé, le bouton « + Payer les RTT » est désactivé. Aucun paiement rétroactif n\'est autorisé pour préserver la cohérence du report N-1.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rtt-semaines-mois',
|
||||
title: 'Attribution des semaines aux mois',
|
||||
@@ -570,7 +601,7 @@ export const documentationSections: DocSection[] = [
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'Génère un PDF A4 paysage avec le détail mensuel pour la paie.' },
|
||||
{ type: 'list', content: 'Sélecteur de mois (défaut = mois courant)\nDonnées groupées par site\nColonnes : nom, base contrat, jours de présence cadre, heures de nuit, panier de nuit, heures RTT payées, congés (nombre + dates), maladie/AT (nombre + dates), primes conducteur (PDJ, repas, nuitée, samedi), observations' },
|
||||
{ type: 'list', content: 'Sélecteur de mois (défaut = mois courant)\nDonnées groupées par site\nColonnes : nom, base contrat, jours de présence cadre, heures de nuit, panier de nuit, heures RTT payées, congés (nombre + dates), maladie/AT (nombre + dates), primes conducteur (PDJ, repas, nuitée, samedi), observations\nColonne « Repas » chauffeur : somme déjeuner + dîner sur le mois (un jour avec les deux compte 2 repas)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -588,7 +619,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)' },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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é."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,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>
|
||||
|
||||
@@ -110,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>
|
||||
|
||||
@@ -179,7 +192,11 @@ const {
|
||||
formatMinutes,
|
||||
isSelectedDateHoliday,
|
||||
selectedHolidayLabel,
|
||||
handleSave
|
||||
handleSave,
|
||||
isWeekCommentDrawerOpen,
|
||||
weekCommentContext,
|
||||
openWeekCommentDrawer,
|
||||
reloadWeeklySummary
|
||||
} = useDriverHoursPage()
|
||||
|
||||
useHead({
|
||||
|
||||
@@ -160,15 +160,28 @@
|
||||
:absences="employeeAbsences"
|
||||
:summary="leaveSummary"
|
||||
:public-holidays="publicHolidays"
|
||||
:selected-year="selectedLeaveYear"
|
||||
:available-years="availableLeaveYears"
|
||||
:current-year="currentLeaveYear"
|
||||
@update-fractioned-days="submitFractionedDays"
|
||||
@update-paid-leave-days="submitPaidLeaveDays"
|
||||
@update-selected-year="setSelectedLeaveYear"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="showRttTab && activeTab === 'rtt'" class="h-full">
|
||||
<div v-if="isRttLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
||||
Chargement...
|
||||
</div>
|
||||
<EmployeesRttTab v-else class="h-full" :summary="rttSummary" @submit-rtt-payment="submitRttPayment" />
|
||||
<EmployeesRttTab
|
||||
v-else
|
||||
class="h-full"
|
||||
:summary="rttSummary"
|
||||
:selected-year="selectedRttYear"
|
||||
:available-years="availableRttYears"
|
||||
:current-year="currentRttYear"
|
||||
@submit-rtt-payment="submitRttPayment"
|
||||
@update-selected-year="setSelectedRttYear"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="activeTab === 'mileage'" class="h-full">
|
||||
<div v-if="isMileageLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
||||
@@ -253,6 +266,14 @@ const {
|
||||
leaveSummary,
|
||||
rttSummary,
|
||||
publicHolidays,
|
||||
selectedLeaveYear,
|
||||
currentLeaveYear,
|
||||
availableLeaveYears,
|
||||
setSelectedLeaveYear,
|
||||
selectedRttYear,
|
||||
currentRttYear,
|
||||
availableRttYears,
|
||||
setSelectedRttYear,
|
||||
showLeaveTab,
|
||||
showRttTab,
|
||||
contractHistory,
|
||||
|
||||
@@ -81,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>
|
||||
|
||||
@@ -117,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>
|
||||
|
||||
@@ -193,7 +206,11 @@ const {
|
||||
deleteAbsenceFromDrawer,
|
||||
closeAbsenceDrawer,
|
||||
formatMinutes,
|
||||
handleSave
|
||||
handleSave,
|
||||
isWeekCommentDrawerOpen,
|
||||
weekCommentContext,
|
||||
openWeekCommentDrawer,
|
||||
reloadWeeklySummary
|
||||
} = useHoursPage()
|
||||
|
||||
useHead({
|
||||
|
||||
@@ -16,5 +16,6 @@ export type EmployeeLeaveSummary = {
|
||||
previousYearPaidDays: number
|
||||
presenceDaysByMonth: Record<string, number>
|
||||
presenceDaysToToday: number
|
||||
dataStartDate: string | null
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export type EmployeeRttWeekSummary = {
|
||||
base50Minutes: number
|
||||
bonus50Minutes: number
|
||||
totalMinutes: number
|
||||
cumulativeBalanceMinutes: number
|
||||
}
|
||||
|
||||
export type RttMonthPayment = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
24
frontend/services/employee-week-comments.ts
Normal file
24
frontend/services/employee-week-comments.ts
Normal 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' })
|
||||
}
|
||||
29
migrations/Version20260417100000.php
Normal file
29
migrations/Version20260417100000.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -41,4 +41,7 @@ final class EmployeeLeaveSummary
|
||||
|
||||
/** 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;
|
||||
|
||||
/** Date de mise en service du logiciel (env RTT_START_DATE) — borne minimale pour les sélecteurs d'historique. */
|
||||
public ?string $dataStartDate = null;
|
||||
}
|
||||
|
||||
@@ -17,5 +17,6 @@ final class EmployeeRttWeekSummary
|
||||
public int $base50Minutes = 0,
|
||||
public int $bonus50Minutes = 0,
|
||||
public int $totalMinutes = 0,
|
||||
public int $cumulativeBalanceMinutes = 0,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -22,5 +22,6 @@ final class WeeklyDaySummary
|
||||
public bool $hasDinner = false,
|
||||
public bool $hasOvernight = false,
|
||||
public int $virtualHolidayMinutes = 0,
|
||||
public ?string $holidayLabel = null,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
|
||||
136
src/Entity/EmployeeWeekComment.php
Normal file
136
src/Entity/EmployeeWeekComment.php
Normal 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();
|
||||
}
|
||||
}
|
||||
58
src/Repository/EmployeeWeekCommentRepository.php
Normal file
58
src/Repository/EmployeeWeekCommentRepository.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -45,6 +45,8 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
private const float CDI_NON_FORFAIT_4H_SATURDAY_ACCRUAL_PER_MONTH = 0.0;
|
||||
private const float LONG_MALADIE_MONTHLY_ACCRUAL = 2.0;
|
||||
|
||||
private ?string $dataStartDate;
|
||||
|
||||
public function __construct(
|
||||
private Security $security,
|
||||
private RequestStack $requestStack,
|
||||
@@ -58,7 +60,10 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
private PublicHolidayServiceInterface $publicHolidayService,
|
||||
private SuspensionDaysCalculator $suspensionDaysCalculator,
|
||||
private WorkHourRepository $workHourRepository,
|
||||
) {}
|
||||
string $dataStartDate = '',
|
||||
) {
|
||||
$this->dataStartDate = '' !== $dataStartDate ? $dataStartDate : null;
|
||||
}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): EmployeeLeaveSummary
|
||||
{
|
||||
@@ -83,9 +88,10 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
|
||||
$year = $this->resolveYear($employee);
|
||||
|
||||
$summary = new EmployeeLeaveSummary();
|
||||
$summary->year = $year;
|
||||
$summary->ruleCode = LeaveRuleCode::UNSUPPORTED->value;
|
||||
$summary = new EmployeeLeaveSummary();
|
||||
$summary->year = $year;
|
||||
$summary->ruleCode = LeaveRuleCode::UNSUPPORTED->value;
|
||||
$summary->dataStartDate = $this->dataStartDate;
|
||||
|
||||
$yearSummary = $this->computeYearSummary($employee, $year);
|
||||
if (null === $yearSummary) {
|
||||
|
||||
@@ -110,14 +110,11 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
||||
$summary->currentYearRecoveryMinutes = array_sum(array_map(static fn ($d) => $d->totalMinutes, $currentByWeekStart));
|
||||
$summary->availableMinutes = $summary->carryFromPreviousYearMinutes + $summary->currentYearRecoveryMinutes;
|
||||
|
||||
// Pass rttStartDate only if it falls within this exercise
|
||||
if (null !== $this->rttStartDate) {
|
||||
$startDate = new DateTimeImmutable($this->rttStartDate);
|
||||
if ($startDate >= $periodFrom && $startDate <= $periodTo) {
|
||||
$summary->rttStartDate = $this->rttStartDate;
|
||||
}
|
||||
}
|
||||
$summary->weeks = $this->buildWeekSummaries($weekRanges, $currentByWeekStart, $periodFrom, $periodTo);
|
||||
// Always expose rttStartDate so the frontend can use it as a hard floor
|
||||
// for the year selector. Frontend already uses month-level comparison
|
||||
// to hide carry/report rows when the date is outside the exercise.
|
||||
$summary->rttStartDate = $this->rttStartDate;
|
||||
$summary->weeks = $this->buildWeekSummaries($weekRanges, $currentByWeekStart, $periodFrom, $periodTo);
|
||||
|
||||
// Post-process: distribute deficit weeks across cumulative balance (50% first, then 25%)
|
||||
$cumulative50 = $carry->base50Minutes + $carry->bonus50Minutes;
|
||||
@@ -164,6 +161,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;
|
||||
|
||||
|
||||
80
src/State/EmployeeWeekCommentWriteProcessor.php
Normal file
80
src/State/EmployeeWeekCommentWriteProcessor.php
Normal 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() ?? '')) : '?';
|
||||
}
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
76
tests/State/EmployeeWeekCommentWriteProcessorTest.php
Normal file
76
tests/State/EmployeeWeekCommentWriteProcessorTest.php
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user