feat(heures) : calendrier des jours validés (vue Jour) + harmonisation Malio UI (#30)
Auto Tag Develop / tag (push) Successful in 10s
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>
This commit was merged in pull request #30.
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use App\State\WorkHourValidationStatusProvider;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/work-hours/validation-status',
|
||||
security: "is_granted('ROLE_USER')",
|
||||
provider: WorkHourValidationStatusProvider::class
|
||||
),
|
||||
],
|
||||
paginationEnabled: false
|
||||
)]
|
||||
final class WorkHourValidationStatus
|
||||
{
|
||||
public string $from = '';
|
||||
|
||||
public string $to = '';
|
||||
|
||||
/**
|
||||
* Jours entièrement validés (admin) sur la plage, au format Y-m-d.
|
||||
* Un jour est présent ssi il porte au moins une ligne (non-conducteur)
|
||||
* et aucune n'est en attente de validation (isValid=false).
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
public array $validatedDays = [];
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user