2d284b897b
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1119 lines
44 KiB
Markdown
1119 lines
44 KiB
Markdown
# 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.
|