- {
{ toast: false }
)
}
+
+// Jours entièrement validés (admin) sur une plage, pour colorer le calendrier de
+// la vue Jour. `validatedDays` = liste de dates Y-m-d (cf. doc/hours-validated-days).
+// `driver` : true → écran Heures Conducteurs (seuls les conducteurs), false → écran Heures.
+export const getWorkHourValidationStatus = async (
+ from: string,
+ to: string,
+ options?: { driver?: boolean }
+) => {
+ const api = useApi()
+ const query: Record = { from, to }
+ if (options?.driver) query.driver = '1'
+ const data = await api.get<{ from: string; to: string; validatedDays: string[] }>(
+ '/work-hours/validation-status',
+ query,
+ { toast: false }
+ )
+
+ return data?.validatedDays ?? []
+}
diff --git a/src/ApiResource/WorkHourValidationStatus.php b/src/ApiResource/WorkHourValidationStatus.php
new file mode 100644
index 0000000..6b54621
--- /dev/null
+++ b/src/ApiResource/WorkHourValidationStatus.php
@@ -0,0 +1,35 @@
+
+ */
+ public array $validatedDays = [];
+}
diff --git a/src/State/WorkHourValidationStatusProvider.php b/src/State/WorkHourValidationStatusProvider.php
new file mode 100644
index 0000000..399a5e9
--- /dev/null
+++ b/src/State/WorkHourValidationStatusProvider.php
@@ -0,0 +1,143 @@
+security->getUser();
+ if (!$user instanceof User) {
+ throw new AccessDeniedHttpException('Authentication required.');
+ }
+
+ [$from, $to] = $this->resolveRange();
+ // ?driver=1 → ne garder que les conducteurs (écran Heures Conducteurs) ;
+ // défaut → ne garder que les non-conducteurs (écran Heures).
+ $driverOnly = filter_var(
+ $this->requestStack->getCurrentRequest()?->query->get('driver'),
+ FILTER_VALIDATE_BOOLEAN
+ );
+
+ $employees = $this->employeeRepository->findScoped($user);
+ $workHours = $this->workHourRepository->findByDateRangeAndEmployees($from, $to, $employees);
+
+ // Agrégation par jour : total = lignes non-conducteur, pending = lignes isValid=false.
+ /** @var array $byDate */
+ $byDate = [];
+ // Mémoïsation de la résolution conducteur par (employé, jour) : un même
+ // couple peut revenir et resolveIsDriver... interroge la BDD.
+ $driverCache = [];
+
+ foreach ($workHours as $workHour) {
+ $employee = $workHour->getEmployee();
+ if (!$employee instanceof Employee) {
+ continue;
+ }
+ $date = DateTimeImmutable::createFromInterface($workHour->getWorkDate());
+ $dateKey = $date->format('Y-m-d');
+
+ $cacheKey = $employee->getId().'|'.$dateKey;
+ $isDriver = $driverCache[$cacheKey]
+ ??= $this->contractResolver->resolveIsDriverForEmployeeAndDate($employee, $date);
+ if ($isDriver !== $driverOnly) {
+ continue;
+ }
+
+ $bucket = &$byDate[$dateKey];
+ $bucket ??= ['total' => 0, 'pending' => 0];
+ ++$bucket['total'];
+ if (!$workHour->isValid()) {
+ ++$bucket['pending'];
+ }
+ unset($bucket);
+ }
+
+ $validatedDays = [];
+ foreach ($byDate as $dateKey => $counts) {
+ if ($counts['total'] > 0 && 0 === $counts['pending']) {
+ $validatedDays[] = $dateKey;
+ }
+ }
+ sort($validatedDays);
+
+ $response = new WorkHourValidationStatus();
+ $response->from = $from->format('Y-m-d');
+ $response->to = $to->format('Y-m-d');
+ $response->validatedDays = $validatedDays;
+
+ return $response;
+ }
+
+ /**
+ * @return array{0: DateTimeImmutable, 1: DateTimeImmutable}
+ */
+ private function resolveRange(): array
+ {
+ $query = $this->requestStack->getCurrentRequest()?->query;
+ $from = $this->parseDate((string) ($query?->get('from') ?? ''), 'from');
+ $to = $this->parseDate((string) ($query?->get('to') ?? ''), 'to');
+
+ if ($from > $to) {
+ throw new UnprocessableEntityHttpException('from must be before or equal to to.');
+ }
+ if ($from->diff($to)->days > self::MAX_RANGE_DAYS) {
+ throw new UnprocessableEntityHttpException(sprintf('Range must not exceed %d days.', self::MAX_RANGE_DAYS));
+ }
+
+ return [$from, $to];
+ }
+
+ private function parseDate(string $raw, string $field): DateTimeImmutable
+ {
+ $date = DateTimeImmutable::createFromFormat('Y-m-d', $raw);
+ if (!$date || $date->format('Y-m-d') !== $raw) {
+ throw new UnprocessableEntityHttpException(sprintf('%s must use Y-m-d format.', $field));
+ }
+
+ // Normalise à minuit pour comparer des jours, pas des instants.
+ return $date->setTime(0, 0);
+ }
+}
diff --git a/tests/State/WorkHourValidationStatusProviderTest.php b/tests/State/WorkHourValidationStatusProviderTest.php
new file mode 100644
index 0000000..6a3993a
--- /dev/null
+++ b/tests/State/WorkHourValidationStatusProviderTest.php
@@ -0,0 +1,189 @@
+security = $this->createStub(Security::class);
+ $this->employeeRepository = $this->createStub(EmployeeScopedRepositoryInterface::class);
+ $this->workHourRepository = $this->createStub(WorkHourReadRepositoryInterface::class);
+ $this->requestStack = new RequestStack();
+ }
+
+ public function testThrowsWhenAnonymous(): void
+ {
+ $this->security->method('getUser')->willReturn(null);
+
+ $this->expectException(AccessDeniedHttpException::class);
+ $this->buildProvider()->provide(new Get());
+ }
+
+ public function testThrowsWhenDateFormatInvalid(): void
+ {
+ $this->security->method('getUser')->willReturn(new User());
+ $this->requestStack->push(new Request(query: ['from' => '01-06-2026', 'to' => '2026-06-30']));
+
+ $this->expectException(UnprocessableEntityHttpException::class);
+ $this->buildProvider()->provide(new Get());
+ }
+
+ public function testThrowsWhenFromAfterTo(): void
+ {
+ $this->security->method('getUser')->willReturn(new User());
+ $this->requestStack->push(new Request(query: ['from' => '2026-06-30', 'to' => '2026-06-01']));
+
+ $this->expectException(UnprocessableEntityHttpException::class);
+ $this->buildProvider()->provide(new Get());
+ }
+
+ public function testThrowsWhenRangeTooLarge(): void
+ {
+ $this->security->method('getUser')->willReturn(new User());
+ $this->requestStack->push(new Request(query: ['from' => '2024-01-01', 'to' => '2026-01-01']));
+
+ $this->expectException(UnprocessableEntityHttpException::class);
+ $this->buildProvider()->provide(new Get());
+ }
+
+ public function testComputesValidatedDays(): void
+ {
+ $user = new User();
+ $alice = $this->buildEmployee(1);
+ $bob = $this->buildEmployee(2);
+ $driver = $this->buildEmployee(3);
+
+ // 2026-06-01 : Alice + Bob validés → vert.
+ // 2026-06-02 : Alice validée, Bob en attente → pas vert.
+ // 2026-06-03 : seul un conducteur (validé) → exclu → total non-conducteur 0 → pas vert.
+ $workHours = [
+ $this->buildWorkHour($alice, '2026-06-01', true),
+ $this->buildWorkHour($bob, '2026-06-01', true),
+ $this->buildWorkHour($alice, '2026-06-02', true),
+ $this->buildWorkHour($bob, '2026-06-02', false),
+ $this->buildWorkHour($driver, '2026-06-03', true),
+ ];
+
+ $this->security->method('getUser')->willReturn($user);
+ $this->requestStack->push(new Request(query: ['from' => '2026-06-01', 'to' => '2026-06-30']));
+ $this->employeeRepository->method('findScoped')->with($user)->willReturn([$alice, $bob, $driver]);
+ $this->workHourRepository->method('findByDateRangeAndEmployees')->willReturn($workHours);
+
+ $resolver = $this->createStub(EmployeeContractResolver::class);
+ $resolver->method('resolveIsDriverForEmployeeAndDate')
+ ->willReturnCallback(static fn (Employee $e): bool => 3 === $e->getId())
+ ;
+
+ $result = $this->buildProvider($resolver)->provide(new Get());
+
+ self::assertSame('2026-06-01', $result->from);
+ self::assertSame('2026-06-30', $result->to);
+ self::assertSame(['2026-06-01'], $result->validatedDays);
+ }
+
+ public function testComputesValidatedDaysForDriverScope(): void
+ {
+ $user = new User();
+ $alice = $this->buildEmployee(1); // non-conducteur
+ $driver = $this->buildEmployee(3); // conducteur
+
+ // ?driver=1 : 01/06 conducteur validé → vert ; 02/06 conducteur en attente → non ;
+ // 03/06 seule Alice (non-conducteur) validée → ignorée → non.
+ $workHours = [
+ $this->buildWorkHour($driver, '2026-06-01', true),
+ $this->buildWorkHour($driver, '2026-06-02', false),
+ $this->buildWorkHour($alice, '2026-06-03', true),
+ ];
+
+ $this->security->method('getUser')->willReturn($user);
+ $this->requestStack->push(new Request(query: ['from' => '2026-06-01', 'to' => '2026-06-30', 'driver' => '1']));
+ $this->employeeRepository->method('findScoped')->willReturn([$alice, $driver]);
+ $this->workHourRepository->method('findByDateRangeAndEmployees')->willReturn($workHours);
+
+ $resolver = $this->createStub(EmployeeContractResolver::class);
+ $resolver->method('resolveIsDriverForEmployeeAndDate')
+ ->willReturnCallback(static fn (Employee $e): bool => 3 === $e->getId())
+ ;
+
+ $result = $this->buildProvider($resolver)->provide(new Get());
+
+ self::assertSame(['2026-06-01'], $result->validatedDays);
+ }
+
+ public function testEmptyWhenNoWorkHours(): void
+ {
+ $user = new User();
+ $this->security->method('getUser')->willReturn($user);
+ $this->requestStack->push(new Request(query: ['from' => '2026-06-01', 'to' => '2026-06-30']));
+ $this->employeeRepository->method('findScoped')->willReturn([]);
+ $this->workHourRepository->method('findByDateRangeAndEmployees')->willReturn([]);
+
+ $result = $this->buildProvider()->provide(new Get());
+
+ self::assertSame([], $result->validatedDays);
+ }
+
+ private function buildProvider(?EmployeeContractResolver $resolver = null): WorkHourValidationStatusProvider
+ {
+ $resolver ??= $this->createStub(EmployeeContractResolver::class);
+
+ return new WorkHourValidationStatusProvider(
+ $this->security,
+ $this->requestStack,
+ $this->employeeRepository,
+ $this->workHourRepository,
+ $resolver,
+ );
+ }
+
+ private function buildEmployee(int $id): Employee
+ {
+ $employee = new Employee()
+ ->setFirstName('Test')
+ ->setLastName('Employee')
+ ;
+ $reflection = new ReflectionObject($employee);
+ $property = $reflection->getProperty('id');
+ $property->setAccessible(true);
+ $property->setValue($employee, $id);
+
+ return $employee;
+ }
+
+ private function buildWorkHour(Employee $employee, string $date, bool $isValid): WorkHour
+ {
+ return new WorkHour()
+ ->setEmployee($employee)
+ ->setWorkDate(new DateTimeImmutable($date))
+ ->setIsValid($isValid)
+ ;
+ }
+}