9f5106b805
## 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