[C04] Reassignment Dialog

Summary

Cross-cutting, reusable dialog used wherever an event participant holds a race number and the operator needs to swap it. First caller: E02 Event Participants. Future callers: E07 Pre-assignment.

The dialog provides the operator’s explicit signal for what should happen to the old number X. Without it, X can sit in ISSUED indefinitely after a physical replacement — ghost inventory.

Actor & Context

Actor: event organiser, tenant admin, or stock operator. Frequency: ad-hoc; surfaces whenever a number swap is needed (damaged at sign-in, lost in transit, operator override). Precondition: an EP exists with an assigned number; user has permission to reassign. Entry point: Reassign number action on E02 row; Assign inline action on E01 finalisation readiness panel; future per-EP screens.

Main Flow

  1. Dialog opens with EP context (event, name, current number).

  2. New-number picker — pre-populated from the WS1b last-used pick list for person_id (same subtype first; pool fall-through).

  3. Swap-reason picker (required): Damaged, Lost, Other + optional free-text note.

  4. Temporary checkboxTemporary — do not cascade to future events (default unchecked).

  5. Cascade preview — "This change will also detach N future EP(s)." Backed by POST …​/reassign-number-preview (US #559); before that endpoint existed the cascade was only knowable after the swap, so the dialog could report it but never warn about it. The same call supplies the assignable verdict, so an unusable bib is caught while the operator can still pick another.

  6. Submit: a single POST /api/event-participants/{id}/reassign-number. The swapReason on the request decides the old number’s fate server-side, in the same transaction as the swap — the client chains nothing. See Old-number disposition is server-side (US #559, 2026-08-27).

  7. Success banner reports the cascade summary: originating EP swapped, N future EPs detached, temporary y/n, and what became of the old bib (oldNumberDisposition).

Alternative Flows

  • AF-1: Retired 2026-08-27 by Old-number disposition is server-side (US #559, 2026-08-27). This flow described the swap succeeding while the chained action on X failed, leaving X stranded and the admin told to fix it by hand. The disposition now runs inside the reassignment’s transaction, so the two cannot diverge: either both land or neither does. The dialog no longer needs a partial-failure surface.

  • AF-2: Cascade preview shows zero future EPs — proceed without warning.

  • AF-3: Permission denied — dialog closes with a clear error.

Acceptance Criteria

  • Dialog renders per Claude Design pass.

  • Component is reusable (no E02-specific imports).

  • All three swap-reason chains behave per Session 9 of the journal.

  • Cascade summary surfaced after submit.

  • Failure-handling path tested.

  • Each reason’s consequence line matches the oldNumberDisposition the server reports back — no line claims an outcome the server did not perform.

API Surface

Corrected 2026-08-23. Two paths in the previous revision did not exist in admin-service and were verified against the code:

  • GET /api/persons/{personId}/number-pickList → actually GET /api/people/{personId}/race-numbers (people, not persons).

  • POST /api/race-numbers/mark-lost → does not exist. The LOST action is EP-scoped: POST /api/event-participants/{epId}/mark-number-lost.

The v3 hand-off prompt in the appendix still carries both errors and must not be used verbatim.

Call Purpose

GET /api/people/{personId}/race-numbers

Last-used pick list for the new-number picker (WS1b). Implemented by PersonRaceNumberResourceEx.

POST /api/event-participants/{id}/reassign-number

Primary reassignment (US #505). Body is NumberReassignmentRequestDTO(numberId, swapReason, note, temporary); response is NumberReassignmentResponseDTO carrying old/new number ids, the effective temporary flag, and one DetachedPeer entry per cascaded EP — which is exactly what the cascade summary renders.

POST /api/event-participants/{id}/reassign-number-preview

Read-only dry-run of the above (US #559). Same request body, writes nothing. Returns NumberReassignmentPreviewDTO: the peers the cascade would detach, the oldNumberDisposition that would apply, and an assignable verdict with a human-readable assignabilityRejection when the candidate number cannot be taken (UNFIT, DESTROYED, held by another person). Safe to call on every candidate the operator tries and abandons.

POST /api/race-numbers/flag-unfit

No longer chained by C04. Still the correct endpoint for a deliberate flag-unfit from T04. Damaged now flags X inside the reassignment transaction.

POST /api/event-participants/{epId}/mark-number-lost

No longer chained by C04, and it could never have worked here. It resolves the number from the EP’s current number_id, which after a swap is the replacement — so the documented chain would have marked the newly issued bib LOST. Retained for the WS2 flow it was written for, where the participant has lost the bib they still hold.

Old-number disposition is server-side (US #559, 2026-08-27)

The dialog exists to capture the operator’s signal for what happens to the old number X. Until this change that signal decided nothing: swapReason was a free-form string that reached the audit note and stopped there. reassignNumber repointed the EP and left X ISSUED with its person_id intact, attached to no EventParticipant — the exact ghost inventory the dialog was created to prevent.

The client was supposed to close that gap by chaining a second call. Against the real endpoints, only one of the three reasons could:

Reason Consequence line shown to the operator What actually happened

Damaged

X flagged Unfit, removed from the pool

Correct — flag-unfit takes explicit numberIds.

Lost

X marked Lost

Wrong bib. The only LOST endpoint is EP-scoped and reads the EP’s current number, which by then is the replacement.

Other

"X returns to the available pool"

Nothing. No chained call existed. X stayed ISSUED.

Two of the three consequence lines were false, and the consequence line is the one thing the operator reads to confirm what they are authorising.

Decision. swapReason becomes the discriminator for X’s fate, applied by admin-service in the same transaction as the swap:

  • DAMAGED → flag X UNFIT_FOR_SERVICE

  • LOST → record a LOST marker against X explicitly, never resolved from the EP

  • OTHER → return X to stock (ISSUEDIN_STOCK)

  • anything unrecognised, including absent → X untouched, exactly as before

The last row is load-bearing: swapReason has always been an open string, so callers predating this change may send labels that mean nothing to the enum. They keep the behaviour they were written against rather than having a disposition guessed for them.

Why server-side rather than a client chain. It is the only way LOST can name the right bib without the portal reordering its calls in a way that inverts AF-1; one transaction removes AF-1 as a reachable state instead of warning about it; and E07, the E01 readiness panel and the import paths inherit correct disposal without each re-implementing the chain.

Side effect worth knowing. Damaged and Other route through the existing flag-unfit / return primitives, which attach to an OPEN OperationalBatch of the matching type (auto-opening one if none exists). Reassignment-driven flags and returns therefore appear in FLAG_UNFIT / RETURN batch manifests and T05 stock reports alongside deliberate ones — which is the point: a bib pulled from circulation by a mid-event swap should not be invisible to stock-take reconciliation.

PRINT_BATCH — the envelope-printing group — is not affected. It is gated by isPrintRelevant, which admits ASSIGNED and REASSIGNED only, so the single row a swap contributes to it is the REASSIGNED on the new number: the assigned number, which is what the envelope needs. The old bib’s disposition lands in a different batch type and cannot enter a print batch; LOST attaches no batch at all. The only residual is that Other may auto-open a RETURN batch holding a single reassignment-driven return — the same shape as any T03 return, and invisible to envelope printing.

Out of Scope

  • Changing the event participant’s category. Decided 2026-08-23: that is a separate dialog, C08, so C04 keeps its single job — the fate of the old bib — and stays reusable for E07 and the E01 readiness panel.

  • Bulk reassignment across many EPs — single-EP only for v1.

  • Auto-disposal chain from Damaged — admin runs dispose as a separate step.

  • SMS notification when X is flagged — separate Feature.

Design Anchors

Design Decisions

  • Per-reason consequence-on-X — inline, live-updating (2026-04-29). Below the swap-reason picker, a single line updates live as the operator changes the radio:

    • Damaged → "Bib X will be flagged Unfit For Service and removed from the available pool."

    • Lost → "Bib X will be marked Lost."

    • Other → "Bib X returns to the available pool."

      Rationale: explicit signal for what happens to X is the entire reason the dialog exists (avoids ghost inventory in ISSUED); hiding it behind a confirm step demotes the most important decision in the dialog. Implies the swap-reason picker is radios, not a dropdown, so the consequence line is always visible alongside the choice.

  • Single-EP scope (2026-04-29). Confirmed: dialog handles one EP / one number swap at a time. Bulk reassignment remains out of scope per existing spec.

  • "About this dialog" information panel (2026-04-29). Persistent collapsible panel near the top of the dialog with a short purpose paragraph + bulleted operational details (the same plain-language explanations of the three reason consequences). Default expanded on first open per browser, collapsed thereafter (state persisted in localStorage). Trains operators in-flow without forcing them to read external docs. Cross-cutting pattern — applied symmetrically in C05 and E06; candidate for promotion to UI Design Principles.

Notes

Reuses backend behaviour from US #505 (shipped). UI candidate for @ems/shared-ui migration once a second consumer surfaces.

Design complete. The Claude Design pass is done and linked above as :design-url: — eleven artboards covering the primary states, all three reason consequences, cascade and temporary, and the AF-1 / AF-3 result events on the parent surface.

The v3 prompt in the appendix was never used: it carries the two incorrect API paths corrected above, and the design was authored directly against this .adoc instead. It is retained for the audit trail only.

Repaired 2026-08-26. The design canvas had been broken since the C05 reorganisation — it loaded C05-E06-import-flow/shared/flow-ui.jsx, a folder that no longer exists, and called flowBtn(), since renamed impBtn. Its chrome now comes from shared/dialog-shell.jsx, shared with C08, so a dialog no longer reaches into another feature’s private module. The design README also still documented the two wrong API paths and has been corrected to match this page.

Appendix A: Claude Design Prompts

Prompts persisted for audit trail. Most recent first. The v3 prompt is the active hand-off prompt; earlier versions are retained for lessons-learned reference.

v3 — 2026-04-29 — derived from this .adoc

Superseded 2026-08-23. Contains the two incorrect API paths corrected above. Regenerate from the current .adoc before any hand-off.

Status: superseded. Source: this .adoc at :status: handoff-ready.

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, E01, E05). Match modal-width, header
treatment, button placement, and footer toolbar to C01.

Design **C04 — the Reassignment Dialog**: a cross-cutting reusable
modal where an event operator swaps one race number for another on an
event participant (EP). Single-EP, single-swap. First caller is E02
(event participants list); future callers include E07 (pre-assignment)
and an event-finalisation readiness panel — the dialog must have NO
caller-specific imports.

The dialog's central job: capture the operator's explicit signal for
what should happen to the OLD number X. Without this signal, X can sit
in `ISSUED` indefinitely after a physical replacement, creating ghost
inventory.

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

1. Header
   - Title: "Reassign number".
   - Sub-title: EP context — "<event> · <participant name> · currently
     assigned bib <X>".
   - Close (X) button top-right.

2. "About this dialog" information panel
   - Collapsible, default expanded on first open per browser, collapsed
     thereafter (state persisted in localStorage).
   - Short purpose paragraph: "Swap a participant's race number and
     decide what happens to the old number."
   - Bulleted operational details — the same plain-language explanations
     of the three reason consequences below, restated in plain prose.

3. New-number picker
   - Pre-populated from GET /api/persons/{personId}/number-pickList
     (returns the WS1b last-used pick list — same subtype first; pool
     fall-through).
   - Filterable dropdown (same component as C05 cell-mapping; filter
     auto-appears above ~10 options).

4. Swap-reason picker — RADIOS (not a dropdown)
   Three options:
   - `Damaged`
   - `Lost`
   - `Other`
   Plus an optional free-text "Operator note" field below.

5. Per-reason consequence-on-X line — INLINE, live-updating
   Single line directly beneath the radios that updates as the operator
   changes selection:
   - `Damaged` → "Bib <X> will be flagged Unfit For Service and removed
     from the available pool."
   - `Lost` → "Bib <X> will be marked Lost."
   - `Other` → "Bib <X> returns to the available pool."

6. Temporary checkbox
   - Label: "Temporary — do not cascade to future events"
   - Default: unchecked.

7. Cascade preview
   - One-line message: "This change will also detach N future EP(s)."
   - Hidden if N is zero.

8. Footer toolbar
   - "Cancel" (left) — closes dialog, no API calls.
   - "Confirm" (right, primary) — fires the API chain below.

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

On Confirm:

- Always: POST /api/event-participants/{id}/reassign-number
  (request includes new-number-id, swapReason, temporary flag,
  optional note).
- Then chain on the OLD bib X based on swapReason:
  - `Damaged` → POST /api/race-numbers/flag-unfit
  - `Lost`    → POST /api/race-numbers/mark-lost
  - `Other`   → no chained call.

Success — close the dialog and show a success banner on the parent
screen with the cascade summary: originating EP swapped + N future EPs
detached + temporary y/n.

Failure modes:
- AF-1: Reassignment succeeds, chained X-side action fails → success
  banner for the swap PLUS a warning toast that X needs manual attention.
  Primary swap NOT rolled back.
- AF-3: Permission denied → close dialog, show error toast on parent.

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

- HTML/JSX + matching styles for the dialog.
- README explaining:
  - The three reason chains and why the consequence line is inline
    (it's the operator's only signal for what happens to X).
  - The reusability contract — no E02-specific imports; the dialog
    receives EP context as a prop and emits a result event.
  - The "About this dialog" information-panel pattern — and that the
    same pattern should appear on full-screen flows like C05/E06 (right
    rail rather than top-of-modal).

v1 — 2026-04-29 — discarded

Status: discarded. Reason: drafted from session memory without reading this .adoc. Invented four swap reasons (Lost / Damaged / Mis-issued / Operator override) instead of the canonical three (Damaged / Lost / Other); missed the Temporary checkbox for cascade control; missed the Cascade preview line; didn’t reference the WS1b last-used pick list for the new-number picker.

Continuing the EMS admin portal design language. C04 is a dialog (modal)
used cross-cuttingly anywhere in the portal where an operator needs to
swap one entity for another — most concretely, race-number reassignment
during the event-day operations workflow.

Context: an event participant has been assigned race-number 142.
Mid-event the operator needs to swap them to race-number 187 (lost bib,
duplicate issue, deliberate move). The dialog must:

- Show: current assignment ("Bib 142 → Jane Doe, F35-39, Started 06:42").
- Ask for the swap target: "Assign Jane Doe to bib ___" with autocomplete
  on available bibs (filtered by the participant's category — bib 187 must
  be in the F35-39 category's number range).
- Ask for a reason: dropdown of {Lost, Damaged/Unfit, Mis-issued, Operator
  override}. The reason picker chains the right downstream consequences:
    - Lost / Damaged → mark the OLD bib as UNFIT_FOR_SERVICE (don't
      auto-reissue; operator does stock-take later).
    - Mis-issued / Operator override → mark the OLD bib IN_STOCK (back
      to the available pool).
- Show consequences inline as the operator picks a reason: "Bib 142
  will be marked UNFIT_FOR_SERVICE and removed from the available pool"
  vs "Bib 142 will return to the available pool". This is critical —
  operators get this wrong on paper currently.
- Free-text "operator note" (optional, for audit trail).
- Confirm + Cancel buttons. Confirm fires the swap and closes the dialog.
  An undo toast at the bottom of the parent page gives 10 seconds to
  reverse if it was a mistake.

Edge case to design for: target bib is already assigned to someone else.
The dialog should detect this and show a conflict warning with the option
to swap them too (chained reassignment) or pick a different bib.

Style: matches the portal modal patterns established by C01 (user/tenant
switcher) — same modal width, header treatment, button placement, footer
toolbar.

Output: HTML/JSX + styles, README explaining how this dialog is reused
across at least 3 contexts (race-number reassignment, operational batch
re-print, person merge confirmation). README also describes the
"undo toast" pattern so it can be standardised across the portal.