# 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.php` — *modifier*. Nouvelles signatures `findByFilters`/`countByFilters`.
- `src/Repository/AuditLogRepository.php` — *modifier*. Implémentation + méthode privée `applyFilters` (DRY).
- `src/State/AuditLogProvider.php` — *modifier*. Lit `perPage` + `username`/`ip`/`device` + `entityType[]`/`action[]`.
- `src/ApiResource/AuditLogResource.php` — *modifier*. Ajout `QueryParameter` documentaires.
- `tests/State/AuditLogProviderTest.php` — *modifier*. Tests des nouveaux filtres + perPage (spy repo).

**Frontend**
- `frontend/services/audit-logs.ts` — *modifier*. `AuditLogFilters` étendu + sérialisation des params.
- `frontend/composables/useAuditLogsList.ts` — *créer*. État + actions de l'écran.
- `frontend/pages/audit-logs.vue` — *réécrire*. Toolbar + `MalioDataTable` + 2 drawers.

**Docs**
- `doc/audit-logging.md`, `CLAUDE.md` — *modifier*.

---

## 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
<?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
<?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
<?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
<?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:

```php
                    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**

```bash
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:

```ts
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**

```bash
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`:

```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**

```bash
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:

```vue
<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**

```bash
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:

```markdown
- 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:

```markdown
- **É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 3–4.

- [ ] **Step 4: Commit**

```bash
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.
