Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ec0d4b074 | ||
| eaf8a11e2b | |||
|
|
02fc94fbed | ||
| eb5910dffe | |||
| 78f73ed2e9 | |||
| eacf52425a |
@@ -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
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.98'
|
||||
app.version: '0.1.100'
|
||||
|
||||
@@ -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
|
||||
@@ -379,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 |
|
||||
@@ -443,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
|
||||
|
||||
@@ -461,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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -571,7 +581,7 @@ export const documentationSections: DocSection[] = [
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'Génère un PDF A4 paysage avec le détail mensuel pour la paie.' },
|
||||
{ type: 'list', content: 'Sélecteur de mois (défaut = mois courant)\nDonnées groupées par site\nColonnes : nom, base contrat, jours de présence cadre, heures de nuit, panier de nuit, heures RTT payées, congés (nombre + dates), maladie/AT (nombre + dates), primes conducteur (PDJ, repas, nuitée, samedi), observations' },
|
||||
{ type: 'list', content: 'Sélecteur de mois (défaut = mois courant)\nDonnées groupées par site\nColonnes : nom, base contrat, jours de présence cadre, heures de nuit, panier de nuit, heures RTT payées, congés (nombre + dates), maladie/AT (nombre + dates), primes conducteur (PDJ, repas, nuitée, samedi), observations\nColonne « Repas » chauffeur : somme déjeuner + dîner sur le mois (un jour avec les deux compte 2 repas)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -589,7 +599,7 @@ export const documentationSections: DocSection[] = [
|
||||
requiredLevel: 'admin',
|
||||
blocks: [
|
||||
{ type: 'paragraph', content: 'Génère un PDF par employé avec le détail jour par jour de ses heures sur une année.' },
|
||||
{ type: 'list', content: 'Accessible depuis la fiche employé (bouton imprimante)\nChoix de l\'année civile (janvier à décembre)\nColonnes adaptées au mode de suivi (TIME, PRESENCE, conducteur)\nSections séparées en cas de changement de contrat en cours d\'année' },
|
||||
{ type: 'list', content: 'Accessible depuis la fiche employé (bouton imprimante)\nChoix de l\'année civile (janvier à décembre)\nColonnes adaptées au mode de suivi (TIME, PRESENCE, conducteur)\nSections séparées en cas de changement de contrat en cours d\'année\nLes jours fériés apparaissent toujours (ligne bleue) avec la mention « Férié : {nom} » dans la colonne Absence ; le total reprend les heures contractuelles créditées (hors Forfait)' },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
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