[T02] Number Stock View

Summary

Current stock view of all RaceNumbers. Filter by NumberType / subtype / state; KPI tiles per state; drill-down to per-number audit log. Bulk-select hands off to T04 for flag-unfit / dispose, or to T03 for return.

The hub of the D2b tenant inventory area: T03 stock-return is launched from here (via the global "Return" sidebar entry or by selecting rows and choosing Return), and T04 bulk flag/dispose is launched from selections here.

T02 v1 ships without tenant scoping on RaceNumber reads. Tenant isolation for RaceNumber / NumberType is a separate cross-cutting workstream — see Out of Scope below and orchestration journal CD-7.

Actor & Context

Actor: tenant admin, stock operator. Frequency: weekly+ during event seasons; less often otherwise. Precondition: user has TENANT_ADMIN permission; sidebar tenant scope active (see C01 § sidebar — added in Thread A). Production today is single-tenant (WPCA only); see Out of Scope for the tenant-isolation limitation in v1. Entry point: Tenant-admin sidebar Inventory ▸ Stock (ADO-541-sidebar-tenant-scope).

Layout

T02 renders inside C01’s shell — sidebar (tenant scope) + topbar are C01’s. T02 owns the main content area.

Region Content

Topbar

Breadcrumb Tenant ▸ Inventory ▸ Stock + Jump-to (⌘K) + notifications bell. Provided by C01.

Filter strip

NumberType (single-select) + subtype (single-select, scoped to selected NumberType) + state (multi-select with chip removal) + free-text search q + reset. Sticky to the top of the main area when the table scrolls.

KPI tile strip

One tile per state: IN_STOCK, ISSUED, IN_USE, UNFIT_FOR_SERVICE, DESTROYED. Each tile shows the count (sourced from /stats?groupBy=state) and is itself a clickable filter shortcut — clicking a tile narrows the table to that state (sets state.in to that single value and clears any prior multi-state selection). Active tile renders in the accent treatment. MANUFACTURED is omitted from the strip — it is an onboarding-transient state without operational meaning here.

Table

Columns: number (bold) · type · subtype · state pill · person (link if person_id set, else blank) · last_used (date or em-dash) · sequence · tag-mate barcode (formatter — read-only). Sort: any column except state and tag-mate. Default sort: type asc, sequence asc. Pagination: server-driven, page size 50.

Bulk-select toolbar

Checkbox column on the left. When ≥1 row is selected, the toolbar materialises above the table with the selected count and three actions: Return → opens T03 with the selected ids pre-loaded; Flag UNFIT → opens T04 confirm dialog; Dispose → opens T04 confirm dialog. Flag UNFIT and Dispose obey the row-state gating below (see Row-state action gating — ADR-0003).

Drill-down side panel

Row click opens a right-rail panel showing the per-number audit log (transition reason, actor, timestamp, optional note, batch_id link). Loads from GET /api/race-numbers/{id}/state-log — Thread B is now scoped to ship this endpoint per orchestration journal CD-5; T02 ships with the panel.

Main Flow

  1. Render shell (C01) with sidebar in tenant scope, breadcrumb Tenant ▸ Inventory ▸ Stock.

  2. Resolve filter state from query string (deep-linkable filters); empty state defaults to no filters and the full stock view.

  3. Call GET /api/race-numbers/stats?groupBy=state (CD-4 — flat enum-keyed map) and render the KPI tile strip.

  4. Call GET /api/race-numbers/stock?…​&page=0&size=50&sort=…​ (CD-6 — RaceNumberResourceEx#stockView, criteria-object) and render the table.

  5. Operator narrows by filter strip / KPI tile / search; URL query string updates so the view is shareable / bookmarkable.

  6. Operator clicks a row → drill-down side panel loads GET /api/race-numbers/{id}/state-log (CD-5 — List<RaceNumberStateLogDTO> ordered createdDate DESC).

  7. Operator selects rows; bulk-select toolbar appears; chosen action navigates to T03 (Return) or opens the T04 confirm dialog (Flag UNFIT / Dispose) inline.

  8. After T03 / T04 returns control, the table refresh is automatic (re-fetches the current page + KPI strip).

Row-state action gating (ADR-0003 enforcement at row level)

Per ADR-0003, UNFIT_FOR_SERVICE is an admin-owned fitness verdict that nothing outside this screen / T04 may flip. The race-number lifecycle gives UNFIT_FOR_SERVICE exactly two valid forward transitions: → DESTROYED (via dispose) and → UNFIT_FOR_SERVICE (return — clears person_id, state preserved with "recommend disposal" note).

The bulk-select toolbar therefore gates per-row actions by the selected rows' states:

Selected row state Return Flag UNFIT Dispose Notes

IN_STOCK

no-op (idempotent)

✅ enabled

❌ disabled

Already in stock; Return produces NO_OP rows in T03 summary. Dispose is rejected — only UNFIT may dispose.

ISSUED

✅ enabled

✅ enabled

❌ disabled

Standard return path. Flag UNFIT keeps person_id, marks unfit.

IN_USE

✅ enabled

✅ enabled

❌ disabled

Same as ISSUED — last_used already stamped from result import.

UNFIT_FOR_SERVICE

✅ enabled (preserves UNFIT, clears person_id)

❌ disabled — already UNFIT

✅ enabled

The → DESTROYED path lives only on UNFIT rows.

DESTROYED

❌ disabled — terminal

❌ disabled — terminal

❌ disabled — terminal

Read-only. Row renders quieter (greyed pill); checkbox is uncheckable.

Mixed selections (e.g. UNFIT + ISSUED rows) constrain to the intersectionDispose is enabled only if every selected row is UNFIT; Flag UNFIT is enabled only if no selected row is already UNFIT or DESTROYED. Disabled buttons render with tooltips per the project rule on disabled-affordance disclosure (see project memory feedback_explain_disabled_ui.md):

  • Dispose disabled tooltip — "Only UNFIT numbers can be disposed. Filter to UNFIT rows or remove non-UNFIT items from the selection."

  • Flag UNFIT disabled tooltip when an UNFIT row is in the selection — "Some selected numbers are already UNFIT. Remove them from the selection to flag the rest."

  • Return disabled tooltip on a DESTROYED-only selection — "DESTROYED numbers are terminal — they cannot be returned to stock."

Disabled-with-tooltip is preferred over hidden — operators must see the policy.

Alternative Flows

  • AF-1 — UNFIT row selected, operator hovers Dispose: button enabled with helper text "These N UNFIT numbers will be marked DESTROYED. This is irreversible." (Confirm dialog handled by T04.)

  • AF-2 — DESTROYED rows in the table: rendered greyed, checkbox uncheckable, row click does NOT open the drill-down panel (read-only). Mouse hover shows tooltip "Terminal state — record retained for audit only."

  • AF-3 — Empty stock for selected filters: empty-state card with "No numbers match these filters" + a Clear filters action that resets the strip and re-fetches.

  • AF-4 — Drill-down endpoint returns 5xx: side panel renders a one-line "Couldn’t load audit log — try again" with a retry link; no error toast (the rest of T02 stays usable).

  • AF-5 — Sidebar tenant scope not active (user is not admin): T02 route is hidden by C01’s role gate; a deep-link to /inventory/numbers while non-admin redirects to E05 with an info toast "Tenant inventory requires admin permission."

  • AF-6 — Bulk-action returns partial success (some rows skipped/rejected): T04’s per-id outcome summary handles the visualisation; T02 just refreshes the table when the operator dismisses the summary.

  • AF-7 — KPI tile click while a multi-state selection is active in the filter strip: the click replaces the multi-state selection with the single tile state. (Pre-emptive call-out: the alternative — toggling — is rejected because the tile is meant as a quick-narrow shortcut, not a filter modifier.)

Acceptance Criteria

  • T02 renders inside C01’s shell with the tenant scope sidebar active.

  • Filter strip exposes NumberType / subtype / state / free-text q; reset clears all four; URL query string updates as filters change so the view is shareable.

  • KPI tile strip shows counts for the five operational states (MANUFACTURED omitted); tile click narrows the table to that single state.

  • Table renders the eight columns; default sort is type asc + sequence asc; pagination is server-driven at page size 50.

  • Row-state action gating per the table above; disabled actions render with tooltips per feedback_explain_disabled_ui.md.

  • DESTROYED rows are visually quieted, uncheckable, and the drill-down does not open on click.

  • Bulk-select toolbar materialises only when ≥1 row is selected; selection survives pagination up to a soft cap of 200 rows; over the cap, surface an inline "select-all-matching not yet supported" notice.

  • After T03 / T04 returns, the table + KPI strip auto-refresh; URL filters preserved.

  • Drill-down side panel renders the per-number audit log from GET /api/race-numbers/{id}/state-log (entries newest first); an error from that endpoint surfaces as an inline retry-link in the panel only, never a global toast.

API Surface

Call Purpose

GET /api/race-numbers/stock?{stateIn,typeIdEquals,subTypeIdEquals,personIdSpecified,sequenceGreaterOrEqualThan,sequenceLessOrEqualThan,lastUsedGreaterOrEqualThan,lastUsedLessOrEqualThan,q,page,size,sort}

Filtered + paginated stock list. Existing RaceNumberResourceEx#stockView (CD-6). Criteria-object surface verified / extended by Thread B’s criteria sweep (state.in, typeId.equals, subTypeId.equals, q minimum). Free-text q is the only sweep dependency — if Thread B reports q as out of pattern, T02 drops the search box from v1; KPI-tile + filter-strip narrowing remains sufficient.

GET /api/race-numbers/stats?groupBy=state

KPI tile counts. CD-4: returns a flat enum-keyed map keyed by RaceNumberState enum names — { "IN_STOCK": 1234, "ISSUED": 567, "IN_USE": 890, "UNFIT_FOR_SERVICE": 12, "DESTROYED": 5 }. Future groupBy dimensions ship on a separate endpoint with a different shape.

GET /api/race-numbers/{id}/state-log

Audit log for the drill-down side panel. CD-5: returns List<RaceNumberStateLogDTO> ordered by createdDate DESC. Thread B is scoped to ship this endpoint in the same wave as /stats.

POST /api/race-numbers/return, POST /api/race-numbers/flag-unfit, POST /api/race-numbers/dispose

Used indirectly via T03 / T04. T02 itself only forwards the selected ids; the request bodies + response handling live on the bulk screens.

Out of Scope

  • Stock-take return workflow — T03.

  • Bulk flag/dispose actions — T04.

  • Reports — T05.

  • Pre-assignment for events — E07.

  • Manufactured-numbers onboarding (C02 / T06 / T07) — Track 3 (Thread E).

  • Auto-assignment UX (Feature #403 dependent) — explicitly out of scope this round per the plan.

  • Customer notification when a number is flagged UNFIT — out of scope this round.

  • Tenant-scoped reads on RaceNumber / NumberType — T02 v1 ships unscoped. The schema gap (no organisation_id on NumberType or RaceNumber) is a multi-PR, cross-repo workstream carved out as a separate ADO Bug under Epic #35 per orchestration journal CD-7. Acceptable for v1 because (a) production today is single-tenant (WPCA only); (b) Thread A’s sidebar entries are admin-role gated, so the only multi-tenant exposure path is via OrgPermission, not raw API; (c) the carved-out workstream is the proper fix and is filed. Implementation US should add // TODO: tenant-scope per ADO-<bug-id> comments at the controller / query-service level.

Design Anchors

Design Decisions

  • KPI tile strip omits MANUFACTURED (2026-05-07). MANUFACTURED is onboarding-transient; including it would inflate the strip with a state operators don’t act on at T02. T02 is a stock-operations view, not an onboarding view (C02 / T06 / T07 cover onboarding). If a MANUFACTURED count is ever needed it surfaces in T05 reports.

  • Filter URL state (2026-05-07). Filter selections, the active KPI tile, and the current page are all reflected in the URL query string so the view is shareable / bookmarkable. Same pattern as tanstack-event-participant-list adopts (see plan’s "second adopter of the Tanstack filter substrate" framing). Implementation must NOT modify app/shared/filter/ to support this — if a substrate gap is found, fix in prototypes/filterable-list/ first per the filter journal’s mirror rule.

  • Row-state action gating mirrors ADR-0003 + state machine (2026-05-07). The gating table above is the UI projection of the four valid transitions documented in the lifecycle journal’s Updated Transitions block (sessions 2/3). Disabled actions render with tooltips explaining the policy — this is required by project memory feedback_explain_disabled_ui.md (lesson from the WPCA stock-take incident class).

  • Drill-down panel ships in v1 (2026-05-07). All four T02 components — filter strip, KPI strip, table, bulk-select toolbar, drill-down panel — run against endpoints that exist (or are scoped to ship in this wave per CD-5). The earlier "drill-down is gated" caveat is closed.

  • Bulk-select selection model (2026-05-07). Selection is held in component state and persists across pagination up to a soft cap (200 rows). Beyond that, the toolbar surfaces a "More than 200 rows selected — select-all-matching is not yet supported" inline notice; operator must narrow the filter. This avoids the unbounded-selection-overhead trap and matches the EP list’s behaviour. Reviewable cap value during implementation.

  • Tenant scoping deferred to a separate workstream (2026-05-07, CD-7). The schema gap is real but multi-PR; T02 v1 ships unscoped with a documented limitation. Implementation US must add a // TODO: tenant-scope per ADO-<bug-id> comment at the read sites so the deferral is visible in code review.

Open Questions

  1. q (free-text search) — Thread B’s criteria sweep determines whether q is in pattern on RaceNumberCriteria. If not, T02 drops the search box from v1; filter strip + KPI tile narrowing remains sufficient. (Sole remaining open question after CD-4/-5/-6/-7.)

Notes

Backend WS1a + WS1b shipped; T02 surfaces existing endpoints. Hub for the rest of D2b — T03 / T04 launch from selections here.

Status: handoff-ready (2026-05-07). Coordination Backlog #1 / #2 / #3 resolved in master journal as CD-4 / CD-5 / CD-6; CD-7 defers tenant scoping with a documented limitation. The Claude Design hand-off prompt below is now paste-ready; the orchestrator dispatches it.

Appendix A: Claude Design Prompts

Prompts persisted for audit trail. Most recent first. The v1 prompt is the active hand-off prompt; it will be re-emitted if Coordination Backlog resolutions change the API surface materially.

v1 — 2026-05-07 — derived from this .adoc

Status: paste-ready. Source: this .adoc at :status: handoff-ready. Locked API shapes per CD-4 (stats = flat enum-keyed map) and CD-5 (state-log = List<RaceNumberStateLogDTO> ordered DESC). Tenant scoping deferred per CD-7.

I'm designing a screen for the EMS admin portal — a Spring Boot + Angular SPA
admin tool used by event operators (race directors) and tenant admins to manage
participants, events, and operational data. Visual language matches existing
portal screens you've designed in this project (C01, C03, E01, E02, E05) —
JHipster 8 / Angular 16 / PrimeNG / ng-bootstrap stack; match fonts, spacing,
palette, density.

Design **T02 — the Number Stock View**: a tenant-scoped current stock view of
all race numbers (bib numbers, boards). The tenant admin / stock operator
uses it to see what's in stock, what's been issued, what's flagged unfit, and
to launch bulk actions (return, flag UNFIT, dispose).

T02 sits inside C01's shell. Sidebar is in the `tenant` scope (a new third
scope alongside `portfolio` and `event`, added in this design round) with an
"Inventory" group: Stock (this screen), Return, Bulk, Onboard, Manufacturer
export. T02 owns the main content area only.

A cross-cutting design rule: any disabled, hidden-by-policy, or limited
affordance MUST surface its reason inline (tooltip on hover, helper text,
empty-state copy). Operators should never have to guess why a control is
unavailable — this is a hard project rule.

============================================================
Layout (top to bottom inside the main content area)
============================================================

1. Filter strip — sticky to the top of the main area when the table scrolls.
   - NumberType (single-select dropdown).
   - Subtype (single-select; options scoped to the selected NumberType;
     disabled with tooltip "Pick a NumberType to filter by subtype" when
     no type is selected).
   - State (multi-select chip picker over five states: IN_STOCK, ISSUED,
     IN_USE, UNFIT_FOR_SERVICE, DESTROYED). MANUFACTURED is omitted.
   - Free-text search q (a single text input).
   - Reset (right-aligned ghost button) — clears all four filters and
     unsets any active KPI tile.

2. KPI tile strip — five tiles, one per operational state:
     IN_STOCK · ISSUED · IN_USE · UNFIT_FOR_SERVICE · DESTROYED
   Each tile shows the state name, the count, and an icon. The active
   tile renders in the accent treatment (indigo, matching C01 / E05).
   Clicking a tile narrows the table to that single state — it REPLACES
   any multi-state selection in the chip picker, not toggles. KPI counts
   come from a server endpoint (see "API surface" below); they should
   re-fetch when filters change.

3. Table — main content.
   Columns (left to right):
     - checkbox (bulk-select)
     - number (bold)
     - type
     - subtype
     - state pill (coloured per state; UNFIT in amber, DESTROYED in
       quieter grey, IN_STOCK in green, ISSUED in indigo, IN_USE in
       blue)
     - person (link if person_id set, em-dash if blank)
     - last_used (date, em-dash if null)
     - sequence (right-aligned numeric)
     - tag-mate barcode (monospace, em-dash if null)
   Default sort: type asc, sequence asc. Sortable on every column except
   state and tag-mate. Pagination: server-driven, page size 50, standard
   PrimeNG paginator.

   Row treatment:
     - Hover: subtle background tint, row click opens the drill-down
       panel (right rail).
     - DESTROYED rows: greyed text, checkbox uncheckable (greyed),
       click does NOT open the drill-down (terminal — read-only).
     - Selected rows: accent-tinted background, checkbox checked.

4. Bulk-select toolbar — materialises ABOVE the table (between filter
   strip and table) when ≥1 row is selected.
   - Left: "{N} selected" label + a "Clear selection" link.
   - Right: three buttons: Return  ·  Flag UNFIT  ·  Dispose
     Buttons are gated by the row states in the current selection:
     - Return: enabled unless every selected row is DESTROYED.
       Tooltip on disabled: "DESTROYED numbers are terminal — they
       cannot be returned to stock."
     - Flag UNFIT: enabled only if NO selected row is currently UNFIT
       or DESTROYED.
       Tooltip on disabled (UNFIT in selection): "Some selected
       numbers are already UNFIT. Remove them from the selection to
       flag the rest."
       Tooltip on disabled (DESTROYED in selection): "DESTROYED
       numbers cannot be flagged."
     - Dispose: enabled only if EVERY selected row is UNFIT.
       Tooltip on disabled: "Only UNFIT numbers can be disposed.
       Filter to UNFIT rows or remove non-UNFIT items from the
       selection."

   On click:
     - Return → navigates to T03 (the Stock Return screen) with the
       selected ids passed in route state.
     - Flag UNFIT → opens the T04 confirm dialog inline (modal)
       pre-loaded with the selected ids.
     - Dispose → opens the T04 confirm dialog inline (modal)
       pre-loaded with the selected ids; this dialog must show the
       irreversible-DESTROYED warning copy from T04.

   Selection persists across pagination up to a soft cap of 200 rows;
   beyond that, render an inline notice ("More than 200 rows selected
   — select-all-matching is not yet supported") and disable further
   row checks.

5. Drill-down side panel — right rail, opens on row click for any
   non-DESTROYED row.
   - Header: "Audit log — bib {number}" + close (X).
   - Body: per-transition list, newest first (server returns
     `List<RaceNumberStateLogDTO>` ordered by createdDate DESC).
     Each entry shows:
     - state transition (e.g. "ISSUED → IN_USE") rendered as two
       state pills with an arrow.
     - reason code (e.g. "RETURNED", "RESULT", "ASSIGNED",
       "REASSIGNED", "FLAG_UNFIT", "DISPOSE", "STOCK_TAKE_SCAN").
     - actor (principal login).
     - timestamp.
     - optional note (italic, indented).
     - optional batch link ("Print batch #PB-2026-04-22-001" or
       "Stock-take #ST-2026-05-01-001") — clickable when present.
   - Empty state when no log entries: "No audit log entries yet."
     (Defensive — number was created without state transitions.)
   - Error state (5xx from /state-log): one-line "Couldn't load
     audit log — try again" with a retry link inside the panel; do
     NOT raise a global error toast (rest of T02 stays usable).

============================================================
Filter & state behaviour
============================================================

- All filter selections + the active KPI tile + the current page +
  current sort are reflected in the URL query string so the view is
  shareable. Format follows the JHipster Tanstack convention (e.g.
  `?stateIn=S,F&typeIdEquals=78&q=H10&page=2&sort=sequence,asc`).

- After a bulk action returns control to T02 (T03 navigation back, or
  T04 dialog close), the table and KPI strip refresh automatically.

- Empty state: if the filtered query returns zero rows, render an
  empty-state card with "No numbers match these filters" and a
  "Clear filters" action that resets the strip.

============================================================
API surface
============================================================

GET /api/race-numbers/stock
  Query params (criteria-object):
    stateIn, typeIdEquals, subTypeIdEquals, personIdSpecified,
    sequenceGreaterOrEqualThan, sequenceLessOrEqualThan,
    lastUsedGreaterOrEqualThan, lastUsedLessOrEqualThan, q
  + standard Spring Pageable: page, size, sort
  Returns: paginated page of RaceNumberDTO with state, type, subtype,
  number, sequence, personId, personName, lastUsed, tagBarcode.

GET /api/race-numbers/stats?groupBy=state
  Returns: flat enum-keyed map of state → count for the KPI tiles.
    { "IN_STOCK": 1234, "ISSUED": 567, "IN_USE": 890,
      "UNFIT_FOR_SERVICE": 12, "DESTROYED": 5 }
  Keys are the canonical RaceNumberState enum names.

GET /api/race-numbers/{id}/state-log
  Returns: List<RaceNumberStateLogDTO> ordered by createdDate DESC.
  Each entry: { id, raceNumberId, transitionFromState, transitionToState,
  reason, note?, createdBy, createdDate, batchId? }.

============================================================
Output
============================================================

For each of these screen states:
  - default view (no filters, all rows, KPI strip showing all states)
  - filtered to UNFIT_FOR_SERVICE (KPI tile click)
  - bulk-select with mixed selection (Dispose disabled with tooltip)
  - bulk-select with UNFIT-only selection (Dispose enabled,
    irreversible warning visible in the confirm dialog mock)
  - drill-down panel open on a row with rich audit log (≥3 entries)
  - empty-state (filters yield zero rows)

Provide:
  - HTML/JSX + matching styles per screen state.
  - Screen README explaining what T02 does, the row-state action
    gating matrix, the URL-state contract, and the API endpoints
    consumed.
  - Cross-screen note: T02 hands off to T03 (route navigation) and T04
    (modal launch). The T03 and T04 screens are designed separately
    in this same round; T02 must not duplicate their internals.