[T04] Bulk Flag UNFIT / Dispose

Summary

Cross-cutting reusable confirm dialog. Bulk admin actions on RaceNumbers: flag a selection as UNFIT_FOR_SERVICE, or dispose UNFIT rows to DESTROYED. Honours ADR-0003 — UNFIT is the admin-owned fitness verdict and DESTROYED is the only valid forward transition out of UNFIT.

T04 is a modal launched from T02's bulk-select toolbar (and from T03's Dispose these N shortcut on the DISPOSAL_RECOMMENDED group). It does not own a route; it does not own a list. It receives a set of RaceNumber.id values + a mode (flag-unfit | dispose) from its caller, runs the operator through a confirm-and-execute step, and emits a result event with the per-id outcome summary.

Actor & Context

Actor: tenant admin. Frequency: ad-hoc; surfaces during stock takes, post-event reconciliation, and post-return UNFIT cleanup. Precondition: user has TENANT_ADMIN permission; selection from T02 (or T03 outcome group). Entry point:

  • T02 bulk-select toolbar — Flag UNFIT button (mode = flag-unfit) or Dispose button (mode = dispose).

  • T03 outcome summary — Dispose these N button on the DISPOSAL_RECOMMENDED group (mode = dispose).

  • (Future) C04 Damaged chain — calls T04 in flag-unfit mode for the old number after the reassignment lands. Out of scope this round.

Layout

T04 is a modal dialog, sized to match C04’s modal. Header + body + footer toolbar.

Region Content

Header

Title — "Flag {N} numbers as UNFIT" (mode = flag-unfit) or "Dispose {N} UNFIT numbers" (mode = dispose). Sub-title repeats the count and lists the first three bib numbers (P3014, P3015, P3018, +N more) so the operator visually confirms the selection. Close (X) top-right.

"About this dialog" panel

Persistent collapsible panel (matches C04 / C05 / E06 pattern). Stage-aware copy — flag-unfit mode explains what UNFIT means for retrieval and pick-list; dispose mode carries the irreversible-DESTROYED warning copy below as its primary content.

Selection summary

Three counts laid out as inline tiles, derived from the selection and validated client-side: * flag-unfit mode tiles: Will flag · Already UNFIT (skipped) · DESTROYED (rejected). * dispose mode tiles: Will dispose · Not UNFIT (rejected) · Already DESTROYED (skipped).

The "rejected" tile in either mode renders with a warning treatment when its count > 0 and links to a Remove rejected items action that updates the selection in the dialog (does NOT mutate the caller’s selection until the operator confirms).

Optional note

Free text input. Passes through to the API as the request’s note field. Defaults to empty.

Footer toolbar

Left: Cancel (secondary) — closes dialog without action; emits a cancelled result. Right: primary action — Flag UNFIT (mode = flag-unfit) or Dispose — irreversible (mode = dispose).

Main Flow

  1. Caller invokes T04 with { ids: Long[], mode: 'flag-unfit' | 'dispose' }.

  2. Modal opens with header reflecting mode + count, body showing the selection summary tiles.

  3. Validation. Client computes the three counts:

    • flag-unfit: Will flag = state ∈ {IN_STOCK, ISSUED, IN_USE}; Already UNFIT = state = UNFIT_FOR_SERVICE; DESTROYED = state = DESTROYED.

    • dispose: Will dispose = state = UNFIT_FOR_SERVICE; Not UNFIT = state ∈ {IN_STOCK, ISSUED, IN_USE}; Already DESTROYED = state = DESTROYED.

  4. The Remove rejected items action (visible only when the rejected tile > 0) drops the rejected ids from the local working selection, leaving only valid + skipped ids. The primary action is disabled while the rejected tile > 0 and the operator has not chosen to either remove them or abort.

  5. Operator clicks the primary action.

    1. flag-unfitPOST /api/race-numbers/flag-unfit with { numberIds, note }.

    2. disposePOST /api/race-numbers/dispose with { numberIds, note }. Confirm-twice rule for dispose — see Irreversible-DESTROYED confirm copy below.

  6. Response is rendered as a per-id outcome list (modal body switches to summary view): OK / SKIPPED / REJECTED / NOT_FOUND.

  7. Operator clicks Done — modal closes; T04 emits a result event with { mode, outcomeSummary } so the caller (T02 / T03) can refresh its view.

Irreversible-DESTROYED confirm copy (mode = dispose)

DESTROYED is the terminal state in the race-number lifecycle (per ADR-0003 — see ADR-0003). The record is retained for audit and historical result integrity, but the number can never be re-issued, paired, or transition to any other state. The confirm copy must be visually unambiguous:

  • Banner inside the "About this dialog" panel, mode-specific. Bold red border, warning icon. Copy:

    Disposal is permanent.

    + Marking a number DESTROYED is irreversible. The record stays in the database for audit and historical results, but the number can never be re-issued, paired with a tag, or transitioned to any other state. There is no undo and no admin override.

    Use this only after the physical numbers have been disposed of (returned to manufacturer, scrapped, or otherwise put beyond reuse).

  • Selection summary tile copyWill dispose tile reads "{N} numbers will be marked DESTROYED" with a small "irreversible" sub-line.

  • Primary button label — "Dispose {N} — irreversible" (not just "Dispose"). The label includes the count and the word "irreversible" so the keyboard-confirmation moment ("Enter") cannot be confused with a milder action.

  • Confirm-twice rule. Clicking the primary button does NOT immediately fire the request. It opens an inline secondary confirm row inside the dialog (no separate modal — keeps focus continuity):

    "Are you sure? This will mark {N} numbers DESTROYED. There is no undo."

    with a No, go back (secondary, default focus) and Yes — dispose {N} (primary, requires explicit click).

    The second confirm is the only interaction that fires the API call. Pressing Esc or clicking No, go back returns to the prior view with the selection + note intact.

  • Disabled-affordance disclosure. If the dialog opens with a mixed selection (e.g. dispose mode but the selection contains non-UNFIT rows), the primary Dispose button is disabled and the rejected tile carries the explanatory tooltip per the project rule on disabled-action disclosure (feedback_explain_disabled_ui.md). The operator must either Remove rejected items or Cancel.

The same confirm-twice rule does NOT apply to flag-unfit mode — flagging is reversible (admin can clear the flag manually) so a single confirmation step is sufficient.

Alternative Flows

  • AF-1 — Mixed selection in dispose mode (some non-UNFIT rows): primary disabled; rejected tile shows count + Remove rejected items action; tooltip "Only UNFIT numbers can be disposed. Remove non-UNFIT items from the selection or cancel."

  • AF-2 — All rows already UNFIT in flag-unfit mode: primary disabled; tile copy "All selected numbers are already UNFIT — nothing to flag." Operator cancels or returns to T02.

  • AF-3 — All rows already DESTROYED in dispose mode: primary disabled; tile copy "All selected numbers are already DESTROYED — nothing to dispose."

  • AF-4 — Permission denied (server returns 403): modal closes with an error toast on the parent screen. No partial state.

  • AF-5 — Network failure during submit: dialog stays open, primary re-enables, inline notice "Couldn’t submit. {error}. Try again — your selection is preserved."

  • AF-6 — Partial server-side success (some ids OK, some SKIPPED/REJECTED/NOT_FOUND): summary view groups by outcome with counts; operator clicks Done; caller refreshes the list view.

  • AF-7 — Operator presses Esc during the second-confirm of a dispose: returns to the first view of the dialog (selection + note intact). Pressing Esc again closes the dialog (cancelled).

Acceptance Criteria

  • Modal accepts { ids: Long[], mode: 'flag-unfit' | 'dispose' } from caller; emits { mode, outcomeSummary } result event on close.

  • No T02-specific or T03-specific imports; the dialog is reusable from a future C04 chain.

  • Selection summary tiles compute counts client-side from the rows' states; Remove rejected items action mutates only the dialog’s local selection.

  • dispose mode carries the irreversible-DESTROYED banner inside the "About this dialog" panel, the explicit "irreversible" word in the Will dispose tile, the Dispose {N} — irreversible primary button, and the confirm-twice inline secondary confirm step. flag-unfit mode does not.

  • Disabled primary always carries a tooltip explaining why (per feedback_explain_disabled_ui.md).

  • On submit, calls the matching endpoint exactly once; renders per-id outcome summary from the response DTO.

  • Behaves correctly when called from T03’s Dispose these N shortcut (the selection from DISPOSAL_RECOMMENDED group is uniformly UNFIT, so the rejected tile is always 0).

API Surface

Call Purpose

POST /api/race-numbers/flag-unfit

Batch UNFIT flag. Request: { "numberIds": [Long…​], "note": "string?" }. Response: BulkRaceNumberActionResponseDTO with per-id outcome (OK / SKIPPED / REJECTED / NOT_FOUND). Existing endpoint on RaceNumberResourceEx.

POST /api/race-numbers/dispose

Batch UNFIT → DESTROYED. Request shape and response shape match flag-unfit. Existing endpoint. Server-side enforcement: only UNFIT_FOR_SERVICE rows transition; others come back as REJECTED. Defence-in-depth — the client gating above is operator UX; the server is the safety net.

Out of Scope

  • Reassignment swap-reason chaining (Damaged → flag-unfit) — C04 (T04 is the eventual target for that chain, but the chaining wiring lands later).

  • Stock-take return flag-unfit chaining (return→flag) — T03 handles the return + recommend-disposal surfacing; T04 is invoked separately for the actual flag.

  • Single-number flag-unfit screen — implicit via a 1-item selection here.

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

  • Re-opening DESTROYED — explicitly impossible by design; no UI affordance exists or will exist.

Design Anchors

  • Portal Pattern

  • UI Design Principles

  • T02 — primary caller (bulk-select toolbar)

  • T03 — secondary caller (Dispose these N shortcut)

  • ADR-0001 — single state-log writer

  • ADR-0003 — UNFIT verdict immutability + DESTROYED semantics

  • feedback_explain_disabled_ui.md — disabled-action tooltip rule

Design Decisions

  • Cross-cutting modal, not a route (2026-05-07). T04 is a modal dialog reusable from any caller (T02 today, T03 today, C04 later). No route ownership; no list ownership. The caller passes { ids, mode } and receives { mode, outcomeSummary } back. Same architecture as C04.

  • Two modes in one dialog (2026-05-07). flag-unfit and dispose share the modal shell but render mode-specific copy throughout — header, banner, tile labels, primary button label. The two modes were considered as separate dialogs; merging keeps the shared scaffolding (selection summary, optional note, response handling) in one place. Differentiating copy at every visible touchpoint is the contract.

  • Client gating + server enforcement (2026-05-07). Client gates dispose to UNFIT-only and flag-unfit to non-UNFIT. Server enforces the same rule independently and returns REJECTED for any violation. Belt-and-braces — a UI bypass cannot punch through the lifecycle invariants.

  • Confirm-twice ONLY on dispose (2026-05-07). Disposal is irreversible; the confirm-twice rule is the one place in the portal where double-confirmation is appropriate (cf. project memory’s "explain disabled or limited UI affordances inline" lesson — extra friction on irreversible actions is consistent with that). Flagging is reversible, so it goes through a single confirmation step.

  • Inline secondary confirm, not a separate modal (2026-05-07). The second confirm appears inside the existing dialog (replaces the primary button row with a No, go back + Yes — dispose N row). A separate modal-on-modal was considered and rejected: too easy to dismiss the wrong layer, and focus management gets murky. Inline keeps the operator’s eye on the selection summary the whole time.

  • Default focus on No, go back (2026-05-07). On the second confirm, the secondary button receives focus. Operator must explicitly tab + click (or click directly) to fire the dispose. Pressing Enter without intent does NOT proceed. Mirrors the safe-default pattern used in destructive-action dialogs across the platform.

  • No undo, no soft-delete (2026-05-07). DESTROYED is genuinely terminal at the data layer; there is no admin override. The dialog does NOT advertise a "request undo" affordance because there is none. Honesty about consequences is the whole point of the irreversible copy.

  • Right-rail "About this dialog" panel (2026-05-07). Same cross-cutting pattern as C04 / C05 / E06. In dispose mode, the panel’s body is dominated by the irreversible warning banner — the panel collapse state is reset to expanded each time the dialog opens in dispose mode (override for safety; in flag-unfit mode, the user’s persisted preference applies).

Open Questions

  1. Confirm-twice copy — text is drafted from the lifecycle journal’s terminal-state semantics. If a real operator finds the wording too verbose / not stern enough, iterate after first user smoke. Captured here so the iteration is visible.

  2. Mode entry point from C04 Damaged — out of scope this round, but the modal contract ({ ids, mode }) is designed to support it directly. C04 redesign will adopt this entry point when it lands.

Notes

Backend already exposes both endpoints. UI is a thin confirmation + summary on top of two existing service-layer methods. The complexity here is operator safety copy, not service wiring.

Status: handoff-ready (2026-05-07). T04 is a modal with no direct dependency on /stats or /state-log; it consumes only the existing /flag-unfit and /dispose endpoints. The Claude Design hand-off prompt below is 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. T04 consumes only the existing /flag-unfit and /dispose endpoints — no API-shape dependencies on the resolved Coordination Decisions.

I'm designing a modal dialog for the EMS admin portal — same Spring Boot
+ Angular stack, same visual language as the screens you've designed in
this project (C01, C03, C04, E01, E05, E06). Match modal-width, header
treatment, button placement, and footer toolbar to C04 (the Reassignment
Dialog).

Design **T04 — Bulk Flag UNFIT / Dispose**: a cross-cutting reusable
modal where a tenant admin runs one of two bulk admin actions on a set
of race numbers — flag them UNFIT (mode = `flag-unfit`) or dispose
already-UNFIT numbers to DESTROYED (mode = `dispose`).

The dialog is launched from at least two callers:
- T02 bulk-select toolbar — both modes.
- T03 outcome summary `Dispose these N` shortcut on the
  `DISPOSAL_RECOMMENDED` group — `dispose` mode only.
A future caller is C04's `Damaged` reassignment chain — `flag-unfit`
mode only. The dialog therefore must have NO caller-specific imports;
it receives `{ ids: Long[], mode: 'flag-unfit'|'dispose' }` as a prop
and emits `{ mode, outcomeSummary }` as a result event.

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.

A second cross-cutting rule: every full-screen flow and every modal in
this portal includes an "About this screen" / "About this dialog"
panel — for modals, this lives at the top of the dialog body (above
the selection summary). Same pattern as C04 / C05 / E06.

============================================================
Dialog structure (top to bottom)
============================================================

1. Header
   - Title (mode-specific):
     - flag-unfit → "Flag {N} numbers as UNFIT"
     - dispose    → "Dispose {N} UNFIT numbers"
   - Sub-title: count + first three bib numbers + "+M more" (e.g.
     "5 numbers — P3014, P3015, P3018, +2 more").
   - Close (X) top-right.

2. "About this dialog" information panel
   - Collapsible, default expanded on first open per browser, collapse
     state persisted in localStorage.
   - flag-unfit body: short purpose paragraph + bullets explaining what
     UNFIT means: the number is removed from the auto-assignable pool,
     stays with its current holder until physical return, retrievals
     get coordinated separately, and the flag can be cleared by an
     admin if needed.
   - dispose body: dominated by an IRREVERSIBLE WARNING BANNER — bold
     red border, warning icon, copy:

       Disposal is permanent.

       Marking a number DESTROYED is irreversible. The record stays in
       the database for audit and historical results, but the number
       can never be re-issued, paired with a tag, or transitioned to
       any other state. There is no undo and no admin override.

       Use this only after the physical numbers have been disposed of
       (returned to manufacturer, scrapped, or otherwise put beyond
       reuse).

   - For dispose mode ONLY, override the user's collapse preference —
     re-open the panel each time the dialog opens in dispose mode.

3. Selection summary tiles — three inline tiles, mode-specific:
   - flag-unfit:
       Will flag (count) · Already UNFIT (count, skipped) · DESTROYED (count, rejected)
   - dispose:
       Will dispose (count) · Not UNFIT (count, rejected) · Already DESTROYED (count, skipped)

   Tiles are coloured by tone — primary (will act on), neutral (skipped),
   warning (rejected). For dispose mode, the `Will dispose` tile carries
   a small "irreversible" sub-line in red.

   When the rejected tile > 0, render below the tiles a small action row:
       "Remove these {N} from the selection — they can't be {disposed/flagged}."
   with a "Remove rejected items" link button. Clicking it drops the
   rejected ids from the dialog's local working selection and recomputes
   the tile counts. Does NOT mutate the caller's selection until the
   operator confirms the action.

   While the rejected tile > 0 AND the operator has not removed them,
   the primary button is disabled with the tooltip:
   - dispose:    "Only UNFIT numbers can be disposed. Remove non-UNFIT
                  items from the selection or cancel."
   - flag-unfit: "Some selected numbers can't be flagged (already UNFIT
                  or DESTROYED). Remove them from the selection or
                  cancel."

4. Optional "Note (audit log)" text input
   - Free text, single line. Helper: "Adds a note to the audit-log
     entry for every {flagged/disposed} number."

5. Footer toolbar
   - Left: Cancel (secondary) — closes dialog, no API calls. Emits
     `{ cancelled: true }`.
   - Right: primary — mode-specific label:
     - flag-unfit → "Flag {N} as UNFIT"
     - dispose    → "Dispose {N} — irreversible"

============================================================
Submit behaviour
============================================================

flag-unfit mode (single confirmation step):
- Click primary → POST /api/race-numbers/flag-unfit with
  { numberIds, note }.
- Response → modal body replaces the selection-summary section with a
  per-id outcome list grouped by outcome (OK / SKIPPED / REJECTED /
  NOT_FOUND).
- Footer becomes "Done" (single primary).
- Done click → modal closes; emits { mode: 'flag-unfit',
  outcomeSummary: response }.

dispose mode (CONFIRM-TWICE rule — irreversible):
- Click primary → does NOT fire the request yet. Replaces the footer
  toolbar (and ONLY the footer toolbar — selection summary stays
  visible above) with a secondary-confirm row:

    "Are you sure? This will mark {N} numbers DESTROYED. There is no
     undo."

    [No, go back] (secondary, RECEIVES FOCUS)   [Yes — dispose {N}] (primary)

- Default focus is on "No, go back". Pressing Enter does NOT proceed.
- Pressing Esc returns to the prior footer (one Esc = back to first
  primary; two Esc = close dialog, cancelled).
- Click "Yes — dispose {N}" → POST /api/race-numbers/dispose with
  { numberIds, note }.
- Response → same per-id outcome list rendering as flag-unfit.
- Done click → modal closes; emits { mode: 'dispose',
  outcomeSummary: response }.

Failure modes:
- Network failure mid-submit: dialog stays open, primary re-enables,
  inline notice in red above the footer: "Couldn't submit. {error}.
  Try again — your selection is preserved."
- Permission denied (403): modal closes with an error toast on the
  parent screen.

============================================================
Per-id outcome list (response rendering)
============================================================

After the API call returns, the dialog body's selection-summary section
is replaced with a per-id outcome list, grouped by outcome code:

  - OK         — flagged or disposed successfully.
  - SKIPPED    — already in the target state (no-op).
  - REJECTED   — server-side gating rejected the transition (mode
                 mismatch, permission, etc.).
  - NOT_FOUND  — id did not match a RaceNumber record.

Each row inside a group: bib number (bold) + state-before pill + arrow
+ state-after pill (or em-dash for SKIPPED / NOT_FOUND).

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

For each of these dialog states:
  - flag-unfit, clean selection (5 rows, all valid)
  - flag-unfit, mixed selection (3 valid, 1 already UNFIT, 1 DESTROYED)
    — primary disabled with tooltip
  - dispose, clean selection (5 UNFIT rows) — first view
  - dispose, clean selection — second confirm row visible
  - dispose, mixed selection (3 UNFIT, 2 IN_STOCK, 1 DESTROYED) —
    primary disabled with tooltip + "Remove rejected items" action
  - response view, mixed outcomes (3 OK, 1 SKIPPED, 1 REJECTED)
  - failure-state inline notice (network error)

Provide:
  - HTML/JSX + matching styles per state.
  - Screen README explaining:
    - The two modes and how the dialog renders mode-specific copy
      throughout (header, banner, tiles, primary label).
    - The confirm-twice rule for dispose and why it does NOT apply to
      flag-unfit.
    - The reusability contract — no caller-specific imports; the
      dialog receives `{ ids, mode }` as a prop and emits
      `{ mode, outcomeSummary }`.
    - Default focus + keyboard behaviour on the secondary confirm
      step (focus on "No, go back"; Enter does not proceed; Esc
      returns to prior view).