feat: ajout des commentaires à la semaine (#15)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket | |------------------|-----------------| | | | ## Description de la PR ## Modification du .env ## Check list - [x] Pas de régression - [ ] TU/TI/TF rédigée - [x] TU/TI/TF OK - [ ] CHANGELOG modifié Reviewed-on: #15 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #15.
This commit is contained in:
@@ -48,6 +48,12 @@
|
|||||||
- Saisie d'heures (ou de jours de présence) autorisée sur un férié
|
- 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`.
|
- **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
|
## Validation Rules
|
||||||
- `isValid` (RH): locks line for everyone (admin can only untoggle validation)
|
- `isValid` (RH): locks line for everyone (admin can only untoggle validation)
|
||||||
- `isSiteValid` (site manager): locks for non-admin, admin can still edit
|
- `isSiteValid` (site manager): locks for non-admin, admin can still edit
|
||||||
|
|||||||
@@ -33,8 +33,11 @@
|
|||||||
{{ row.firstName }} {{ row.lastName }}
|
{{ row.firstName }} {{ row.lastName }}
|
||||||
<span class="font-normal text-neutral-600">({{ row.contractName ?? '-' }})</span>
|
<span class="font-normal text-neutral-600">({{ row.contractName ?? '-' }})</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="text-[11px] text-neutral-500 truncate">
|
<p class="text-[11px] text-neutral-500 truncate inline-flex items-center gap-2">
|
||||||
{{ row.siteName ?? 'Sans site' }}<span v-if="row.contractNature"> — {{ contractNatureLabel(row.contractNature) }}</span>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -118,9 +121,12 @@ const cellTitle = (daily: {
|
|||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
isWeekLoading: boolean
|
isWeekLoading: boolean
|
||||||
|
isAdmin: boolean
|
||||||
weekGridCols: string
|
weekGridCols: string
|
||||||
weeklySummary: WeeklyWorkHourSummary | null
|
weeklySummary: WeeklyWorkHourSummary | null
|
||||||
weekDayHeaders: Array<{ date: string; weekday: string; dayDate: string }>
|
weekDayHeaders: Array<{ date: string; weekday: string; dayDate: string }>
|
||||||
formatMinutes: (minutes: number) => string
|
formatMinutes: (minutes: number) => string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{ (e: 'open-comment', row: WeeklyWorkHourSummary['rows'][number]): void }>()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -94,8 +94,11 @@
|
|||||||
{{ row.firstName }} {{ row.lastName }}
|
{{ row.firstName }} {{ row.lastName }}
|
||||||
<span class="font-normal text-neutral-600">({{ row.contractName ?? '-' }})</span>
|
<span class="font-normal text-neutral-600">({{ row.contractName ?? '-' }})</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="text-[11px] text-neutral-500 truncate">
|
<p class="text-[11px] text-neutral-500 truncate inline-flex items-center gap-2">
|
||||||
{{ row.siteName ?? 'Sans site' }}<span v-if="row.contractNature"> — {{ contractNatureLabel(row.contractNature) }}</span>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -179,9 +182,12 @@ const cellTitle = (daily: {
|
|||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
isWeekLoading: boolean
|
isWeekLoading: boolean
|
||||||
|
isAdmin: boolean
|
||||||
weekGridCols: string
|
weekGridCols: string
|
||||||
weeklySummary: WeeklyWorkHourSummary | null
|
weeklySummary: WeeklyWorkHourSummary | null
|
||||||
weekDayHeaders: Array<{ date: string; label: string }>
|
weekDayHeaders: Array<{ date: string; label: string }>
|
||||||
formatMinutes: (minutes: number) => string
|
formatMinutes: (minutes: number) => string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{ (e: 'open-comment', row: WeeklyWorkHourSummary['rows'][number]): void }>()
|
||||||
</script>
|
</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 {
|
return {
|
||||||
isAdmin,
|
isAdmin,
|
||||||
isSelfUser,
|
isSelfUser,
|
||||||
@@ -993,6 +1002,10 @@ export const useDriverHoursPage = () => {
|
|||||||
deleteAbsenceFromDrawer,
|
deleteAbsenceFromDrawer,
|
||||||
closeAbsenceDrawer,
|
closeAbsenceDrawer,
|
||||||
formatMinutes,
|
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 {
|
return {
|
||||||
isAdmin,
|
isAdmin,
|
||||||
isSelfUser,
|
isSelfUser,
|
||||||
@@ -1186,6 +1195,10 @@ export const useHoursPage = () => {
|
|||||||
deleteAbsenceFromDrawer,
|
deleteAbsenceFromDrawer,
|
||||||
closeAbsenceDrawer,
|
closeAbsenceDrawer,
|
||||||
formatMinutes,
|
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' },
|
{ 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.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -59,6 +59,10 @@
|
|||||||
},
|
},
|
||||||
"leaveRecap": {
|
"leaveRecap": {
|
||||||
"load": "Impossible de charger le récap des congés."
|
"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": {
|
"success": {
|
||||||
@@ -110,6 +114,10 @@
|
|||||||
"create": "Observation créée.",
|
"create": "Observation créée.",
|
||||||
"update": "Observation mise à jour.",
|
"update": "Observation mise à jour.",
|
||||||
"delete": "Observation supprimée."
|
"delete": "Observation supprimée."
|
||||||
|
},
|
||||||
|
"weekComment": {
|
||||||
|
"save": "Commentaire enregistré.",
|
||||||
|
"delete": "Commentaire supprimé."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,11 +74,13 @@
|
|||||||
<DriverHoursWeekView
|
<DriverHoursWeekView
|
||||||
v-else-if="isAdmin && viewMode === 'week'"
|
v-else-if="isAdmin && viewMode === 'week'"
|
||||||
:is-week-loading="isWeekLoading"
|
:is-week-loading="isWeekLoading"
|
||||||
|
:is-admin="isAdmin"
|
||||||
:week-grid-cols="weekGridCols"
|
:week-grid-cols="weekGridCols"
|
||||||
:weekly-summary="filteredWeeklySummary"
|
:weekly-summary="filteredWeeklySummary"
|
||||||
:week-day-headers="weekDayHeaders"
|
:week-day-headers="weekDayHeaders"
|
||||||
:format-minutes="formatMinutes"
|
:format-minutes="formatMinutes"
|
||||||
class="max-h-[calc(100vh-300px)]"
|
class="max-h-[calc(100vh-300px)]"
|
||||||
|
@open-comment="openWeekCommentDrawer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -110,6 +112,17 @@
|
|||||||
@cancel="closeAbsenceDrawer"
|
@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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -179,7 +192,11 @@ const {
|
|||||||
formatMinutes,
|
formatMinutes,
|
||||||
isSelectedDateHoliday,
|
isSelectedDateHoliday,
|
||||||
selectedHolidayLabel,
|
selectedHolidayLabel,
|
||||||
handleSave
|
handleSave,
|
||||||
|
isWeekCommentDrawerOpen,
|
||||||
|
weekCommentContext,
|
||||||
|
openWeekCommentDrawer,
|
||||||
|
reloadWeeklySummary
|
||||||
} = useDriverHoursPage()
|
} = useDriverHoursPage()
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
|
|||||||
@@ -81,11 +81,13 @@
|
|||||||
<HoursWeekView
|
<HoursWeekView
|
||||||
v-else-if="isAdmin && viewMode === 'week'"
|
v-else-if="isAdmin && viewMode === 'week'"
|
||||||
:is-week-loading="isWeekLoading"
|
:is-week-loading="isWeekLoading"
|
||||||
|
:is-admin="isAdmin"
|
||||||
:week-grid-cols="weekGridCols"
|
:week-grid-cols="weekGridCols"
|
||||||
:weekly-summary="filteredWeeklySummary"
|
:weekly-summary="filteredWeeklySummary"
|
||||||
:week-day-headers="weekDayHeaders"
|
:week-day-headers="weekDayHeaders"
|
||||||
:format-minutes="formatMinutes"
|
:format-minutes="formatMinutes"
|
||||||
class="max-h-[calc(100vh-300px)]"
|
class="max-h-[calc(100vh-300px)]"
|
||||||
|
@open-comment="openWeekCommentDrawer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -117,6 +119,17 @@
|
|||||||
@cancel="closeAbsenceDrawer"
|
@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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -193,7 +206,11 @@ const {
|
|||||||
deleteAbsenceFromDrawer,
|
deleteAbsenceFromDrawer,
|
||||||
closeAbsenceDrawer,
|
closeAbsenceDrawer,
|
||||||
formatMinutes,
|
formatMinutes,
|
||||||
handleSave
|
handleSave,
|
||||||
|
isWeekCommentDrawerOpen,
|
||||||
|
weekCommentContext,
|
||||||
|
openWeekCommentDrawer,
|
||||||
|
reloadWeeklySummary
|
||||||
} = useHoursPage()
|
} = useHoursPage()
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ export type WeeklyWorkHourRowSummary = {
|
|||||||
weeklyOvernightCount?: number
|
weeklyOvernightCount?: number
|
||||||
hasContractForWeek?: boolean
|
hasContractForWeek?: boolean
|
||||||
contractNature?: 'CDI' | 'CDD' | 'INTERIM' | null
|
contractNature?: 'CDI' | 'CDD' | 'INTERIM' | null
|
||||||
|
comment?: string | null
|
||||||
|
commentId?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WeeklyWorkHourSummary = {
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,5 +35,7 @@ final class WeeklySummaryRow
|
|||||||
public int $weeklyOvernightCount = 0,
|
public int $weeklyOvernightCount = 0,
|
||||||
public bool $hasContractForWeek = true,
|
public bool $hasContractForWeek = true,
|
||||||
public ?string $contractNature = null,
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
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() ?? '')) : '?';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ use App\Dto\WorkHours\WorkMetrics;
|
|||||||
use App\Entity\Absence;
|
use App\Entity\Absence;
|
||||||
use App\Entity\Contract;
|
use App\Entity\Contract;
|
||||||
use App\Entity\Employee;
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\EmployeeWeekComment;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Entity\WorkHour;
|
use App\Entity\WorkHour;
|
||||||
use App\Enum\ContractNature;
|
use App\Enum\ContractNature;
|
||||||
@@ -21,6 +22,7 @@ use App\Enum\TrackingMode;
|
|||||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
||||||
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
|
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
|
||||||
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
||||||
|
use App\Repository\EmployeeWeekCommentRepository;
|
||||||
use App\Service\Contracts\EmployeeContractResolver;
|
use App\Service\Contracts\EmployeeContractResolver;
|
||||||
use App\Service\PublicHolidayServiceInterface;
|
use App\Service\PublicHolidayServiceInterface;
|
||||||
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
||||||
@@ -48,6 +50,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
private DailyReferenceMinutesResolver $dailyReferenceResolver,
|
private DailyReferenceMinutesResolver $dailyReferenceResolver,
|
||||||
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
|
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
|
||||||
private PublicHolidayServiceInterface $publicHolidayService,
|
private PublicHolidayServiceInterface $publicHolidayService,
|
||||||
|
private EmployeeWeekCommentRepository $weekCommentRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourWeeklySummary
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourWeeklySummary
|
||||||
@@ -65,11 +68,13 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($weekStart, $weekEnd, $employees);
|
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($weekStart, $weekEnd, $employees);
|
||||||
$absences = $this->absenceRepository->findForPrint($weekStart, $weekEnd, $employees);
|
$absences = $this->absenceRepository->findForPrint($weekStart, $weekEnd, $employees);
|
||||||
|
|
||||||
|
$weekComments = $this->weekCommentRepository->findByWeekAndEmployees($weekStart, $employees);
|
||||||
|
|
||||||
$summary = new WorkHourWeeklySummary();
|
$summary = new WorkHourWeeklySummary();
|
||||||
$summary->weekStart = $weekStart->format('Y-m-d');
|
$summary->weekStart = $weekStart->format('Y-m-d');
|
||||||
$summary->weekEnd = $weekEnd->format('Y-m-d');
|
$summary->weekEnd = $weekEnd->format('Y-m-d');
|
||||||
$summary->days = $days;
|
$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;
|
return $summary;
|
||||||
}
|
}
|
||||||
@@ -112,14 +117,15 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<Employee> $employees
|
* @param list<Employee> $employees
|
||||||
* @param list<WorkHour> $workHours
|
* @param list<WorkHour> $workHours
|
||||||
* @param list<Absence> $absences
|
* @param list<Absence> $absences
|
||||||
* @param list<string> $days
|
* @param list<string> $days
|
||||||
|
* @param array<int, EmployeeWeekComment> $weekComments
|
||||||
*
|
*
|
||||||
* @return list<WeeklySummaryRow>
|
* @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);
|
$contractsByEmployeeDate = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
|
||||||
$contractNaturesByEmployeeDate = $this->contractResolver->resolveNaturesForEmployeesAndDays($employees, $days);
|
$contractNaturesByEmployeeDate = $this->contractResolver->resolveNaturesForEmployeesAndDays($employees, $days);
|
||||||
@@ -375,6 +381,8 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
weeklyOvernightCount: $weeklyOvernightCount,
|
weeklyOvernightCount: $weeklyOvernightCount,
|
||||||
hasContractForWeek: $hasContractForWeek,
|
hasContractForWeek: $hasContractForWeek,
|
||||||
contractNature: $weekAnchorContractNature->value,
|
contractNature: $weekAnchorContractNature->value,
|
||||||
|
comment: ($weekComments[$employeeId] ?? null)?->getContent(),
|
||||||
|
commentId: ($weekComments[$employeeId] ?? null)?->getId(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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\AbsenceReadRepositoryInterface;
|
||||||
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
|
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
|
||||||
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
||||||
|
use App\Repository\EmployeeWeekCommentRepository;
|
||||||
use App\Service\Contracts\EmployeeContractResolver;
|
use App\Service\Contracts\EmployeeContractResolver;
|
||||||
use App\Service\PublicHolidayServiceInterface;
|
use App\Service\PublicHolidayServiceInterface;
|
||||||
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
||||||
@@ -67,6 +68,7 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
|
|||||||
new DailyReferenceMinutesResolver(),
|
new DailyReferenceMinutesResolver(),
|
||||||
$this->buildHolidayResolver(),
|
$this->buildHolidayResolver(),
|
||||||
$this->buildHolidayService(),
|
$this->buildHolidayService(),
|
||||||
|
$this->buildWeekCommentRepoStub(),
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->expectException(AccessDeniedHttpException::class);
|
$this->expectException(AccessDeniedHttpException::class);
|
||||||
@@ -130,6 +132,7 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
|
|||||||
new DailyReferenceMinutesResolver(),
|
new DailyReferenceMinutesResolver(),
|
||||||
$this->buildHolidayResolver(),
|
$this->buildHolidayResolver(),
|
||||||
$this->buildHolidayService(),
|
$this->buildHolidayService(),
|
||||||
|
$this->buildWeekCommentRepoStub(),
|
||||||
);
|
);
|
||||||
|
|
||||||
$result = $provider->provide(new Get());
|
$result = $provider->provide(new Get());
|
||||||
@@ -180,6 +183,14 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
|
|||||||
$property->setValue($entity, $id);
|
$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
|
private function buildHolidayResolver(array $holidayMap = []): HolidayVirtualHoursResolver
|
||||||
{
|
{
|
||||||
return new HolidayVirtualHoursResolver(
|
return new HolidayVirtualHoursResolver(
|
||||||
|
|||||||
Reference in New Issue
Block a user