[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 UNFITbutton (mode =flag-unfit) orDisposebutton (mode =dispose). -
T03 outcome summary —
Dispose these Nbutton on theDISPOSAL_RECOMMENDEDgroup (mode =dispose). -
(Future) C04
Damagedchain — calls T04 inflag-unfitmode 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 = |
"About this dialog" panel |
Persistent collapsible panel (matches C04 / C05 / E06 pattern). Stage-aware copy — |
Selection summary |
Three counts laid out as inline tiles, derived from the selection and validated client-side:
* The "rejected" tile in either mode renders with a warning treatment when its count > 0 and links to a |
Optional note |
Free text input. Passes through to the API as the request’s |
Footer toolbar |
Left: |
Main Flow
-
Caller invokes T04 with
{ ids: Long[], mode: 'flag-unfit' | 'dispose' }. -
Modal opens with header reflecting mode + count, body showing the selection summary tiles.
-
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.
-
-
The
Remove rejected itemsaction (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. -
Operator clicks the primary action.
-
flag-unfit→POST /api/race-numbers/flag-unfitwith{ numberIds, note }. -
dispose→POST /api/race-numbers/disposewith{ numberIds, note }. Confirm-twice rule for dispose — see Irreversible-DESTROYED confirm copy below.
-
-
Response is rendered as a per-id outcome list (modal body switches to summary view):
OK/SKIPPED/REJECTED/NOT_FOUND. -
Operator clicks
Done— modal closes; T04 emits aresultevent 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
DESTROYEDis 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 copy —
Will disposetile 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) andYes — dispose {N}(primary, requires explicit click).The second confirm is the only interaction that fires the API call. Pressing
Escor clickingNo, go backreturns to the prior view with the selection + note intact. -
Disabled-affordance disclosure. If the dialog opens with a mixed selection (e.g.
disposemode but the selection contains non-UNFIT rows), the primaryDisposebutton 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 eitherRemove rejected itemsorCancel.
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
disposemode (some non-UNFIT rows): primary disabled; rejected tile shows count +Remove rejected itemsaction; tooltip "Only UNFIT numbers can be disposed. Remove non-UNFIT items from the selection or cancel." -
AF-2 — All rows already UNFIT in
flag-unfitmode: 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
disposemode: 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, someSKIPPED/REJECTED/NOT_FOUND): summary view groups by outcome with counts; operator clicksDone; caller refreshes the list view. -
AF-7 — Operator presses
Escduring the second-confirm of a dispose: returns to the first view of the dialog (selection + note intact). PressingEscagain 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 itemsaction mutates only the dialog’s local selection. -
disposemode carries the irreversible-DESTROYED banner inside the "About this dialog" panel, the explicit "irreversible" word in theWill disposetile, theDispose {N} — irreversibleprimary button, and the confirm-twice inline secondary confirm step.flag-unfitmode 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 Nshortcut (the selection fromDISPOSAL_RECOMMENDEDgroup is uniformly UNFIT, so the rejected tile is always 0).
API Surface
| Call | Purpose |
|---|---|
|
Batch UNFIT flag. Request: |
|
Batch UNFIT → DESTROYED. Request shape and response shape match |
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
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-unfitanddisposeshare 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
disposeto UNFIT-only andflag-unfitto non-UNFIT. Server enforces the same rule independently and returnsREJECTEDfor 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 Nrow). 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
disposemode, the panel’s body is dominated by the irreversible warning banner — the panel collapse state is reset to expanded each time the dialog opens indisposemode (override for safety; inflag-unfitmode, the user’s persisted preference applies).
Open Questions
-
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.
-
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: |
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).