diff --git a/CLAUDE.md b/CLAUDE.md index 290894f..4ac3718 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,7 @@ - Contract nature (per period): CDI, CDD, INTERIM - **Agence d'intérim** (`InterimAgency` entity, table `interim_agencies`): optionnelle sur `EmployeeContractPeriod` quand nature = INTERIM. Pas de CRUD UI — gérée en BDD. API lecture seule `GET /interim_agencies`. Affichée "Intérim (NomAgence)" sur la liste employés et l'historique contrat. - Employee contract history: `employee_contract_periods`, resolved by `EmployeeContractResolver` -- **Écrans Heures / Heures Conducteurs (vue jour)** : le libellé nature (CDI/CDD/Intérim) sous le nom de l'employé est résolu **à la date filtrée** via `WorkHourDayContext.contractNature` (alimenté par `EmployeeContractResolver::resolveNatureForEmployeeAndDate`), pas via `Employee.currentContractNature` (qui est résolu à aujourd'hui). Idem pour le **mode de suivi (TIME/PRESENCE), les heures hebdo et le libellé de contrat** sur la vue Jour : résolus à la date filtrée via `WorkHourDayContext` (`trackingMode`/`weeklyHours`/`contractType`/`contractName`, peuplés depuis `EmployeeContractResolver::resolveForEmployeeAndDate`), pas via `employee.contract` (résolu à aujourd'hui). Côté front, `resolveDayContract()` (`useHoursPage.ts`) pilote l'affichage et `handleSave` (heures vs présence par date). +- **Écrans Heures / Heures Conducteurs (vue jour)** : le libellé nature (CDI/CDD/Intérim) sous le nom de l'employé est résolu **à la date filtrée** via `WorkHourDayContext.contractNature` (alimenté par `EmployeeContractResolver::resolveNatureForEmployeeAndDate`), pas via `Employee.currentContractNature` (qui est résolu à aujourd'hui). Idem pour le **mode de suivi (TIME/PRESENCE), les heures hebdo et le libellé de contrat** sur la vue Jour : résolus à la date filtrée via `WorkHourDayContext` (`trackingMode`/`weeklyHours`/`contractType`/`contractName`, peuplés depuis `EmployeeContractResolver::resolveForEmployeeAndDate`), pas via `employee.contract` (résolu à aujourd'hui). Côté front, `resolveDayContract()` (`useHoursPage.ts`) pilote l'affichage et `handleSave` (heures vs présence par date). **Jours travaillés (CUSTOM)** : le libellé sous le nom affiche en suffixe les jours du planning `workDaysHours` au format court `LU,MA,ME,JE,VE` (ex. `BUREAU — CDI — LU,JE`). Exposé via `WorkHourDayContext.workDaysHours` (peuplé par `EmployeeContractResolver::resolveWorkDaysMinutesForEmployeeAndDate`, à la date filtrée), formaté front par `formatWorkedDaysShort` (`utils/contract.ts`) et accédé via `getRowWorkedDaysLabel` (`useHoursPage.ts`). Affiché **uniquement écran Heures** (`HoursDayView.vue`, mobile + desktop) ; naturellement limité aux CUSTOM (seuls eux ont `workDaysHours` → null sinon, rien affiché). Pas sur Heures Conducteurs (pas de planning workDaysHours). - **Exports heures annuelles** (par salarié `EmployeeYearlyHoursPrintProvider` + tous `EmployeeYearlyHoursBulkPrintProvider`, via `YearlyHoursExportBuilder`) : **tous les jours sous contrat sont affichés**, même vides ou non saisis (jusqu'à aujourd'hui). Seuls les jours hors contrat sont omis (`buildSegments` : un seul filtre `!$hasData && null === $contract`). Ne pas réintroduire de saut des jours de semaine vides. Samedis/dimanches grisés (`#c0c0c0`) dans les templates `employee-yearly-hours/print*.html.twig`. NB : l'export *tous employés* sur l'année peut dépasser `memory_limit=256M` (Dompdf) — limitation pré-existante, voir avec l'infra si besoin. - **Export heures vue Jour** (`WorkHourDayExportProvider`, endpoint `GET /work-hours/day-export?workDate=&siteIds=`, `ROLE_USER`) : bouton « Exporter » à droite du titre « Heures », **visible uniquement en vue Jour** (`v-if="(isAdmin || isSiteManager) && viewMode === 'day'"`, masqué en vue Semaine et pour `ROLE_SELF`). **Accessible aux admins ET aux chefs de site** : le périmètre est résolu côté backend via `EmployeeRepository::findScoped($user)` (admin → tous les sites, chef de site → ses sites uniquement, cf. `EmployeeScopeService`), donc un `siteIds` hors périmètre est ignoré ; le drawer front ne propose que les sites visibles (`sites` dérivé des employés scopés). PDF A4 portrait d'**une seule journée**, **regroupé par site**, colonnes de la vue Jour **sans « Valider »** (colonne **Total en gras**). Mêmes employés que l'écran : non-conducteurs, sous contrat à la date, sites cochés et dans le périmètre (lignes vides incluses). **Tri intra-site identique au calendrier** : `displayOrder` (ordre manuel), puis nom, puis prénom (cf. `compareEmployeesInSite` front). Calcul des cellules mutualisé via `YearlyHoursExportBuilder::buildDayRowsForEmployees` (Jour/Nuit/Total incluent crédit absence + crédit virtuel férié). Colonne **Statut = code** du type d'absence (`AbsenceType::getCode`, ex. `AT`) sur sa couleur de fond ; férié sans absence → nom du férié sur `#b3e5fc`. Chaque row porte `statut` (code), `statutLabel` (libellé, pour la légende) et `statutColor`. **Légende** sous le tableau (carré coloré contenant le code + libellé à droite), construite côté provider à partir des codes présents (hors férié, dédupliquée par code, triée). Gabarit `templates/work-hour-day-export/print.html.twig`. - **Export Contingent heures de nuit** (`NightHoursContingentPrintProvider`, endpoint `GET /night-hours-contingent/print?year=YYYY`, `ROLE_USER`) : option « Contingent H.nuit » du drawer Export de la liste employés. PDF **A4 paysage**, lignes = employés **groupés par site** et triés `displayOrder`/nom/prénom (comme le day-export), colonnes = 12 mois civils, chacun avec 2 sous-colonnes **H.nuit** et **N.jours**. Heures de nuit = minutes dans la fenêtre **21h→6h** via le service partagé `App\Service\WorkHours\NightHoursCalculator` (source unique mutualisée avec `WorkHourWeeklySummaryProvider`, `YearlyHoursExportBuilder`, `RttRecoveryComputationService` et `SalaryRecapPrintProvider`). Conducteurs inclus via `WorkHour.nightHoursMinutes`. **N.jours** = nb de jours où minutes de nuit ≥ 240 (4h). **Aucun crédit** absence/férié. Agrégation : `App\Service\WorkHours\NightContingentExportBuilder`. Gabarit `templates/night-hours-contingent/print.html.twig`. diff --git a/doc/functional-rules.md b/doc/functional-rules.md index 628cc88..fbc02ed 100644 --- a/doc/functional-rules.md +++ b/doc/functional-rules.md @@ -61,6 +61,7 @@ Documents complementaires: - Libellé nature de contrat (CDI/CDD/Intérim) affiché sous le nom: - résolu à la date filtrée (période de contrat couvrant ce jour), pas à aujourd'hui - masqué si aucun contrat à cette date (cas rarissime en vue jour puisque l'employé est alors déjà filtré) +- **Jours travaillés (contrats CUSTOM)** : pour un contrat CUSTOM (planning `workDaysHours` renseigné), les jours effectivement travaillés sont affichés en suffixe du libellé `Site — Nature`, au format court `LU,MA,ME,JE,VE` (ex. `BUREAU — CDI — LU,JE` pour un temps partiel travaillant lundi et jeudi). Résolu à la date filtrée. Les contrats 35h/39h/Forfait/Intérim n'ont pas de planning → aucun suffixe. Écran Heures uniquement (pas Heures Conducteurs). - **Vue Jour (Heures) — contrat à la date affichée** : le mode de suivi (saisie d'heures vs cases de présence), le libellé de contrat et la logique de sauvegarde sont résolus selon la période de contrat valable à la date filtrée (champs `trackingMode`/`weeklyHours`/`contractType`/`contractName` portés par `WorkHourDayContext`, alimentés par `EmployeeContractResolver::resolveForEmployeeAndDate`), et non selon le contrat courant de l'employé. Un salarié passé 39h/35h → Forfait conserve donc la saisie d'heures sur ses dates antérieures à la bascule, et bascule en cases de présence à partir de la date de passage en forfait. La vue Semaine était déjà résolue par date. - **Exports heures annuelles (par salarié et tous salariés)** : affichent **tous les jours sous contrat**, même vides ou non saisis, jusqu'à la date du jour ; seuls les jours hors contrat (avant embauche, après départ, suspension) sont omis. Les samedis et dimanches sont grisés (gris foncé), les jours fériés en bleu. - **Récap salaire (export PDF mensuel)** : seuls les salariés ayant un contrat couvrant tout ou partie du mois imprimé apparaissent (filtre `hasContractInRange`). Un salarié dont le contrat est terminé avant le mois (ex. parti en février) n'est pas listé sur le récap des mois suivants. diff --git a/frontend/components/hours/HoursDayView.vue b/frontend/components/hours/HoursDayView.vue index 21e9c2b..4da82a7 100644 --- a/frontend/components/hours/HoursDayView.vue +++ b/frontend/components/hours/HoursDayView.vue @@ -14,7 +14,7 @@ ({{ contractLabel(employee) }})

- {{ employee.site?.name ?? 'Sans site' }} — {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }} + {{ employee.site?.name ?? 'Sans site' }} — {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }} — {{ getRowWorkedDaysLabel(employee.id) }}

@@ -212,7 +212,7 @@

- {{ employee.site?.name ?? 'Sans site' }} — {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }} + {{ employee.site?.name ?? 'Sans site' }} — {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }} — {{ getRowWorkedDaysLabel(employee.id) }} boolean getRowFormationLabel: (employeeId: number) => string getRowContractNature: (employeeId: number) => 'CDI' | 'CDD' | 'INTERIM' | null + getRowWorkedDaysLabel: (employeeId: number) => string | null getRowUpdatedAt: (employeeId: number) => string getPresenceDayValue: (employeeId: number) => string onAbsenceClick: (employeeId: number) => void diff --git a/frontend/composables/useHoursPage.ts b/frontend/composables/useHoursPage.ts index f571c22..2908abc 100644 --- a/frontend/composables/useHoursPage.ts +++ b/frontend/composables/useHoursPage.ts @@ -517,6 +517,11 @@ export const useHoursPage = () => { return dayContextByEmployeeId.value.get(employeeId)?.contractNature ?? null } + // Jours travaillés du planning (contrats CUSTOM uniquement), ex. "LU,VE". null sinon. + const getRowWorkedDaysLabel = (employeeId: number): string | null => { + return formatWorkedDaysShort(dayContextByEmployeeId.value.get(employeeId)?.workDaysHours) + } + const getRowUpdatedAt = (employeeId: number): string => { const raw = rows.value[employeeId]?.updatedAt if (!raw) return '' @@ -1207,6 +1212,7 @@ export const useHoursPage = () => { hasRowFormation, getRowFormationLabel, getRowContractNature, + getRowWorkedDaysLabel, getRowUpdatedAt, getPresenceDayValue, openAbsenceDrawer, diff --git a/frontend/data/documentation-content.ts b/frontend/data/documentation-content.ts index 96a49bf..d5ab1b0 100644 --- a/frontend/data/documentation-content.ts +++ b/frontend/data/documentation-content.ts @@ -29,6 +29,7 @@ export const documentationSections: DocSection[] = [ { type: 'list', content: 'Boutons "Hier" / "Aujourd\'hui" / "Demain" pour naviguer rapidement\nSélecteur de date pour choisir une date spécifique\nFiltrage par site si vous avez accès à plusieurs sites' }, { type: 'paragraph', content: 'Seuls les employés ayant un contrat actif à la date sélectionnée sont affichés.' }, { type: 'note', content: 'Sous le nom de l\'employé, la nature du contrat (CDI / CDD / Intérim) affichée correspond à la période couvrant la date filtrée, et non à aujourd\'hui.' }, + { type: 'paragraph', content: 'Pour un contrat à temps partiel avec un planning de jours travaillés (contrat « personnalisé »), les jours travaillés sont rappelés à la suite du libellé site et nature, en abrégé : par exemple « BUREAU — CDI — LU,JE » pour un salarié travaillant le lundi et le jeudi. Les contrats 35h, 39h, forfait et intérim n\'affichent pas ce rappel.' }, { type: 'paragraph', content: 'Sur la vue Jour, l\'affichage (saisie d\'heures ou cases de présence) et le libellé de contrat correspondent au contrat de l\'employé à la date consultée. Si un salarié a changé de type de contrat (par exemple un passage en forfait), les jours antérieurs à ce changement restent affichés selon l\'ancien contrat.' }, ], }, diff --git a/frontend/pages/hours.vue b/frontend/pages/hours.vue index 4167499..0c43c1c 100644 --- a/frontend/pages/hours.vue +++ b/frontend/pages/hours.vue @@ -87,6 +87,7 @@ :has-row-formation="hasRowFormation" :get-row-formation-label="getRowFormationLabel" :get-row-contract-nature="getRowContractNature" + :get-row-worked-days-label="getRowWorkedDaysLabel" :get-row-updated-at="getRowUpdatedAt" :get-presence-day-value="getPresenceDayValue" :on-absence-click="openAbsenceDrawer" @@ -215,6 +216,7 @@ const { hasRowFormation, getRowFormationLabel, getRowContractNature, + getRowWorkedDaysLabel, getRowUpdatedAt, getPresenceDayValue, openAbsenceDrawer, diff --git a/frontend/services/dto/work-hour.ts b/frontend/services/dto/work-hour.ts index 6dd61f0..5bf32c6 100644 --- a/frontend/services/dto/work-hour.ts +++ b/frontend/services/dto/work-hour.ts @@ -119,6 +119,7 @@ export type WorkHourDayContextRow = { weeklyHours?: number | null contractType?: ContractType | null contractName?: string | null + workDaysHours?: Record | null } export type WorkHourDayContext = { diff --git a/frontend/utils/contract.ts b/frontend/utils/contract.ts index f3c70b1..63dc4f8 100644 --- a/frontend/utils/contract.ts +++ b/frontend/utils/contract.ts @@ -37,6 +37,26 @@ export const requiresWorkDaysHours = ( const DAY_SHORT_LABELS: Record = { 1: 'Lun', 2: 'Mar', 3: 'Mer', 4: 'Jeu', 5: 'Ven' } +const DAY_TINY_LABELS: Record = { 1: 'LU', 2: 'MA', 3: 'ME', 4: 'JE', 5: 'VE' } + +/** + * Very compact worked-days summary for the day view header, e.g. "LU,VE". + * Lists the iso days actually worked (minutes > 0), uppercase 2-letter, comma-separated. + * Returns null when the schedule is empty/unset (non-CUSTOM contracts have no schedule). + */ +export const formatWorkedDaysShort = ( + workDaysHours: Record | null | undefined +): string | null => { + if (!workDaysHours) return null + const days = Object.entries(workDaysHours) + .map(([iso, minutes]) => [Number(iso), Number(minutes)] as const) + .filter(([iso, minutes]) => iso >= 1 && iso <= 5 && minutes > 0) + .sort(([a], [b]) => a - b) + .map(([iso]) => DAY_TINY_LABELS[iso]) + if (days.length === 0) return null + return days.join(',') +} + /** * Compact human-readable summary of a per-day schedule, e.g. "Lun 2h, Jeu 2h". * Returns null when the schedule is empty/unset. diff --git a/src/ApiResource/WorkHourDayContext.php b/src/ApiResource/WorkHourDayContext.php index 043b623..1591ec5 100644 --- a/src/ApiResource/WorkHourDayContext.php +++ b/src/ApiResource/WorkHourDayContext.php @@ -41,7 +41,8 @@ final class WorkHourDayContext * trackingMode:?string, * weeklyHours:?int, * contractType:?string, - * contractName:?string + * contractName:?string, + * workDaysHours:?array * }> */ public array $rows = []; diff --git a/src/Dto/WorkHours/DayContextRow.php b/src/Dto/WorkHours/DayContextRow.php index b195281..36b1d9d 100644 --- a/src/Dto/WorkHours/DayContextRow.php +++ b/src/Dto/WorkHours/DayContextRow.php @@ -25,6 +25,8 @@ final class DayContextRow public ?int $weeklyHours = null, public ?string $contractType = null, public ?string $contractName = null, + /** @var null|array iso day (1=Mon..5=Fri) → minutes, planning des jours travaillés (CUSTOM uniquement) */ + public ?array $workDaysHours = null, ) {} public function setFormation(string $label): void @@ -87,7 +89,8 @@ final class DayContextRow * trackingMode:?string, * weeklyHours:?int, * contractType:?string, - * contractName:?string + * contractName:?string, + * workDaysHours:?array * } */ public function toArray(): array @@ -111,6 +114,7 @@ final class DayContextRow 'weeklyHours' => $this->weeklyHours, 'contractType' => $this->contractType, 'contractName' => $this->contractName, + 'workDaysHours' => $this->workDaysHours, ]; } diff --git a/src/State/WorkHourDayContextProvider.php b/src/State/WorkHourDayContextProvider.php index badc63a..4a79a77 100644 --- a/src/State/WorkHourDayContextProvider.php +++ b/src/State/WorkHourDayContextProvider.php @@ -72,6 +72,7 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface weeklyHours: $contract?->getWeeklyHours(), contractType: $contract?->getType()->value, contractName: $contract?->getName(), + workDaysHours: $workDaysMinutes, ); } diff --git a/tests/State/WorkHourDayContextProviderTest.php b/tests/State/WorkHourDayContextProviderTest.php index 40e72f8..3e5d490 100644 --- a/tests/State/WorkHourDayContextProviderTest.php +++ b/tests/State/WorkHourDayContextProviderTest.php @@ -23,6 +23,7 @@ use App\Service\WorkHours\HolidayVirtualHoursResolver; use App\Service\WorkHours\WorkedHoursCreditPolicy; use App\State\WorkHourDayContextProvider; use DateTime; +use DateTimeImmutable; use PHPUnit\Framework\TestCase; use ReflectionObject; use Symfony\Bundle\SecurityBundle\Security; @@ -150,8 +151,7 @@ final class WorkHourDayContextProviderTest extends TestCase // Resolver renvoie le contrat 39h avant 2026-03-01, le forfait à partir de cette date. $resolver = $this->createStub(EmployeeContractResolver::class); $resolver->method('resolveForEmployeeAndDate')->willReturnCallback( - static fn (Employee $e, \DateTimeImmutable $d): ?Contract => - $d < new \DateTimeImmutable('2026-03-01') ? $timeContract : $forfaitContract + static fn (Employee $e, DateTimeImmutable $d): ?Contract => $d < new DateTimeImmutable('2026-03-01') ? $timeContract : $forfaitContract ); $resolver->method('resolveNatureForEmployeeAndDate')->willReturn(ContractNature::CDI); @@ -180,6 +180,67 @@ final class WorkHourDayContextProviderTest extends TestCase self::assertSame('Contrat', $row['contractName']); } + public function testRowCarriesWorkDaysHoursForCustomContract(): void + { + $user = new User(); + $employee = $this->buildEmployee(1, Contract::TRACKING_TIME, 4); + + $resolver = $this->createStub(EmployeeContractResolver::class); + $resolver->method('resolveForEmployeeAndDate')->willReturn($employee->getContract()); + $resolver->method('resolveNatureForEmployeeAndDate')->willReturn(ContractNature::CDI); + // Contrat 4h travaillé le lundi et le vendredi (120 min chacun). + $resolver->method('resolveWorkDaysMinutesForEmployeeAndDate')->willReturn([1 => 120, 5 => 120]); + + $this->requestStack->push(new Request(query: ['workDate' => '2026-02-16'])); + $this->security->method('getUser')->willReturn($user); + $this->employeeRepository->method('findScoped')->with($user)->willReturn([$employee]); + $this->absenceRepository->method('findByDateAndEmployees')->willReturn([]); + + $provider = new WorkHourDayContextProvider( + $this->security, + $this->requestStack, + $this->employeeRepository, + $this->absenceRepository, + $this->formationRepository, + $resolver, + new AbsenceSegmentsResolver(), + new WorkedHoursCreditPolicy($this->buildResolverStub(), new DailyReferenceMinutesResolver()), + $this->buildHolidayResolver(), + ); + + $row = $provider->provide(new Get())->rows[0]; + + self::assertSame([1 => 120, 5 => 120], $row['workDaysHours']); + } + + public function testRowHasNullWorkDaysHoursForStandardContract(): void + { + $user = new User(); + $employee = $this->buildEmployee(1, Contract::TRACKING_TIME, 35); + + // buildResolverStub ne stube pas resolveWorkDaysMinutesForEmployeeAndDate → null (35h n'a pas de planning). + $this->requestStack->push(new Request(query: ['workDate' => '2026-02-16'])); + $this->security->method('getUser')->willReturn($user); + $this->employeeRepository->method('findScoped')->with($user)->willReturn([$employee]); + $this->absenceRepository->method('findByDateAndEmployees')->willReturn([]); + + $provider = new WorkHourDayContextProvider( + $this->security, + $this->requestStack, + $this->employeeRepository, + $this->absenceRepository, + $this->formationRepository, + $this->buildResolverStub(), + new AbsenceSegmentsResolver(), + new WorkedHoursCreditPolicy($this->buildResolverStub(), new DailyReferenceMinutesResolver()), + $this->buildHolidayResolver(), + ); + + $row = $provider->provide(new Get())->rows[0]; + + self::assertNull($row['workDaysHours']); + } + private function buildEmployee(int $id, string $trackingMode, ?int $weeklyHours): Employee { $contract = new Contract()