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

1119 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 34.
- [ ] **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.