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">
<EmployeeNameFilterInput v-model="employeeFilter" />
</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>
</template>
<script setup lang="ts">
import type { Site } from '~/services/dto/site'
import type { AbsenceType } from '~/services/dto/absence-type'
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
import { weekInputValueToYmd, ymdToWeekInputValue } from '~/utils/date'
@@ -143,6 +155,7 @@ const employeeFilter = defineModel<string>('employeeFilter', { required: true })
defineProps<{
isAdmin: boolean
sites: Site[]
absenceTypes: AbsenceType[]
formattedSelectedDate: string
shortcutButtonClass: (target: 'yesterday' | 'today' | 'tomorrow') => 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>
</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-else>
<div>J {{ formatMinutes(daily.dayMinutes) }}</div>
@@ -52,13 +59,13 @@
{{ row.trackingMode === 'PRESENCE' ? '-' : formatMinutes(row.weeklyOvertimeTotalMinutes ?? 0) }}
</div>
<div class="font-semibold">
{{ row.trackingMode === 'PRESENCE' ? '-' : formatMinutes(row.weeklyOvertime25Minutes ?? 0) }}
{{ row.trackingMode === 'PRESENCE' || isInterimContract(row.contractType) ? '-' : formatMinutes(row.weeklyOvertime25Minutes ?? 0) }}
</div>
<div class="font-semibold">
{{ row.trackingMode === 'PRESENCE' ? '-' : formatMinutes(row.weeklyOvertime50Minutes ?? 0) }}
{{ row.trackingMode === 'PRESENCE' || isInterimContract(row.contractType) ? '-' : formatMinutes(row.weeklyOvertime50Minutes ?? 0) }}
</div>
<div class="font-semibold">
{{ row.trackingMode === 'PRESENCE' ? '-' : formatMinutes(row.weeklyRecoveryMinutes ?? 0) }}
{{ row.trackingMode === 'PRESENCE' || isInterimContract(row.contractType) ? '-' : formatMinutes(row.weeklyRecoveryMinutes ?? 0) }}
</div>
</div>
</div>
@@ -67,6 +74,19 @@
<script setup lang="ts">
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<{
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 { Absence } from '~/services/dto/absence'
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 { listScopedEmployees } from '~/services/employees'
import { listAbsenceTypes } from '~/services/absence-types'
@@ -275,14 +276,17 @@ export const useHoursPage = () => {
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 isRowLocked = (employeeId: number) => rows.value[employeeId]?.isValid ?? false
const contractLabel = (employee: Employee) => {
const contract = employee.contract
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.name
@@ -392,7 +396,7 @@ export const useHoursPage = () => {
const isEveningLockedByAbsence = (employeeId: number) => {
const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (!dayRow) return false
return dayRow.absentMorning && dayRow.absentAfternoon
return dayRow.absentAfternoon
}
const formatMinutes = (minutes: number) => {
@@ -475,6 +479,42 @@ export const useHoursPage = () => {
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 form = absenceForm.value
if (isAbsenceSubmitting.value || form.employeeId === '' || form.typeId === '') return
@@ -504,9 +544,9 @@ export const useHoursPage = () => {
})
}
applyLocalClearFromAbsence(Number(form.employeeId), form.startHalf, form.endHalf)
closeAbsenceDrawer()
await refreshByDate()
await loadAbsences()
await refreshAfterAbsenceChange()
} finally {
isAbsenceSubmitting.value = false
}
@@ -519,8 +559,7 @@ export const useHoursPage = () => {
try {
await deleteAbsence(editingAbsence.value.id)
closeAbsenceDrawer()
await refreshByDate()
await loadAbsences()
await refreshAfterAbsenceChange()
} finally {
isAbsenceSubmitting.value = false
}

View File

@@ -6,17 +6,10 @@
<img src="/malio.png" alt="Logo" class="w-auto"/>
</div>
<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">
<NuxtLink
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"
>
Tableau de bord
@@ -28,6 +21,15 @@
>
Calendrier
</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
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"

View File

@@ -11,6 +11,7 @@
v-model:employee-filter="employeeFilter"
:is-admin="isAdmin"
:sites="sites"
:absence-types="absenceTypes"
:formatted-selected-date="formattedSelectedDate"
:shortcut-button-class="shortcutButtonClass"
: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 = {
id: number
name: string
trackingMode: 'TIME' | 'PRESENCE'
trackingMode: TrackingMode
type: ContractType
weeklyHours?: number | null
isActive?: boolean
}

View File

@@ -1,4 +1,5 @@
import type { Employee } from './employee'
import type { ContractType, TrackingMode } from './contract'
export type WorkHour = {
id: number
@@ -33,6 +34,9 @@ export type WeeklyWorkHourDailySummary = {
nightMinutes: number
totalMinutes: number
present?: number | null
hasAbsence?: boolean
absenceLabel?: string | null
absenceColor?: string | null
}
export type WeeklyWorkHourRowSummary = {
@@ -41,7 +45,8 @@ export type WeeklyWorkHourRowSummary = {
lastName: string
siteName?: string | null
contractName?: string | null
trackingMode?: 'TIME' | 'PRESENCE' | null
contractType?: ContractType | null
trackingMode?: TrackingMode | null
daily: WeeklyWorkHourDailySummary[]
weeklyDayMinutes: number
weeklyNightMinutes: number

View File

@@ -6,6 +6,7 @@ namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\Dto\WorkHours\WeeklySummaryRow;
use App\State\WorkHourWeeklySummaryProvider;
#[ApiResource(
@@ -26,30 +27,6 @@ final class WorkHourWeeklySummary
/** @var list<string> */
public array $days = [];
/**
* @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
* }>
*/
/** @var list<WeeklySummaryRow> */
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;
use ApiPlatform\Metadata\ApiResource;
use App\Enum\ContractType;
use App\Enum\TrackingMode;
use Doctrine\ORM\Mapping as ORM;
use InvalidArgumentException;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
@@ -18,8 +21,8 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Table(name: 'contracts')]
class Contract
{
public const string TRACKING_TIME = 'TIME';
public const string TRACKING_PRESENCE = 'PRESENCE';
public const string TRACKING_TIME = TrackingMode::TIME->value;
public const string TRACKING_PRESENCE = TrackingMode::PRESENCE->value;
#[ORM\Id]
#[ORM\GeneratedValue]
@@ -65,13 +68,29 @@ class Contract
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;
}
#[Groups(['contract:read', 'employee:read'])]
public function getType(): ContractType
{
return ContractType::resolve($this->name, $this->trackingMode, $this->weeklyHours);
}
public function getWeeklyHours(): ?int
{
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 findOneByEmployeeAndDate(Employee $employee, DateTimeInterface $date): ?WorkHour;
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;
}
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;
use App\Entity\Absence;
use App\Entity\Contract;
use App\Enum\TrackingMode;
use DateMalformedStringException;
use DateTimeImmutable;
@@ -24,7 +24,7 @@ final class WorkedHoursCreditPolicy
$employee = $absence->getEmployee();
// 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;
}
@@ -49,7 +49,7 @@ final class WorkedHoursCreditPolicy
}
$employee = $absence->getEmployee();
if (Contract::TRACKING_PRESENCE !== $employee?->getContract()?->getTrackingMode()) {
if (TrackingMode::PRESENCE->value !== $employee?->getContract()?->getTrackingMode()) {
return 0.0;
}

View File

@@ -8,6 +8,7 @@ use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Absence;
use App\Entity\Employee;
use App\Enum\HalfDay;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
@@ -81,6 +82,7 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
->setStartHalf($first['startHalf'])
->setEndHalf($first['endHalf'])
;
$this->clearWorkHoursForSegment($employee, $first);
$this->entityManager->persist($data);
foreach ($segments as $segment) {
@@ -94,6 +96,7 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
->setEndHalf($segment['endHalf'])
;
$this->clearWorkHoursForSegment($employee, $segment);
$this->entityManager->persist($absence);
}
@@ -174,4 +177,47 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
{
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 App\ApiResource\WorkHourBulkUpsert;
use App\ApiResource\WorkHourBulkUpsertResult;
use App\Entity\Contract;
use App\Entity\User;
use App\Entity\WorkHour;
use App\Enum\TrackingMode;
use App\Repository\EmployeeRepository;
use App\Repository\WorkHourRepository;
use DateTimeImmutable;
@@ -75,7 +75,7 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
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);
$existing = $existingByEmployeeId[$employeeId] ?? null;

View File

@@ -7,11 +7,15 @@ namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\WorkHourWeeklySummary;
use App\Dto\WorkHours\WeeklyDaySummary;
use App\Dto\WorkHours\WeeklySummaryRow;
use App\Dto\WorkHours\WorkMetrics;
use App\Entity\Absence;
use App\Entity\Employee;
use App\Entity\User;
use App\Entity\WorkHour;
use App\Enum\ContractType;
use App\Enum\TrackingMode;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
@@ -102,23 +106,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
* @param list<Absence> $absences
* @param list<string> $days
*
* @return 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
* }>
* @return list<WeeklySummaryRow>
*/
private function buildRows(array $employees, array $workHours, array $absences, array $days): array
{
@@ -140,6 +128,9 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
$creditedByEmployeeDate = [];
$creditedPresenceByEmployeeDate = [];
$absenceByEmployeeDate = [];
$absenceLabelByEmployeeDate = [];
$absenceColorByEmployeeDate = [];
foreach ($absences as $absence) {
$employeeId = $absence->getEmployee()?->getId();
if (!$employeeId) {
@@ -154,7 +145,16 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
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)
+ $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $absentMorning, $absentAfternoon);
$creditedPresenceByEmployeeDate[$employeeId][$date] = ($creditedPresenceByEmployeeDate[$employeeId][$date] ?? 0.0)
@@ -175,7 +175,7 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
$weeklyPresenceCount = 0.0;
$daily = [];
// 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) {
$entry = $metricsByEmployeeDate[$employeeId][$date] ?? null;
@@ -198,46 +198,51 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
$weeklyPresenceCount += $present;
}
$daily[] = [
'date' => $date,
'dayMinutes' => $metrics->dayMinutes,
'nightMinutes' => $metrics->nightMinutes,
'totalMinutes' => $metrics->totalMinutes,
'present' => $present,
];
$daily[] = new WeeklyDaySummary(
date: $date,
dayMinutes: $metrics->dayMinutes,
nightMinutes: $metrics->nightMinutes,
totalMinutes: $metrics->totalMinutes,
present: $present,
hasAbsence: $absenceByEmployeeDate[$employeeId][$date] ?? false,
absenceLabel: $absenceLabelByEmployeeDate[$employeeId][$date] ?? null,
absenceColor: $absenceColorByEmployeeDate[$employeeId][$date] ?? null,
);
}
$contractWeeklyHours = $employee->getContract()?->getWeeklyHours();
$disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($employee);
$weeklyOvertimeTotalMinutes = $isPresenceTracking
? 0
: $this->computeOvertimeTotalMinutes($weeklyTotalMinutes, $contractWeeklyHours);
$weeklyOvertime25Minutes = $isPresenceTracking
$weeklyOvertime25Minutes = ($isPresenceTracking || $disableOvertimeBonuses)
? 0
: $this->computeOvertime25BonusMinutes($weeklyTotalMinutes, $contractWeeklyHours);
$weeklyOvertime50Minutes = $isPresenceTracking
$weeklyOvertime50Minutes = ($isPresenceTracking || $disableOvertimeBonuses)
? 0
: $this->computeOvertime50BonusMinutes($weeklyTotalMinutes);
$weeklyRecoveryMinutes = $isPresenceTracking
$weeklyRecoveryMinutes = ($isPresenceTracking || $disableOvertimeBonuses)
? 0
: $weeklyOvertimeTotalMinutes + $weeklyOvertime25Minutes + $weeklyOvertime50Minutes;
$rows[] = [
'employeeId' => $employeeId,
'firstName' => $employee->getFirstName(),
'lastName' => $employee->getLastName(),
'siteName' => $employee->getSite()?->getName(),
'contractName' => $employee->getContract()?->getName(),
'trackingMode' => $employee->getContract()?->getTrackingMode(),
'daily' => $daily,
'weeklyDayMinutes' => $weeklyDayMinutes,
'weeklyNightMinutes' => $weeklyNightMinutes,
'weeklyTotalMinutes' => $weeklyTotalMinutes,
'weeklyPresenceCount' => $weeklyPresenceCount,
'weeklyOvertimeTotalMinutes' => $weeklyOvertimeTotalMinutes,
'weeklyOvertime25Minutes' => $weeklyOvertime25Minutes,
'weeklyOvertime50Minutes' => $weeklyOvertime50Minutes,
'weeklyRecoveryMinutes' => $weeklyRecoveryMinutes,
];
$rows[] = new WeeklySummaryRow(
employeeId: $employeeId,
firstName: $employee->getFirstName(),
lastName: $employee->getLastName(),
siteName: $employee->getSite()?->getName(),
contractName: $employee->getContract()?->getName(),
contractType: $employee->getContract()?->getType()->value,
trackingMode: $employee->getContract()?->getTrackingMode(),
daily: $daily,
weeklyDayMinutes: $weeklyDayMinutes,
weeklyNightMinutes: $weeklyNightMinutes,
weeklyTotalMinutes: $weeklyTotalMinutes,
weeklyPresenceCount: $weeklyPresenceCount,
weeklyOvertimeTotalMinutes: $weeklyOvertimeTotalMinutes,
weeklyOvertime25Minutes: $weeklyOvertime25Minutes,
weeklyOvertime50Minutes: $weeklyOvertime50Minutes,
weeklyRecoveryMinutes: $weeklyRecoveryMinutes
);
}
return $rows;
@@ -369,4 +374,16 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
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();
$timeEmployee = $this->buildEmployee(1, 'TIME', 35, 'Alice');
$presenceEmployee = $this->buildEmployee(2, 'PRESENCE', null, 'Bob');
$employees = [$timeEmployee, $presenceEmployee];
$interimEmployee = $this->buildEmployee(3, 'TIME', 35, 'Charly', 'Interim');
$employees = [$timeEmployee, $presenceEmployee, $interimEmployee];
$workHours = [];
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')
->setMorningTo('19:00')
;
$workHours[] = new WorkHour()
->setEmployee($interimEmployee)
->setWorkDate(new DateTimeImmutable($date))
->setMorningFrom('09:00')
->setMorningTo('19:00')
;
}
$absenceType = new AbsenceType()
@@ -117,22 +124,29 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
self::assertSame('2026-02-16', $result->weekStart);
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(900, $result->rows[0]['weeklyOvertimeTotalMinutes']);
self::assertSame(120, $result->rows[0]['weeklyOvertime25Minutes']);
self::assertSame(210, $result->rows[0]['weeklyOvertime50Minutes']);
self::assertSame(1230, $result->rows[0]['weeklyRecoveryMinutes']);
self::assertSame(3000, $result->rows[0]->weeklyTotalMinutes);
self::assertSame(900, $result->rows[0]->weeklyOvertimeTotalMinutes);
self::assertSame(120, $result->rows[0]->weeklyOvertime25Minutes);
self::assertSame(210, $result->rows[0]->weeklyOvertime50Minutes);
self::assertSame(1230, $result->rows[0]->weeklyRecoveryMinutes);
self::assertSame(1.0, $result->rows[1]['weeklyPresenceCount']);
self::assertSame(0, $result->rows[1]['weeklyOvertimeTotalMinutes']);
self::assertSame(1.0, $result->rows[1]->weeklyPresenceCount);
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()
->setName($trackingMode)
->setName($contractName ?? $trackingMode)
->setTrackingMode($trackingMode)
->setWeeklyHours($weeklyHours)
;