Files
apf_portal/apps/portal-admin
julien 9f5106b805
CI / commits (push) Has been skipped
CI / scan (push) Successful in 3m26s
CI / a11y (push) Successful in 1m44s
CI / perf (push) Successful in 3m57s
CI / check (push) Successful in 1m24s
feat(portal-admin): audit log tabs (Table / Charts) + server-side stats consumption (#174)
## Summary

PR 2 of the tabs + full-result-charts chantier — closes the loop opened in #173. The audit log page now splits into a **Table** tab (existing behaviour) and a **Charts** tab fed by the server-side stats endpoint shipped in PR 1.

```
PR 1 (#173, merged) — BFF GET /api/admin/audit/stats + Redis 5min cache
                      + admin.audit.stats.query audit event + ADR-0013 amendment.
PR 2 (this one)     — SPA Tabs UX (Table / Charts) + consume the stats endpoint,
                      replacing the per-page client-side aggregations from #172.
```

## What lands

### Tabs UX — WAI-ARIA tab pattern

The two tabs sit between the filter card and the content area. Visual treatment is intentionally minimal: thin brand-coloured underline on the active tab, focus rings on `:focus-visible`, no surrounding chrome. The panel below inherits the page surface so each tab swap reads as a content-only change.

ARIA wiring:

- `<div role="tablist">` with two `<button role="tab">` children.
- `aria-selected` mirrors the active tab; `aria-controls` points each tab at its panel id; roving `tabindex` (active = `0`, inactive = `-1`) keeps Tab linear.
- Arrow-key navigation between tabs is bound on the individual buttons (not the tablist div) — focusable elements only, satisfies the `template/click-events-have-key-events` lint without an artificial `tabindex="-1"` on the container.
- Two `<section role="tabpanel">` with `[hidden]` binding, `aria-labelledby` pointing back at the tab id.

### Stats consumption — replaces the per-page computeds

`AuditEventsService` grows a `stats(filters)` method that calls `GET /api/admin/audit/stats` and returns `AdminAuditStats` (mirror of the BFF DTO). The audit page replaces the four per-page `computed()`s — `totalOnPage`, `dailyVolume`, `outcomeBreakdown`, `dailyByEventType` — with four signals:

```ts
readonly stats        = signal<AdminAuditStats | null>(null);
readonly statsLoading = signal(false);
readonly statsError   = signal<string | null>(null);
readonly hasChartData = computed(() => (this.stats()?.total ?? 0) > 0);
```

The chart-tile components on the Charts panel consume `stats()?.dailyVolume`, `stats()?.outcomeBreakdown`, `stats()?.eventTypeByDay`, and `stats()?.total` unchanged — same shape as the old per-page projections, just sourced server-side.

### Lazy fetch policy

| Interaction              | Action on `stats`                              |
| ------------------------ | ---------------------------------------------- |
| Page load (Table active) | No call — default tab is Table, stats untouched |
| Click Charts (first time) | Fetch                                          |
| Click Table → Charts     | Fetch only if cleared by a filter change       |
| Apply / clear filters    | Clear `stats`, re-fetch **only if Charts active** |
| Filter by row's actor    | Clear `stats`, re-fetch if Charts active       |
| Next / previous page     | Do **not** invalidate stats (pagination is presentation, the aggregated set is unchanged) |

The Redis cache in PR 1 absorbs repeated identical fetches at ~5 ms; the policy here just makes sure we don't fire a stats call when nobody's looking at it.

### Honest panel copy

The Charts panel's note now reads:

> Aggregations are computed across the full filtered set (server-side), not just the events on the current page. Results are cached for 5 minutes per filter combination.

Replaces the previous "this only reflects the current page" disclaimer that #172 carried as a temporary truth.

## Notes for the reviewer

- **Why a hand-coded tablist instead of a shared `libs/shared/ui/tabs` primitive?** Two tabs, one consumer, ~30 LOC of template + ~25 LOC of SCSS. The two-occurrences-is-duplication rule says wait until a second consumer appears (cf. CMS module or user-list module slated for the next chantier), then extract with the shape both consumers have actually demanded.
- **Why is `setTab()` `async`?** It triggers `fetchStats()` on the first switch to Charts, and the spec exercises that flow with `await`. Keeping it `async` lets the test sequence assertions deterministically without `tick()`/fakeAsync.
- **Why does `search()` clear `stats` *before* the fetch even though `fetchStats()` clears it again at the top?** So the empty-state never flickers through a "stale 1000-event" total while the new fetch is in flight on a slow link. The double-clear is intentional.
- **Bundle impact.** The audit chunk grew from ~82 KB to ~84.5 KB gzip (two new signals, a stats-mapping HTTP call, the tablist template + SCSS). Well under the lazy-chunk 100 KB ceiling from [ADR-0017](docs/decisions/0017-performance-budgets-lighthouse-ci.md).
- **`audit.scss` is 7.5 KB** — over the 6 KB component-style warning, under the 8 KB error. The new `.tablist` / `.tab` rules account for the bump. If a third consumer of the tab pattern lands, the SCSS extraction comes with it.

## Test plan

- [x] `pnpm nx test portal-admin` — **62 specs pass** (was 58: removed 3 obsolete "per-page chart" cases, added 7 new tabs/stats cases).
- [x] `pnpm nx run portal-admin:lint` — clean (the two pre-existing `use-lifecycle-interface` warnings on `audit.ts` / `users.ts` and the spec non-null assertion are untouched, not regressions).
- [x] `pnpm nx build portal-admin` — clean, audit chunk 84.5 KB gzip.
- [x] `pnpm nx run-many -t lint test build -p portal-shell,portal-admin,portal-bff,shared-charts` — clean.
- [ ] **Manual smoke** — `pnpm nx serve portal-admin` + `pnpm nx serve portal-bff`, sign in with `Portal.Admin`, navigate to `/admin/audit`:
  - Page loads on the Table tab — DevTools Network shows `GET /api/admin/audit` only, **no** `/audit/stats` call.
  - Click **Charts** → spinner state, then three chart tiles render with server-side totals.
  - Apply a filter (e.g. `eventType=auth.sign_in`) while on Charts → tiles re-render with the filtered aggregates; donut centre matches the server `total`.
  - Switch back to Table, page through results → no new `/audit/stats` calls (pagination doesn't invalidate).
  - Arrow-Right / Arrow-Left between the two tabs with keyboard — focus moves, ARIA selection follows, `Tab` skips past the inactive tab to the panel content.
  - Toggle dark mode → tablist + active underline + tab focus rings all read correctly in both modes.

## What's next

- Persist the active tab in the URL (`?tab=charts`) so deep-linking and Back/Forward survive the swap. Out of scope for this chantier — once the user-list module lands and we have a second tab consumer, persistence becomes a shared concern.
- Extract `libs/shared/ui/tabs` when a second consumer materialises (CMS or user-list module).
- Surface cache observability — the stats endpoint Redis cache is silent in v1. A future ADR-0024 follow-up could add a `X-Audit-Stats-Cache: hit|miss` header for ops or an OTel attribute on the BFF span. Out of scope here, but worth flagging while the design is fresh.

---------

Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr>
Reviewed-on: #174
2026-05-17 00:16:47 +02:00
..