Files
SIRH/src/State/WorkHourValidationStatusProvider.php
T
tristan 74abecbe03
Auto Tag Develop / tag (push) Successful in 10s
feat(heures) : calendrier des jours validés (vue Jour) + harmonisation Malio UI (#30)
## Fonctionnel
- Calendrier MalioDate en vue Jour (écrans Heures ET Heures Conducteurs) : les jours entièrement validés par un admin sont peints en vert.
  - Endpoint `GET /work-hours/validation-status?from=&to=[&driver=1]` (scope conducteur inversé via `driver=1`), périmètre complet (ignore le filtre sites).
  - Chargement à la volée par mois (event `@month-change`), refresh après validation / saisie / absence.

## Harmonisation @malio/layer-ui 1.7.11
- `reserveMessageSpace=false` sur tous les champs (alignement).
- Tous les drawers migrés sur `MalioDrawer` (titre via slot `#header`, `AppDrawer` custom supprimé).
- Boutons d'action en `MalioButton` ; deux boutons côte à côte partagent l'espace.
- Inputs date en `MalioDate`, sélecteur semaine en `MalioDateWeek`.
- Boutons d'ajout uniformisés sur « Ajouter » + icône.

## Divers
- `.env` : `EXCLUDED_PUBLIC_HOLIDAYS="null"`.
- Doc : `doc/hours-validated-days.md`, `documentation-content.ts`, `CLAUDE.md`.
- Tests : provider `WorkHourValidationStatus` (suite complète 236/236 OK via pre-commit hook).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #30
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-06-16 13:53:03 +00:00

144 lines
5.7 KiB
PHP

<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\WorkHourValidationStatus;
use App\Entity\Employee;
use App\Entity\User;
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
use App\Repository\Contract\WorkHourReadRepositoryInterface;
use App\Service\Contracts\EmployeeContractResolver;
use DateTimeImmutable;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
/**
* Statut de validation par jour pour le calendrier de la vue Jour (écran Heures).
*
* Un jour est « entièrement validé » (peint en vert côté front) ssi, dans le
* périmètre de l'utilisateur (admin = tous les sites, chef de site = ses sites) :
* - il porte au moins une ligne WorkHour de non-conducteur ce jour-là, ET
* - aucune de ces lignes n'est en attente de validation (isValid=false).
*
* Par défaut les conducteurs sont exclus (écran Heures). Avec `?driver=1`, le filtre
* s'inverse et seuls les conducteurs sont pris en compte (écran Heures Conducteurs).
* Le filtre sites de l'écran est volontairement ignoré :
* le statut reflète tout le périmètre (objectif RH : repérer le moindre jour
* incomplet, où qu'il soit). Un jour sans aucune ligne reste neutre (absent de
* la liste).
*/
final readonly class WorkHourValidationStatusProvider implements ProviderInterface
{
/** Garde-fou : borne la plage demandée pour éviter une requête démesurée. */
private const int MAX_RANGE_DAYS = 366;
public function __construct(
private Security $security,
private RequestStack $requestStack,
private EmployeeScopedRepositoryInterface $employeeRepository,
private WorkHourReadRepositoryInterface $workHourRepository,
private EmployeeContractResolver $contractResolver,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): WorkHourValidationStatus
{
$user = $this->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<string, array{total:int, pending:int}> $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);
}
}