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é
|
- 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
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.87'
|
app.version: '0.1.88'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<AppDrawer v-model="drawerOpen" title="Export heures annuelles">
|
<AppDrawer v-model="drawerOpen" title="Export heures">
|
||||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||||
<div>
|
<div>
|
||||||
<label class="text-md font-semibold text-neutral-700" for="yearly-hours-year">
|
<label class="text-md font-semibold text-neutral-700" for="yearly-hours-year">
|
||||||
@@ -14,6 +14,20 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div class="flex justify-center pt-2">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -37,7 +51,7 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(event: 'update:modelValue', value: boolean): void
|
(event: 'update:modelValue', value: boolean): void
|
||||||
(event: 'submit', year: number): void
|
(event: 'submit', payload: { year: number; month: number | null }): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const drawerOpen = computed({
|
const drawerOpen = computed({
|
||||||
@@ -47,13 +61,31 @@ const drawerOpen = computed({
|
|||||||
|
|
||||||
const currentYear = new Date().getFullYear()
|
const currentYear = new Date().getFullYear()
|
||||||
const years = Array.from({ length: 6 }, (_, i) => currentYear - i)
|
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 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 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 selectFieldClass = computed(() => `${baseInputClass} border-neutral-300`)
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
emit('submit', selectedYear.value)
|
emit('submit', {
|
||||||
|
year: selectedYear.value,
|
||||||
|
month: selectedMonth.value === '' ? null : selectedMonth.value
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -61,6 +93,7 @@ watch(
|
|||||||
(isOpen) => {
|
(isOpen) => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
selectedYear.value = currentYear
|
selectedYear.value = currentYear
|
||||||
|
selectedMonth.value = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -42,7 +42,9 @@
|
|||||||
<span class="font-normal text-neutral-600">({{ contractLabel(employee) }})</span>
|
<span class="font-normal text-neutral-600">({{ contractLabel(employee) }})</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="text-neutral-500 truncate inline-flex items-center gap-2">
|
<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
|
<span
|
||||||
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
|
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
|
||||||
class="rounded-full bg-green-500 flex justify-center item-center text-white p-0.5"
|
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 type { Employee } from '~/services/dto/employee'
|
||||||
import TimeSelect from '~/components/ui/TimeSelect.vue'
|
import TimeSelect from '~/components/ui/TimeSelect.vue'
|
||||||
import type { DriverHourRow } from '~/services/dto/work-hour'
|
import type { DriverHourRow } from '~/services/dto/work-hour'
|
||||||
|
import { contractNatureLabel } from '~/utils/contract'
|
||||||
|
|
||||||
const rows = defineModel<Record<number, DriverHourRow>>('rows', { required: true })
|
const rows = defineModel<Record<number, DriverHourRow>>('rows', { required: true })
|
||||||
const bulkValidationInput = ref<HTMLInputElement | null>(null)
|
const bulkValidationInput = ref<HTMLInputElement | null>(null)
|
||||||
|
|||||||
@@ -33,7 +33,12 @@
|
|||||||
{{ 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">{{ 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>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -89,6 +94,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { WeeklyWorkHourSummary } from '~/services/dto/work-hour'
|
import type { WeeklyWorkHourSummary } from '~/services/dto/work-hour'
|
||||||
|
import { contractNatureLabel } from '~/utils/contract'
|
||||||
|
|
||||||
const getDailyCellStyle = (daily: {
|
const getDailyCellStyle = (daily: {
|
||||||
hasAbsence?: boolean
|
hasAbsence?: boolean
|
||||||
@@ -100,9 +106,12 @@ const getDailyCellStyle = (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>
|
||||||
|
|||||||
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>
|
<span class="font-normal text-neutral-600">({{ contractLabel(employee) }})</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="text-neutral-500 truncate inline-flex items-center gap-2">
|
<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
|
<span
|
||||||
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
|
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
|
||||||
class="rounded-full bg-green-500 flex justify-center item-center text-white p-0.5"
|
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 type {Employee} from '~/services/dto/employee'
|
||||||
import TimeSelect from '~/components/ui/TimeSelect.vue'
|
import TimeSelect from '~/components/ui/TimeSelect.vue'
|
||||||
import type {HourRow} from './types'
|
import type {HourRow} from './types'
|
||||||
|
import { contractNatureLabel } from '~/utils/contract'
|
||||||
|
|
||||||
const rows = defineModel<Record<number, HourRow>>('rows', {required: true})
|
const rows = defineModel<Record<number, HourRow>>('rows', {required: true})
|
||||||
const bulkValidationInput = ref<HTMLInputElement | null>(null)
|
const bulkValidationInput = ref<HTMLInputElement | null>(null)
|
||||||
|
|||||||
@@ -29,7 +29,12 @@
|
|||||||
{{ 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">{{ 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>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -81,6 +86,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { WeeklyWorkHourSummary } from '~/services/dto/work-hour'
|
import type { WeeklyWorkHourSummary } from '~/services/dto/work-hour'
|
||||||
import { CONTRACT_TYPES, type ContractType } from '~/services/dto/contract'
|
import { CONTRACT_TYPES, type ContractType } from '~/services/dto/contract'
|
||||||
|
import { contractNatureLabel } from '~/utils/contract'
|
||||||
|
|
||||||
const isInterimContract = (contractType?: ContractType | null) => {
|
const isInterimContract = (contractType?: ContractType | null) => {
|
||||||
return contractType === CONTRACT_TYPES.INTERIM
|
return contractType === CONTRACT_TYPES.INTERIM
|
||||||
@@ -96,9 +102,12 @@ const getDailyCellStyle = (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>
|
||||||
|
|||||||
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 {
|
return {
|
||||||
isAdmin,
|
isAdmin,
|
||||||
isSelfUser,
|
isSelfUser,
|
||||||
@@ -988,6 +997,10 @@ export const useDriverHoursPage = () => {
|
|||||||
deleteAbsenceFromDrawer,
|
deleteAbsenceFromDrawer,
|
||||||
closeAbsenceDrawer,
|
closeAbsenceDrawer,
|
||||||
formatMinutes,
|
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 {
|
return {
|
||||||
isAdmin,
|
isAdmin,
|
||||||
isSelfUser,
|
isSelfUser,
|
||||||
@@ -1181,6 +1190,10 @@ export const useHoursPage = () => {
|
|||||||
deleteAbsenceFromDrawer,
|
deleteAbsenceFromDrawer,
|
||||||
closeAbsenceDrawer,
|
closeAbsenceDrawer,
|
||||||
formatMinutes,
|
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' },
|
{ 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
|
<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>
|
||||||
|
|
||||||
@@ -108,6 +110,18 @@
|
|||||||
@delete="deleteAbsenceFromDrawer"
|
@delete="deleteAbsenceFromDrawer"
|
||||||
@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>
|
||||||
|
|
||||||
@@ -176,7 +190,11 @@ const {
|
|||||||
formatMinutes,
|
formatMinutes,
|
||||||
isSelectedDateHoliday,
|
isSelectedDateHoliday,
|
||||||
selectedHolidayLabel,
|
selectedHolidayLabel,
|
||||||
handleSave
|
handleSave,
|
||||||
|
isWeekCommentDrawerOpen,
|
||||||
|
weekCommentContext,
|
||||||
|
openWeekCommentDrawer,
|
||||||
|
reloadWeeklySummary
|
||||||
} = useDriverHoursPage()
|
} = useDriverHoursPage()
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<h1 class="text-[32px] font-bold">{{ employee.firstName }} {{ employee.lastName }}</h1>
|
<h1 class="text-[32px] font-bold">{{ employee.firstName }} {{ employee.lastName }}</h1>
|
||||||
<button
|
<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"
|
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"
|
@click="isYearlyHoursDrawerOpen = true"
|
||||||
>
|
>
|
||||||
<Icon name="mdi:printer" size="24" />
|
<Icon name="mdi:printer" size="24" />
|
||||||
@@ -321,9 +321,10 @@ const {
|
|||||||
submitDeleteObservation
|
submitDeleteObservation
|
||||||
} = useEmployeeDetailPage()
|
} = useEmployeeDetailPage()
|
||||||
|
|
||||||
const handleYearlyHoursPrint = async (year: number) => {
|
const handleYearlyHoursPrint = async (payload: { year: number; month: number | null }) => {
|
||||||
if (!employee.value) return
|
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
|
isYearlyHoursDrawerOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,11 +80,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>
|
||||||
|
|
||||||
@@ -115,6 +117,18 @@
|
|||||||
@delete="deleteAbsenceFromDrawer"
|
@delete="deleteAbsenceFromDrawer"
|
||||||
@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>
|
||||||
|
|
||||||
@@ -190,7 +204,11 @@ const {
|
|||||||
deleteAbsenceFromDrawer,
|
deleteAbsenceFromDrawer,
|
||||||
closeAbsenceDrawer,
|
closeAbsenceDrawer,
|
||||||
formatMinutes,
|
formatMinutes,
|
||||||
handleSave
|
handleSave,
|
||||||
|
isWeekCommentDrawerOpen,
|
||||||
|
weekCommentContext,
|
||||||
|
openWeekCommentDrawer,
|
||||||
|
reloadWeeklySummary
|
||||||
} = useHoursPage()
|
} = useHoursPage()
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ export type WeeklyWorkHourRowSummary = {
|
|||||||
weeklyDinnerCount?: number
|
weeklyDinnerCount?: number
|
||||||
weeklyOvernightCount?: number
|
weeklyOvernightCount?: number
|
||||||
hasContractForWeek?: boolean
|
hasContractForWeek?: boolean
|
||||||
|
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.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 $weeklyDinnerCount = 0,
|
||||||
public int $weeklyOvernightCount = 0,
|
public int $weeklyOvernightCount = 0,
|
||||||
public bool $hasContractForWeek = true,
|
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\Dto\WorkHours\WorkMetrics;
|
||||||
use App\Entity\Employee;
|
use App\Entity\Employee;
|
||||||
use App\Entity\WorkHour;
|
use App\Entity\WorkHour;
|
||||||
|
use App\Enum\ContractNature;
|
||||||
|
use App\Enum\ContractType;
|
||||||
use App\Enum\TrackingMode;
|
use App\Enum\TrackingMode;
|
||||||
use App\Repository\AbsenceRepository;
|
use App\Repository\AbsenceRepository;
|
||||||
use App\Repository\EmployeeRepository;
|
use App\Repository\EmployeeRepository;
|
||||||
@@ -62,8 +64,22 @@ class EmployeeYearlyHoursPrintProvider implements ProviderInterface
|
|||||||
}
|
}
|
||||||
$year = (int) $yearRaw;
|
$year = (int) $yearRaw;
|
||||||
|
|
||||||
$from = new DateTimeImmutable("{$year}-01-01");
|
$monthRaw = (string) $request->query->get('month', '');
|
||||||
$to = new DateTimeImmutable("{$year}-12-31");
|
$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);
|
$days = $this->buildDays($from, $to);
|
||||||
|
|
||||||
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($from, $to, [$employee]);
|
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($from, $to, [$employee]);
|
||||||
@@ -83,28 +99,39 @@ class EmployeeYearlyHoursPrintProvider implements ProviderInterface
|
|||||||
$absenceData,
|
$absenceData,
|
||||||
);
|
);
|
||||||
|
|
||||||
$employeeName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? ''));
|
$employeeName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? ''));
|
||||||
|
$contractLabel = $this->buildContractLabel($employee);
|
||||||
|
|
||||||
$options = new Options();
|
$options = new Options();
|
||||||
$options->set('isRemoteEnabled', true);
|
$options->set('isRemoteEnabled', true);
|
||||||
|
|
||||||
$dompdf = new Dompdf($options);
|
$dompdf = new Dompdf($options);
|
||||||
$html = $this->twig->render('employee-yearly-hours/print.html.twig', [
|
$html = $this->twig->render('employee-yearly-hours/print.html.twig', [
|
||||||
'employeeName' => $employeeName,
|
'employeeName' => $employeeName,
|
||||||
'year' => $year,
|
'contractLabel' => $contractLabel,
|
||||||
'segments' => $segments,
|
'year' => $year,
|
||||||
|
'month' => $month,
|
||||||
|
'segments' => $segments,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$dompdf->loadHtml($html);
|
$dompdf->loadHtml($html);
|
||||||
$dompdf->setPaper('A4', 'portrait');
|
$dompdf->setPaper('A4', 'portrait');
|
||||||
$dompdf->render();
|
$dompdf->render();
|
||||||
|
|
||||||
$filename = sprintf(
|
$filename = null !== $month
|
||||||
'%s_%s_%d.pdf',
|
? sprintf(
|
||||||
$this->sanitizeFilename($employee->getLastName() ?? ''),
|
'%s_%s_%d-%02d.pdf',
|
||||||
$this->sanitizeFilename($employee->getFirstName() ?? ''),
|
$this->sanitizeFilename($employee->getLastName() ?? ''),
|
||||||
$year,
|
$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, [
|
return new Response($dompdf->output(), Response::HTTP_OK, [
|
||||||
'Content-Type' => 'application/pdf',
|
'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>
|
* @return list<string>
|
||||||
*/
|
*/
|
||||||
@@ -211,13 +268,44 @@ class EmployeeYearlyHoursPrintProvider implements ProviderInterface
|
|||||||
$currentRows = [];
|
$currentRows = [];
|
||||||
$currentName = null;
|
$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) {
|
foreach ($days as $date) {
|
||||||
$contract = $contractsByDate[$date] ?? null;
|
$hasRow = null !== ($workHoursByDate[$date] ?? null)
|
||||||
$isDriver = $driverByDate[$date] ?? false;
|
|| ($absenceData['hasDayAbsence'][$date] ?? false);
|
||||||
$wh = $workHoursByDate[$date] ?? null;
|
if ($hasRow) {
|
||||||
$hasData = null !== $wh || ($absenceData['hasDayAbsence'][$date] ?? false);
|
$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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,6 +332,7 @@ class EmployeeYearlyHoursPrintProvider implements ProviderInterface
|
|||||||
$row = [
|
$row = [
|
||||||
'date' => new DateTimeImmutable($date)->format('d/m/Y'),
|
'date' => new DateTimeImmutable($date)->format('d/m/Y'),
|
||||||
'absenceLabel' => $absenceLabel,
|
'absenceLabel' => $absenceLabel,
|
||||||
|
'isWeekend' => $isWeekend,
|
||||||
];
|
];
|
||||||
|
|
||||||
if ('presence' === $mode) {
|
if ('presence' === $mode) {
|
||||||
|
|||||||
@@ -18,12 +18,14 @@ use App\Repository\MileageAllowanceRepository;
|
|||||||
use App\Repository\ObservationRepository;
|
use App\Repository\ObservationRepository;
|
||||||
use App\Repository\WorkHourRepository;
|
use App\Repository\WorkHourRepository;
|
||||||
use App\Service\Contracts\EmployeeContractResolver;
|
use App\Service\Contracts\EmployeeContractResolver;
|
||||||
|
use App\Service\PublicHolidayServiceInterface;
|
||||||
use DateInterval;
|
use DateInterval;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Dompdf\Dompdf;
|
use Dompdf\Dompdf;
|
||||||
use Dompdf\Options;
|
use Dompdf\Options;
|
||||||
use Symfony\Component\HttpFoundation\RequestStack;
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Throwable;
|
||||||
use Twig\Environment;
|
use Twig\Environment;
|
||||||
|
|
||||||
class SalaryRecapPrintProvider implements ProviderInterface
|
class SalaryRecapPrintProvider implements ProviderInterface
|
||||||
@@ -39,6 +41,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
|||||||
private MileageAllowanceRepository $mileageAllowanceRepository,
|
private MileageAllowanceRepository $mileageAllowanceRepository,
|
||||||
private ObservationRepository $observationRepository,
|
private ObservationRepository $observationRepository,
|
||||||
private EmployeeContractResolver $contractResolver,
|
private EmployeeContractResolver $contractResolver,
|
||||||
|
private PublicHolidayServiceInterface $publicHolidayService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
|
||||||
@@ -71,6 +74,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
|||||||
$days = $this->buildDays($from, $to);
|
$days = $this->buildDays($from, $to);
|
||||||
$contractMap = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
|
$contractMap = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
|
||||||
$driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days);
|
$driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days);
|
||||||
|
$holidayMap = $this->buildHolidayMap($from, $to);
|
||||||
|
|
||||||
$workHourMap = $this->buildWorkHourMap($workHours);
|
$workHourMap = $this->buildWorkHourMap($workHours);
|
||||||
$absenceMap = $this->buildAbsenceMap($absences);
|
$absenceMap = $this->buildAbsenceMap($absences);
|
||||||
@@ -79,7 +83,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
|||||||
$mileageMap = $this->buildMileageMap($mileages);
|
$mileageMap = $this->buildMileageMap($mileages);
|
||||||
$observationMap = $this->buildObservationMap($observations);
|
$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 = new Options();
|
||||||
$options->set('isRemoteEnabled', true);
|
$options->set('isRemoteEnabled', true);
|
||||||
@@ -208,6 +212,29 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
|||||||
return $map;
|
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>
|
* @return array<int, string>
|
||||||
*/
|
*/
|
||||||
@@ -236,6 +263,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
|||||||
array $bonusMap,
|
array $bonusMap,
|
||||||
array $mileageMap,
|
array $mileageMap,
|
||||||
array $observationMap,
|
array $observationMap,
|
||||||
|
array $holidayMap,
|
||||||
): array {
|
): array {
|
||||||
$siteGroups = [];
|
$siteGroups = [];
|
||||||
|
|
||||||
@@ -257,6 +285,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
|||||||
$bonusMap[$employeeId] ?? 0.0,
|
$bonusMap[$employeeId] ?? 0.0,
|
||||||
$mileageMap[$employeeId] ?? 0.0,
|
$mileageMap[$employeeId] ?? 0.0,
|
||||||
$observationMap[$employeeId] ?? '',
|
$observationMap[$employeeId] ?? '',
|
||||||
|
$holidayMap,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isset($siteGroups[$siteId])) {
|
if (!isset($siteGroups[$siteId])) {
|
||||||
@@ -285,18 +314,20 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
|||||||
float $bonusAmount,
|
float $bonusAmount,
|
||||||
float $mileageKm,
|
float $mileageKm,
|
||||||
string $observation,
|
string $observation,
|
||||||
|
array $holidayMap,
|
||||||
): array {
|
): array {
|
||||||
$contractName = null;
|
$contractName = null;
|
||||||
$presenceDays = 0.0;
|
$presenceDays = 0.0;
|
||||||
$nightMinutesTotal = 0;
|
$nightMinutesTotal = 0;
|
||||||
$nightBasketCount = 0;
|
$nightBasketCount = 0;
|
||||||
$sundayMinutesTotal = 0;
|
$sundayMinutesTotal = 0;
|
||||||
$isDriverAnyDay = false;
|
$holidayMinutesTotal = 0;
|
||||||
$driverBreakfast = 0;
|
$isDriverAnyDay = false;
|
||||||
$driverMeals = 0;
|
$driverBreakfast = 0;
|
||||||
$driverOvernight = 0;
|
$driverMeals = 0;
|
||||||
$driverSaturdays = 0;
|
$driverOvernight = 0;
|
||||||
$isForfait = false;
|
$driverSaturdays = 0;
|
||||||
|
$isForfait = false;
|
||||||
|
|
||||||
foreach ($days as $date) {
|
foreach ($days as $date) {
|
||||||
$contract = $contractsByDate[$date] ?? null;
|
$contract = $contractsByDate[$date] ?? null;
|
||||||
@@ -318,10 +349,13 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
|||||||
|
|
||||||
$dayOfWeek = (int) new DateTimeImmutable($date)->format('N');
|
$dayOfWeek = (int) new DateTimeImmutable($date)->format('N');
|
||||||
|
|
||||||
|
$isHoliday = isset($holidayMap[$date]);
|
||||||
|
|
||||||
if ($isDriver) {
|
if ($isDriver) {
|
||||||
$nightMinutesTotal += $wh->getNightHoursMinutes() ?? 0;
|
$nightMinutesTotal += $wh->getNightHoursMinutes() ?? 0;
|
||||||
$dayMin = $wh->getDayHoursMinutes() ?? 0;
|
$dayMin = $wh->getDayHoursMinutes() ?? 0;
|
||||||
$nightMin = $wh->getNightHoursMinutes() ?? 0;
|
$nightMin = $wh->getNightHoursMinutes() ?? 0;
|
||||||
|
$workshopMin = $wh->getWorkshopHoursMinutes() ?? 0;
|
||||||
if (($nightMin > $dayMin && $nightMin > 0) || $nightMin >= 240) {
|
if (($nightMin > $dayMin && $nightMin > 0) || $nightMin >= 240) {
|
||||||
++$nightBasketCount;
|
++$nightBasketCount;
|
||||||
}
|
}
|
||||||
@@ -336,12 +370,16 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
|||||||
++$driverOvernight;
|
++$driverOvernight;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (6 === $dayOfWeek && ($dayMin > 0 || $nightMin > 0 || ($wh->getWorkshopHoursMinutes() ?? 0) > 0)) {
|
if (6 === $dayOfWeek && ($dayMin > 0 || $nightMin > 0 || $workshopMin > 0)) {
|
||||||
++$driverSaturdays;
|
++$driverSaturdays;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (7 === $dayOfWeek) {
|
if (7 === $dayOfWeek) {
|
||||||
$sundayMinutesTotal += $dayMin + $nightMin + ($wh->getWorkshopHoursMinutes() ?? 0);
|
$sundayMinutesTotal += $dayMin + $nightMin + $workshopMin;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isHoliday) {
|
||||||
|
$holidayMinutesTotal += $dayMin + $nightMin + $workshopMin;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$metrics = $this->computeNightMinutes($wh);
|
$metrics = $this->computeNightMinutes($wh);
|
||||||
@@ -359,6 +397,10 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
|||||||
$sundayMinutesTotal += $this->computeOverflowAfterMidnight($wh);
|
$sundayMinutesTotal += $this->computeOverflowAfterMidnight($wh);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($isHoliday) {
|
||||||
|
$holidayMinutesTotal += $metrics['dayMinutes'] + $metrics['nightMinutes'];
|
||||||
|
}
|
||||||
|
|
||||||
if ($isForfait) {
|
if ($isForfait) {
|
||||||
if ($wh->getIsPresentMorning()) {
|
if ($wh->getIsPresentMorning()) {
|
||||||
$presenceDays += 0.5;
|
$presenceDays += 0.5;
|
||||||
@@ -373,9 +415,10 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
|||||||
$conges = $this->countAbsencesByCode($absences, ['C']);
|
$conges = $this->countAbsencesByCode($absences, ['C']);
|
||||||
$maladie = $this->countAbsencesByCode($absences, ['M', 'AT']);
|
$maladie = $this->countAbsencesByCode($absences, ['M', 'AT']);
|
||||||
|
|
||||||
$nightHours = round($nightMinutesTotal / 60, 2);
|
$nightHours = round($nightMinutesTotal / 60, 2);
|
||||||
$paidHours = round($rttPaidMinutes / 60, 2);
|
$paidHours = round($rttPaidMinutes / 60, 2);
|
||||||
$sundayHours = round($sundayMinutesTotal / 60, 2);
|
$sundayHours = round($sundayMinutesTotal / 60, 2);
|
||||||
|
$holidayHours = round($holidayMinutesTotal / 60, 2);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'lastName' => mb_strimwidth($employee->getLastName() ?? '', 0, 15, '...'),
|
'lastName' => mb_strimwidth($employee->getLastName() ?? '', 0, 15, '...'),
|
||||||
@@ -387,6 +430,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
|||||||
'nightBasketCount' => $nightBasketCount,
|
'nightBasketCount' => $nightBasketCount,
|
||||||
'paidHours' => $paidHours,
|
'paidHours' => $paidHours,
|
||||||
'sundayHours' => $sundayHours,
|
'sundayHours' => $sundayHours,
|
||||||
|
'holidayHours' => $holidayHours,
|
||||||
'bonusAmount' => $bonusAmount,
|
'bonusAmount' => $bonusAmount,
|
||||||
'congesCount' => $conges['count'],
|
'congesCount' => $conges['count'],
|
||||||
'congesDates' => $conges['dates'],
|
'congesDates' => $conges['dates'],
|
||||||
|
|||||||
@@ -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\WorkHours\AbsenceSegmentsResolver;
|
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
||||||
use App\Service\WorkHours\DailyReferenceMinutesResolver;
|
use App\Service\WorkHours\DailyReferenceMinutesResolver;
|
||||||
@@ -45,6 +47,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
private EmployeeContractResolver $contractResolver,
|
private EmployeeContractResolver $contractResolver,
|
||||||
private DailyReferenceMinutesResolver $dailyReferenceResolver,
|
private DailyReferenceMinutesResolver $dailyReferenceResolver,
|
||||||
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
|
private HolidayVirtualHoursResolver $holidayVirtualHoursResolver,
|
||||||
|
private EmployeeWeekCommentRepository $weekCommentRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourWeeklySummary
|
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);
|
$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;
|
||||||
}
|
}
|
||||||
@@ -109,14 +114,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);
|
||||||
@@ -369,6 +375,9 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
weeklyDinnerCount: $weeklyDinnerCount,
|
weeklyDinnerCount: $weeklyDinnerCount,
|
||||||
weeklyOvernightCount: $weeklyOvernightCount,
|
weeklyOvernightCount: $weeklyOvernightCount,
|
||||||
hasContractForWeek: $hasContractForWeek,
|
hasContractForWeek: $hasContractForWeek,
|
||||||
|
contractNature: $weekAnchorContractNature->value,
|
||||||
|
comment: ($weekComments[$employeeId] ?? null)?->getContent(),
|
||||||
|
commentId: ($weekComments[$employeeId] ?? null)?->getId(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,24 @@
|
|||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.title-bar {
|
||||||
|
position: relative;
|
||||||
|
margin: 0 0 4mm 0;
|
||||||
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 16px;
|
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 {
|
h2 {
|
||||||
@@ -54,11 +68,70 @@
|
|||||||
td.time { text-align: center; }
|
td.time { text-align: center; }
|
||||||
td.presence { text-align: center; }
|
td.presence { text-align: center; }
|
||||||
td.total { text-align: center; font-weight: bold; }
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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 %}
|
{% for segment in segments %}
|
||||||
{% if segments|length > 1 %}
|
{% if segments|length > 1 %}
|
||||||
@@ -78,7 +151,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for row in segment.rows %}
|
{% for row in segment.rows %}
|
||||||
<tr>
|
<tr class="{{ row.isWeekend ? 'weekend' : '' }}">
|
||||||
<td class="date">{{ row.date }}</td>
|
<td class="date">{{ row.date }}</td>
|
||||||
<td class="absence">{{ row.absenceLabel ?? '' }}</td>
|
<td class="absence">{{ row.absenceLabel ?? '' }}</td>
|
||||||
<td class="presence">{{ row.presentMorning ? 'X' : '' }}</td>
|
<td class="presence">{{ row.presentMorning ? 'X' : '' }}</td>
|
||||||
@@ -102,7 +175,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for row in segment.rows %}
|
{% for row in segment.rows %}
|
||||||
<tr>
|
<tr class="{{ row.isWeekend ? 'weekend' : '' }}">
|
||||||
<td class="date">{{ row.date }}</td>
|
<td class="date">{{ row.date }}</td>
|
||||||
<td class="absence">{{ row.absenceLabel ?? '' }}</td>
|
<td class="absence">{{ row.absenceLabel ?? '' }}</td>
|
||||||
<td class="time">{{ row.dayHours }}</td>
|
<td class="time">{{ row.dayHours }}</td>
|
||||||
@@ -130,7 +203,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for row in segment.rows %}
|
{% for row in segment.rows %}
|
||||||
<tr>
|
<tr class="{{ row.isWeekend ? 'weekend' : '' }}">
|
||||||
<td class="date">{{ row.date }}</td>
|
<td class="date">{{ row.date }}</td>
|
||||||
<td class="absence">{{ row.absenceLabel ?? '' }}</td>
|
<td class="absence">{{ row.absenceLabel ?? '' }}</td>
|
||||||
<td class="time">{{ row.morningFrom }}</td>
|
<td class="time">{{ row.morningFrom }}</td>
|
||||||
@@ -147,5 +220,36 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% 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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -28,13 +28,22 @@
|
|||||||
.date-box {
|
.date-box {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
left: 0;
|
||||||
border: 2px solid #000;
|
border: 2px solid #000;
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.export-date {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #333;
|
||||||
|
padding-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
table.recap {
|
table.recap {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
@@ -77,8 +86,9 @@
|
|||||||
<body>
|
<body>
|
||||||
|
|
||||||
<div class="title-bar">
|
<div class="title-bar">
|
||||||
<h1>RECAPITULATIF CONGES & RTT</h1>
|
|
||||||
<div class="date-box">{{ today|date('d/m/Y') }}</div>
|
<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>
|
</div>
|
||||||
|
|
||||||
<table class="recap">
|
<table class="recap">
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 2mm;
|
padding: 2mm;
|
||||||
font-family: Helvetica, sans-serif;
|
font-family: Helvetica, sans-serif;
|
||||||
font-size: 10px;
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-bar {
|
.title-bar {
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
.month-box {
|
.month-box {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
left: 0;
|
||||||
border: 2px solid #000;
|
border: 2px solid #000;
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -36,16 +36,25 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.export-date {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #333;
|
||||||
|
padding-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
table.recap {
|
table.recap {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
table-layout: auto;
|
table-layout: auto;
|
||||||
border: 4px solid #0a0a0a;
|
border: 2px solid #0a0a0a;
|
||||||
}
|
}
|
||||||
|
|
||||||
th, td {
|
th, td {
|
||||||
border: 2px solid #0a0a0a;
|
border: 1px solid #0a0a0a;
|
||||||
padding: 3px 3px;
|
padding: 2px 2px;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -60,7 +69,7 @@
|
|||||||
thead th {
|
thead th {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 10px;
|
font-size: 9px;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,16 +83,16 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
font-size: 10px;
|
font-size: 9px;
|
||||||
}
|
}
|
||||||
td.obs {
|
td.obs {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
font-size: 9px;
|
font-size: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody td { font-size: 10px; }
|
tbody td { font-size: 9px; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -94,43 +103,45 @@
|
|||||||
} %}
|
} %}
|
||||||
|
|
||||||
<div class="title-bar">
|
<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>
|
<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>
|
</div>
|
||||||
|
|
||||||
<table class="recap">
|
<table class="recap">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th rowspan="2" style="width: 24mm; text-align: left;">Nom</th>
|
<th rowspan="2" style="width: 20mm; text-align: left;">Nom</th>
|
||||||
<th rowspan="2" style="width: 12mm;">Base</th>
|
<th rowspan="2" style="width: 10mm;">Base</th>
|
||||||
<th rowspan="2" style="width: 12mm;">Jour de<br>présence<br>Cadre</th>
|
<th rowspan="2" style="width: 10mm;">Jour de<br>présence<br>Cadre</th>
|
||||||
<th rowspan="2" style="width: 9mm;">Frais<br>Kms</th>
|
<th rowspan="2" style="width: 8mm;">Frais<br>Kms</th>
|
||||||
<th rowspan="2" style="width: 9mm;">Heures<br>de<br>nuit</th>
|
<th rowspan="2" style="width: 8mm;">Heures<br>de<br>nuit</th>
|
||||||
<th rowspan="2" style="width: 9mm;">Panier<br>de<br>nuit</th>
|
<th rowspan="2" style="width: 8mm;">Panier<br>de<br>nuit</th>
|
||||||
<th rowspan="2" style="width: 12mm;">Heures<br>payés</th>
|
<th rowspan="2" style="width: 10mm;">Heures<br>payés</th>
|
||||||
<th rowspan="2" style="width: 9mm;">Heures<br>dim.</th>
|
<th rowspan="2" style="width: 8mm;">Heures<br>férié</th>
|
||||||
<th rowspan="2" style="width: 9mm;">Prime</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">Congés</th>
|
||||||
<th colspan="2">Maladie</th>
|
<th colspan="2">Maladie</th>
|
||||||
<th colspan="4">CHAUFFEUR</th>
|
<th colspan="4">CHAUFFEUR</th>
|
||||||
<th rowspan="2" style="width: 26mm;">Observations</th>
|
<th rowspan="2" style="width: 20mm;">Observations</th>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width: 10mm;">Nbre</th>
|
<th style="width: 8mm;">Nbre</th>
|
||||||
<th style="width: 26mm;">Date</th>
|
<th style="width: 22mm;">Date</th>
|
||||||
<th style="width: 10mm;">Nbre</th>
|
<th style="width: 8mm;">Nbre</th>
|
||||||
<th style="width: 26mm;">Date</th>
|
<th style="width: 22mm;">Date</th>
|
||||||
<th style="width: 8mm;">PDJ</th>
|
<th style="width: 7mm;">PDJ</th>
|
||||||
<th style="width: 10mm;">REPAS</th>
|
<th style="width: 9mm;">REPAS</th>
|
||||||
<th style="width: 12mm;">NUITEE</th>
|
<th style="width: 10mm;">NUITEE</th>
|
||||||
<th style="width: 12mm;">samedi</th>
|
<th style="width: 10mm;">samedi</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for siteId, group in siteGroups %}
|
{% for siteId, group in siteGroups %}
|
||||||
{% set siteColor = group.color ?? '#B3E5FC' %}
|
{% set siteColor = group.color ?? '#B3E5FC' %}
|
||||||
<tr class="site-header">
|
<tr class="site-header">
|
||||||
<td style="background: {{ siteColor }}; text-align: left;" colspan="18">
|
<td style="background: {{ siteColor }}; text-align: left;" colspan="19">
|
||||||
{{ group.name }}
|
{{ group.name }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -143,6 +154,7 @@
|
|||||||
<td class="num">{{ row.nightHours > 0 ? row.nightHours : '' }}</td>
|
<td class="num">{{ row.nightHours > 0 ? row.nightHours : '' }}</td>
|
||||||
<td class="num">{{ row.nightBasketCount > 0 ? row.nightBasketCount : '' }}</td>
|
<td class="num">{{ row.nightBasketCount > 0 ? row.nightBasketCount : '' }}</td>
|
||||||
<td class="num">{{ row.paidHours > 0 ? row.paidHours : '' }}</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.sundayHours > 0 ? row.sundayHours : '' }}</td>
|
||||||
<td class="num">{{ row.bonusAmount > 0 ? row.bonusAmount ~ ' €' : '' }}</td>
|
<td class="num">{{ row.bonusAmount > 0 ? row.bonusAmount ~ ' €' : '' }}</td>
|
||||||
<td class="num">{{ row.congesCount > 0 ? row.congesCount : '' }}</td>
|
<td class="num">{{ row.congesCount > 0 ? row.congesCount : '' }}</td>
|
||||||
@@ -157,7 +169,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="18">Aucun employé.</td>
|
<td colspan="19">Aucun employé.</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -94,6 +94,87 @@ final class EmployeeContractPeriodValidatorTest extends TestCase
|
|||||||
$this->validator->assertNextStartDateCompatible(new DateTimeImmutable('2026-03-10'), $currentPeriod);
|
$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
|
private function buildCurrentPeriod(string $startDate, ?string $endDate): EmployeeContractPeriod
|
||||||
{
|
{
|
||||||
$contract = new Contract()
|
$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\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;
|
||||||
@@ -66,6 +67,7 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
|
|||||||
$this->buildResolverStub(),
|
$this->buildResolverStub(),
|
||||||
new DailyReferenceMinutesResolver(),
|
new DailyReferenceMinutesResolver(),
|
||||||
$this->buildHolidayResolver(),
|
$this->buildHolidayResolver(),
|
||||||
|
$this->buildWeekCommentRepoStub(),
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->expectException(AccessDeniedHttpException::class);
|
$this->expectException(AccessDeniedHttpException::class);
|
||||||
@@ -128,6 +130,7 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
|
|||||||
$this->buildWeeklyResolverStub($employees),
|
$this->buildWeeklyResolverStub($employees),
|
||||||
new DailyReferenceMinutesResolver(),
|
new DailyReferenceMinutesResolver(),
|
||||||
$this->buildHolidayResolver(),
|
$this->buildHolidayResolver(),
|
||||||
|
$this->buildWeekCommentRepoStub(),
|
||||||
);
|
);
|
||||||
|
|
||||||
$result = $provider->provide(new Get());
|
$result = $provider->provide(new Get());
|
||||||
@@ -178,6 +181,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
|
||||||
{
|
{
|
||||||
$service = $this->createStub(PublicHolidayServiceInterface::class);
|
$service = $this->createStub(PublicHolidayServiceInterface::class);
|
||||||
|
|||||||
Reference in New Issue
Block a user