Files
SIRH/docs/superpowers/plans/2026-06-24-audit-log-screen-rework.md
T
2026-06-24 11:14:40 +02:00

44 KiB
Raw Blame History

Refonte écran Journal d'activité — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Refondre l'écran audit-logs.vue avec un MalioDataTable, un drawer de filtre façon STARSEED et un drawer de détail, en exploitant les champs forensiques (IP, appareil, User-Agent, device id) et en enrichissant le backend (perPage + nouveaux filtres).

Architecture: Backend — AuditLogProvider/repository/interface gagnent perPage + filtres username/ip/device (LIKE insensible à la casse) et entityType[]/action[] (IN). Frontend — un composable dédié useAuditLogsList porte l'état brouillon/appliqué + pagination ; la page se réduit à une toolbar, un MalioDataTable et deux MalioDrawer (filtre + détail).

Tech Stack: Symfony 7 + API Platform + Doctrine (PostgreSQL) ; Nuxt 4 + Vue 3 + TS + @malio/layer-ui 1.7.15.

Global Constraints

  • Écran réservé ROLE_SUPER_ADMIN (inchangé). (spec)
  • Libellés UI en français en dur (convention drawers SIRH employees/index.vue/sites.vue), PAS d'i18n. (spec)
  • Filtres non persistés en URL ; état local uniquement. (spec)
  • Tous les <select>/<input>/<button> natifs → composants Malio (MalioSelect, MalioInputText, MalioDateRange, MalioCheckbox, MalioButton, MalioDrawer, MalioAccordion, MalioDataTable). Auto-importés (préfixe Malio*).
  • Ne PAS lancer npm run build (règle projet — feedback utilisateur). Vérif front = revue du diff.
  • Migrations : N/A (aucune migration ici ; colonnes déjà créées au lot précédent).
  • DTO PHP ↔ DTO TS alignés. (CLAUDE.md)
  • Tout changement fonctionnel met à jour doc/ + CLAUDE.md. In-app doc (documentation-content.ts) hors périmètre (outil caché super-admin, décision actée). (spec)
  • Commit hook : <type> : <message> (espace AVANT :). Types : build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test. Lance php-cs-fixer + tout PHPUnit.
  • Test ciblé : make test FILES=<chemin>. Conteneur PHP : php-sirh-fpm. Doc Malio : node_modules/@malio/layer-ui/COMPONENTS.md.

File Structure

Backend

  • src/Repository/Contract/AuditLogReadRepositoryInterface.phpmodifier. Nouvelles signatures findByFilters/countByFilters.
  • src/Repository/AuditLogRepository.phpmodifier. Implémentation + méthode privée applyFilters (DRY).
  • src/State/AuditLogProvider.phpmodifier. Lit perPage + username/ip/device + entityType[]/action[].
  • src/ApiResource/AuditLogResource.phpmodifier. Ajout QueryParameter documentaires.
  • tests/State/AuditLogProviderTest.phpmodifier. Tests des nouveaux filtres + perPage (spy repo).

Frontend

  • frontend/services/audit-logs.tsmodifier. AuditLogFilters étendu + sérialisation des params.
  • frontend/composables/useAuditLogsList.tscréer. État + actions de l'écran.
  • frontend/pages/audit-logs.vueréécrire. Toolbar + MalioDataTable + 2 drawers.

Docs

  • doc/audit-logging.md, CLAUDE.mdmodifier.

Task 1: Backend — filtres enrichis + perPage

Files:

  • Modify: src/Repository/Contract/AuditLogReadRepositoryInterface.php
  • Modify: src/Repository/AuditLogRepository.php
  • Modify: src/State/AuditLogProvider.php
  • Modify: src/ApiResource/AuditLogResource.php
  • Test: tests/State/AuditLogProviderTest.php

Interfaces:

  • Consumes: rien de nouveau (entité AuditLog déjà dotée des champs forensiques).

  • Produces: endpoint GET /audit-logs acceptant perPage, username, ip, device, entityType[], action[] (+ employeeId/from/to/page existants). Réponse JSON inchangée en forme ({items,total,page,perPage}), perPage reflète la valeur effective. Signatures repo (consommées par personne d'autre que le provider) :

    • findByFilters(?int $employeeId, ?DateTimeImmutable $from, ?DateTimeImmutable $to, ?array $entityTypes, ?array $actions, ?string $username, ?string $ip, ?string $device, int $limit = 50, int $offset = 0): array
    • countByFilters(?int $employeeId, ?DateTimeImmutable $from, ?DateTimeImmutable $to, ?array $entityTypes, ?array $actions, ?string $username, ?string $ip, ?string $device): int
  • Step 1: Write the failing tests

Replace the full contents of tests/State/AuditLogProviderTest.php with:

<?php

declare(strict_types=1);

namespace App\Tests\State;

use ApiPlatform\Metadata\Operation;
use App\Entity\AuditLog;
use App\Repository\Contract\AuditLogReadRepositoryInterface;
use App\State\AuditLogProvider;
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;

/**
 * @internal
 */
final class AuditLogProviderTest extends TestCase
{
    private function spyRepository(array $items = [], int $count = 0): AuditLogReadRepositoryInterface
    {
        return new class($items, $count) implements AuditLogReadRepositoryInterface {
            public array $findArgs = [];
            public array $countArgs = [];

            public function __construct(private array $items, private int $count) {}

            public function findByFilters(?int $employeeId = null, ?DateTimeImmutable $from = null, ?DateTimeImmutable $to = null, ?array $entityTypes = null, ?array $actions = null, ?string $username = null, ?string $ip = null, ?string $device = null, int $limit = 50, int $offset = 0): array
            {
                $this->findArgs = compact('employeeId', 'from', 'to', 'entityTypes', 'actions', 'username', 'ip', 'device', 'limit', 'offset');

                return $this->items;
            }

            public function countByFilters(?int $employeeId = null, ?DateTimeImmutable $from = null, ?DateTimeImmutable $to = null, ?array $entityTypes = null, ?array $actions = null, ?string $username = null, ?string $ip = null, ?string $device = null): int
            {
                $this->countArgs = compact('employeeId', 'from', 'to', 'entityTypes', 'actions', 'username', 'ip', 'device');

                return $this->count;
            }
        };
    }

    private function provideWith(AuditLogReadRepositoryInterface $repo, array $query): \Symfony\Component\HttpFoundation\JsonResponse
    {
        $stack = new RequestStack();
        $stack->push(Request::create('/api/audit-logs', 'GET', $query));
        $provider = new AuditLogProvider($stack, $repo);

        return $provider->provide($this->createStub(Operation::class));
    }

    public function testExposesForensicFields(): void
    {
        $log = new AuditLog()
            ->setUsername('usine')
            ->setAction('create')
            ->setEntityType('work_hour')
            ->setDescription('desc')
            ->setIpAddress('203.0.113.7')
            ->setUserAgent('UA-string')
            ->setDeviceLabel('Mobile · Android · Chrome')
            ->setDeviceId('device-abc')
        ;

        $response = $this->provideWith($this->spyRepository([$log], 1), []);
        $item = json_decode((string) $response->getContent(), true)['items'][0];

        self::assertSame('203.0.113.7', $item['ipAddress']);
        self::assertSame('UA-string', $item['userAgent']);
        self::assertSame('Mobile · Android · Chrome', $item['deviceLabel']);
        self::assertSame('device-abc', $item['deviceId']);
    }

    public function testPassesNewFiltersToRepository(): void
    {
        $repo = $this->spyRepository();
        $this->provideWith($repo, [
            'employeeId' => '5',
            'username'   => 'usine',
            'ip'         => '10.0.',
            'device'     => 'android',
            'entityType' => ['work_hour', 'absence'],
            'action'     => ['create'],
            'perPage'    => '25',
            'page'       => '2',
        ]);

        self::assertSame(5, $repo->findArgs['employeeId']);
        self::assertSame('usine', $repo->findArgs['username']);
        self::assertSame('10.0.', $repo->findArgs['ip']);
        self::assertSame('android', $repo->findArgs['device']);
        self::assertSame(['work_hour', 'absence'], $repo->findArgs['entityTypes']);
        self::assertSame(['create'], $repo->findArgs['actions']);
        self::assertSame(25, $repo->findArgs['limit']);
        self::assertSame(25, $repo->findArgs['offset']); // page 2, perPage 25 -> offset 25
    }

    public function testBlankFiltersBecomeNull(): void
    {
        $repo = $this->spyRepository();
        $this->provideWith($repo, ['username' => '  ', 'ip' => '', 'device' => '']);

        self::assertNull($repo->findArgs['username']);
        self::assertNull($repo->findArgs['ip']);
        self::assertNull($repo->findArgs['device']);
        self::assertNull($repo->findArgs['entityTypes']);
        self::assertNull($repo->findArgs['actions']);
    }

    public function testPerPageOutOfRangeFallsBackTo50(): void
    {
        $repo = $this->spyRepository();
        $response = $this->provideWith($repo, ['perPage' => '999']);

        self::assertSame(50, $repo->findArgs['limit']);
        self::assertSame(50, json_decode((string) $response->getContent(), true)['perPage']);
    }
}
  • Step 2: Run tests to verify they fail

Run: make test FILES=tests/State/AuditLogProviderTest.php Expected: FAIL — findByFilters() signature mismatch / Undefined array key "username" (provider not updated yet).

  • Step 3: Update the repository interface

Replace the full contents of src/Repository/Contract/AuditLogReadRepositoryInterface.php with:

<?php

declare(strict_types=1);

namespace App\Repository\Contract;

use App\Entity\AuditLog;
use DateTimeImmutable;

interface AuditLogReadRepositoryInterface
{
    /**
     * @param list<string>|null $entityTypes
     * @param list<string>|null $actions
     *
     * @return list<AuditLog>
     */
    public function findByFilters(
        ?int $employeeId = null,
        ?DateTimeImmutable $from = null,
        ?DateTimeImmutable $to = null,
        ?array $entityTypes = null,
        ?array $actions = null,
        ?string $username = null,
        ?string $ip = null,
        ?string $device = null,
        int $limit = 50,
        int $offset = 0,
    ): array;

    /**
     * @param list<string>|null $entityTypes
     * @param list<string>|null $actions
     */
    public function countByFilters(
        ?int $employeeId = null,
        ?DateTimeImmutable $from = null,
        ?DateTimeImmutable $to = null,
        ?array $entityTypes = null,
        ?array $actions = null,
        ?string $username = null,
        ?string $ip = null,
        ?string $device = null,
    ): int;
}
  • Step 4: Update the repository implementation

Replace the full contents of src/Repository/AuditLogRepository.php with:

<?php

declare(strict_types=1);

namespace App\Repository;

use App\Entity\AuditLog;
use App\Repository\Contract\AuditLogReadRepositoryInterface;
use DateTimeImmutable;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @extends ServiceEntityRepository<AuditLog>
 */
final class AuditLogRepository extends ServiceEntityRepository implements AuditLogReadRepositoryInterface
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, AuditLog::class);
    }

    public function findByFilters(
        ?int $employeeId = null,
        ?DateTimeImmutable $from = null,
        ?DateTimeImmutable $to = null,
        ?array $entityTypes = null,
        ?array $actions = null,
        ?string $username = null,
        ?string $ip = null,
        ?string $device = null,
        int $limit = 50,
        int $offset = 0,
    ): array {
        $qb = $this->createQueryBuilder('a')
            ->orderBy('a.createdAt', 'DESC')
            ->setMaxResults($limit)
            ->setFirstResult($offset)
        ;
        $this->applyFilters($qb, $employeeId, $from, $to, $entityTypes, $actions, $username, $ip, $device);

        return $qb->getQuery()->getResult();
    }

    public function countByFilters(
        ?int $employeeId = null,
        ?DateTimeImmutable $from = null,
        ?DateTimeImmutable $to = null,
        ?array $entityTypes = null,
        ?array $actions = null,
        ?string $username = null,
        ?string $ip = null,
        ?string $device = null,
    ): int {
        $qb = $this->createQueryBuilder('a')->select('COUNT(a.id)');
        $this->applyFilters($qb, $employeeId, $from, $to, $entityTypes, $actions, $username, $ip, $device);

        return (int) $qb->getQuery()->getSingleScalarResult();
    }

    /**
     * @param list<string>|null $entityTypes
     * @param list<string>|null $actions
     */
    private function applyFilters(
        QueryBuilder $qb,
        ?int $employeeId,
        ?DateTimeImmutable $from,
        ?DateTimeImmutable $to,
        ?array $entityTypes,
        ?array $actions,
        ?string $username,
        ?string $ip,
        ?string $device,
    ): void {
        if (null !== $employeeId) {
            $qb->andWhere('a.employee = :employeeId')->setParameter('employeeId', $employeeId);
        }
        if (null !== $from) {
            $qb->andWhere('a.affectedDate >= :from')->setParameter('from', $from);
        }
        if (null !== $to) {
            $qb->andWhere('a.affectedDate <= :to')->setParameter('to', $to);
        }
        if (null !== $entityTypes && [] !== $entityTypes) {
            $qb->andWhere('a.entityType IN (:entityTypes)')->setParameter('entityTypes', $entityTypes);
        }
        if (null !== $actions && [] !== $actions) {
            $qb->andWhere('a.action IN (:actions)')->setParameter('actions', $actions);
        }
        if (null !== $username && '' !== $username) {
            $qb->andWhere('LOWER(a.username) LIKE :username')->setParameter('username', '%'.mb_strtolower($username).'%');
        }
        if (null !== $ip && '' !== $ip) {
            $qb->andWhere('LOWER(a.ipAddress) LIKE :ip')->setParameter('ip', '%'.mb_strtolower($ip).'%');
        }
        if (null !== $device && '' !== $device) {
            $qb->andWhere('(LOWER(a.deviceLabel) LIKE :device OR LOWER(a.deviceId) LIKE :device)')
                ->setParameter('device', '%'.mb_strtolower($device).'%')
            ;
        }
    }
}
  • Step 5: Update the provider

Replace the full contents of src/State/AuditLogProvider.php with:

<?php

declare(strict_types=1);

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Repository\Contract\AuditLogReadRepositoryInterface;
use DateTimeImmutable;
use DateTimeZone;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RequestStack;

class AuditLogProvider implements ProviderInterface
{
    private const DEFAULT_PER_PAGE = 50;
    private const ALLOWED_PER_PAGE = [25, 50, 100];

    public function __construct(
        private readonly RequestStack $requestStack,
        private readonly AuditLogReadRepositoryInterface $auditLogRepository,
    ) {}

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): JsonResponse
    {
        $request = $this->requestStack->getCurrentRequest();
        if (!$request) {
            return new JsonResponse(['items' => [], 'total' => 0]);
        }

        $query = $request->query;
        $all   = $query->all();

        $employeeId = $query->get('employeeId');
        $from       = $query->get('from');
        $to         = $query->get('to');
        $page       = max(1, (int) $query->get('page', '1'));

        $perPage = (int) $query->get('perPage', (string) self::DEFAULT_PER_PAGE);
        if (!in_array($perPage, self::ALLOWED_PER_PAGE, true)) {
            $perPage = self::DEFAULT_PER_PAGE;
        }

        $entityTypes = $this->normalizeList($all['entityType'] ?? null);
        $actions     = $this->normalizeList($all['action'] ?? null);
        $username    = $this->normalizeString($query->get('username'));
        $ip          = $this->normalizeString($query->get('ip'));
        $device      = $this->normalizeString($query->get('device'));

        $empId  = $employeeId ? (int) $employeeId : null;
        $fromDt = $from ? new DateTimeImmutable((string) $from) : null;
        $toDt   = $to ? new DateTimeImmutable((string) $to) : null;
        $offset = ($page - 1) * $perPage;

        $total = $this->auditLogRepository->countByFilters($empId, $fromDt, $toDt, $entityTypes, $actions, $username, $ip, $device);
        $logs  = $this->auditLogRepository->findByFilters($empId, $fromDt, $toDt, $entityTypes, $actions, $username, $ip, $device, $perPage, $offset);

        $items = [];
        foreach ($logs as $log) {
            $employee     = $log->getEmployee();
            $employeeName = $employee
                ? trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? ''))
                : null;

            $items[] = [
                'id'           => $log->getId(),
                'employeeName' => $employeeName,
                'employeeId'   => $employee?->getId(),
                'username'     => $log->getUsername(),
                'action'       => $log->getAction(),
                'entityType'   => $log->getEntityType(),
                'description'  => $log->getDescription(),
                'changes'      => $log->getChanges(),
                'affectedDate' => $log->getAffectedDate()?->format('Y-m-d'),
                'ipAddress'    => $log->getIpAddress(),
                'userAgent'    => $log->getUserAgent(),
                'deviceLabel'  => $log->getDeviceLabel(),
                'deviceId'     => $log->getDeviceId(),
                'createdAt'    => $log->getCreatedAt()->setTimezone(new DateTimeZone('Europe/Paris'))->format('Y-m-d H:i:s'),
            ];
        }

        return new JsonResponse([
            'items'   => $items,
            'total'   => $total,
            'page'    => $page,
            'perPage' => $perPage,
        ]);
    }

    /**
     * @return list<string>|null
     */
    private function normalizeList(mixed $value): ?array
    {
        $list = array_values(array_filter(
            (array) ($value ?? []),
            static fn ($v): bool => is_string($v) && '' !== trim($v),
        ));

        return [] === $list ? null : $list;
    }

    private function normalizeString(mixed $value): ?string
    {
        $trimmed = trim((string) ($value ?? ''));

        return '' === $trimmed ? null : $trimmed;
    }
}
  • Step 6: Add documentary query parameters to the resource

In src/ApiResource/AuditLogResource.php, replace the parameters: [...] block with:

                    parameters: [
                        new QueryParameter(key: 'employeeId'),
                        new QueryParameter(key: 'from'),
                        new QueryParameter(key: 'to'),
                        new QueryParameter(key: 'entityType'),
                        new QueryParameter(key: 'action'),
                        new QueryParameter(key: 'username'),
                        new QueryParameter(key: 'ip'),
                        new QueryParameter(key: 'device'),
                        new QueryParameter(key: 'page'),
                        new QueryParameter(key: 'perPage'),
                    ],
  • Step 7: Run tests to verify they pass

Run: make test FILES=tests/State/AuditLogProviderTest.php Expected: PASS (4 tests). Output pristine (no PHPUnit notices — the spy is a real class, not a mock).

  • Step 8: Run the full suite (no regression)

Run: make test Expected: OK (… tests) all green.

  • Step 9: Commit
git add src/Repository/Contract/AuditLogReadRepositoryInterface.php src/Repository/AuditLogRepository.php src/State/AuditLogProvider.php src/ApiResource/AuditLogResource.php tests/State/AuditLogProviderTest.php
git commit -m "feat(audit) : filtres journal enrichis (utilisateur/ip/appareil, multi-type/action, perPage)"

Task 2: Frontend — service & DTO filtres

Files:

  • Modify: frontend/services/audit-logs.ts

Interfaces:

  • Consumes: endpoint enrichi de Task 1.

  • Produces: AuditLogFilters (employeeId?, from?, to?, entityType?: string[], action?: string[], username?, ip?, device?, page?, perPage?) ; fetchAuditLogs(filters): Promise<AuditLogPage> (consommé par Task 3). AuditLogPage inchangé.

  • Step 1: Rewrite the service

Replace the full contents of frontend/services/audit-logs.ts with:

import type { AuditLog } from './dto/audit-log'

export type AuditLogFilters = {
  employeeId?: number
  from?: string
  to?: string
  entityType?: string[]
  action?: string[]
  username?: string
  ip?: string
  device?: string
  page?: number
  perPage?: number
}

export type AuditLogPage = {
  items: AuditLog[]
  total: number
  page: number
  perPage: number
}

export const fetchAuditLogs = async (filters: AuditLogFilters = {}): Promise<AuditLogPage> => {
  const api = useApi()
  const params: Record<string, string | string[]> = {}

  if (filters.employeeId) params.employeeId = String(filters.employeeId)
  if (filters.from) params.from = filters.from
  if (filters.to) params.to = filters.to
  if (filters.entityType && filters.entityType.length > 0) params['entityType[]'] = filters.entityType
  if (filters.action && filters.action.length > 0) params['action[]'] = filters.action
  if (filters.username && filters.username.trim() !== '') params.username = filters.username.trim()
  if (filters.ip && filters.ip.trim() !== '') params.ip = filters.ip.trim()
  if (filters.device && filters.device.trim() !== '') params.device = filters.device.trim()
  if (filters.page) params.page = String(filters.page)
  if (filters.perPage) params.perPage = String(filters.perPage)

  return api.get<AuditLogPage>('/audit-logs', params, { toast: false })
}
  • Step 2: Verify (review only — no build)

Do NOT run npm run build (project rule). Re-read the diff:

  • Array filters use the [] suffix keys (entityType[], action[]) so Symfony parses them as arrays.

  • Empty/blank filters are omitted (no empty params sent).

  • Type is Record<string, string | string[]> (arrays allowed).

  • Step 3: Commit

git add frontend/services/audit-logs.ts
git commit -m "feat(audit) : étend AuditLogFilters (multi-type/action, user/ip/appareil, perPage)"

Task 3: Frontend — composable useAuditLogsList

Files:

  • Create: frontend/composables/useAuditLogsList.ts

Interfaces:

  • Consumes: fetchAuditLogs/AuditLogFilters (Task 2) ; listEmployees (~/services/employees) ; AuditLog (~/services/dto/audit-log).

  • Produces: useAuditLogsList() returning the reactive state + actions consumed by the page (Task 4):

    • state refs: items: Ref<AuditLog[]>, total: Ref<number>, page: Ref<number>, perPage: Ref<number>, loading: Ref<boolean>, filterOpen: Ref<boolean>, activeFilterCount: ComputedRef<number>, employeeOptions: Ref<{value:number,text:string}[]>
    • draft refs: draftEmployeeId: Ref<number|undefined>, draftRange: Ref<{start:string,end:string}|null>, draftEntityTypes: Ref<string[]>, draftActions: Ref<string[]>, draftUsername: Ref<string>, draftIp: Ref<string>, draftDevice: Ref<string>
    • actions: init(), goToPage(n:number), setPerPage(n:number), openFilters(), applyFilters(), resetFilters(), toggleEntityType(v:string,sel:boolean), toggleAction(v:string,sel:boolean)
  • Step 1: Create the composable

Create frontend/composables/useAuditLogsList.ts:

import { ref, computed } from 'vue'
import type { AuditLog } from '~/services/dto/audit-log'
import { fetchAuditLogs, type AuditLogFilters } from '~/services/audit-logs'
import { listEmployees } from '~/services/employees'

type Range = { start: string, end: string } | null
type SelectOption = { value: number, text: string }

export const useAuditLogsList = () => {
  const items = ref<AuditLog[]>([])
  const total = ref(0)
  const page = ref(1)
  const perPage = ref(50)
  const loading = ref(false)
  const filterOpen = ref(false)
  const employeeOptions = ref<SelectOption[]>([])

  // Applied filters (drive the fetch)
  const appliedEmployeeId = ref<number | undefined>(undefined)
  const appliedRange = ref<Range>(null)
  const appliedEntityTypes = ref<string[]>([])
  const appliedActions = ref<string[]>([])
  const appliedUsername = ref('')
  const appliedIp = ref('')
  const appliedDevice = ref('')

  // Draft filters (edited inside the drawer)
  const draftEmployeeId = ref<number | undefined>(undefined)
  const draftRange = ref<Range>(null)
  const draftEntityTypes = ref<string[]>([])
  const draftActions = ref<string[]>([])
  const draftUsername = ref('')
  const draftIp = ref('')
  const draftDevice = ref('')

  const activeFilterCount = computed(() => {
    let n = 0
    if (appliedEmployeeId.value !== undefined) n++
    if (appliedRange.value?.start || appliedRange.value?.end) n++
    if (appliedEntityTypes.value.length > 0) n++
    if (appliedActions.value.length > 0) n++
    if (appliedUsername.value.trim() !== '') n++
    if (appliedIp.value.trim() !== '') n++
    if (appliedDevice.value.trim() !== '') n++
    return n
  })

  const buildFilters = (): AuditLogFilters => ({
    employeeId: appliedEmployeeId.value,
    from: appliedRange.value?.start || undefined,
    to: appliedRange.value?.end || undefined,
    entityType: appliedEntityTypes.value.length > 0 ? [...appliedEntityTypes.value] : undefined,
    action: appliedActions.value.length > 0 ? [...appliedActions.value] : undefined,
    username: appliedUsername.value.trim() || undefined,
    ip: appliedIp.value.trim() || undefined,
    device: appliedDevice.value.trim() || undefined,
    page: page.value,
    perPage: perPage.value,
  })

  // Race guard: only the latest request may commit its result.
  let requestSeq = 0
  const load = async () => {
    const seq = ++requestSeq
    loading.value = true
    try {
      const result = await fetchAuditLogs(buildFilters())
      if (seq !== requestSeq) return
      items.value = result.items
      total.value = result.total
      page.value = result.page
      perPage.value = result.perPage
    } finally {
      if (seq === requestSeq) loading.value = false
    }
  }

  const init = async () => {
    try {
      const employees = await listEmployees()
      employeeOptions.value = employees.map(e => ({
        value: e.id,
        text: `${e.lastName} ${e.firstName}`,
      }))
    } catch {
      employeeOptions.value = []
    }
    await load()
  }

  const goToPage = (n: number) => {
    page.value = n
    load()
  }

  const setPerPage = (n: number) => {
    perPage.value = n
    page.value = 1
    load()
  }

  const openFilters = () => {
    draftEmployeeId.value = appliedEmployeeId.value
    draftRange.value = appliedRange.value ? { ...appliedRange.value } : null
    draftEntityTypes.value = [...appliedEntityTypes.value]
    draftActions.value = [...appliedActions.value]
    draftUsername.value = appliedUsername.value
    draftIp.value = appliedIp.value
    draftDevice.value = appliedDevice.value
    filterOpen.value = true
  }

  const applyFilters = () => {
    appliedEmployeeId.value = draftEmployeeId.value
    appliedRange.value = draftRange.value ? { ...draftRange.value } : null
    appliedEntityTypes.value = [...draftEntityTypes.value]
    appliedActions.value = [...draftActions.value]
    appliedUsername.value = draftUsername.value
    appliedIp.value = draftIp.value
    appliedDevice.value = draftDevice.value
    page.value = 1
    filterOpen.value = false
    load()
  }

  const resetFilters = () => {
    draftEmployeeId.value = undefined
    draftRange.value = null
    draftEntityTypes.value = []
    draftActions.value = []
    draftUsername.value = ''
    draftIp.value = ''
    draftDevice.value = ''
    appliedEmployeeId.value = undefined
    appliedRange.value = null
    appliedEntityTypes.value = []
    appliedActions.value = []
    appliedUsername.value = ''
    appliedIp.value = ''
    appliedDevice.value = ''
    page.value = 1
    load() // drawer stays open
  }

  const toggle = (arr: typeof draftEntityTypes, value: string, selected: boolean) => {
    arr.value = selected ? [...arr.value, value] : arr.value.filter(v => v !== value)
  }
  const toggleEntityType = (value: string, selected: boolean) => toggle(draftEntityTypes, value, selected)
  const toggleAction = (value: string, selected: boolean) => toggle(draftActions, value, selected)

  return {
    items, total, page, perPage, loading, filterOpen, employeeOptions, activeFilterCount,
    draftEmployeeId, draftRange, draftEntityTypes, draftActions, draftUsername, draftIp, draftDevice,
    init, goToPage, setPerPage, openFilters, applyFilters, resetFilters, toggleEntityType, toggleAction,
  }
}
  • Step 2: Verify (review only — no build)

Re-read the diff:

  • Draft and applied states are distinct; applyFilters copies draft→applied + resets page + reloads + closes; resetFilters clears both + reloads but keeps the drawer open.

  • load() has a race guard (requestSeq) so stale responses are discarded.

  • init() degrades gracefully if listEmployees fails.

  • buildFilters omits empty values (so fetchAuditLogs sends nothing for them).

  • Step 3: Commit

git add frontend/composables/useAuditLogsList.ts
git commit -m "feat(audit) : composable useAuditLogsList (filtres brouillon/appliqué + pagination)"

Task 4: Frontend — réécriture de audit-logs.vue

Files:

  • Modify (full rewrite): frontend/pages/audit-logs.vue

Interfaces:

  • Consumes: useAuditLogsList() (Task 3) ; AuditLog DTO ; Malio components (auto-imported).

  • Produces: l'écran final.

  • Step 1: Rewrite the page

Replace the full contents of frontend/pages/audit-logs.vue with:

<template>
  <div class="h-full flex flex-col overflow-hidden">
    <div class="flex items-center justify-between pb-6">
      <h1 class="text-4xl font-bold text-primary-500">Journal des actions</h1>
      <MalioButton
        variant="tertiary"
        :label="filterButtonLabel"
        icon-name="mdi:tune"
        @click="list.openFilters()"
      />
    </div>

    <div class="min-h-0 flex-1 overflow-auto">
      <MalioDataTable
        :columns="columns"
        :items="list.items.value"
        :total-items="list.total.value"
        :page="list.page.value"
        :per-page="list.perPage.value"
        :per-page-options="[25, 50, 100]"
        empty-message="Aucune entrée trouvée."
        @row-click="openDetail"
        @update:page="list.goToPage($event)"
        @update:per-page="list.setPerPage($event)"
      >
        <template #cell-createdAt="{ item }">
          {{ formatDateTime((item as AuditLog).createdAt) }}
        </template>
        <template #cell-action="{ item }">
          <span class="rounded px-2 py-0.5 text-xs font-bold text-white" :class="actionClass((item as AuditLog).action)">
            {{ actionLabel((item as AuditLog).action) }}
          </span>
        </template>
        <template #cell-entityType="{ item }">
          {{ entityTypeLabel((item as AuditLog).entityType) }}
        </template>
        <template #cell-employeeName="{ item }">
          {{ (item as AuditLog).employeeName ?? '—' }}
        </template>
        <template #cell-deviceLabel="{ item }">
          {{ (item as AuditLog).deviceLabel ?? '—' }}
        </template>
        <template #cell-description="{ item }">
          <span class="block max-w-[320px] truncate" :title="(item as AuditLog).description">{{ (item as AuditLog).description }}</span>
        </template>
      </MalioDataTable>
    </div>

    <!-- Filter drawer -->
    <MalioDrawer
      v-model="list.filterOpen.value"
      drawer-class="max-w-[450px]"
      body-class="p-0"
      footer-class="justify-between border-t border-black p-6"
    >
      <template #header>
        <h2 class="text-[32px] font-semibold text-primary-500">Filtres</h2>
      </template>

      <MalioAccordion>
        <MalioAccordionItem title="Période" value="period">
          <MalioDateRange v-model="list.draftRange.value" clearable />
        </MalioAccordionItem>

        <MalioAccordionItem title="Employé" value="employee">
          <MalioSelect
            v-model="list.draftEmployeeId.value"
            :options="list.employeeOptions.value"
            empty-option-label="Tous"
          />
        </MalioAccordionItem>

        <MalioAccordionItem title="Type d'entité" value="entityType">
          <div class="flex flex-col">
            <MalioCheckbox
              v-for="opt in entityTypeOptions"
              :id="`filter-type-${opt.value}`"
              :key="opt.value"
              :label="opt.label"
              :model-value="list.draftEntityTypes.value.includes(opt.value)"
              @update:model-value="(val: boolean) => list.toggleEntityType(opt.value, val)"
            />
          </div>
        </MalioAccordionItem>

        <MalioAccordionItem title="Action" value="action">
          <div class="flex flex-col">
            <MalioCheckbox
              v-for="opt in actionOptions"
              :id="`filter-action-${opt.value}`"
              :key="opt.value"
              :label="opt.label"
              :model-value="list.draftActions.value.includes(opt.value)"
              @update:model-value="(val: boolean) => list.toggleAction(opt.value, val)"
            />
          </div>
        </MalioAccordionItem>

        <MalioAccordionItem title="Utilisateur / compte" value="username">
          <MalioInputText v-model="list.draftUsername.value" icon-name="mdi:magnify" />
        </MalioAccordionItem>

        <MalioAccordionItem title="IP" value="ip">
          <MalioInputText v-model="list.draftIp.value" icon-name="mdi:magnify" />
        </MalioAccordionItem>

        <MalioAccordionItem title="Appareil" value="device">
          <MalioInputText v-model="list.draftDevice.value" icon-name="mdi:magnify" />
        </MalioAccordionItem>
      </MalioAccordion>

      <template #footer>
        <MalioButton variant="tertiary" label="Réinitialiser" @click="list.resetFilters()" />
        <MalioButton variant="primary" label="Appliquer" button-class="w-[170px]" @click="list.applyFilters()" />
      </template>
    </MalioDrawer>

    <!-- Detail drawer -->
    <MalioDrawer v-model="detailOpen" drawer-class="max-w-xl">
      <template #header>
        <h2 class="text-[32px] font-semibold text-primary-500">Détail de l'action</h2>
      </template>

      <div v-if="selected" class="space-y-6 text-md text-primary-500">
        <section class="space-y-1">
          <p><span class="font-semibold">Utilisateur :</span> {{ selected.username }}</p>
          <p><span class="font-semibold">Employé :</span> {{ selected.employeeName ?? '—' }}</p>
          <p><span class="font-semibold">Date action :</span> {{ formatDateTime(selected.createdAt) }}</p>
          <p><span class="font-semibold">Date affectée :</span> {{ selected.affectedDate ? formatDate(selected.affectedDate) : '—' }}</p>
          <p>
            <span class="font-semibold">Action :</span>
            <span class="ml-1 rounded px-2 py-0.5 text-xs font-bold text-white" :class="actionClass(selected.action)">{{ actionLabel(selected.action) }}</span>
          </p>
          <p><span class="font-semibold">Type :</span> {{ entityTypeLabel(selected.entityType) }}</p>
        </section>

        <section class="space-y-1">
          <h3 class="font-bold">Contexte technique</h3>
          <p><span class="font-semibold">IP :</span> {{ selected.ipAddress ?? '—' }}</p>
          <p><span class="font-semibold">Appareil :</span> {{ selected.deviceLabel ?? '—' }}</p>
          <p><span class="font-semibold">User-Agent :</span> <span class="break-all text-sm font-normal">{{ selected.userAgent ?? '—' }}</span></p>
          <p><span class="font-semibold">Device id :</span> <span class="break-all text-sm font-normal">{{ selected.deviceId ?? '—' }}</span></p>
        </section>

        <section class="space-y-1">
          <h3 class="font-bold">Changements</h3>
          <div v-if="changeRows.length > 0" class="space-y-1">
            <div v-for="row in changeRows" :key="row.key" class="text-sm">
              <span class="font-semibold">{{ row.key }} :</span>
              <span class="text-red-600">{{ row.old }}</span>
              <span class="px-1">→</span>
              <span class="text-green-600">{{ row.new }}</span>
            </div>
          </div>
          <p v-else class="text-sm font-normal text-neutral-400">Aucun détail de modification.</p>
        </section>
      </div>
    </MalioDrawer>
  </div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'
import type { AuditLog } from '~/services/dto/audit-log'
import { useAuditLogsList } from '~/composables/useAuditLogsList'

definePageMeta({ middleware: 'super-admin' })
useHead({ title: 'Journal des actions' })

const list = useAuditLogsList()

const columns = [
  { key: 'createdAt', label: 'Date action' },
  { key: 'username', label: 'Utilisateur' },
  { key: 'action', label: 'Action' },
  { key: 'entityType', label: 'Type' },
  { key: 'employeeName', label: 'Employé' },
  { key: 'deviceLabel', label: 'Appareil' },
  { key: 'description', label: 'Description' },
]

const entityTypeOptions = [
  { value: 'work_hour', label: 'Heures' },
  { value: 'absence', label: 'Absence' },
  { value: 'employee', label: 'Employé' },
  { value: 'contract_suspension', label: 'Suspension' },
  { value: 'rtt_payment', label: 'RTT' },
  { value: 'fractioned_days', label: 'Fract.' },
  { value: 'paid_leave_days', label: 'Congés payés' },
  { value: 'week_comment', label: 'Commentaire' },
]

const actionOptions = [
  { value: 'create', label: 'Créer' },
  { value: 'update', label: 'Modifier' },
  { value: 'delete', label: 'Supprimer' },
  { value: 'validate', label: 'Valider' },
  { value: 'site_validate', label: 'Valider (site)' },
]

const filterButtonLabel = computed(() =>
  list.activeFilterCount.value > 0 ? `Filtrer (${list.activeFilterCount.value})` : 'Filtrer',
)

// Detail drawer
const detailOpen = ref(false)
const selected = ref<AuditLog | null>(null)

const openDetail = (item: Record<string, unknown>) => {
  selected.value = item as unknown as AuditLog
  detailOpen.value = true
}

const changeRows = computed(() => {
  const c = selected.value?.changes
  if (!c) return []
  const keys = new Set<string>([...Object.keys(c.old ?? {}), ...Object.keys(c.new ?? {})])
  return [...keys].map(key => ({
    key,
    old: c.old?.[key] === undefined ? '—' : JSON.stringify(c.old[key]),
    new: c.new?.[key] === undefined ? '—' : JSON.stringify(c.new[key]),
  }))
})

const formatDateTime = (dt: string) => {
  const d = new Date(dt)
  return d.toLocaleDateString('fr-FR') + ' ' + d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
}

const formatDate = (d: string) => d.split('-').reverse().join('/')

const actionLabel = (action: string): string => ({
  create: 'Créer', update: 'Modifier', delete: 'Suppr.', validate: 'Valid.', site_validate: 'Valid. site',
}[action] ?? action)

const actionClass = (action: string): string => ({
  create: 'bg-green-500', update: 'bg-blue-500', delete: 'bg-red-500', validate: 'bg-purple-500', site_validate: 'bg-indigo-500',
}[action] ?? 'bg-neutral-500')

const entityTypeLabel = (type: string): string => ({
  work_hour: 'Heures', absence: 'Absence', employee: 'Employé', contract_suspension: 'Suspension',
  rtt_payment: 'RTT', fractioned_days: 'Fract.', paid_leave_days: 'Congés payés', week_comment: 'Commentaire',
}[type] ?? type)

onMounted(() => { list.init() })
</script>
  • Step 2: Verify (review only — no build)

Do NOT run npm run build. Re-read the diff and confirm:

  • No native <select>/<input>/<button> remain (all Malio).

  • MalioDataTable columns keys match AuditLog fields; cell slots use #cell-{key}.

  • v-model on Malio components binds the composable's refs via .value (the composable returns raw refs, so template uses list.xxx.value). The two MalioDrawer v-models (list.filterOpen.value, local detailOpen) open/close correctly.

  • @row-click opens the detail drawer with the clicked row; changeRows renders the old→new diff.

  • Pagination is handled by MalioDataTable (no hand-rolled prev/next remains).

  • Step 3: (Optional) typecheck without building

If a typecheck script exists and is cheap, you MAY run docker exec -u www-data php-sirh-fpm sh -lc 'cd frontend && npx vue-tsc --noEmit' to catch type errors. If it is slow or unavailable, skip and rely on review. NEVER run npm run build.

  • Step 4: Commit
git add frontend/pages/audit-logs.vue
git commit -m "feat(audit) : refonte écran journal (MalioDataTable + drawers filtre & détail)"

Task 5: Documentation

Files:

  • Modify: doc/audit-logging.md
  • Modify: CLAUDE.md

Interfaces: N/A.

  • Step 1: Update doc/audit-logging.md

Find the "Filtres disponibles" section and replace its list with:

- Par employé (affecté)
- Par période (date affectée) — sélecteur de plage
- Par type(s) d'entité (multi-sélection)
- Par action(s) (multi-sélection)
- Par utilisateur / compte (recherche partielle, insensible à la casse)
- Par IP (recherche partielle)
- Par appareil (recherche partielle sur le libellé ou le device id)

Pagination : `perPage` (25 / 50 / 100, défaut 50) + `page`.

L'écran utilise un `MalioDataTable`, un **drawer de filtre** (bouton « Filtrer » avec compteur de filtres actifs, état brouillon/appliqué, Réinitialiser/Appliquer) et un **drawer de détail** ouvert au clic sur une ligne (méta + contexte technique IP/appareil/User-Agent/device id + diff lisible des changements).
  • Step 2: Update CLAUDE.md

In the ## Audit Logging section, append a bullet after the forensic-context bullet:

- **Écran Journal refondu** (`frontend/pages/audit-logs.vue` + `useAuditLogsList`) : tableau en `MalioDataTable` (1er usage SIRH), **drawer de filtre** façon STARSEED (`MalioDrawer` + `MalioAccordion`, état brouillon/appliqué, badge compteur, Réinitialiser/Appliquer), **drawer de détail** au clic ligne. Filtres backend : `username`/`ip`/`device` (LIKE insensible casse), `entityType[]`/`action[]` (IN), `perPage` (25/50/100). Logique dans `useAuditLogsList` ; libellés FR en dur ; filtres hors URL. Provider/`AuditLogReadRepositoryInterface`/repository portent les nouveaux critères.
  • Step 3: Verify references resolve

Run: grep -rn "useAuditLogsList\|MalioDataTable" doc/audit-logging.md CLAUDE.md frontend/composables/ frontend/pages/audit-logs.vue Expected: references resolve to the files created/modified in Tasks 34.

  • Step 4: Commit
git add doc/audit-logging.md CLAUDE.md
git commit -m "docs(audit) : documente la refonte de l'écran journal"

Self-Review (auteur du plan)

Spec coverage :

  • A. MalioDataTable + colonnes → Task 4 ✓
  • B. Drawer de détail (méta + technique + diff lisible) → Task 4 ✓
  • C. Drawer de filtre STARSEED (accordion, brouillon/appliqué, badge, reset/apply) → Tasks 3+4 ✓
  • D. Composable useAuditLogsList dédié (pas de générique) → Task 3 ✓
  • E. Backend (perPage clamp, username/ip/device ILIKE, entityType[]/action[] IN, interface+repo+provider+resource, DTO TS) → Tasks 1+2 ✓
  • F. Conventions (FR en dur, hors URL, super-admin, docs, in-app exclue) → respectées (Tasks 4,5 + constraints) ✓

Placeholder scan : aucun TBD/TODO ; tout le code fourni. Seules souplesses explicites : Task 4 Step 3 (typecheck optionnel, jamais npm run build).

Type consistency : signatures repo findByFilters/countByFilters identiques entre interface (Task 1 Step 3), implémentation (Step 4), spy de test (Step 1) et appels provider (Step 5). Clés filtres (entityType[]/action[]/username/ip/device/perPage) cohérentes entre service TS (Task 2), provider (Task 1) et drawer (Task 4). Le composable (Task 3) expose des refs consommées en list.xxx.value dans la page (Task 4) — cohérent (retour de refs brutes, pas de reactive/destructuring). AuditLog DTO (déjà étendu au lot précédent) porte changes/ipAddress/userAgent/deviceLabel/deviceId utilisés par le drawer de détail.