Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
44 KiB
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éfixeMalio*). - 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 signaturesfindByFilters/countByFilters.src/Repository/AuditLogRepository.php— modifier. Implémentation + méthode privéeapplyFilters(DRY).src/State/AuditLogProvider.php— modifier. LitperPage+username/ip/device+entityType[]/action[].src/ApiResource/AuditLogResource.php— modifier. AjoutQueryParameterdocumentaires.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é
AuditLogdéjà dotée des champs forensiques). -
Produces: endpoint
GET /audit-logsacceptantperPage,username,ip,device,entityType[],action[](+employeeId/from/to/pageexistants). Réponse JSON inchangée en forme ({items,total,page,perPage}),perPagereflè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): arraycountByFilters(?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).AuditLogPageinchangé. -
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)
- state refs:
-
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;
applyFilterscopies draft→applied + resets page + reloads + closes;resetFiltersclears both + reloads but keeps the drawer open. -
load()has a race guard (requestSeq) so stale responses are discarded. -
init()degrades gracefully iflistEmployeesfails. -
buildFiltersomits empty values (sofetchAuditLogssends 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) ;AuditLogDTO ; 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). -
MalioDataTablecolumns keys matchAuditLogfields; cell slots use#cell-{key}. -
v-modelon Malio components binds the composable's refs via.value(the composable returns raw refs, so template useslist.xxx.value). The twoMalioDrawerv-models (list.filterOpen.value, localdetailOpen) open/close correctly. -
@row-clickopens the detail drawer with the clicked row;changeRowsrenders 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 3–4.
- 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
useAuditLogsListdé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.