fix : affichage des absences dans les heures vue semaine + refacto
This commit is contained in:
105
AGENTS.md
Normal file
105
AGENTS.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
État des lieux opérationnel du projet SIRH (backend + frontend), à utiliser comme base sur les prochaines interventions.
|
||||||
|
|
||||||
|
## 1) Stack et structure
|
||||||
|
|
||||||
|
- Backend: Symfony + API Platform + Doctrine ORM
|
||||||
|
- Frontend: Nuxt 4 + Vue 3 + TypeScript + Tailwind
|
||||||
|
- Exécution locale: Docker via `makefile`
|
||||||
|
|
||||||
|
Arborescence clé:
|
||||||
|
- `src/`: domaine, API resources, state providers/processors, services
|
||||||
|
- `tests/`: TU backend (PHPUnit)
|
||||||
|
- `frontend/`: app Nuxt (pages, composants, composables, services)
|
||||||
|
- `migrations/`: migrations Doctrine
|
||||||
|
|
||||||
|
## 2) Commandes utiles
|
||||||
|
|
||||||
|
- Démarrer stack: `make start`
|
||||||
|
- Tests backend: `make test`
|
||||||
|
- Build frontend: `cd frontend && npm run build`
|
||||||
|
- Dev frontend: `make dev-nuxt`
|
||||||
|
|
||||||
|
## 3) Domaine métier (résumé)
|
||||||
|
|
||||||
|
### Contrats
|
||||||
|
- Entité: `Contract`
|
||||||
|
- Champs principaux: `name`, `trackingMode`, `weeklyHours`, `isActive`
|
||||||
|
- `trackingMode`:
|
||||||
|
- `TIME`: suivi par heures
|
||||||
|
- `PRESENCE`: suivi présence demi-journées/journées
|
||||||
|
- Enums backend:
|
||||||
|
- `App\Enum\TrackingMode`
|
||||||
|
- `App\Enum\ContractType` (`FORFAIT`, `35H`, `39H`, `INTERIM`, `CUSTOM`)
|
||||||
|
- `Contract::getType()` est exposé en API (`contract:read`, `employee:read`)
|
||||||
|
|
||||||
|
### Heures / absences
|
||||||
|
- Les absences sont découpées en enregistrements journaliers (pas de période unique stockée).
|
||||||
|
- Une ligne d’heures validée est verrouillée côté métier.
|
||||||
|
- Règles de crédit absence (`countAsWorkedHours=true`) gérées dans `WorkedHoursCreditPolicy`:
|
||||||
|
- contrats présence: crédit en unités de présence
|
||||||
|
- contrats temps: crédit en minutes selon règles contrat (35h, 39h, 4h, fallback)
|
||||||
|
|
||||||
|
## 4) Écrans principaux
|
||||||
|
|
||||||
|
### Page Heures (`frontend/pages/hours.vue`)
|
||||||
|
- Vue Jour + Vue Semaine (semaine réservée admin)
|
||||||
|
- Toolbar dédiée: `frontend/components/hours/HoursToolbar.vue`
|
||||||
|
- Vue jour: `frontend/components/hours/HoursDayView.vue`
|
||||||
|
- Vue semaine: `frontend/components/hours/HoursWeekView.vue`
|
||||||
|
- Logique page: `frontend/composables/useHoursPage.ts`
|
||||||
|
|
||||||
|
### Points UX déjà en place
|
||||||
|
- Toolbar semaine: raccourcis semaine précédente / actuelle / suivante
|
||||||
|
- Légende absences affichée dans la toolbar (admin + vue semaine)
|
||||||
|
- Cellules semaine avec absence: couleur du type d’absence (plus rouge fixe)
|
||||||
|
- Pour user non-admin: restrictions d’édition selon validations/absences
|
||||||
|
|
||||||
|
## 5) API / calculs hebdo
|
||||||
|
|
||||||
|
- Provider: `src/State/WorkHourWeeklySummaryProvider.php`
|
||||||
|
- DTOs:
|
||||||
|
- `src/Dto/WorkHours/WeeklySummaryRow.php`
|
||||||
|
- `src/Dto/WorkHours/WeeklyDaySummary.php`
|
||||||
|
- Le résumé hebdo renvoie notamment:
|
||||||
|
- `trackingMode`
|
||||||
|
- `contractName`
|
||||||
|
- `contractType`
|
||||||
|
- détails journaliers (jour/nuit/total, présence, absence label/couleur)
|
||||||
|
|
||||||
|
### Heures supp
|
||||||
|
- Règles métier:
|
||||||
|
- contrats <= 35h: tranche 25% de 35h à 43h, puis 50% au-delà
|
||||||
|
- contrats >= 39h: tranche 25% de 39h à 43h, puis 50% au-delà
|
||||||
|
- contrats `INTERIM`: pas de bonus 25/50 ni récup
|
||||||
|
|
||||||
|
## 6) Conventions techniques
|
||||||
|
|
||||||
|
- Favoriser DTO explicites plutôt que tableaux associatifs bruts.
|
||||||
|
- Utiliser les interfaces repository dans providers/processors testés.
|
||||||
|
- Centraliser les règles métier dans services/providers backend plutôt que dupliquer côté front.
|
||||||
|
- Front: éviter les calculs métier lourds; consommer les champs API déjà calculés.
|
||||||
|
|
||||||
|
## 7) Tests et qualité
|
||||||
|
|
||||||
|
- Les TU backend passent actuellement via `make test`.
|
||||||
|
- Le build frontend passe via `npm run build`.
|
||||||
|
- À chaque évolution métier:
|
||||||
|
- mettre à jour les tests provider/processor/service impactés
|
||||||
|
- maintenir la cohérence des DTO TypeScript (`frontend/services/dto/*`)
|
||||||
|
|
||||||
|
## 8) Fichiers sensibles (à lire avant modif)
|
||||||
|
|
||||||
|
- `src/State/WorkHourWeeklySummaryProvider.php`
|
||||||
|
- `src/Service/WorkHours/WorkedHoursCreditPolicy.php`
|
||||||
|
- `src/State/AbsenceWriteProcessor.php`
|
||||||
|
- `src/State/WorkHourBulkUpsertProcessor.php`
|
||||||
|
- `frontend/composables/useHoursPage.ts`
|
||||||
|
- `frontend/components/hours/HoursWeekView.vue`
|
||||||
|
|
||||||
|
## 9) Décisions de conception actuelles
|
||||||
|
|
||||||
|
- Les absences sont stockées par jour (facilite verrouillage/édition fine).
|
||||||
|
- Les règles de calcul (crédits, majorations, récup) sont portées côté backend.
|
||||||
|
- Le front reste centré sur l’affichage/interaction et réutilise les données enrichies de l’API.
|
||||||
@@ -126,11 +126,23 @@
|
|||||||
<div v-if="isAdmin" class="w-80 max-w-full">
|
<div v-if="isAdmin" class="w-80 max-w-full">
|
||||||
<EmployeeNameFilterInput v-model="employeeFilter" />
|
<EmployeeNameFilterInput v-model="employeeFilter" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="isAdmin && viewMode === 'week' && absenceTypes.length > 0"
|
||||||
|
class="flex flex-wrap items-center gap-6"
|
||||||
|
>
|
||||||
|
<p class="font-bold">Légende :</p>
|
||||||
|
<div v-for="type in absenceTypes" :key="type.id" class="flex items-center gap-2">
|
||||||
|
<div :style="{ backgroundColor: type.color }" class="h-4 w-4 rounded"></div>
|
||||||
|
<p>{{ type.label }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Site } from '~/services/dto/site'
|
import type { Site } from '~/services/dto/site'
|
||||||
|
import type { AbsenceType } from '~/services/dto/absence-type'
|
||||||
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
|
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
|
||||||
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
||||||
import { weekInputValueToYmd, ymdToWeekInputValue } from '~/utils/date'
|
import { weekInputValueToYmd, ymdToWeekInputValue } from '~/utils/date'
|
||||||
@@ -143,6 +155,7 @@ const employeeFilter = defineModel<string>('employeeFilter', { required: true })
|
|||||||
defineProps<{
|
defineProps<{
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
sites: Site[]
|
sites: Site[]
|
||||||
|
absenceTypes: AbsenceType[]
|
||||||
formattedSelectedDate: string
|
formattedSelectedDate: string
|
||||||
shortcutButtonClass: (target: 'yesterday' | 'today' | 'tomorrow') => string
|
shortcutButtonClass: (target: 'yesterday' | 'today' | 'tomorrow') => string
|
||||||
weekShortcutButtonClass: (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => string
|
weekShortcutButtonClass: (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => string
|
||||||
|
|||||||
@@ -30,7 +30,14 @@
|
|||||||
<p class="text-[11px] text-neutral-500 truncate">{{ row.siteName ?? 'Sans site' }}</p>
|
<p class="text-[11px] text-neutral-500 truncate">{{ row.siteName ?? 'Sans site' }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-for="daily in row.daily" :key="daily.date" class="text-left leading-4">
|
<div
|
||||||
|
v-for="daily in row.daily"
|
||||||
|
:key="daily.date"
|
||||||
|
class="text-left leading-4 rounded-md px-2 py-1"
|
||||||
|
:class="daily.hasAbsence ? 'text-white' : ''"
|
||||||
|
:style="getDailyCellStyle(daily)"
|
||||||
|
:title="daily.absenceLabel ?? ''"
|
||||||
|
>
|
||||||
<template v-if="row.trackingMode === 'PRESENCE'">{{ daily.present ?? 0 }}</template>
|
<template v-if="row.trackingMode === 'PRESENCE'">{{ daily.present ?? 0 }}</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div>J {{ formatMinutes(daily.dayMinutes) }}</div>
|
<div>J {{ formatMinutes(daily.dayMinutes) }}</div>
|
||||||
@@ -52,13 +59,13 @@
|
|||||||
{{ row.trackingMode === 'PRESENCE' ? '-' : formatMinutes(row.weeklyOvertimeTotalMinutes ?? 0) }}
|
{{ row.trackingMode === 'PRESENCE' ? '-' : formatMinutes(row.weeklyOvertimeTotalMinutes ?? 0) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="font-semibold">
|
<div class="font-semibold">
|
||||||
{{ row.trackingMode === 'PRESENCE' ? '-' : formatMinutes(row.weeklyOvertime25Minutes ?? 0) }}
|
{{ row.trackingMode === 'PRESENCE' || isInterimContract(row.contractType) ? '-' : formatMinutes(row.weeklyOvertime25Minutes ?? 0) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="font-semibold">
|
<div class="font-semibold">
|
||||||
{{ row.trackingMode === 'PRESENCE' ? '-' : formatMinutes(row.weeklyOvertime50Minutes ?? 0) }}
|
{{ row.trackingMode === 'PRESENCE' || isInterimContract(row.contractType) ? '-' : formatMinutes(row.weeklyOvertime50Minutes ?? 0) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="font-semibold">
|
<div class="font-semibold">
|
||||||
{{ row.trackingMode === 'PRESENCE' ? '-' : formatMinutes(row.weeklyRecoveryMinutes ?? 0) }}
|
{{ row.trackingMode === 'PRESENCE' || isInterimContract(row.contractType) ? '-' : formatMinutes(row.weeklyRecoveryMinutes ?? 0) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,6 +74,19 @@
|
|||||||
|
|
||||||
<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'
|
||||||
|
|
||||||
|
const isInterimContract = (contractType?: ContractType | null) => {
|
||||||
|
return contractType === CONTRACT_TYPES.INTERIM
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDailyCellStyle = (daily: {
|
||||||
|
hasAbsence?: boolean
|
||||||
|
absenceColor?: string | null
|
||||||
|
}) => {
|
||||||
|
if (!daily.hasAbsence) return undefined
|
||||||
|
return { backgroundColor: daily.absenceColor || '#dc2626' }
|
||||||
|
}
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
isWeekLoading: boolean
|
isWeekLoading: boolean
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { WorkHour, WorkHourDayContext, WeeklyWorkHourSummary } from '~/serv
|
|||||||
import type { AbsenceType } from '~/services/dto/absence-type'
|
import type { AbsenceType } from '~/services/dto/absence-type'
|
||||||
import type { Absence } from '~/services/dto/absence'
|
import type { Absence } from '~/services/dto/absence'
|
||||||
import type { HalfDay } from '~/services/dto/half-day'
|
import type { HalfDay } from '~/services/dto/half-day'
|
||||||
|
import { CONTRACT_TYPES, TRACKING_MODES } from '~/services/dto/contract'
|
||||||
import type { HourRow } from '~/components/hours/types'
|
import type { HourRow } from '~/components/hours/types'
|
||||||
import { listScopedEmployees } from '~/services/employees'
|
import { listScopedEmployees } from '~/services/employees'
|
||||||
import { listAbsenceTypes } from '~/services/absence-types'
|
import { listAbsenceTypes } from '~/services/absence-types'
|
||||||
@@ -275,14 +276,17 @@ export const useHoursPage = () => {
|
|||||||
isValid: false
|
isValid: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const isPresenceTracking = (employee: Employee) => employee.contract?.trackingMode === 'PRESENCE'
|
const isPresenceTracking = (employee: Employee) => employee.contract?.trackingMode === TRACKING_MODES.PRESENCE
|
||||||
const isTimeTracking = (employee: Employee) => !isPresenceTracking(employee)
|
const isTimeTracking = (employee: Employee) => !isPresenceTracking(employee)
|
||||||
const isRowLocked = (employeeId: number) => rows.value[employeeId]?.isValid ?? false
|
const isRowLocked = (employeeId: number) => rows.value[employeeId]?.isValid ?? false
|
||||||
|
|
||||||
const contractLabel = (employee: Employee) => {
|
const contractLabel = (employee: Employee) => {
|
||||||
const contract = employee.contract
|
const contract = employee.contract
|
||||||
if (!contract) return '-'
|
if (!contract) return '-'
|
||||||
if (contract.weeklyHours !== null && contract.weeklyHours !== undefined && contract.trackingMode === 'TIME') {
|
if (contract.type === CONTRACT_TYPES.INTERIM) {
|
||||||
|
return contract.name
|
||||||
|
}
|
||||||
|
if (contract.weeklyHours !== null && contract.weeklyHours !== undefined && contract.trackingMode === TRACKING_MODES.TIME) {
|
||||||
return `${contract.weeklyHours}h`
|
return `${contract.weeklyHours}h`
|
||||||
}
|
}
|
||||||
return contract.name
|
return contract.name
|
||||||
@@ -392,7 +396,7 @@ export const useHoursPage = () => {
|
|||||||
const isEveningLockedByAbsence = (employeeId: number) => {
|
const isEveningLockedByAbsence = (employeeId: number) => {
|
||||||
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
||||||
if (!dayRow) return false
|
if (!dayRow) return false
|
||||||
return dayRow.absentMorning && dayRow.absentAfternoon
|
return dayRow.absentAfternoon
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatMinutes = (minutes: number) => {
|
const formatMinutes = (minutes: number) => {
|
||||||
@@ -475,6 +479,42 @@ export const useHoursPage = () => {
|
|||||||
isAbsenceDrawerOpen.value = true
|
isAbsenceDrawerOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const applyLocalClearFromAbsence = (employeeId: number, startHalf: HalfDay, endHalf: HalfDay) => {
|
||||||
|
const row = rows.value[employeeId]
|
||||||
|
if (!row) return
|
||||||
|
|
||||||
|
if (startHalf === 'AM' && endHalf === 'AM') {
|
||||||
|
row.morningFrom = ''
|
||||||
|
row.morningTo = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startHalf === 'PM' && endHalf === 'PM') {
|
||||||
|
row.afternoonFrom = ''
|
||||||
|
row.afternoonTo = ''
|
||||||
|
row.eveningFrom = ''
|
||||||
|
row.eveningTo = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
row.morningFrom = ''
|
||||||
|
row.morningTo = ''
|
||||||
|
row.afternoonFrom = ''
|
||||||
|
row.afternoonTo = ''
|
||||||
|
row.eveningFrom = ''
|
||||||
|
row.eveningTo = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshAfterAbsenceChange = async () => {
|
||||||
|
if (isAdmin.value) {
|
||||||
|
await Promise.all([loadWeeklySummary(), loadDayContext(), loadAbsences()])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
weeklySummary.value = null
|
||||||
|
await Promise.all([loadDayContext(), loadAbsences()])
|
||||||
|
}
|
||||||
|
|
||||||
const submitAbsence = async () => {
|
const submitAbsence = async () => {
|
||||||
const form = absenceForm.value
|
const form = absenceForm.value
|
||||||
if (isAbsenceSubmitting.value || form.employeeId === '' || form.typeId === '') return
|
if (isAbsenceSubmitting.value || form.employeeId === '' || form.typeId === '') return
|
||||||
@@ -504,9 +544,9 @@ export const useHoursPage = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyLocalClearFromAbsence(Number(form.employeeId), form.startHalf, form.endHalf)
|
||||||
closeAbsenceDrawer()
|
closeAbsenceDrawer()
|
||||||
await refreshByDate()
|
await refreshAfterAbsenceChange()
|
||||||
await loadAbsences()
|
|
||||||
} finally {
|
} finally {
|
||||||
isAbsenceSubmitting.value = false
|
isAbsenceSubmitting.value = false
|
||||||
}
|
}
|
||||||
@@ -519,8 +559,7 @@ export const useHoursPage = () => {
|
|||||||
try {
|
try {
|
||||||
await deleteAbsence(editingAbsence.value.id)
|
await deleteAbsence(editingAbsence.value.id)
|
||||||
closeAbsenceDrawer()
|
closeAbsenceDrawer()
|
||||||
await refreshByDate()
|
await refreshAfterAbsenceChange()
|
||||||
await loadAbsences()
|
|
||||||
} finally {
|
} finally {
|
||||||
isAbsenceSubmitting.value = false
|
isAbsenceSubmitting.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,17 +6,10 @@
|
|||||||
<img src="/malio.png" alt="Logo" class="w-auto"/>
|
<img src="/malio.png" alt="Logo" class="w-auto"/>
|
||||||
</div>
|
</div>
|
||||||
<nav class="flex-1 px-4 pb-6">
|
<nav class="flex-1 px-4 pb-6">
|
||||||
<NuxtLink
|
|
||||||
to="/hours"
|
|
||||||
class="flex items-center gap-3 px-4 pb-3 pt-6 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500 border-t border-secondary-500"
|
|
||||||
active-class="bg-tertiary-500 text-primary-500"
|
|
||||||
>
|
|
||||||
Heures
|
|
||||||
</NuxtLink>
|
|
||||||
<template v-if="isAdmin">
|
<template v-if="isAdmin">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/"
|
to="/"
|
||||||
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500"
|
class="flex items-center gap-3 px-4 pb-3 pt-6 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500 border-t border-secondary-500"
|
||||||
active-class="bg-tertiary-500 text-primary-500"
|
active-class="bg-tertiary-500 text-primary-500"
|
||||||
>
|
>
|
||||||
Tableau de bord
|
Tableau de bord
|
||||||
@@ -28,6 +21,15 @@
|
|||||||
>
|
>
|
||||||
Calendrier
|
Calendrier
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
<NuxtLink
|
||||||
|
to="/hours"
|
||||||
|
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500"
|
||||||
|
active-class="bg-tertiary-500 text-primary-500"
|
||||||
|
>
|
||||||
|
Heures
|
||||||
|
</NuxtLink>
|
||||||
|
<template v-if="isAdmin">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/employees"
|
to="/employees"
|
||||||
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500"
|
class="flex items-center gap-3 px-4 py-3 text-md font-semibold text-neutral-700 hover:bg-tertiary-500 hover:text-primary-500"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
v-model:employee-filter="employeeFilter"
|
v-model:employee-filter="employeeFilter"
|
||||||
:is-admin="isAdmin"
|
:is-admin="isAdmin"
|
||||||
:sites="sites"
|
:sites="sites"
|
||||||
|
:absence-types="absenceTypes"
|
||||||
:formatted-selected-date="formattedSelectedDate"
|
:formatted-selected-date="formattedSelectedDate"
|
||||||
:shortcut-button-class="shortcutButtonClass"
|
:shortcut-button-class="shortcutButtonClass"
|
||||||
:week-shortcut-button-class="weekShortcutButtonClass"
|
:week-shortcut-button-class="weekShortcutButtonClass"
|
||||||
|
|||||||
@@ -1,7 +1,25 @@
|
|||||||
|
export const TRACKING_MODES = {
|
||||||
|
TIME: 'TIME',
|
||||||
|
PRESENCE: 'PRESENCE'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type TrackingMode = (typeof TRACKING_MODES)[keyof typeof TRACKING_MODES]
|
||||||
|
|
||||||
|
export const CONTRACT_TYPES = {
|
||||||
|
FORFAIT: 'FORFAIT',
|
||||||
|
H35: '35H',
|
||||||
|
H39: '39H',
|
||||||
|
INTERIM: 'INTERIM',
|
||||||
|
CUSTOM: 'CUSTOM'
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type ContractType = (typeof CONTRACT_TYPES)[keyof typeof CONTRACT_TYPES]
|
||||||
|
|
||||||
export type Contract = {
|
export type Contract = {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
trackingMode: 'TIME' | 'PRESENCE'
|
trackingMode: TrackingMode
|
||||||
|
type: ContractType
|
||||||
weeklyHours?: number | null
|
weeklyHours?: number | null
|
||||||
isActive?: boolean
|
isActive?: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Employee } from './employee'
|
import type { Employee } from './employee'
|
||||||
|
import type { ContractType, TrackingMode } from './contract'
|
||||||
|
|
||||||
export type WorkHour = {
|
export type WorkHour = {
|
||||||
id: number
|
id: number
|
||||||
@@ -33,6 +34,9 @@ export type WeeklyWorkHourDailySummary = {
|
|||||||
nightMinutes: number
|
nightMinutes: number
|
||||||
totalMinutes: number
|
totalMinutes: number
|
||||||
present?: number | null
|
present?: number | null
|
||||||
|
hasAbsence?: boolean
|
||||||
|
absenceLabel?: string | null
|
||||||
|
absenceColor?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WeeklyWorkHourRowSummary = {
|
export type WeeklyWorkHourRowSummary = {
|
||||||
@@ -41,7 +45,8 @@ export type WeeklyWorkHourRowSummary = {
|
|||||||
lastName: string
|
lastName: string
|
||||||
siteName?: string | null
|
siteName?: string | null
|
||||||
contractName?: string | null
|
contractName?: string | null
|
||||||
trackingMode?: 'TIME' | 'PRESENCE' | null
|
contractType?: ContractType | null
|
||||||
|
trackingMode?: TrackingMode | null
|
||||||
daily: WeeklyWorkHourDailySummary[]
|
daily: WeeklyWorkHourDailySummary[]
|
||||||
weeklyDayMinutes: number
|
weeklyDayMinutes: number
|
||||||
weeklyNightMinutes: number
|
weeklyNightMinutes: number
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace App\ApiResource;
|
|||||||
|
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
use ApiPlatform\Metadata\Get;
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use App\Dto\WorkHours\WeeklySummaryRow;
|
||||||
use App\State\WorkHourWeeklySummaryProvider;
|
use App\State\WorkHourWeeklySummaryProvider;
|
||||||
|
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
@@ -26,30 +27,6 @@ final class WorkHourWeeklySummary
|
|||||||
/** @var list<string> */
|
/** @var list<string> */
|
||||||
public array $days = [];
|
public array $days = [];
|
||||||
|
|
||||||
/**
|
/** @var list<WeeklySummaryRow> */
|
||||||
* @var list<array{
|
|
||||||
* employeeId:int,
|
|
||||||
* firstName:string,
|
|
||||||
* lastName:string,
|
|
||||||
* siteName:?string,
|
|
||||||
* contractName:?string,
|
|
||||||
* trackingMode:?string,
|
|
||||||
* daily:list<array{
|
|
||||||
* date:string,
|
|
||||||
* dayMinutes:int,
|
|
||||||
* nightMinutes:int,
|
|
||||||
* totalMinutes:int,
|
|
||||||
* present:?float
|
|
||||||
* }>,
|
|
||||||
* weeklyDayMinutes:int,
|
|
||||||
* weeklyNightMinutes:int,
|
|
||||||
* weeklyTotalMinutes:int,
|
|
||||||
* weeklyPresenceCount:float,
|
|
||||||
* weeklyOvertimeTotalMinutes:int,
|
|
||||||
* weeklyOvertime25Minutes:int,
|
|
||||||
* weeklyOvertime50Minutes:int,
|
|
||||||
* weeklyRecoveryMinutes:int
|
|
||||||
* }>
|
|
||||||
*/
|
|
||||||
public array $rows = [];
|
public array $rows = [];
|
||||||
}
|
}
|
||||||
|
|||||||
19
src/Dto/WorkHours/WeeklyDaySummary.php
Normal file
19
src/Dto/WorkHours/WeeklyDaySummary.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Dto\WorkHours;
|
||||||
|
|
||||||
|
final class WeeklyDaySummary
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public string $date,
|
||||||
|
public int $dayMinutes,
|
||||||
|
public int $nightMinutes,
|
||||||
|
public int $totalMinutes,
|
||||||
|
public ?float $present = null,
|
||||||
|
public bool $hasAbsence = false,
|
||||||
|
public ?string $absenceLabel = null,
|
||||||
|
public ?string $absenceColor = null,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
30
src/Dto/WorkHours/WeeklySummaryRow.php
Normal file
30
src/Dto/WorkHours/WeeklySummaryRow.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Dto\WorkHours;
|
||||||
|
|
||||||
|
final class WeeklySummaryRow
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<WeeklyDaySummary> $daily
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public int $employeeId,
|
||||||
|
public string $firstName,
|
||||||
|
public string $lastName,
|
||||||
|
public ?string $siteName,
|
||||||
|
public ?string $contractName,
|
||||||
|
public ?string $contractType,
|
||||||
|
public ?string $trackingMode,
|
||||||
|
public array $daily,
|
||||||
|
public int $weeklyDayMinutes,
|
||||||
|
public int $weeklyNightMinutes,
|
||||||
|
public int $weeklyTotalMinutes,
|
||||||
|
public float $weeklyPresenceCount,
|
||||||
|
public int $weeklyOvertimeTotalMinutes,
|
||||||
|
public int $weeklyOvertime25Minutes,
|
||||||
|
public int $weeklyOvertime50Minutes,
|
||||||
|
public int $weeklyRecoveryMinutes,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -5,7 +5,10 @@ declare(strict_types=1);
|
|||||||
namespace App\Entity;
|
namespace App\Entity;
|
||||||
|
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use App\Enum\ContractType;
|
||||||
|
use App\Enum\TrackingMode;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use InvalidArgumentException;
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
@@ -18,8 +21,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
#[ORM\Table(name: 'contracts')]
|
#[ORM\Table(name: 'contracts')]
|
||||||
class Contract
|
class Contract
|
||||||
{
|
{
|
||||||
public const string TRACKING_TIME = 'TIME';
|
public const string TRACKING_TIME = TrackingMode::TIME->value;
|
||||||
public const string TRACKING_PRESENCE = 'PRESENCE';
|
public const string TRACKING_PRESENCE = TrackingMode::PRESENCE->value;
|
||||||
|
|
||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
@@ -65,13 +68,29 @@ class Contract
|
|||||||
return $this->trackingMode;
|
return $this->trackingMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setTrackingMode(string $trackingMode): self
|
public function getTrackingModeEnum(): TrackingMode
|
||||||
{
|
{
|
||||||
$this->trackingMode = $trackingMode;
|
return TrackingMode::tryFrom($this->trackingMode) ?? TrackingMode::TIME;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTrackingMode(string|TrackingMode $trackingMode): self
|
||||||
|
{
|
||||||
|
$value = $trackingMode instanceof TrackingMode ? $trackingMode->value : $trackingMode;
|
||||||
|
if (null === TrackingMode::tryFrom($value)) {
|
||||||
|
throw new InvalidArgumentException(sprintf('Invalid tracking mode "%s".', $value));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->trackingMode = $value;
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Groups(['contract:read', 'employee:read'])]
|
||||||
|
public function getType(): ContractType
|
||||||
|
{
|
||||||
|
return ContractType::resolve($this->name, $this->trackingMode, $this->weeklyHours);
|
||||||
|
}
|
||||||
|
|
||||||
public function getWeeklyHours(): ?int
|
public function getWeeklyHours(): ?int
|
||||||
{
|
{
|
||||||
return $this->weeklyHours;
|
return $this->weeklyHours;
|
||||||
|
|||||||
47
src/Enum/ContractType.php
Normal file
47
src/Enum/ContractType.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Enum;
|
||||||
|
|
||||||
|
enum ContractType: string
|
||||||
|
{
|
||||||
|
case FORFAIT = 'FORFAIT';
|
||||||
|
case H35 = '35H';
|
||||||
|
case H39 = '39H';
|
||||||
|
case INTERIM = 'INTERIM';
|
||||||
|
case CUSTOM = 'CUSTOM';
|
||||||
|
|
||||||
|
public static function resolve(?string $name, ?string $trackingMode, ?int $weeklyHours): self
|
||||||
|
{
|
||||||
|
if (TrackingMode::PRESENCE->value === $trackingMode) {
|
||||||
|
return self::FORFAIT;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedName = self::normalize($name);
|
||||||
|
if ('interim' === $normalizedName) {
|
||||||
|
return self::INTERIM;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (35 === $weeklyHours) {
|
||||||
|
return self::H35;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (39 === $weeklyHours) {
|
||||||
|
return self::H39;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::CUSTOM;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function normalize(?string $value): string
|
||||||
|
{
|
||||||
|
if (null === $value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalized = mb_strtolower(trim($value));
|
||||||
|
|
||||||
|
return str_replace('é', 'e', $normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/Enum/TrackingMode.php
Normal file
11
src/Enum/TrackingMode.php
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Enum;
|
||||||
|
|
||||||
|
enum TrackingMode: string
|
||||||
|
{
|
||||||
|
case TIME = 'TIME';
|
||||||
|
case PRESENCE = 'PRESENCE';
|
||||||
|
}
|
||||||
@@ -18,5 +18,7 @@ interface WorkHourReadRepositoryInterface
|
|||||||
*/
|
*/
|
||||||
public function findByDateRangeAndEmployees(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array;
|
public function findByDateRangeAndEmployees(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array;
|
||||||
|
|
||||||
|
public function findOneByEmployeeAndDate(Employee $employee, DateTimeInterface $date): ?WorkHour;
|
||||||
|
|
||||||
public function hasValidatedInRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): bool;
|
public function hasValidatedInRange(Employee $employee, DateTimeInterface $from, DateTimeInterface $to): bool;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,4 +101,20 @@ final class WorkHourRepository extends ServiceEntityRepository implements WorkHo
|
|||||||
|
|
||||||
return ((int) $qb->getQuery()->getSingleScalarResult()) > 0;
|
return ((int) $qb->getQuery()->getSingleScalarResult()) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function findOneByEmployeeAndDate(Employee $employee, DateTimeInterface $date): ?WorkHour
|
||||||
|
{
|
||||||
|
$workDate = DateTimeImmutable::createFromInterface($date);
|
||||||
|
|
||||||
|
$qb = $this->createQueryBuilder('w')
|
||||||
|
->andWhere('w.employee = :employee')
|
||||||
|
->andWhere('w.workDate = :workDate')
|
||||||
|
->setParameter('employee', $employee)
|
||||||
|
->setParameter('workDate', $workDate)
|
||||||
|
->setMaxResults(1)
|
||||||
|
;
|
||||||
|
|
||||||
|
/** @var null|WorkHour $workHour */
|
||||||
|
return $qb->getQuery()->getOneOrNullResult();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Service\WorkHours;
|
namespace App\Service\WorkHours;
|
||||||
|
|
||||||
use App\Entity\Absence;
|
use App\Entity\Absence;
|
||||||
use App\Entity\Contract;
|
use App\Enum\TrackingMode;
|
||||||
use DateMalformedStringException;
|
use DateMalformedStringException;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ final class WorkedHoursCreditPolicy
|
|||||||
|
|
||||||
$employee = $absence->getEmployee();
|
$employee = $absence->getEmployee();
|
||||||
// Les contrats suivis en "présence" ne cumulent pas d'heures en minutes.
|
// Les contrats suivis en "présence" ne cumulent pas d'heures en minutes.
|
||||||
if (Contract::TRACKING_TIME !== $employee?->getContract()?->getTrackingMode()) {
|
if (TrackingMode::TIME->value !== $employee?->getContract()?->getTrackingMode()) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ final class WorkedHoursCreditPolicy
|
|||||||
}
|
}
|
||||||
|
|
||||||
$employee = $absence->getEmployee();
|
$employee = $absence->getEmployee();
|
||||||
if (Contract::TRACKING_PRESENCE !== $employee?->getContract()?->getTrackingMode()) {
|
if (TrackingMode::PRESENCE->value !== $employee?->getContract()?->getTrackingMode()) {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use ApiPlatform\Metadata\DeleteOperationInterface;
|
|||||||
use ApiPlatform\Metadata\Operation;
|
use ApiPlatform\Metadata\Operation;
|
||||||
use ApiPlatform\State\ProcessorInterface;
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
use App\Entity\Absence;
|
use App\Entity\Absence;
|
||||||
|
use App\Entity\Employee;
|
||||||
use App\Enum\HalfDay;
|
use App\Enum\HalfDay;
|
||||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
||||||
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
||||||
@@ -81,6 +82,7 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
|
|||||||
->setStartHalf($first['startHalf'])
|
->setStartHalf($first['startHalf'])
|
||||||
->setEndHalf($first['endHalf'])
|
->setEndHalf($first['endHalf'])
|
||||||
;
|
;
|
||||||
|
$this->clearWorkHoursForSegment($employee, $first);
|
||||||
$this->entityManager->persist($data);
|
$this->entityManager->persist($data);
|
||||||
|
|
||||||
foreach ($segments as $segment) {
|
foreach ($segments as $segment) {
|
||||||
@@ -94,6 +96,7 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
|
|||||||
->setEndHalf($segment['endHalf'])
|
->setEndHalf($segment['endHalf'])
|
||||||
;
|
;
|
||||||
|
|
||||||
|
$this->clearWorkHoursForSegment($employee, $segment);
|
||||||
$this->entityManager->persist($absence);
|
$this->entityManager->persist($absence);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,4 +177,47 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
|
|||||||
{
|
{
|
||||||
return DateTime::createFromImmutable($date);
|
return DateTime::createFromImmutable($date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{date: DateTimeImmutable, startHalf: HalfDay, endHalf: HalfDay} $segment
|
||||||
|
*/
|
||||||
|
private function clearWorkHoursForSegment(Employee $employee, array $segment): void
|
||||||
|
{
|
||||||
|
$workHour = $this->workHourRepository->findOneByEmployeeAndDate($employee, $segment['date']);
|
||||||
|
if (null === $workHour) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Demi-journée matin: on efface uniquement la plage du matin.
|
||||||
|
if (HalfDay::AM === $segment['startHalf'] && HalfDay::AM === $segment['endHalf']) {
|
||||||
|
$workHour
|
||||||
|
->setMorningFrom(null)
|
||||||
|
->setMorningTo(null)
|
||||||
|
;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Demi-journée après-midi: on efface après-midi + soirée.
|
||||||
|
if (HalfDay::PM === $segment['startHalf'] && HalfDay::PM === $segment['endHalf']) {
|
||||||
|
$workHour
|
||||||
|
->setAfternoonFrom(null)
|
||||||
|
->setAfternoonTo(null)
|
||||||
|
->setEveningFrom(null)
|
||||||
|
->setEveningTo(null)
|
||||||
|
;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Journée complète: on efface toutes les plages horaires.
|
||||||
|
$workHour
|
||||||
|
->setMorningFrom(null)
|
||||||
|
->setMorningTo(null)
|
||||||
|
->setAfternoonFrom(null)
|
||||||
|
->setAfternoonTo(null)
|
||||||
|
->setEveningFrom(null)
|
||||||
|
->setEveningTo(null)
|
||||||
|
;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ use ApiPlatform\Metadata\Operation;
|
|||||||
use ApiPlatform\State\ProcessorInterface;
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
use App\ApiResource\WorkHourBulkUpsert;
|
use App\ApiResource\WorkHourBulkUpsert;
|
||||||
use App\ApiResource\WorkHourBulkUpsertResult;
|
use App\ApiResource\WorkHourBulkUpsertResult;
|
||||||
use App\Entity\Contract;
|
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Entity\WorkHour;
|
use App\Entity\WorkHour;
|
||||||
|
use App\Enum\TrackingMode;
|
||||||
use App\Repository\EmployeeRepository;
|
use App\Repository\EmployeeRepository;
|
||||||
use App\Repository\WorkHourRepository;
|
use App\Repository\WorkHourRepository;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
@@ -75,7 +75,7 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
|||||||
throw new AccessDeniedHttpException(sprintf('Employee %d is outside your scope.', $employeeId));
|
throw new AccessDeniedHttpException(sprintf('Employee %d is outside your scope.', $employeeId));
|
||||||
}
|
}
|
||||||
|
|
||||||
$isPresenceTracking = Contract::TRACKING_PRESENCE === $employee->getContract()?->getTrackingMode();
|
$isPresenceTracking = TrackingMode::PRESENCE->value === $employee->getContract()?->getTrackingMode();
|
||||||
$normalized = $this->normalizeEntry($entry, $employeeId, $isPresenceTracking);
|
$normalized = $this->normalizeEntry($entry, $employeeId, $isPresenceTracking);
|
||||||
$existing = $existingByEmployeeId[$employeeId] ?? null;
|
$existing = $existingByEmployeeId[$employeeId] ?? null;
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,15 @@ namespace App\State;
|
|||||||
use ApiPlatform\Metadata\Operation;
|
use ApiPlatform\Metadata\Operation;
|
||||||
use ApiPlatform\State\ProviderInterface;
|
use ApiPlatform\State\ProviderInterface;
|
||||||
use App\ApiResource\WorkHourWeeklySummary;
|
use App\ApiResource\WorkHourWeeklySummary;
|
||||||
|
use App\Dto\WorkHours\WeeklyDaySummary;
|
||||||
|
use App\Dto\WorkHours\WeeklySummaryRow;
|
||||||
use App\Dto\WorkHours\WorkMetrics;
|
use App\Dto\WorkHours\WorkMetrics;
|
||||||
use App\Entity\Absence;
|
use App\Entity\Absence;
|
||||||
use App\Entity\Employee;
|
use App\Entity\Employee;
|
||||||
use App\Entity\User;
|
use App\Entity\User;
|
||||||
use App\Entity\WorkHour;
|
use App\Entity\WorkHour;
|
||||||
|
use App\Enum\ContractType;
|
||||||
|
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;
|
||||||
@@ -102,23 +106,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
* @param list<Absence> $absences
|
* @param list<Absence> $absences
|
||||||
* @param list<string> $days
|
* @param list<string> $days
|
||||||
*
|
*
|
||||||
* @return list<array{
|
* @return list<WeeklySummaryRow>
|
||||||
* employeeId:int,
|
|
||||||
* firstName:string,
|
|
||||||
* lastName:string,
|
|
||||||
* siteName:?string,
|
|
||||||
* contractName:?string,
|
|
||||||
* trackingMode:?string,
|
|
||||||
* daily:list<array{date:string, dayMinutes:int, nightMinutes:int, totalMinutes:int, present:?float}>,
|
|
||||||
* weeklyDayMinutes:int,
|
|
||||||
* weeklyNightMinutes:int,
|
|
||||||
* weeklyTotalMinutes:int,
|
|
||||||
* weeklyPresenceCount:float,
|
|
||||||
* weeklyOvertimeTotalMinutes:int,
|
|
||||||
* weeklyOvertime25Minutes:int,
|
|
||||||
* weeklyOvertime50Minutes:int,
|
|
||||||
* weeklyRecoveryMinutes:int
|
|
||||||
* }>
|
|
||||||
*/
|
*/
|
||||||
private function buildRows(array $employees, array $workHours, array $absences, array $days): array
|
private function buildRows(array $employees, array $workHours, array $absences, array $days): array
|
||||||
{
|
{
|
||||||
@@ -140,6 +128,9 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
|
|
||||||
$creditedByEmployeeDate = [];
|
$creditedByEmployeeDate = [];
|
||||||
$creditedPresenceByEmployeeDate = [];
|
$creditedPresenceByEmployeeDate = [];
|
||||||
|
$absenceByEmployeeDate = [];
|
||||||
|
$absenceLabelByEmployeeDate = [];
|
||||||
|
$absenceColorByEmployeeDate = [];
|
||||||
foreach ($absences as $absence) {
|
foreach ($absences as $absence) {
|
||||||
$employeeId = $absence->getEmployee()?->getId();
|
$employeeId = $absence->getEmployee()?->getId();
|
||||||
if (!$employeeId) {
|
if (!$employeeId) {
|
||||||
@@ -154,7 +145,16 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
[$absentMorning, $absentAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date);
|
[$absentMorning, $absentAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date);
|
||||||
|
if ($absentMorning || $absentAfternoon) {
|
||||||
|
$absenceByEmployeeDate[$employeeId][$date] = true;
|
||||||
|
if (!isset($absenceLabelByEmployeeDate[$employeeId][$date])) {
|
||||||
|
$absenceLabelByEmployeeDate[$employeeId][$date] = $absence->getType()?->getLabel();
|
||||||
|
}
|
||||||
|
if (!isset($absenceColorByEmployeeDate[$employeeId][$date])) {
|
||||||
|
$absenceColorByEmployeeDate[$employeeId][$date] = $absence->getType()?->getColor();
|
||||||
|
}
|
||||||
|
}
|
||||||
$creditedByEmployeeDate[$employeeId][$date] = ($creditedByEmployeeDate[$employeeId][$date] ?? 0)
|
$creditedByEmployeeDate[$employeeId][$date] = ($creditedByEmployeeDate[$employeeId][$date] ?? 0)
|
||||||
+ $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $absentMorning, $absentAfternoon);
|
+ $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $absentMorning, $absentAfternoon);
|
||||||
$creditedPresenceByEmployeeDate[$employeeId][$date] = ($creditedPresenceByEmployeeDate[$employeeId][$date] ?? 0.0)
|
$creditedPresenceByEmployeeDate[$employeeId][$date] = ($creditedPresenceByEmployeeDate[$employeeId][$date] ?? 0.0)
|
||||||
@@ -175,7 +175,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
$weeklyPresenceCount = 0.0;
|
$weeklyPresenceCount = 0.0;
|
||||||
$daily = [];
|
$daily = [];
|
||||||
// Les contrats au suivi "présence" ne manipulent pas les heures, mais des demi-journées.
|
// Les contrats au suivi "présence" ne manipulent pas les heures, mais des demi-journées.
|
||||||
$isPresenceTracking = 'PRESENCE' === $employee->getContract()?->getTrackingMode();
|
$isPresenceTracking = TrackingMode::PRESENCE->value === $employee->getContract()?->getTrackingMode();
|
||||||
|
|
||||||
foreach ($days as $date) {
|
foreach ($days as $date) {
|
||||||
$entry = $metricsByEmployeeDate[$employeeId][$date] ?? null;
|
$entry = $metricsByEmployeeDate[$employeeId][$date] ?? null;
|
||||||
@@ -198,46 +198,51 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
$weeklyPresenceCount += $present;
|
$weeklyPresenceCount += $present;
|
||||||
}
|
}
|
||||||
|
|
||||||
$daily[] = [
|
$daily[] = new WeeklyDaySummary(
|
||||||
'date' => $date,
|
date: $date,
|
||||||
'dayMinutes' => $metrics->dayMinutes,
|
dayMinutes: $metrics->dayMinutes,
|
||||||
'nightMinutes' => $metrics->nightMinutes,
|
nightMinutes: $metrics->nightMinutes,
|
||||||
'totalMinutes' => $metrics->totalMinutes,
|
totalMinutes: $metrics->totalMinutes,
|
||||||
'present' => $present,
|
present: $present,
|
||||||
];
|
hasAbsence: $absenceByEmployeeDate[$employeeId][$date] ?? false,
|
||||||
|
absenceLabel: $absenceLabelByEmployeeDate[$employeeId][$date] ?? null,
|
||||||
|
absenceColor: $absenceColorByEmployeeDate[$employeeId][$date] ?? null,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$contractWeeklyHours = $employee->getContract()?->getWeeklyHours();
|
$contractWeeklyHours = $employee->getContract()?->getWeeklyHours();
|
||||||
|
$disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($employee);
|
||||||
$weeklyOvertimeTotalMinutes = $isPresenceTracking
|
$weeklyOvertimeTotalMinutes = $isPresenceTracking
|
||||||
? 0
|
? 0
|
||||||
: $this->computeOvertimeTotalMinutes($weeklyTotalMinutes, $contractWeeklyHours);
|
: $this->computeOvertimeTotalMinutes($weeklyTotalMinutes, $contractWeeklyHours);
|
||||||
$weeklyOvertime25Minutes = $isPresenceTracking
|
$weeklyOvertime25Minutes = ($isPresenceTracking || $disableOvertimeBonuses)
|
||||||
? 0
|
? 0
|
||||||
: $this->computeOvertime25BonusMinutes($weeklyTotalMinutes, $contractWeeklyHours);
|
: $this->computeOvertime25BonusMinutes($weeklyTotalMinutes, $contractWeeklyHours);
|
||||||
$weeklyOvertime50Minutes = $isPresenceTracking
|
$weeklyOvertime50Minutes = ($isPresenceTracking || $disableOvertimeBonuses)
|
||||||
? 0
|
? 0
|
||||||
: $this->computeOvertime50BonusMinutes($weeklyTotalMinutes);
|
: $this->computeOvertime50BonusMinutes($weeklyTotalMinutes);
|
||||||
$weeklyRecoveryMinutes = $isPresenceTracking
|
$weeklyRecoveryMinutes = ($isPresenceTracking || $disableOvertimeBonuses)
|
||||||
? 0
|
? 0
|
||||||
: $weeklyOvertimeTotalMinutes + $weeklyOvertime25Minutes + $weeklyOvertime50Minutes;
|
: $weeklyOvertimeTotalMinutes + $weeklyOvertime25Minutes + $weeklyOvertime50Minutes;
|
||||||
|
|
||||||
$rows[] = [
|
$rows[] = new WeeklySummaryRow(
|
||||||
'employeeId' => $employeeId,
|
employeeId: $employeeId,
|
||||||
'firstName' => $employee->getFirstName(),
|
firstName: $employee->getFirstName(),
|
||||||
'lastName' => $employee->getLastName(),
|
lastName: $employee->getLastName(),
|
||||||
'siteName' => $employee->getSite()?->getName(),
|
siteName: $employee->getSite()?->getName(),
|
||||||
'contractName' => $employee->getContract()?->getName(),
|
contractName: $employee->getContract()?->getName(),
|
||||||
'trackingMode' => $employee->getContract()?->getTrackingMode(),
|
contractType: $employee->getContract()?->getType()->value,
|
||||||
'daily' => $daily,
|
trackingMode: $employee->getContract()?->getTrackingMode(),
|
||||||
'weeklyDayMinutes' => $weeklyDayMinutes,
|
daily: $daily,
|
||||||
'weeklyNightMinutes' => $weeklyNightMinutes,
|
weeklyDayMinutes: $weeklyDayMinutes,
|
||||||
'weeklyTotalMinutes' => $weeklyTotalMinutes,
|
weeklyNightMinutes: $weeklyNightMinutes,
|
||||||
'weeklyPresenceCount' => $weeklyPresenceCount,
|
weeklyTotalMinutes: $weeklyTotalMinutes,
|
||||||
'weeklyOvertimeTotalMinutes' => $weeklyOvertimeTotalMinutes,
|
weeklyPresenceCount: $weeklyPresenceCount,
|
||||||
'weeklyOvertime25Minutes' => $weeklyOvertime25Minutes,
|
weeklyOvertimeTotalMinutes: $weeklyOvertimeTotalMinutes,
|
||||||
'weeklyOvertime50Minutes' => $weeklyOvertime50Minutes,
|
weeklyOvertime25Minutes: $weeklyOvertime25Minutes,
|
||||||
'weeklyRecoveryMinutes' => $weeklyRecoveryMinutes,
|
weeklyOvertime50Minutes: $weeklyOvertime50Minutes,
|
||||||
];
|
weeklyRecoveryMinutes: $weeklyRecoveryMinutes
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $rows;
|
return $rows;
|
||||||
@@ -369,4 +374,16 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
|||||||
|
|
||||||
return (int) round($trancheMinutes * 0.5);
|
return (int) round($trancheMinutes * 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function hasDisabledOvertimeBonuses(Employee $employee): bool
|
||||||
|
{
|
||||||
|
$contract = $employee->getContract();
|
||||||
|
$type = ContractType::resolve(
|
||||||
|
$contract?->getName(),
|
||||||
|
$contract?->getTrackingMode(),
|
||||||
|
$contract?->getWeeklyHours()
|
||||||
|
);
|
||||||
|
|
||||||
|
return ContractType::INTERIM === $type;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,8 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
|
|||||||
$user = new User();
|
$user = new User();
|
||||||
$timeEmployee = $this->buildEmployee(1, 'TIME', 35, 'Alice');
|
$timeEmployee = $this->buildEmployee(1, 'TIME', 35, 'Alice');
|
||||||
$presenceEmployee = $this->buildEmployee(2, 'PRESENCE', null, 'Bob');
|
$presenceEmployee = $this->buildEmployee(2, 'PRESENCE', null, 'Bob');
|
||||||
$employees = [$timeEmployee, $presenceEmployee];
|
$interimEmployee = $this->buildEmployee(3, 'TIME', 35, 'Charly', 'Interim');
|
||||||
|
$employees = [$timeEmployee, $presenceEmployee, $interimEmployee];
|
||||||
|
|
||||||
$workHours = [];
|
$workHours = [];
|
||||||
foreach (['2026-02-16', '2026-02-17', '2026-02-18', '2026-02-19', '2026-02-20'] as $date) {
|
foreach (['2026-02-16', '2026-02-17', '2026-02-18', '2026-02-19', '2026-02-20'] as $date) {
|
||||||
@@ -80,6 +81,12 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
|
|||||||
->setMorningFrom('09:00')
|
->setMorningFrom('09:00')
|
||||||
->setMorningTo('19:00')
|
->setMorningTo('19:00')
|
||||||
;
|
;
|
||||||
|
$workHours[] = new WorkHour()
|
||||||
|
->setEmployee($interimEmployee)
|
||||||
|
->setWorkDate(new DateTimeImmutable($date))
|
||||||
|
->setMorningFrom('09:00')
|
||||||
|
->setMorningTo('19:00')
|
||||||
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
$absenceType = new AbsenceType()
|
$absenceType = new AbsenceType()
|
||||||
@@ -117,22 +124,29 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
|
|||||||
|
|
||||||
self::assertSame('2026-02-16', $result->weekStart);
|
self::assertSame('2026-02-16', $result->weekStart);
|
||||||
self::assertSame('2026-02-22', $result->weekEnd);
|
self::assertSame('2026-02-22', $result->weekEnd);
|
||||||
self::assertCount(2, $result->rows);
|
self::assertCount(3, $result->rows);
|
||||||
|
|
||||||
self::assertSame(3000, $result->rows[0]['weeklyTotalMinutes']);
|
self::assertSame(3000, $result->rows[0]->weeklyTotalMinutes);
|
||||||
self::assertSame(900, $result->rows[0]['weeklyOvertimeTotalMinutes']);
|
self::assertSame(900, $result->rows[0]->weeklyOvertimeTotalMinutes);
|
||||||
self::assertSame(120, $result->rows[0]['weeklyOvertime25Minutes']);
|
self::assertSame(120, $result->rows[0]->weeklyOvertime25Minutes);
|
||||||
self::assertSame(210, $result->rows[0]['weeklyOvertime50Minutes']);
|
self::assertSame(210, $result->rows[0]->weeklyOvertime50Minutes);
|
||||||
self::assertSame(1230, $result->rows[0]['weeklyRecoveryMinutes']);
|
self::assertSame(1230, $result->rows[0]->weeklyRecoveryMinutes);
|
||||||
|
|
||||||
self::assertSame(1.0, $result->rows[1]['weeklyPresenceCount']);
|
self::assertSame(1.0, $result->rows[1]->weeklyPresenceCount);
|
||||||
self::assertSame(0, $result->rows[1]['weeklyOvertimeTotalMinutes']);
|
self::assertTrue($result->rows[1]->daily[0]->hasAbsence);
|
||||||
|
self::assertSame('Congé', $result->rows[1]->daily[0]->absenceLabel);
|
||||||
|
self::assertSame('#000', $result->rows[1]->daily[0]->absenceColor);
|
||||||
|
self::assertSame(0, $result->rows[1]->weeklyOvertimeTotalMinutes);
|
||||||
|
self::assertSame(0, $result->rows[2]->weeklyOvertime25Minutes);
|
||||||
|
self::assertSame(0, $result->rows[2]->weeklyOvertime50Minutes);
|
||||||
|
self::assertSame(0, $result->rows[2]->weeklyRecoveryMinutes);
|
||||||
|
self::assertSame(900, $result->rows[2]->weeklyOvertimeTotalMinutes);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function buildEmployee(int $id, string $trackingMode, ?int $weeklyHours, string $firstName): Employee
|
private function buildEmployee(int $id, string $trackingMode, ?int $weeklyHours, string $firstName, ?string $contractName = null): Employee
|
||||||
{
|
{
|
||||||
$contract = new Contract()
|
$contract = new Contract()
|
||||||
->setName($trackingMode)
|
->setName($contractName ?? $trackingMode)
|
||||||
->setTrackingMode($trackingMode)
|
->setTrackingMode($trackingMode)
|
||||||
->setWeeklyHours($weeklyHours)
|
->setWeeklyHours($weeklyHours)
|
||||||
;
|
;
|
||||||
|
|||||||
Reference in New Issue
Block a user