74abecbe03
Auto Tag Develop / tag (push) Successful in 10s
## 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>
144 lines
5.7 KiB
PHP
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);
|
|
}
|
|
}
|