feat(heures) : calendrier des jours validés (vue Jour) + harmonisation Malio UI

- Calendrier MalioDate en vue Jour (Heures + Heures Conducteurs) : jours
  entièrement validés (admin) peints en vert. Endpoint GET
  /work-hours/validation-status?from=&to=[&driver=1] (scope conducteur inversé),
  chargement à la volée par mois, refresh après validation/saisie/absence.
- Suite à @malio/layer-ui 1.7.11 : reserveMessageSpace=false sur les champs ;
  tous les drawers migrés sur MalioDrawer (titre via slot #header, AppDrawer
  custom supprimé) ; boutons d'action en MalioButton (deux boutons partagent
  l'espace) ; inputs date en MalioDate ; MalioDateWeek en vue Semaine.
- Boutons d'ajout uniformisés sur « Ajouter » + icône.
- .env : EXCLUDED_PUBLIC_HOLIDAYS="null".
- Doc : doc/hours-validated-days.md, documentation-content.ts, CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-16 15:47:23 +02:00
parent 5d2b5d1c54
commit 34dc52d92b
37 changed files with 1881 additions and 495 deletions
@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace App\Tests\State;
use ApiPlatform\Metadata\Get;
use App\Entity\Employee;
use App\Entity\User;
use App\Entity\WorkHour;
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
use App\Service\Contracts\EmployeeContractResolver;
use App\State\WorkHourValidationStatusProvider;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use ReflectionObject;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
/**
* @internal
*/
final class WorkHourValidationStatusProviderTest extends TestCase
{
private Security $security;
private EmployeeScopedRepositoryInterface $employeeRepository;
private WorkHourReadRepositoryInterface $workHourRepository;
private RequestStack $requestStack;
protected function setUp(): void
{
$this->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)
;
}
}