fix : affichage des absences dans les heures vue semaine + refacto

This commit is contained in:
2026-02-20 12:20:34 +01:00
parent d9fa301159
commit fb52be128e
21 changed files with 513 additions and 112 deletions

105
AGENTS.md Normal file
View 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 dheures 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 dabsence (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 laffichage/interaction et réutilise les données enrichies de lAPI.

View File

@@ -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

View File

@@ -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

View File

@@ -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
} }

View File

@@ -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"

View File

@@ -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"

View File

@@ -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
} }

View File

@@ -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

View File

@@ -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 = [];
} }

View 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,
) {}
}

View 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,
) {}
}

View File

@@ -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
View 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
View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Enum;
enum TrackingMode: string
{
case TIME = 'TIME';
case PRESENCE = 'PRESENCE';
}

View File

@@ -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;
} }

View File

@@ -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();
}
} }

View File

@@ -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;
} }

View File

@@ -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)
;
}
} }

View File

@@ -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;

View File

@@ -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;
}
} }

View File

@@ -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)
; ;