Compare commits
3 Commits
v0.1.87
...
ccd8e66dcd
| Author | SHA1 | Date | |
|---|---|---|---|
| ccd8e66dcd | |||
|
|
51bf155b0e | ||
| 1095421424 |
@@ -44,6 +44,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.87'
|
||||
app.version: '0.1.88'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<AppDrawer v-model="drawerOpen" title="Export heures annuelles">
|
||||
<AppDrawer v-model="drawerOpen" title="Export heures">
|
||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="yearly-hours-year">
|
||||
@@ -14,6 +14,20 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="yearly-hours-month">
|
||||
Mois
|
||||
</label>
|
||||
<select
|
||||
id="yearly-hours-month"
|
||||
v-model="selectedMonth"
|
||||
:class="selectFieldClass"
|
||||
>
|
||||
<option value="">Toute l'année</option>
|
||||
<option v-for="m in months" :key="m.value" :value="m.value">{{ m.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
@@ -37,7 +51,7 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
(event: 'submit', year: number): void
|
||||
(event: 'submit', payload: { year: number; month: number | null }): void
|
||||
}>()
|
||||
|
||||
const drawerOpen = computed({
|
||||
@@ -47,13 +61,31 @@ const drawerOpen = computed({
|
||||
|
||||
const currentYear = new Date().getFullYear()
|
||||
const years = Array.from({ length: 6 }, (_, i) => currentYear - i)
|
||||
const months = [
|
||||
{ value: 1, label: 'Janvier' },
|
||||
{ value: 2, label: 'Février' },
|
||||
{ value: 3, label: 'Mars' },
|
||||
{ value: 4, label: 'Avril' },
|
||||
{ value: 5, label: 'Mai' },
|
||||
{ value: 6, label: 'Juin' },
|
||||
{ value: 7, label: 'Juillet' },
|
||||
{ value: 8, label: 'Août' },
|
||||
{ value: 9, label: 'Septembre' },
|
||||
{ value: 10, label: 'Octobre' },
|
||||
{ value: 11, label: 'Novembre' },
|
||||
{ value: 12, label: 'Décembre' }
|
||||
]
|
||||
const selectedYear = ref(currentYear)
|
||||
const selectedMonth = ref<number | ''>('')
|
||||
|
||||
const baseInputClass = 'mt-2 w-full rounded-md border px-3 py-2 text-md text-neutral-900'
|
||||
const selectFieldClass = computed(() => `${baseInputClass} border-neutral-300`)
|
||||
|
||||
const handleSubmit = () => {
|
||||
emit('submit', selectedYear.value)
|
||||
emit('submit', {
|
||||
year: selectedYear.value,
|
||||
month: selectedMonth.value === '' ? null : selectedMonth.value
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -61,6 +93,7 @@ watch(
|
||||
(isOpen) => {
|
||||
if (!isOpen) {
|
||||
selectedYear.value = currentYear
|
||||
selectedMonth.value = ''
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -42,7 +42,9 @@
|
||||
<span class="font-normal text-neutral-600">({{ contractLabel(employee) }})</span>
|
||||
</p>
|
||||
<p class="text-neutral-500 truncate inline-flex items-center gap-2">
|
||||
<span>{{ employee.site?.name ?? 'Sans site' }}</span>
|
||||
<span>
|
||||
{{ employee.site?.name ?? 'Sans site' }}<span v-if="employee.currentContractNature"> — {{ contractNatureLabel(employee.currentContractNature) }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
|
||||
class="rounded-full bg-green-500 flex justify-center item-center text-white p-0.5"
|
||||
@@ -170,6 +172,7 @@
|
||||
import type { Employee } from '~/services/dto/employee'
|
||||
import TimeSelect from '~/components/ui/TimeSelect.vue'
|
||||
import type { DriverHourRow } from '~/services/dto/work-hour'
|
||||
import { contractNatureLabel } from '~/utils/contract'
|
||||
|
||||
const rows = defineModel<Record<number, DriverHourRow>>('rows', { required: true })
|
||||
const bulkValidationInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
@@ -33,7 +33,12 @@
|
||||
{{ 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' }}</p>
|
||||
<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>
|
||||
|
||||
<div
|
||||
@@ -89,6 +94,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { WeeklyWorkHourSummary } from '~/services/dto/work-hour'
|
||||
import { contractNatureLabel } from '~/utils/contract'
|
||||
|
||||
const getDailyCellStyle = (daily: {
|
||||
hasAbsence?: boolean
|
||||
@@ -100,9 +106,12 @@ const getDailyCellStyle = (daily: {
|
||||
|
||||
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>
|
||||
|
||||
113
frontend/components/employees/WorkDaysHoursInput.vue
Normal file
113
frontend/components/employees/WorkDaysHoursInput.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="rounded-md border border-neutral-200 bg-neutral-50 p-3 space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-md font-semibold text-neutral-700">
|
||||
Jours travaillés <span v-if="!disabled" class="text-red-600">*</span>
|
||||
</p>
|
||||
<p class="text-sm" :class="totalIsValid ? 'text-green-700' : 'text-red-600'">
|
||||
{{ formatTotal(totalMinutes) }} / {{ formatTotal(expectedMinutes) }}
|
||||
</p>
|
||||
</div>
|
||||
<p v-if="!disabled" class="text-xs text-neutral-500">Somme requise = {{ expectedMinutes / 60 }}h (total hebdo du contrat).</p>
|
||||
<div class="space-y-1">
|
||||
<div v-for="day in days" :key="day.iso" class="flex items-center gap-3">
|
||||
<label class="inline-flex items-center gap-2 min-w-[120px]">
|
||||
<input
|
||||
:checked="day.active"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border-neutral-300 text-primary-500 focus:ring-primary-500"
|
||||
:disabled="disabled"
|
||||
@change="onToggleDay(day.iso, ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
<span class="text-md text-neutral-700">{{ day.label }}</span>
|
||||
</label>
|
||||
<input
|
||||
:value="day.time"
|
||||
type="time"
|
||||
step="60"
|
||||
class="rounded-md border border-neutral-300 bg-white px-2 py-1 text-md text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20 disabled:bg-neutral-100 disabled:text-neutral-400"
|
||||
:disabled="disabled || !day.active"
|
||||
@input="onChangeTime(day.iso, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!totalIsValid" class="text-sm text-red-600">
|
||||
La somme des heures par jour doit égaler exactement {{ expectedMinutes / 60 }}h.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: Record<number, number> | null
|
||||
contractWeeklyHours: number | null
|
||||
disabled?: boolean
|
||||
}>(), { disabled: false })
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: Record<number, number>]
|
||||
}>()
|
||||
|
||||
const DAY_LABELS: Record<number, string> = { 1: 'Lundi', 2: 'Mardi', 3: 'Mercredi', 4: 'Jeudi', 5: 'Vendredi' }
|
||||
|
||||
const expectedMinutes = computed(() => (props.contractWeeklyHours ?? 0) * 60)
|
||||
|
||||
const days = computed(() => {
|
||||
const raw = props.modelValue ?? {}
|
||||
return [1, 2, 3, 4, 5].map((iso) => {
|
||||
const active = Object.prototype.hasOwnProperty.call(raw, iso)
|
||||
const minutes = Number(raw[iso] ?? 0)
|
||||
return {
|
||||
iso,
|
||||
label: DAY_LABELS[iso],
|
||||
active,
|
||||
time: active ? minutesToTime(minutes) : '00:00',
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const totalMinutes = computed(() => {
|
||||
const raw = props.modelValue ?? {}
|
||||
return Object.values(raw).reduce((sum, n) => sum + (Number(n) || 0), 0)
|
||||
})
|
||||
|
||||
const totalIsValid = computed(() => totalMinutes.value === expectedMinutes.value && expectedMinutes.value > 0)
|
||||
|
||||
function minutesToTime(minutes: number): string {
|
||||
const h = Math.floor(minutes / 60)
|
||||
const m = minutes % 60
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function timeToMinutes(value: string): number {
|
||||
const [h, m] = value.split(':').map(Number)
|
||||
return (h || 0) * 60 + (m || 0)
|
||||
}
|
||||
|
||||
function onToggleDay(iso: number, active: boolean) {
|
||||
const next = { ...(props.modelValue ?? {}) }
|
||||
if (active) {
|
||||
next[iso] = next[iso] ?? 0
|
||||
} else {
|
||||
delete next[iso]
|
||||
}
|
||||
emit('update:modelValue', next)
|
||||
}
|
||||
|
||||
function onChangeTime(iso: number, value: string) {
|
||||
const next = { ...(props.modelValue ?? {}) }
|
||||
const minutes = timeToMinutes(value)
|
||||
next[iso] = minutes
|
||||
emit('update:modelValue', next)
|
||||
}
|
||||
|
||||
function formatTotal(min: number): string {
|
||||
const h = Math.floor(min / 60)
|
||||
const m = min % 60
|
||||
return m === 0 ? `${h}h` : `${h}h${String(m).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
defineExpose({ totalIsValid, totalMinutes })
|
||||
</script>
|
||||
@@ -43,7 +43,9 @@
|
||||
<span class="font-normal text-neutral-600">({{ contractLabel(employee) }})</span>
|
||||
</p>
|
||||
<p class="text-neutral-500 truncate inline-flex items-center gap-2">
|
||||
<span>{{ employee.site?.name ?? 'Sans site' }}</span>
|
||||
<span>
|
||||
{{ employee.site?.name ?? 'Sans site' }}<span v-if="employee.currentContractNature"> — {{ contractNatureLabel(employee.currentContractNature) }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
|
||||
class="rounded-full bg-green-500 flex justify-center item-center text-white p-0.5"
|
||||
@@ -196,6 +198,7 @@
|
||||
import type {Employee} from '~/services/dto/employee'
|
||||
import TimeSelect from '~/components/ui/TimeSelect.vue'
|
||||
import type {HourRow} from './types'
|
||||
import { contractNatureLabel } from '~/utils/contract'
|
||||
|
||||
const rows = defineModel<Record<number, HourRow>>('rows', {required: true})
|
||||
const bulkValidationInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
@@ -29,7 +29,12 @@
|
||||
{{ 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' }}</p>
|
||||
<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>
|
||||
|
||||
<div
|
||||
@@ -81,6 +86,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { WeeklyWorkHourSummary } from '~/services/dto/work-hour'
|
||||
import { CONTRACT_TYPES, type ContractType } from '~/services/dto/contract'
|
||||
import { contractNatureLabel } from '~/utils/contract'
|
||||
|
||||
const isInterimContract = (contractType?: ContractType | null) => {
|
||||
return contractType === CONTRACT_TYPES.INTERIM
|
||||
@@ -96,9 +102,12 @@ const getDailyCellStyle = (daily: {
|
||||
|
||||
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>
|
||||
|
||||
67
frontend/components/hours/WeekCommentDrawer.vue
Normal file
67
frontend/components/hours/WeekCommentDrawer.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<AppDrawer v-model="drawerOpen" :title="`Commentaire — ${formatWeekRange}`">
|
||||
<form class="space-y-4" @submit.prevent="onSave">
|
||||
<div v-if="employeeLabel" class="text-md font-semibold text-neutral-700">{{ employeeLabel }}</div>
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="week-comment-content">Commentaire</label>
|
||||
<textarea id="week-comment-content" v-model="content" rows="8" maxlength="5000" class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20" placeholder="Ex. Arrêt maladie lundi, reprise jeudi..." />
|
||||
<p class="mt-1 text-xs text-neutral-400">{{ content.length }} / 5000</p>
|
||||
</div>
|
||||
<div class="sticky bottom-0 -mx-[20px] bg-white px-[20px] py-3 flex justify-between gap-3">
|
||||
<button v-if="commentId" type="button" class="rounded-lg bg-red-500 px-4 py-2 text-md font-semibold text-white hover:bg-red-600 disabled:opacity-50" :disabled="isSubmitting" @click="onDelete">Supprimer</button>
|
||||
<div class="flex gap-3 ml-auto">
|
||||
<button type="button" class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100" @click="drawerOpen = false">Annuler</button>
|
||||
<button type="submit" class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:opacity-50" :disabled="isSubmitting || !canSubmit">Enregistrer</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import AppDrawer from '~/components/AppDrawer.vue'
|
||||
import { createWeekComment, deleteWeekComment, updateWeekComment } from '~/services/employee-week-comments'
|
||||
|
||||
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 }
|
||||
return `${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>
|
||||
@@ -922,6 +922,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,
|
||||
@@ -988,6 +997,10 @@ export const useDriverHoursPage = () => {
|
||||
deleteAbsenceFromDrawer,
|
||||
closeAbsenceDrawer,
|
||||
formatMinutes,
|
||||
handleSave
|
||||
handleSave,
|
||||
isWeekCommentDrawerOpen,
|
||||
weekCommentContext,
|
||||
openWeekCommentDrawer,
|
||||
reloadWeeklySummary
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1108,6 +1108,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,
|
||||
@@ -1181,6 +1190,10 @@ export const useHoursPage = () => {
|
||||
deleteAbsenceFromDrawer,
|
||||
closeAbsenceDrawer,
|
||||
formatMinutes,
|
||||
handleSave
|
||||
handleSave,
|
||||
isWeekCommentDrawerOpen,
|
||||
weekCommentContext,
|
||||
openWeekCommentDrawer,
|
||||
reloadWeeklySummary
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,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.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -73,11 +73,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>
|
||||
|
||||
@@ -108,6 +110,18 @@
|
||||
@delete="deleteAbsenceFromDrawer"
|
||||
@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>
|
||||
|
||||
@@ -176,7 +190,11 @@ const {
|
||||
formatMinutes,
|
||||
isSelectedDateHoliday,
|
||||
selectedHolidayLabel,
|
||||
handleSave
|
||||
handleSave,
|
||||
isWeekCommentDrawerOpen,
|
||||
weekCommentContext,
|
||||
openWeekCommentDrawer,
|
||||
reloadWeeklySummary
|
||||
} = useDriverHoursPage()
|
||||
|
||||
useHead({
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<h1 class="text-[32px] font-bold">{{ employee.firstName }} {{ employee.lastName }}</h1>
|
||||
<button
|
||||
class="inline-flex items-center justify-center rounded-md p-1 transition-colors duration-150 focus:outline-none focus-visible:ring-2 bg-primary-500 hover:bg-secondary-500 active:bg-primary-500 text-white cursor-pointer"
|
||||
title="Export heures annuelles"
|
||||
title="Export heures"
|
||||
@click="isYearlyHoursDrawerOpen = true"
|
||||
>
|
||||
<Icon name="mdi:printer" size="24" />
|
||||
@@ -321,9 +321,10 @@ const {
|
||||
submitDeleteObservation
|
||||
} = useEmployeeDetailPage()
|
||||
|
||||
const handleYearlyHoursPrint = async (year: number) => {
|
||||
const handleYearlyHoursPrint = async (payload: { year: number; month: number | null }) => {
|
||||
if (!employee.value) return
|
||||
await printPdf(`/yearly-hours/print?employeeId=${employee.value.id}&year=${year}`)
|
||||
const monthParam = null !== payload.month ? `&month=${payload.month}` : ''
|
||||
await printPdf(`/yearly-hours/print?employeeId=${employee.value.id}&year=${payload.year}${monthParam}`)
|
||||
isYearlyHoursDrawerOpen.value = false
|
||||
}
|
||||
|
||||
|
||||
@@ -80,11 +80,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>
|
||||
|
||||
@@ -115,6 +117,18 @@
|
||||
@delete="deleteAbsenceFromDrawer"
|
||||
@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>
|
||||
|
||||
@@ -190,7 +204,11 @@ const {
|
||||
deleteAbsenceFromDrawer,
|
||||
closeAbsenceDrawer,
|
||||
formatMinutes,
|
||||
handleSave
|
||||
handleSave,
|
||||
isWeekCommentDrawerOpen,
|
||||
weekCommentContext,
|
||||
openWeekCommentDrawer,
|
||||
reloadWeeklySummary
|
||||
} = useHoursPage()
|
||||
|
||||
useHead({
|
||||
|
||||
@@ -87,6 +87,9 @@ export type WeeklyWorkHourRowSummary = {
|
||||
weeklyDinnerCount?: number
|
||||
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.week-comment.save', toastErrorKey: 'errors.week-comment.save' })
|
||||
}
|
||||
|
||||
export const updateWeekComment = async (id: number, content: string) => {
|
||||
const api = useApi()
|
||||
return api.patch<EmployeeWeekComment>(`/employee_week_comments/${id}`, { content }, { toastSuccessKey: 'success.week-comment.save', toastErrorKey: 'errors.week-comment.save' })
|
||||
}
|
||||
|
||||
export const deleteWeekComment = async (id: number) => {
|
||||
const api = useApi()
|
||||
await api.delete(`/employee_week_comments/${id}`, {}, { toastSuccessKey: 'success.week-comment.delete', toastErrorKey: 'errors.week-comment.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');
|
||||
}
|
||||
}
|
||||
@@ -34,5 +34,8 @@ final class WeeklySummaryRow
|
||||
public int $weeklyDinnerCount = 0,
|
||||
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;
|
||||
}
|
||||
}
|
||||
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() ?? '')) : '?';
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ use ApiPlatform\State\ProviderInterface;
|
||||
use App\Dto\WorkHours\WorkMetrics;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\WorkHour;
|
||||
use App\Enum\ContractNature;
|
||||
use App\Enum\ContractType;
|
||||
use App\Enum\TrackingMode;
|
||||
use App\Repository\AbsenceRepository;
|
||||
use App\Repository\EmployeeRepository;
|
||||
@@ -62,8 +64,22 @@ class EmployeeYearlyHoursPrintProvider implements ProviderInterface
|
||||
}
|
||||
$year = (int) $yearRaw;
|
||||
|
||||
$from = new DateTimeImmutable("{$year}-01-01");
|
||||
$to = new DateTimeImmutable("{$year}-12-31");
|
||||
$monthRaw = (string) $request->query->get('month', '');
|
||||
$month = null;
|
||||
if ('' !== $monthRaw) {
|
||||
if (!preg_match('/^(?:0?[1-9]|1[0-2])$/', $monthRaw)) {
|
||||
throw new UnprocessableEntityHttpException('month must be between 1 and 12.');
|
||||
}
|
||||
$month = (int) $monthRaw;
|
||||
}
|
||||
|
||||
if (null !== $month) {
|
||||
$from = new DateTimeImmutable(sprintf('%d-%02d-01', $year, $month));
|
||||
$to = $from->modify('last day of this month');
|
||||
} else {
|
||||
$from = new DateTimeImmutable("{$year}-01-01");
|
||||
$to = new DateTimeImmutable("{$year}-12-31");
|
||||
}
|
||||
$days = $this->buildDays($from, $to);
|
||||
|
||||
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($from, $to, [$employee]);
|
||||
@@ -83,28 +99,39 @@ class EmployeeYearlyHoursPrintProvider implements ProviderInterface
|
||||
$absenceData,
|
||||
);
|
||||
|
||||
$employeeName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? ''));
|
||||
$employeeName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? ''));
|
||||
$contractLabel = $this->buildContractLabel($employee);
|
||||
|
||||
$options = new Options();
|
||||
$options->set('isRemoteEnabled', true);
|
||||
|
||||
$dompdf = new Dompdf($options);
|
||||
$html = $this->twig->render('employee-yearly-hours/print.html.twig', [
|
||||
'employeeName' => $employeeName,
|
||||
'year' => $year,
|
||||
'segments' => $segments,
|
||||
'employeeName' => $employeeName,
|
||||
'contractLabel' => $contractLabel,
|
||||
'year' => $year,
|
||||
'month' => $month,
|
||||
'segments' => $segments,
|
||||
]);
|
||||
|
||||
$dompdf->loadHtml($html);
|
||||
$dompdf->setPaper('A4', 'portrait');
|
||||
$dompdf->render();
|
||||
|
||||
$filename = sprintf(
|
||||
'%s_%s_%d.pdf',
|
||||
$this->sanitizeFilename($employee->getLastName() ?? ''),
|
||||
$this->sanitizeFilename($employee->getFirstName() ?? ''),
|
||||
$year,
|
||||
);
|
||||
$filename = null !== $month
|
||||
? sprintf(
|
||||
'%s_%s_%d-%02d.pdf',
|
||||
$this->sanitizeFilename($employee->getLastName() ?? ''),
|
||||
$this->sanitizeFilename($employee->getFirstName() ?? ''),
|
||||
$year,
|
||||
$month,
|
||||
)
|
||||
: sprintf(
|
||||
'%s_%s_%d.pdf',
|
||||
$this->sanitizeFilename($employee->getLastName() ?? ''),
|
||||
$this->sanitizeFilename($employee->getFirstName() ?? ''),
|
||||
$year,
|
||||
);
|
||||
|
||||
return new Response($dompdf->output(), Response::HTTP_OK, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
@@ -112,6 +139,36 @@ class EmployeeYearlyHoursPrintProvider implements ProviderInterface
|
||||
]);
|
||||
}
|
||||
|
||||
private function buildContractLabel(Employee $employee): ?string
|
||||
{
|
||||
$contract = $employee->getContract();
|
||||
if (null === $contract) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$natureRaw = $employee->getCurrentContractNature();
|
||||
$nature = ContractNature::tryFrom($natureRaw) ?? ContractNature::CDI;
|
||||
$natureLabel = match ($nature) {
|
||||
ContractNature::CDI => 'CDI',
|
||||
ContractNature::CDD => 'CDD',
|
||||
ContractNature::INTERIM => 'Intérim',
|
||||
};
|
||||
|
||||
$contractType = $contract->getType();
|
||||
if (ContractType::FORFAIT === $contractType) {
|
||||
return $natureLabel.' Forfait';
|
||||
}
|
||||
|
||||
$weeklyHours = $contract->getWeeklyHours();
|
||||
if (null !== $weeklyHours && $weeklyHours > 0) {
|
||||
return sprintf('%s %d heures', $natureLabel, $weeklyHours);
|
||||
}
|
||||
|
||||
$name = $contract->getName();
|
||||
|
||||
return null !== $name && '' !== $name ? $natureLabel.' '.$name : $natureLabel;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
@@ -211,13 +268,44 @@ class EmployeeYearlyHoursPrintProvider implements ProviderInterface
|
||||
$currentRows = [];
|
||||
$currentName = null;
|
||||
|
||||
// Crop the output window to [first data day, today] to avoid padding the
|
||||
// export with empty rows (notably weekends before the first saisie or after today).
|
||||
$firstDataDate = null;
|
||||
foreach ($days as $date) {
|
||||
$contract = $contractsByDate[$date] ?? null;
|
||||
$isDriver = $driverByDate[$date] ?? false;
|
||||
$wh = $workHoursByDate[$date] ?? null;
|
||||
$hasData = null !== $wh || ($absenceData['hasDayAbsence'][$date] ?? false);
|
||||
$hasRow = null !== ($workHoursByDate[$date] ?? null)
|
||||
|| ($absenceData['hasDayAbsence'][$date] ?? false);
|
||||
if ($hasRow) {
|
||||
$firstDataDate = $date;
|
||||
|
||||
if (!$hasData) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (null === $firstDataDate) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$todayYmd = new DateTimeImmutable('today')->format('Y-m-d');
|
||||
|
||||
foreach ($days as $date) {
|
||||
if ($date < $firstDataDate || $date > $todayYmd) {
|
||||
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;
|
||||
|
||||
// Keep weekend rows even when empty so the reader can distinguish
|
||||
// worked vs non-worked Saturdays/Sundays at a glance.
|
||||
if (!$hasData && !$isWeekend) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$hasData && null === $contract) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -244,6 +332,7 @@ class EmployeeYearlyHoursPrintProvider implements ProviderInterface
|
||||
$row = [
|
||||
'date' => new DateTimeImmutable($date)->format('d/m/Y'),
|
||||
'absenceLabel' => $absenceLabel,
|
||||
'isWeekend' => $isWeekend,
|
||||
];
|
||||
|
||||
if ('presence' === $mode) {
|
||||
|
||||
@@ -18,12 +18,14 @@ use App\Repository\MileageAllowanceRepository;
|
||||
use App\Repository\ObservationRepository;
|
||||
use App\Repository\WorkHourRepository;
|
||||
use App\Service\Contracts\EmployeeContractResolver;
|
||||
use App\Service\PublicHolidayServiceInterface;
|
||||
use DateInterval;
|
||||
use DateTimeImmutable;
|
||||
use Dompdf\Dompdf;
|
||||
use Dompdf\Options;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Throwable;
|
||||
use Twig\Environment;
|
||||
|
||||
class SalaryRecapPrintProvider implements ProviderInterface
|
||||
@@ -39,6 +41,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
private MileageAllowanceRepository $mileageAllowanceRepository,
|
||||
private ObservationRepository $observationRepository,
|
||||
private EmployeeContractResolver $contractResolver,
|
||||
private PublicHolidayServiceInterface $publicHolidayService,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
|
||||
@@ -71,6 +74,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
$days = $this->buildDays($from, $to);
|
||||
$contractMap = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
|
||||
$driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days);
|
||||
$holidayMap = $this->buildHolidayMap($from, $to);
|
||||
|
||||
$workHourMap = $this->buildWorkHourMap($workHours);
|
||||
$absenceMap = $this->buildAbsenceMap($absences);
|
||||
@@ -79,7 +83,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
$mileageMap = $this->buildMileageMap($mileages);
|
||||
$observationMap = $this->buildObservationMap($observations);
|
||||
|
||||
$siteGroups = $this->aggregateBySite($employees, $days, $contractMap, $driverMap, $workHourMap, $absenceMap, $rttPaymentMap, $bonusMap, $mileageMap, $observationMap);
|
||||
$siteGroups = $this->aggregateBySite($employees, $days, $contractMap, $driverMap, $workHourMap, $absenceMap, $rttPaymentMap, $bonusMap, $mileageMap, $observationMap, $holidayMap);
|
||||
|
||||
$options = new Options();
|
||||
$options->set('isRemoteEnabled', true);
|
||||
@@ -208,6 +212,29 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
@@ -236,6 +263,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
array $bonusMap,
|
||||
array $mileageMap,
|
||||
array $observationMap,
|
||||
array $holidayMap,
|
||||
): array {
|
||||
$siteGroups = [];
|
||||
|
||||
@@ -257,6 +285,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
$bonusMap[$employeeId] ?? 0.0,
|
||||
$mileageMap[$employeeId] ?? 0.0,
|
||||
$observationMap[$employeeId] ?? '',
|
||||
$holidayMap,
|
||||
);
|
||||
|
||||
if (!isset($siteGroups[$siteId])) {
|
||||
@@ -285,18 +314,20 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
float $bonusAmount,
|
||||
float $mileageKm,
|
||||
string $observation,
|
||||
array $holidayMap,
|
||||
): array {
|
||||
$contractName = null;
|
||||
$presenceDays = 0.0;
|
||||
$nightMinutesTotal = 0;
|
||||
$nightBasketCount = 0;
|
||||
$sundayMinutesTotal = 0;
|
||||
$isDriverAnyDay = false;
|
||||
$driverBreakfast = 0;
|
||||
$driverMeals = 0;
|
||||
$driverOvernight = 0;
|
||||
$driverSaturdays = 0;
|
||||
$isForfait = false;
|
||||
$contractName = null;
|
||||
$presenceDays = 0.0;
|
||||
$nightMinutesTotal = 0;
|
||||
$nightBasketCount = 0;
|
||||
$sundayMinutesTotal = 0;
|
||||
$holidayMinutesTotal = 0;
|
||||
$isDriverAnyDay = false;
|
||||
$driverBreakfast = 0;
|
||||
$driverMeals = 0;
|
||||
$driverOvernight = 0;
|
||||
$driverSaturdays = 0;
|
||||
$isForfait = false;
|
||||
|
||||
foreach ($days as $date) {
|
||||
$contract = $contractsByDate[$date] ?? null;
|
||||
@@ -318,10 +349,13 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
|
||||
$dayOfWeek = (int) new DateTimeImmutable($date)->format('N');
|
||||
|
||||
$isHoliday = isset($holidayMap[$date]);
|
||||
|
||||
if ($isDriver) {
|
||||
$nightMinutesTotal += $wh->getNightHoursMinutes() ?? 0;
|
||||
$dayMin = $wh->getDayHoursMinutes() ?? 0;
|
||||
$nightMin = $wh->getNightHoursMinutes() ?? 0;
|
||||
$dayMin = $wh->getDayHoursMinutes() ?? 0;
|
||||
$nightMin = $wh->getNightHoursMinutes() ?? 0;
|
||||
$workshopMin = $wh->getWorkshopHoursMinutes() ?? 0;
|
||||
if (($nightMin > $dayMin && $nightMin > 0) || $nightMin >= 240) {
|
||||
++$nightBasketCount;
|
||||
}
|
||||
@@ -336,12 +370,16 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
++$driverOvernight;
|
||||
}
|
||||
|
||||
if (6 === $dayOfWeek && ($dayMin > 0 || $nightMin > 0 || ($wh->getWorkshopHoursMinutes() ?? 0) > 0)) {
|
||||
if (6 === $dayOfWeek && ($dayMin > 0 || $nightMin > 0 || $workshopMin > 0)) {
|
||||
++$driverSaturdays;
|
||||
}
|
||||
|
||||
if (7 === $dayOfWeek) {
|
||||
$sundayMinutesTotal += $dayMin + $nightMin + ($wh->getWorkshopHoursMinutes() ?? 0);
|
||||
$sundayMinutesTotal += $dayMin + $nightMin + $workshopMin;
|
||||
}
|
||||
|
||||
if ($isHoliday) {
|
||||
$holidayMinutesTotal += $dayMin + $nightMin + $workshopMin;
|
||||
}
|
||||
} else {
|
||||
$metrics = $this->computeNightMinutes($wh);
|
||||
@@ -359,6 +397,10 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
$sundayMinutesTotal += $this->computeOverflowAfterMidnight($wh);
|
||||
}
|
||||
|
||||
if ($isHoliday) {
|
||||
$holidayMinutesTotal += $metrics['dayMinutes'] + $metrics['nightMinutes'];
|
||||
}
|
||||
|
||||
if ($isForfait) {
|
||||
if ($wh->getIsPresentMorning()) {
|
||||
$presenceDays += 0.5;
|
||||
@@ -373,9 +415,10 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
$conges = $this->countAbsencesByCode($absences, ['C']);
|
||||
$maladie = $this->countAbsencesByCode($absences, ['M', 'AT']);
|
||||
|
||||
$nightHours = round($nightMinutesTotal / 60, 2);
|
||||
$paidHours = round($rttPaidMinutes / 60, 2);
|
||||
$sundayHours = round($sundayMinutesTotal / 60, 2);
|
||||
$nightHours = round($nightMinutesTotal / 60, 2);
|
||||
$paidHours = round($rttPaidMinutes / 60, 2);
|
||||
$sundayHours = round($sundayMinutesTotal / 60, 2);
|
||||
$holidayHours = round($holidayMinutesTotal / 60, 2);
|
||||
|
||||
return [
|
||||
'lastName' => mb_strimwidth($employee->getLastName() ?? '', 0, 15, '...'),
|
||||
@@ -387,6 +430,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
'nightBasketCount' => $nightBasketCount,
|
||||
'paidHours' => $paidHours,
|
||||
'sundayHours' => $sundayHours,
|
||||
'holidayHours' => $holidayHours,
|
||||
'bonusAmount' => $bonusAmount,
|
||||
'congesCount' => $conges['count'],
|
||||
'congesDates' => $conges['dates'],
|
||||
|
||||
@@ -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,6 +22,7 @@ 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\WorkHours\AbsenceSegmentsResolver;
|
||||
use App\Service\WorkHours\DailyReferenceMinutesResolver;
|
||||
@@ -45,6 +47,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
||||
private EmployeeContractResolver $contractResolver,
|
||||
private DailyReferenceMinutesResolver $dailyReferenceResolver,
|
||||
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
|
||||
private EmployeeWeekCommentRepository $weekCommentRepository,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourWeeklySummary
|
||||
@@ -62,11 +65,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,14 +114,15 @@ 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);
|
||||
@@ -369,6 +375,9 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
||||
weeklyDinnerCount: $weeklyDinnerCount,
|
||||
weeklyOvernightCount: $weeklyOvernightCount,
|
||||
hasContractForWeek: $hasContractForWeek,
|
||||
contractNature: $weekAnchorContractNature->value,
|
||||
comment: ($weekComments[$employeeId] ?? null)?->getContent(),
|
||||
commentId: ($weekComments[$employeeId] ?? null)?->getId(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,10 +14,24 @@
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.title-bar {
|
||||
position: relative;
|
||||
margin: 0 0 4mm 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
margin: 0 0 4mm 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.export-date {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
font-size: 9px;
|
||||
color: #333;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@@ -54,11 +68,70 @@
|
||||
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; }
|
||||
|
||||
.signature-footer {
|
||||
page-break-inside: avoid;
|
||||
margin-top: 6mm;
|
||||
}
|
||||
|
||||
.signature-intro {
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
margin-bottom: 6mm;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.signature-blocks {
|
||||
display: table;
|
||||
width: 100%;
|
||||
table-layout: fixed;
|
||||
border-collapse: separate;
|
||||
border-spacing: 4mm 0;
|
||||
}
|
||||
|
||||
.signature-block {
|
||||
display: table-cell;
|
||||
border: 1px solid #0a0a0a;
|
||||
padding: 3mm;
|
||||
vertical-align: top;
|
||||
width: 33.33%;
|
||||
}
|
||||
|
||||
.signature-block .title {
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
margin-bottom: 7mm;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.signature-block .line {
|
||||
margin-bottom: 2mm;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.signature-block .signature-line {
|
||||
margin-top: 6mm;
|
||||
margin-bottom: 18mm;
|
||||
font-size: 10px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>{{ employeeName }} - {{ year }}</h1>
|
||||
{% set months = {
|
||||
1:'Janvier', 2:'Février', 3:'Mars', 4:'Avril', 5:'Mai', 6:'Juin',
|
||||
7:'Juillet', 8:'Août', 9:'Septembre', 10:'Octobre', 11:'Novembre', 12:'Décembre'
|
||||
} %}
|
||||
<div class="title-bar">
|
||||
<h1>
|
||||
{{ employeeName }}{% if contractLabel %} - {{ contractLabel }}{% endif %}<br>
|
||||
{% if month %}{{ months[month] }} {{ year }}{% else %}{{ year }}{% endif %}
|
||||
</h1>
|
||||
<div class="export-date">Exporté le {{ "now"|date('d/m/Y') }} à {{ "now"|date('H:i:s') }}</div>
|
||||
</div>
|
||||
|
||||
{% for segment in segments %}
|
||||
{% if segments|length > 1 %}
|
||||
@@ -78,7 +151,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in segment.rows %}
|
||||
<tr>
|
||||
<tr class="{{ row.isWeekend ? 'weekend' : '' }}">
|
||||
<td class="date">{{ row.date }}</td>
|
||||
<td class="absence">{{ row.absenceLabel ?? '' }}</td>
|
||||
<td class="presence">{{ row.presentMorning ? 'X' : '' }}</td>
|
||||
@@ -102,7 +175,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in segment.rows %}
|
||||
<tr>
|
||||
<tr class="{{ row.isWeekend ? 'weekend' : '' }}">
|
||||
<td class="date">{{ row.date }}</td>
|
||||
<td class="absence">{{ row.absenceLabel ?? '' }}</td>
|
||||
<td class="time">{{ row.dayHours }}</td>
|
||||
@@ -130,7 +203,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in segment.rows %}
|
||||
<tr>
|
||||
<tr class="{{ row.isWeekend ? 'weekend' : '' }}">
|
||||
<td class="date">{{ row.date }}</td>
|
||||
<td class="absence">{{ row.absenceLabel ?? '' }}</td>
|
||||
<td class="time">{{ row.morningFrom }}</td>
|
||||
@@ -147,5 +220,36 @@
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<div class="signature-footer">
|
||||
<div class="signature-intro">
|
||||
Nom + Prénom<br>
|
||||
Signature avec mention « bon pour accord »
|
||||
</div>
|
||||
|
||||
<div class="signature-blocks">
|
||||
<div class="signature-block">
|
||||
<p class="title">Direction</p>
|
||||
<p class="line">Nom : ...............</p>
|
||||
<p class="line">Prénom : ...............</p>
|
||||
<p class="line">Mention : ........................................</p>
|
||||
<p class="signature-line">Signature :</p>
|
||||
</div>
|
||||
<div class="signature-block">
|
||||
<p class="title">Responsable usine</p>
|
||||
<p class="line">Nom : ...............</p>
|
||||
<p class="line">Prénom : ...............</p>
|
||||
<p class="line">Mention : ........................................</p>
|
||||
<p class="signature-line">Signature :</p>
|
||||
</div>
|
||||
<div class="signature-block">
|
||||
<p class="title">Salarié</p>
|
||||
<p class="line">Nom : ...............</p>
|
||||
<p class="line">Prénom : ...............</p>
|
||||
<p class="line">Mention : ........................................</p>
|
||||
<p class="signature-line">Signature :</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -28,13 +28,22 @@
|
||||
.date-box {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
border: 2px solid #000;
|
||||
padding: 4px 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.export-date {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
font-size: 10px;
|
||||
color: #333;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
table.recap {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -77,8 +86,9 @@
|
||||
<body>
|
||||
|
||||
<div class="title-bar">
|
||||
<h1>RECAPITULATIF CONGES & RTT</h1>
|
||||
<div class="date-box">{{ today|date('d/m/Y') }}</div>
|
||||
<h1>RECAPITULATIF CONGES & RTT</h1>
|
||||
<div class="export-date">Exporté le {{ "now"|date('d/m/Y') }} à {{ "now"|date('H:i:s') }}</div>
|
||||
</div>
|
||||
|
||||
<table class="recap">
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
margin: 0;
|
||||
padding: 2mm;
|
||||
font-family: Helvetica, sans-serif;
|
||||
font-size: 10px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.title-bar {
|
||||
@@ -28,7 +28,7 @@
|
||||
.month-box {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
border: 2px solid #000;
|
||||
padding: 4px 12px;
|
||||
font-size: 14px;
|
||||
@@ -36,16 +36,25 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.export-date {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
font-size: 10px;
|
||||
color: #333;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
table.recap {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: auto;
|
||||
border: 4px solid #0a0a0a;
|
||||
border: 2px solid #0a0a0a;
|
||||
}
|
||||
|
||||
th, td {
|
||||
border: 2px solid #0a0a0a;
|
||||
padding: 3px 3px;
|
||||
border: 1px solid #0a0a0a;
|
||||
padding: 2px 2px;
|
||||
vertical-align: middle;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
@@ -60,7 +69,7 @@
|
||||
thead th {
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
font-size: 10px;
|
||||
font-size: 9px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
@@ -74,16 +83,16 @@
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
font-size: 10px;
|
||||
font-size: 9px;
|
||||
}
|
||||
td.obs {
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
font-size: 9px;
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
tbody td { font-size: 10px; }
|
||||
tbody td { font-size: 9px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -94,43 +103,45 @@
|
||||
} %}
|
||||
|
||||
<div class="title-bar">
|
||||
<h1>RECAPITULATIF SALAIRE DU {{ from|date('d/m/Y') }} au {{ to|date('d/m/Y') }}</h1>
|
||||
<div class="month-box">{{ months[from|date('n')|number_format] }} {{ from|date('Y') }}</div>
|
||||
<h1>RECAPITULATIF SALAIRE DU {{ from|date('d/m/Y') }} au {{ to|date('d/m/Y') }}</h1>
|
||||
<div class="export-date">Exporté le {{ "now"|date('d/m/Y') }} à {{ "now"|date('H:i:s') }}</div>
|
||||
</div>
|
||||
|
||||
<table class="recap">
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowspan="2" style="width: 24mm; text-align: left;">Nom</th>
|
||||
<th rowspan="2" style="width: 12mm;">Base</th>
|
||||
<th rowspan="2" style="width: 12mm;">Jour de<br>présence<br>Cadre</th>
|
||||
<th rowspan="2" style="width: 9mm;">Frais<br>Kms</th>
|
||||
<th rowspan="2" style="width: 9mm;">Heures<br>de<br>nuit</th>
|
||||
<th rowspan="2" style="width: 9mm;">Panier<br>de<br>nuit</th>
|
||||
<th rowspan="2" style="width: 12mm;">Heures<br>payés</th>
|
||||
<th rowspan="2" style="width: 9mm;">Heures<br>dim.</th>
|
||||
<th rowspan="2" style="width: 9mm;">Prime</th>
|
||||
<th rowspan="2" style="width: 20mm; text-align: left;">Nom</th>
|
||||
<th rowspan="2" style="width: 10mm;">Base</th>
|
||||
<th rowspan="2" style="width: 10mm;">Jour de<br>présence<br>Cadre</th>
|
||||
<th rowspan="2" style="width: 8mm;">Frais<br>Kms</th>
|
||||
<th rowspan="2" style="width: 8mm;">Heures<br>de<br>nuit</th>
|
||||
<th rowspan="2" style="width: 8mm;">Panier<br>de<br>nuit</th>
|
||||
<th rowspan="2" style="width: 10mm;">Heures<br>payés</th>
|
||||
<th rowspan="2" style="width: 8mm;">Heures<br>férié</th>
|
||||
<th rowspan="2" style="width: 8mm;">Heures<br>dim.</th>
|
||||
<th rowspan="2" style="width: 8mm;">Prime</th>
|
||||
<th colspan="2">Congés</th>
|
||||
<th colspan="2">Maladie</th>
|
||||
<th colspan="4">CHAUFFEUR</th>
|
||||
<th rowspan="2" style="width: 26mm;">Observations</th>
|
||||
<th rowspan="2" style="width: 20mm;">Observations</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th style="width: 10mm;">Nbre</th>
|
||||
<th style="width: 26mm;">Date</th>
|
||||
<th style="width: 10mm;">Nbre</th>
|
||||
<th style="width: 26mm;">Date</th>
|
||||
<th style="width: 8mm;">PDJ</th>
|
||||
<th style="width: 10mm;">REPAS</th>
|
||||
<th style="width: 12mm;">NUITEE</th>
|
||||
<th style="width: 12mm;">samedi</th>
|
||||
<th style="width: 8mm;">Nbre</th>
|
||||
<th style="width: 22mm;">Date</th>
|
||||
<th style="width: 8mm;">Nbre</th>
|
||||
<th style="width: 22mm;">Date</th>
|
||||
<th style="width: 7mm;">PDJ</th>
|
||||
<th style="width: 9mm;">REPAS</th>
|
||||
<th style="width: 10mm;">NUITEE</th>
|
||||
<th style="width: 10mm;">samedi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for siteId, group in siteGroups %}
|
||||
{% set siteColor = group.color ?? '#B3E5FC' %}
|
||||
<tr class="site-header">
|
||||
<td style="background: {{ siteColor }}; text-align: left;" colspan="18">
|
||||
<td style="background: {{ siteColor }}; text-align: left;" colspan="19">
|
||||
{{ group.name }}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -143,6 +154,7 @@
|
||||
<td class="num">{{ row.nightHours > 0 ? row.nightHours : '' }}</td>
|
||||
<td class="num">{{ row.nightBasketCount > 0 ? row.nightBasketCount : '' }}</td>
|
||||
<td class="num">{{ row.paidHours > 0 ? row.paidHours : '' }}</td>
|
||||
<td class="num">{{ row.holidayHours > 0 ? row.holidayHours : '' }}</td>
|
||||
<td class="num">{{ row.sundayHours > 0 ? row.sundayHours : '' }}</td>
|
||||
<td class="num">{{ row.bonusAmount > 0 ? row.bonusAmount ~ ' €' : '' }}</td>
|
||||
<td class="num">{{ row.congesCount > 0 ? row.congesCount : '' }}</td>
|
||||
@@ -157,7 +169,7 @@
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="18">Aucun employé.</td>
|
||||
<td colspan="19">Aucun employé.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
|
||||
@@ -94,6 +94,87 @@ final class EmployeeContractPeriodValidatorTest extends TestCase
|
||||
$this->validator->assertNextStartDateCompatible(new DateTimeImmutable('2026-03-10'), $currentPeriod);
|
||||
}
|
||||
|
||||
public function testAssertWorkDaysHoursAcceptsNullForStandardContract(): void
|
||||
{
|
||||
$this->validator->assertWorkDaysHours($this->buildContract(35), ContractNature::CDI, null);
|
||||
self::assertTrue(true); // no exception
|
||||
}
|
||||
|
||||
public function testAssertWorkDaysHoursRejectsScheduleOn35hContract(): void
|
||||
{
|
||||
$this->expectException(UnprocessableEntityHttpException::class);
|
||||
$this->validator->assertWorkDaysHours($this->buildContract(35), ContractNature::CDI, [1 => 120]);
|
||||
}
|
||||
|
||||
public function testAssertWorkDaysHoursRejectsScheduleOnForfaitContract(): void
|
||||
{
|
||||
$this->expectException(UnprocessableEntityHttpException::class);
|
||||
$this->validator->assertWorkDaysHours($this->buildContract(null, Contract::TRACKING_PRESENCE), ContractNature::CDI, [1 => 120]);
|
||||
}
|
||||
|
||||
public function testAssertWorkDaysHoursAcceptsNullForInterim(): void
|
||||
{
|
||||
$this->validator->assertWorkDaysHours($this->buildContract(4), ContractNature::INTERIM, null);
|
||||
self::assertTrue(true);
|
||||
}
|
||||
|
||||
public function testAssertWorkDaysHoursRequiresScheduleForCustomContract(): void
|
||||
{
|
||||
$this->expectException(UnprocessableEntityHttpException::class);
|
||||
$this->expectExceptionMessage('workDaysHours is required');
|
||||
$this->validator->assertWorkDaysHours($this->buildContract(4), ContractNature::CDI, null);
|
||||
}
|
||||
|
||||
public function testAssertWorkDaysHoursRequiresScheduleForCustomContractOnEmptyArray(): void
|
||||
{
|
||||
$this->expectException(UnprocessableEntityHttpException::class);
|
||||
$this->expectExceptionMessage('workDaysHours is required');
|
||||
$this->validator->assertWorkDaysHours($this->buildContract(4), ContractNature::CDI, []);
|
||||
}
|
||||
|
||||
public function testAssertWorkDaysHoursRejectsIsoOutsideOneToFive(): void
|
||||
{
|
||||
$this->expectException(UnprocessableEntityHttpException::class);
|
||||
$this->expectExceptionMessage('iso weekdays 1-5');
|
||||
$this->validator->assertWorkDaysHours($this->buildContract(4), ContractNature::CDI, [6 => 120, 7 => 120]);
|
||||
}
|
||||
|
||||
public function testAssertWorkDaysHoursRejectsIsoZero(): void
|
||||
{
|
||||
$this->expectException(UnprocessableEntityHttpException::class);
|
||||
$this->expectExceptionMessage('iso weekdays 1-5');
|
||||
$this->validator->assertWorkDaysHours($this->buildContract(4), ContractNature::CDI, [0 => 240]);
|
||||
}
|
||||
|
||||
public function testAssertWorkDaysHoursRejectsNegativeMinutes(): void
|
||||
{
|
||||
$this->expectException(UnprocessableEntityHttpException::class);
|
||||
$this->expectExceptionMessage('non-negative integer minutes');
|
||||
$this->validator->assertWorkDaysHours($this->buildContract(4), ContractNature::CDI, [1 => -120, 4 => 360]);
|
||||
}
|
||||
|
||||
public function testAssertWorkDaysHoursRejectsSumMismatch(): void
|
||||
{
|
||||
$this->expectException(UnprocessableEntityHttpException::class);
|
||||
$this->expectExceptionMessage('total must equal contract weekly hours');
|
||||
$this->validator->assertWorkDaysHours($this->buildContract(4), ContractNature::CDI, [1 => 60, 4 => 60]);
|
||||
}
|
||||
|
||||
public function testAssertWorkDaysHoursAcceptsValidScheduleFor4hContract(): void
|
||||
{
|
||||
$this->validator->assertWorkDaysHours($this->buildContract(4), ContractNature::CDI, [1 => 120, 4 => 120]);
|
||||
self::assertTrue(true);
|
||||
}
|
||||
|
||||
private function buildContract(?int $weeklyHours, string $trackingMode = Contract::TRACKING_TIME): Contract
|
||||
{
|
||||
return new Contract()
|
||||
->setName('Test')
|
||||
->setTrackingMode($trackingMode)
|
||||
->setWeeklyHours($weeklyHours)
|
||||
;
|
||||
}
|
||||
|
||||
private function buildCurrentPeriod(string $startDate, ?string $endDate): EmployeeContractPeriod
|
||||
{
|
||||
$contract = new Contract()
|
||||
|
||||
181
tests/Service/WorkHours/HolidayVirtualHoursResolverTest.php
Normal file
181
tests/Service/WorkHours/HolidayVirtualHoursResolverTest.php
Normal file
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service\WorkHours;
|
||||
|
||||
use App\Entity\Contract;
|
||||
use App\Service\Contracts\EmployeeContractResolver;
|
||||
use App\Service\PublicHolidayServiceInterface;
|
||||
use App\Service\WorkHours\DailyReferenceMinutesResolver;
|
||||
use App\Service\WorkHours\HolidayVirtualHoursResolver;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class HolidayVirtualHoursResolverTest extends TestCase
|
||||
{
|
||||
private HolidayVirtualHoursResolver $resolver;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$holidayService = $this->createStub(PublicHolidayServiceInterface::class);
|
||||
$holidayService->method('getHolidaysDayByYears')->willReturnCallback(
|
||||
static fn (string $zone, string $year): array => [
|
||||
// Mon 14/07/2025 (lundi)
|
||||
'2025-07-14' => '14 juillet',
|
||||
// Fri 15/08/2025 (vendredi)
|
||||
'2025-08-15' => '15 août',
|
||||
// Sat 11/11/2025 (samedi)
|
||||
'2025-11-15' => 'Samedi test',
|
||||
// Thu 25/12/2025
|
||||
'2025-12-25' => 'Noël',
|
||||
]
|
||||
);
|
||||
|
||||
$this->resolver = new HolidayVirtualHoursResolver(
|
||||
new DailyReferenceMinutesResolver(),
|
||||
$holidayService,
|
||||
$this->createStub(EmployeeContractResolver::class),
|
||||
);
|
||||
}
|
||||
|
||||
public function testReturnsZeroWhenContractIsNull(): void
|
||||
{
|
||||
self::assertSame(0, $this->resolver->resolveVirtualCredit(null, new DateTimeImmutable('2025-07-14')));
|
||||
}
|
||||
|
||||
public function testReturnsZeroForForfaitPresenceContract(): void
|
||||
{
|
||||
$contract = new Contract()
|
||||
->setName('Forfait')
|
||||
->setTrackingMode('PRESENCE')
|
||||
->setWeeklyHours(null)
|
||||
;
|
||||
|
||||
self::assertSame(0, $this->resolver->resolveVirtualCredit($contract, new DateTimeImmutable('2025-07-14')));
|
||||
}
|
||||
|
||||
public function testReturnsZeroWhenDayIsNotHoliday(): void
|
||||
{
|
||||
$contract = $this->build35hContract();
|
||||
self::assertSame(0, $this->resolver->resolveVirtualCredit($contract, new DateTimeImmutable('2025-07-07')));
|
||||
}
|
||||
|
||||
public function testReturnsZeroWhenHolidayFallsOnSaturday(): void
|
||||
{
|
||||
$contract = $this->build35hContract();
|
||||
self::assertSame(0, $this->resolver->resolveVirtualCredit($contract, new DateTimeImmutable('2025-11-15')));
|
||||
}
|
||||
|
||||
public function test35hMondayGetsSevenHours(): void
|
||||
{
|
||||
$contract = $this->build35hContract();
|
||||
self::assertSame(7 * 60, $this->resolver->resolveVirtualCredit($contract, new DateTimeImmutable('2025-07-14')));
|
||||
}
|
||||
|
||||
public function test39hMondayGetsEightHours(): void
|
||||
{
|
||||
$contract = $this->build39hContract();
|
||||
self::assertSame(8 * 60, $this->resolver->resolveVirtualCredit($contract, new DateTimeImmutable('2025-07-14')));
|
||||
}
|
||||
|
||||
public function test39hFridayGetsSevenHours(): void
|
||||
{
|
||||
$contract = $this->build39hContract();
|
||||
self::assertSame(7 * 60, $this->resolver->resolveVirtualCredit($contract, new DateTimeImmutable('2025-08-15')));
|
||||
}
|
||||
|
||||
public function testCustomContractUsesProRataReference(): void
|
||||
{
|
||||
$contract = new Contract()
|
||||
->setName('28h')
|
||||
->setTrackingMode('TIME')
|
||||
->setWeeklyHours(28)
|
||||
;
|
||||
// 28h / 5 = 5.6h = 336 min
|
||||
self::assertSame(336, $this->resolver->resolveVirtualCredit($contract, new DateTimeImmutable('2025-07-14')));
|
||||
}
|
||||
|
||||
public function testInterimContractAlsoReceivesCredit(): void
|
||||
{
|
||||
$contract = new Contract()
|
||||
->setName('Interim')
|
||||
->setTrackingMode('TIME')
|
||||
->setWeeklyHours(35)
|
||||
;
|
||||
|
||||
self::assertSame(7 * 60, $this->resolver->resolveVirtualCredit($contract, new DateTimeImmutable('2025-07-14')));
|
||||
}
|
||||
|
||||
public function testEffectiveDailyMinutesReturnsActualWhenGreaterThanReference(): void
|
||||
{
|
||||
$contract = $this->build39hContract();
|
||||
// 10h worked on a férié Monday with 39h contract (ref = 8h)
|
||||
self::assertSame(600, $this->resolver->resolveEffectiveDailyMinutes($contract, new DateTimeImmutable('2025-07-14'), 600));
|
||||
}
|
||||
|
||||
public function testEffectiveDailyMinutesReturnsReferenceWhenActualLower(): void
|
||||
{
|
||||
$contract = $this->build39hContract();
|
||||
// 4h worked on a férié Monday with 39h contract (ref = 8h) → 8h
|
||||
self::assertSame(8 * 60, $this->resolver->resolveEffectiveDailyMinutes($contract, new DateTimeImmutable('2025-07-14'), 240));
|
||||
}
|
||||
|
||||
public function testEffectiveDailyMinutesDelegatesWhenRuleDoesNotApply(): void
|
||||
{
|
||||
$contract = $this->build39hContract();
|
||||
// Non-holiday day: rule does not apply, return actual
|
||||
self::assertSame(420, $this->resolver->resolveEffectiveDailyMinutes($contract, new DateTimeImmutable('2025-07-07'), 420));
|
||||
}
|
||||
|
||||
public function testFallsBackGracefullyWhenHolidayServiceFails(): void
|
||||
{
|
||||
$failingService = $this->createStub(PublicHolidayServiceInterface::class);
|
||||
$failingService->method('getHolidaysDayByYears')->willThrowException(new RuntimeException('boom'));
|
||||
|
||||
$resolver = new HolidayVirtualHoursResolver(
|
||||
new DailyReferenceMinutesResolver(),
|
||||
$failingService,
|
||||
$this->createStub(EmployeeContractResolver::class),
|
||||
);
|
||||
|
||||
self::assertSame(0, $resolver->resolveVirtualCredit($this->build35hContract(), new DateTimeImmutable('2025-07-14')));
|
||||
}
|
||||
|
||||
public function testScheduledWorkdayGetsCreditOnHoliday(): void
|
||||
{
|
||||
// 4h contract, schedule Mon 2h + Thu 2h
|
||||
$contract = new Contract()->setName('4h')->setTrackingMode('TIME')->setWeeklyHours(4);
|
||||
// Holiday 2025-07-14 is a Monday → 120 min credit
|
||||
self::assertSame(120, $this->resolver->resolveVirtualCredit($contract, new DateTimeImmutable('2025-07-14'), false, [1 => 120, 4 => 120]));
|
||||
}
|
||||
|
||||
public function testUnscheduledWorkdayGetsZeroOnHoliday(): void
|
||||
{
|
||||
$contract = new Contract()->setName('4h')->setTrackingMode('TIME')->setWeeklyHours(4);
|
||||
// Holiday 2025-07-14 is a Monday but schedule only Tue+Fri → 0
|
||||
self::assertSame(0, $this->resolver->resolveVirtualCredit($contract, new DateTimeImmutable('2025-07-14'), false, [2 => 120, 5 => 120]));
|
||||
}
|
||||
|
||||
private function build35hContract(): Contract
|
||||
{
|
||||
return new Contract()
|
||||
->setName('35h')
|
||||
->setTrackingMode('TIME')
|
||||
->setWeeklyHours(35)
|
||||
;
|
||||
}
|
||||
|
||||
private function build39hContract(): Contract
|
||||
{
|
||||
return new Contract()
|
||||
->setName('39h')
|
||||
->setTrackingMode('TIME')
|
||||
->setWeeklyHours(39)
|
||||
;
|
||||
}
|
||||
}
|
||||
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,7 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
|
||||
$this->buildResolverStub(),
|
||||
new DailyReferenceMinutesResolver(),
|
||||
$this->buildHolidayResolver(),
|
||||
$this->buildWeekCommentRepoStub(),
|
||||
);
|
||||
|
||||
$this->expectException(AccessDeniedHttpException::class);
|
||||
@@ -128,6 +130,7 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
|
||||
$this->buildWeeklyResolverStub($employees),
|
||||
new DailyReferenceMinutesResolver(),
|
||||
$this->buildHolidayResolver(),
|
||||
$this->buildWeekCommentRepoStub(),
|
||||
);
|
||||
|
||||
$result = $provider->provide(new Get());
|
||||
@@ -178,6 +181,14 @@ 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
|
||||
{
|
||||
$service = $this->createStub(PublicHolidayServiceInterface::class);
|
||||
|
||||
Reference in New Issue
Block a user