Skip to content

Order Detail (V2) — /orders/{id}

The V2 order-detail surface — view, edit, state transitions, M12 paid-date toggle, receipt re-emit. Owned by Mira. Cross-cuts: V2 scope M8 + M10 + M12 + M13 + M14, order lifecycle, receipt content, add-stringjob (the create-side counterpart), client-management-v2 (the order-history rows that link here), stringer-dashboard (the queue cards that link here too), data-model — Order, i18n architecture.

Source requirement

Goal

Three commitments, in tension-resolution order:

  1. Show the truth — every lifecycle date, the receipt number, the total, the comments, the BYO flags. Stefan's V1 XLSX gives him a row he can scan in two seconds; V2's detail page must be at least that legible. The page is a read surface first — edit affordances live behind explicit clicks.
  2. One tap to advance state, one tap to re-email. The 90% post-Strung path is "client paid, mark paid". The 5% path is "I noticed a typo in the price, fix and re-email". The 5% path is "client lost the receipt, re-send". All three live on this page; none open more than one modal.
  3. Edit + re-emit honesty is structural. Edits to receipt-affecting fields silently auto-re-emit the receipt to the client per order-lifecycle; the page tells the stringer before they commit that the edit will re-email, so there are no surprises. The receipt-emit log is visible on demand for "what did I send when?" audit.

Surfaces

1. /orders/{id}              — detail (view + state-transition CTAs + edit triggers)
2. /orders/{id}/edit         — edit modal (all editable fields in one form)
3. /orders/{id}/paid         — paid-date modal (M12 toggle + backdate picker)
4. /orders/{id}/emit-history — receipt-emit log (collapsed-by-default panel)

The page is mobile-first single-column on sm; on lg it splits the receipt-affecting payload from the lifecycle/state-transition side-rail.

Out of scope (per epic #153)

  • Order deletion. FADP-driven Order erasure is admin-only via the DSAR queue. Hard-delete on the detail page would break receipt-number monotonicity (per ADR-0008) and FK chains. No "Delete order" affordance.
  • Bulk operations. "Mark paid for all of Lukas's open orders" — out of V2; the order-history list on /clients/{id} shows per-row payment chips and individual taps go to detail, where the toggle lives.
  • Cross-stringer order viewing. Privacy invariant — only the owning stringer + Rule #1 grantees can read the order; Rule #1 grantees see a comments-redacted variant per order-lifecycle § Rule #1 redaction. The edit surface is owner-only.
  • Standalone order-list /orders. See open-payments filter location — the natural list views are the dashboard's Recent section and the per-client order history. A future /orders listing is flagged as a future surface (see Future surfaces at the end), not built in this round.
  • Re-print / "open the PDF in a new tab" as an explicit affordance separate from re-email. The receipt PDF is always re-renderable per ADR-0002 R5; this page surfaces a "Download PDF" link that opens the current rendering, but does NOT log it as a re-emit (no email is sent).

Surface 1 — /orders/{id} (detail view)

Viewport sm 375 px (mobile-first)

┌───────────────────────────────────────┐
│ ← Order #2026-0042                    │  Header — back + receipt number
├───────────────────────────────────────┤
│                                       │
│  🟢 Strung           CHF 43.00        │  State badge + total (prominent)
│  For Lukas Müller                     │  Client — tap → /clients/{id}
│  Babolat Pure Aero 98                 │  Racket
│                                       │
│  ─── Lifecycle ───                    │
│   Ordered    2026-05-02               │
│   Strung     2026-05-04               │
│   Paid       —  (open)                │  ← amber when open
│                                       │
│  ┌─────────────────────────────────┐  │
│  │  Mark paid                      │  │  Primary CTA — M12
│  └─────────────────────────────────┘  │
│   ↳ "Backdate…" link below            │
│                                       │
│  ─── Strings ───                      │
│   Main      Luxilon ALU Power 1.25    │
│             24 kg · CHF 18.00         │
│   Cross     same as main · 24 kg      │
│                                       │
│   Labor                  CHF 25.00    │
│   Subtotal (strings)     CHF 18.00    │
│   Total                  CHF 43.00    │
│                                       │
│  ─── Comments ───                     │
│   "Tournament setup, please           │
│    string a touch tighter."           │
│                                       │
│   [ Edit order ]                      │  Secondary CTA → modal
│                                       │
│  ─── Receipt ───                      │
│   Sent to lukas.m@example.com         │
│   on 2026-05-04 — delivered ✓         │
│   Receipt #2026-0042                  │
│                                       │
│   [ Re-email receipt ]                │
│   [ Download PDF ]                    │
│   ▾ Emit history (3)                  │  Collapsed-by-default
│                                       │
│  ─── More ───                         │
│   Order id 7f2a-abcd-…                │  Toggleable
│   Created 2026-05-02 14:23            │
│                                       │
└───────────────────────────────────────┘

Viewport sm 375 px — Draft / Ordered (no receipt yet)

State-driven sections collapse out when not applicable. The Ordered state shows the "Mark strung" CTA in place of "Mark paid":

┌───────────────────────────────────────┐
│ ← Order (Draft)                       │  No receipt number yet
├───────────────────────────────────────┤
│                                       │
│  🟡 Ordered          CHF 43.00        │
│  For Lukas Müller                     │
│  Babolat Pure Aero 98                 │
│                                       │
│  ─── Lifecycle ───                    │
│   Ordered    2026-05-02               │
│   Strung     —                        │
│                                       │
│  ┌─────────────────────────────────┐  │
│  │  Mark strung                    │  │  Primary CTA
│  └─────────────────────────────────┘  │
│   ↳ Sends the receipt to Lukas.       │  Side-effect cue
│                                       │
│  ─── Strings ───                      │
│   ⋯                                   │
│                                       │
│   [ Edit order ]                      │
│                                       │
│  Receipt: not yet emitted — will      │
│  send when you mark this strung.      │
│                                       │
└───────────────────────────────────────┘

Viewport sm 375 px — Strung+, edit triggers re-emit (banner)

When the user opens the edit modal, saves a tracked-field change, and lands back on the detail page, a banner sits between the header and the state badge until dismissed or until the next page load after the auto-re-emit settles:

┌───────────────────────────────────────┐
│ ← Order #2026-0042                    │
├───────────────────────────────────────┤
│ ┌───────────────────────────────────┐ │
│ │ ✉ Receipt re-emitted to Lukas.    │ │  Confirmation banner
│ │   New version sent 14:32.   [×]   │ │
│ └───────────────────────────────────┘ │
│                                       │
│  🟢 Strung           CHF 48.00        │  Total updated
│  ⋯                                    │
└───────────────────────────────────────┘

The banner is post-save (the re-emit already happened, atomically with the save). The pre-save warning lives inside the edit modal — see Edit order modal § Re-emit warning.

Viewport lg 1280 px

Two-column with the receipt-payload on the left (~60%, the "what was strung" facts) and the lifecycle + state-transition + receipt actions on the right (~40%, sticky). Total stays visible as the user scans the strings/pricing detail.

┌───────────────────────────────────────────────────────────────────┐
│ ← Order #2026-0042                                                │
├──────────────────────────────────────┬────────────────────────────┤
│                                      │                            │
│  🟢 Strung    For Lukas Müller       │  Lifecycle                 │
│  Babolat Pure Aero 98                │   Ordered  2026-05-02      │
│                                      │   Strung   2026-05-04      │
│  Strings                             │   Paid     —  (open)       │
│   Main   Luxilon ALU Power 1.25      │                            │
│          24 kg · CHF 18.00           │  ┌──────────────────────┐  │
│   Cross  same as main · 24 kg        │  │  Mark paid           │  │
│                                      │  └──────────────────────┘  │
│  Pricing                             │   Backdate…                │
│   Labor                  CHF 25.00   │                            │
│   Subtotal (strings)     CHF 18.00   │  Receipt                   │
│   Total                  CHF 43.00   │   Sent 2026-05-04          │
│                                      │   to lukas.m@example.com   │
│  Comments                            │   delivered ✓              │
│   "Tournament setup…"                │   #2026-0042               │
│                                      │                            │
│   [ Edit order ]                     │  [ Re-email receipt ]      │
│                                      │  [ Download PDF ]          │
│                                      │  ▾ Emit history (3)        │
│                                      │                            │
│                                      │  More                      │
│                                      │   Order id 7f2a-…          │
│                                      │   Created 2026-05-02       │
└──────────────────────────────────────┴────────────────────────────┘

Component breakdown

Component Notes
Back arrow Returns to the previous list — dashboard, /clients/{id} order-history, or the queue card source. Uses document.referrer with a fallback to /.
Page title Pre-Strung: "Order (Draft)" or "Order (Ordered)". Strung+: "Order #{receipt_number}" — the receipt number IS the recognisable identifier for stringer + client.
Optional menu (⋯) lg only — sits in the top right with rare actions: "Download PDF" (when applicable), "Copy link". On sm these live in the Receipt section. No per-page-overflow menu on sm to reduce noise.

Identity strip (top of page)

Slot Content Source
State badge Reuses the stringer-dashboard § state badge legend. Larger size on detail (24 px text) than on dashboard cards.
Total total_chf, formatted CHF X.XX, right-aligned and prominent (text-h2, slate-900, tabular-nums). Matches the receipt's TL-4 anchor convention so the page-glance number matches the printed PDF.
Client link "For {first} {last}". The whole row is a tap target → /clients/{id} (per the client-management-v2 § order-history round-trip).
Racket "{make} {model} {version}". Read-only on this page — racket changes are admin-override-only per order-lifecycle § Edit matrix.

Lifecycle section

The three-line lifecycle block — "ordered_at, strung_at, paid_at" — is the load-bearing acceptance criterion from the launch-readiness checklist. returned_at is shown only when set (most stringers don't track it; Stefan rarely uses it).

Field Display rule
Ordered ordered_at formatted ISO YYYY-MM-DD; "—" when NULL (self-orders); editable inline via edit modal.
Strung strung_at formatted ISO; "—" when NULL. The "Mark strung now" CTA lives in the side-rail when this is NULL.
Returned Rendered ONLY when returned_at IS NOT NULL. Most users never set it — hiding the empty row keeps the section terse.
Paid paid_at formatted ISO; "— (open)" amber-700 text when NULL (the only place on the page where amber is used; signals "needs attention"). The "Mark paid" CTA lives in the side-rail.

The display dates are dates only, not timestamps — the receipt face shows dates per receipt-content TR-3, and the detail page matches. Hovering / focusing each line reveals the full timestamp in a <title> tooltip for power-users.

State-transition CTAs

The page's primary CTA is state-driven — exactly one CTA is the "obvious next thing" given the current state.

Current state Primary CTA Side-effect Confirmation?
Draft (none — go to edit) n/a
Ordered Mark strung Sets strung_at = today; emits initial receipt (M14); re-renders the page in Strung. No — the side-effect is shown inline as a sub-label ("Sends the receipt to Lukas"). One tap.
Strung Mark paid Sets paid_at = today; does NOT re-emit (per OQ-R-4 — paid_at is not on the receipt). No — one tap.
Strung-with-paid (Paid) "Unmark paid" (secondary) Sets paid_at = NULL. No — easily reversible.
Returned Mark paid (if not already paid) Same as Strung row. No.

Locked decision (Mira, 2026-05-10): state-transition CTAs use no confirmation dialog, with the side-effect described inline as a sub-label under the button. Rationale: every transition is reversible (un-Strung is T2-r per lifecycle; un-Paid is paid_at=NULL; the receipt re-emit is auto-logged but irreversible — the side-effect line warns about that one specifically). Confirmation friction would make the 50+/year happy path painful; reversibility makes accidents recoverable. The single irreversible side-effect (initial receipt email at Mark-strung) is signalled with the explicit sub-label rather than a modal.

TODO(stefan): if the "Mark strung sends the receipt" sub-label is too easy to miss and you want a one-time "Are you sure? This emails Lukas." dialog on Mark-strung specifically, flag it and Mira will re-spec. Default ships as no-modal.

The side-rail also hosts the Backdate… link (sub-link below "Mark paid") which opens the paid-date modal for backdating to a custom date — see Surface 3.

Strings + Pricing section

Read-only display of the receipt payload. The exact same shape as the saved-to-receipt fields (cross-mirrors-main collapses to "same as main" when equal, per receipt-content § cross-equals-main). BYO chip renders inline next to the side: "BYO" badge, slate-300 background. Subtotal + Total live-computed on the server (not client-side); the page renders the static numbers.

Comments section

Display rule Notes
Empty (NULL or whitespace) Section is omitted entirely. Reduces visual noise on the 70% of orders without comments.
Non-empty Free-text rendered in a slate-50 quoted-block, italic. Whitespace-preserving (white-space: pre-wrap) so newlines from the edit modal carry through.
Edit triggers re-emit Yes (per receipt-content § comments band). Banner fires post-save.

Edit-order CTA

A single secondary button ("Edit order") opens the edit modal covering price, tension, BYO, comments. Single-modal-for-all-edits — see edit affordance shape decision.

The button is rendered on every state from Ordered onwards (Draft is the create surface; once it's saved it's at minimum Ordered). Disabled with a tooltip on Cancelled / soft-deleted orders (out of V2 scope).

Receipt section

Slot Content
Send status line "Sent to {email} on {date} — {delivery_status}." Pulls from the latest ReceiptEmitLog row. The status icon: ✓ delivered, ⏳ pending, ⚠ bounced, ⚠ failed.
Receipt number "Receipt #{YYYY-NNNN}". Same display string as the page title; included here for "I want to copy this number to a text message" ergonomics.
Re-email receipt CTA Opens the re-email confirmation modal. Has confirmation — see decision below.
Download PDF Direct link to /orders/{id}/receipt.pdf. Always renders the current order data per ADR-0002 R5 (no re-emit, no log row, no email). For Stefan's own "let me look at it before I re-email" workflow.
Emit history disclosure Native <details>. Collapsed by default. Expanded list shows every ReceiptEmitLog row: sequence_n, kind (initial / auto / manual / admin), date, recipient, delivery status. Useful for "the customer says they never got the receipt — let me check what we sent".

When the order has no receipt yet (Draft / Ordered), the section shows a one-line placeholder: "Receipt: not yet emitted — will send when you mark this strung." No CTAs.

Slot Content
Order id UUID, monospace; collapsed by default behind a "Show technical details" link (same posture as client-management-v2 § More footer).
Created at created_at ISO timestamp. Rarely useful but cheap to surface for "when did I first save this?" debugging.

Edit affordance shape decision (locked)

Three options were on the table:

  1. Per-field inline editing — tap "CHF 43.00" → it becomes a number input → blur to save.
  2. Per-field modal — separate "Edit price", "Edit tension", "Edit comments" modals.
  3. Single "Edit order" modal — one form covering price, tension, BYO, comments.

Locked decision (Mira, 2026-05-10): Single "Edit order" modal (option 3).

Rationale:

  1. Re-emit warning is one decision per save. When the user changes price + comments together, that's still one re-emit; the modal's "this will re-email Lukas" warning fires once. Per-field flows would either fire the warning per-field (annoying) or batch silently (surprising on the second save).
  2. Bundling matches the V1 XLSX edit-row mental model. Stefan edits one row's worth of fields, then tabs to the next. The modal IS the row edit.
  3. Inline editing on sm collides with the read-flow. Tapping the price line to edit means the read-only display is fragile to accidental taps; the explicit "Edit order" button + modal preserves the read surface as a glance-only artefact.
  4. Simpler validation. All edit-time validation rules (Validation rules) run in one form, not three; the cross-field rule "ordered_at can't be after strung_at" only makes sense in one place.
  5. Symmetric with client-management-v2 + edit-notes modal. Mira's prior specs use the single-modal-per-entity pattern; reusing it here keeps the V2 design language consistent.

The cost — Stefan can't tap one field and edit just that — is small. He can ignore the other fields in the modal; the save handler diffs and only writes changed columns.

Surface 2 — /orders/{id}/edit (edit modal)

Triggered by the "Edit order" CTA on the detail page. One form, all editable fields.

   ╔═══════════════════════════════════════╗
   ║  Edit order #2026-0042                ║
   ║                                       ║
   ║  ⚠ Saving will re-email Lukas with    ║   Re-emit warning banner
   ║    a new receipt PDF.                 ║   (Strung+ only)
   ║                                       ║
   ║  ─── Pricing ───                      ║
   ║   Labor                               ║
   ║   [ CHF  25.00              ]         ║
   ║   Main price                          ║
   ║   [ CHF  18.00              ]         ║
   ║   ☐ BYO (main)                        ║
   ║   Cross price                         ║
   ║   [ CHF  18.00              ]         ║
   ║   ☐ BYO (cross)                       ║
   ║                                       ║
   ║  ─── Strings ───                      ║
   ║   Main tension                        ║
   ║   [ −1 ] [   24 kg   ] [ +1 ]         ║
   ║   Cross tension                       ║
   ║   ◉ Same as main                      ║
   ║   ◯ Different                         ║
   ║                                       ║
   ║  ─── Comments ───                     ║
   ║   [ Tournament setup, please    ]     ║
   ║   [ string a touch tighter.     ]     ║
   ║   ⓘ Visible to the client on the      ║
   ║     receipt.                          ║
   ║                                       ║
   ║  ─── Lifecycle ───                    ║
   ║   Ordered                             ║
   ║   [ 2026-05-02 ▾ ]                    ║
   ║   ⓘ Editing this re-emails the receipt║
   ║                                       ║
   ║  ┌───────────┐ ┌────────────────────┐ ║
   ║  │ Cancel    │ │ Save & re-email    │ ║   Save label adapts
   ║  └───────────┘ └────────────────────┘ ║
   ╚═══════════════════════════════════════╝

Editable fields

Field States editable in Re-emit on edit? Source
labor_chf Ordered, Strung, Returned, Paid Yes (Strung+) OQ-L-1 (a) — pricing always editable post-Draft.
main_price_chf / cross_price_chf same Yes (Strung+) same
main_byo / cross_byo same Yes (Strung+) receipt-content BL-4
main_tension_kg / cross_tension_kg same Yes (Strung+) receipt-content TR-2
comments same Yes (Strung+) OQ-R-1 — comments ARE on the client-bound receipt.
ordered_at same Yes (Strung+) OQ-R-4 — receipt shows ordered_at + strung_at.
strung_at Strung+ only; clearing = un-Strung (T2-r) T2-r is its own emission (no auto re-emit per order-lifecycle § Un-Strung re-emit) T2-r is reversible.
paid_at always editable; backdate via the paid-date modal, not here No OQ-R-4 — paid_at not on receipt.
racket_id NOT in this modal (Admin-only) order-lifecycle § Edit matrix — racket changes are admin-override-only.
client_profile_id NOT in this modal (Admin-only) same

paid_at lives in its own Surface 3 modal because the M12 acceptance criteria explicitly call for "calendar picker for backdating" — a separate, dedicated UX. Bundling it with the field-edit modal would dilute the M12-specific affordance.

Re-emit warning (inside the edit modal)

A banner at the top of the modal, only on Strung+ orders, when the modal first opens:

⚠ Saving will re-email Lukas with a new receipt PDF.

The banner is persistent (not dismissable) — it's the load-bearing user-informed-consent for the auto-re-emit. The Save button label changes from "Save" to "Save & re-email" on Strung+ orders, reinforcing the side-effect.

If the user opens the modal and ONLY edits paid_at indirectly (impossible — paid_at is a different surface) or only edits strung_at to clear it (T2-r — different emit semantics), the banner is unchanged but the Save handler routes the appropriate emit kind.

The save handler server-side is the canonical re-emit gate (per Theo's chokepoint model + ADR-0007); the UI banner is a courtesy, not a security boundary. If the stringer somehow saves with no field changes, the handler diff returns "nothing changed, nothing emitted, redirect back".

Locked decision (Mira, 2026-05-10): edits to tracked fields auto-re-emit on save (not user-triggered). Rationale: per order-lifecycle § Triggers that cause an automatic re-emit, the lifecycle requirements specify auto-re-emit; making it user-triggered ("Save & decide later whether to re-email") would create a desync between the live-order state and the customer's PDF. The banner + adapted Save label are the consent surface; the user can Cancel without saving if they want to think more.

Re-emit triggers — full enumeration on this surface

For Pax + Juno's implementation reference, the closed list of fields whose Strung+ edits trigger automatic re-emit (per order-lifecycle § Receipt re-emit triggers + receipt-content § Re-emit triggers):

Triggers re-emit:

  • labor_chf, main_price_chf, cross_price_chf (pricing)
  • main_string_id / main_string_one_off_text / main_tension_kg / main_byo / main_color (main spec — main_color is on the receipt per BL-4 even though rare)
  • cross_string_id / cross_string_one_off_text / cross_tension_kg / cross_byo / cross_color (cross spec)
  • comments (per OQ-R-1)
  • ordered_at (per OQ-R-4)
  • racket_id (admin-override only — but if it changes, it re-emits)
  • client_profile_id (admin-override only — but if it changes, it re-emits)

Does NOT trigger re-emit:

  • paid_at — not on the receipt (OQ-R-4).
  • returned_at — not on the receipt (OQ-R-4).
  • method, dynamic_tension_after — stringer-internal, not on the receipt.
  • total_chf derived recompute — implicit in pricing-field edits; not a distinct trigger.

The auto-re-emit fires inside the same transaction as the save (per the chokepoint pattern); a save failure rolls back the emit row. The user sees one consistent post-save state (the banner — "Receipt re-emitted to Lukas. New version sent {hh:mm}.") or no state change at all (failure → modal stays open with inline error).

Validation

Rule Inline message
Labor, prices < 0 "Price can't be negative."
Tension out of 12–35 kg range "Tension must be between 12 and 35 kg."
ordered_at after strung_at "Ordered date can't be after Strung date." (per order-lifecycle A2)
ordered_at in the future "Ordered date can't be in the future." (per order-lifecycle § future-date refusal)
comments > 2000 chars "Comments are too long (max 2000)."

Server-side is canonical; the UI strings echo the canonical handler error keys.

Surface 3 — /orders/{id}/paid (paid-date modal)

The M12 (per racket-book#66) paid-date toggle. Sits on its own modal because:

  1. The acceptance criteria call out the calendar picker for backdating — a dedicated UX, not a side-feature of the field-edit modal.
  2. Mark / unmark / backdate are all on this one modal — three flows, one surface.
  3. No re-emit interaction. paid_at is not on the receipt; the modal can be tappy + breezy without the auto-re-emit warning.

Triggered by

  • The "Mark paid" primary CTA on the detail page side-rail (when paid_at IS NULL) — opens with today prefilled.
  • The "Backdate…" link below the CTA — opens with the calendar picker as the primary input.
  • The "Unmark paid" secondary action (when paid_at IS NOT NULL) — opens with a confirmation cue + "Clear paid date" button.

Viewport sm 375 px

   ╔═══════════════════════════════════════╗
   ║  Mark paid                            ║
   ║                                       ║
   ║  Total: CHF 43.00                     ║
   ║  Order #2026-0042 — Lukas Müller      ║
   ║                                       ║
   ║  Paid on                              ║
   ║   ◉ Today  (2026-05-10)               ║   Default — one-tap path
   ║   ◯ Backdate to a different date      ║
   ║                                       ║
   ║   ┌───────────────────────────────┐   ║   Calendar picker
   ║   │  May 2026                     │   ║   (revealed when
   ║   │  Mo Tu We Th Fr Sa Su         │   ║    "Backdate…"
   ║   │            1  2  3            │   ║    selected, or
   ║   │   4  5  6  7  8  9  ●10       │   ║    when entering via
   ║   │  11 12 13 14 15 16 17         │   ║    the Backdate link)
   ║   │  ⋯                            │   ║
   ║   └───────────────────────────────┘   ║
   ║                                       ║
   ║  ┌──────────┐  ┌──────────────────┐   ║
   ║  │ Cancel   │  │ Mark paid        │   ║
   ║  └──────────┘  └──────────────────┘   ║
   ╚═══════════════════════════════════════╝

When already-paid, the modal renders in clear mode:

   ╔═══════════════════════════════════════╗
   ║  Already paid                         ║
   ║                                       ║
   ║  Order #2026-0042 — Lukas Müller      ║
   ║  Marked paid 2026-05-08.              ║
   ║                                       ║
   ║  ┌──────────────┐  ┌────────────────┐ ║
   ║  │ Edit date    │  │ Clear paid     │ ║
   ║  └──────────────┘  └────────────────┘ ║
   ║                                       ║
   ║  ⓘ Clearing reverts the order to      ║
   ║    "open payment". The receipt        ║
   ║    is unaffected (paid date is not    ║
   ║    on the receipt).                   ║
   ╚═══════════════════════════════════════╝

Backdate UX

Per the M12 acceptance criteria — calendar picker for backdating. The "Today" radio is the default to keep the 90% case to one tap. Tapping "Backdate to a different date" reveals a native <input type="date"> on sm (mobile-OS native picker — small, familiar, accessible) and a custom inline calendar widget on lg (desktop ergonomics). The cap is todaypaid_at cannot be in the future per order-lifecycle § future-date refusal. The lower cap is strung_at (when set) — paying before stringing is causally impossible per order-lifecycle A2; the picker disables earlier dates.

Direct link entry (/orders/{id}/paid?backdate=1) opens the modal with the "Backdate to a different date" radio pre-selected — for the specific "I'm fixing a paid_at I forgot to log a week ago" workflow.

Re-emit interaction

None. paid_at does NOT re-emit the receipt per OQ-R-4. The page banner ("Receipt re-emitted") is not shown on Mark-paid / Unmark-paid / backdate. The page does refresh the lifecycle line (Paid changes from "— (open)" to the date) and the state badge (Strung → Paid).

Locked decision (Mira, 2026-05-10): Mark paid + backdate + clear all live in one dedicated modal, separate from the field-edit modal. Rationale: M12's acceptance criteria are sufficiently distinct (calendar picker, no re-emit) that bundling into the general-edit modal would obscure the M12 affordance. One purpose-built modal matches "the M12 is its own feature."

Re-email confirmation decision (locked)

The "Re-email receipt" button on the detail page (when the order is Strung+) — does it confirm before sending?

Locked decision (Mira, 2026-05-10): Yes, confirm with a one-step modal.

Rationale:

  1. Manual re-email is intentional, not opportunistic. The user clicked it deliberately; a confirmation isn't friction in the same way state-transition CTAs are.
  2. The recipient address is verifiable in-modal. The confirmation surfaces "to lukas.m@example.com" so the stringer can spot a stale email before sending (e.g. client changed their email last week and the order's email is now wrong — the warning lets them go fix the Person record first).
  3. Asymmetric with auto-re-emit-on-edit. Edit-driven re-emit is implicit (the user is editing the order, the receipt follows the truth); manual re-emit is "I want to send it again with no changes" — that's worth a beat.
  4. Audit log integrity. Manual re-emit writes a manual_re_emit row to ReceiptEmitLog; an accidental click that the user undoes too late shows up in the log forever. The confirmation prevents that.

Re-email confirmation

   ╔═══════════════════════════════════════╗
   ║  Re-email receipt                     ║
   ║                                       ║
   ║  Send a fresh copy of receipt         ║
   ║  #2026-0042 to lukas.m@example.com?   ║
   ║                                       ║
   ║  This will use the current order      ║
   ║  data (no changes will be saved).     ║
   ║                                       ║
   ║  ┌──────────┐  ┌──────────────────┐   ║
   ║  │ Cancel   │  │ Send             │   ║
   ║  └──────────┘  └──────────────────┘   ║
   ╚═══════════════════════════════════════╝

When the order has no email recipient (Person.email IS NULL):

   ⚠ This client doesn't have an email on file.
     Add one to /clients/{id} first, then come back.

The Send button is disabled in this case; a link to the client's edit-profile flow is offered.

Open-payments filter location (pickone decision)

Per client-management-v2 § M12 paid-date toggle decision rationale point 2, the "open payments" filter Stefan asked for in M12's acceptance criteria has natural homes on:

  1. The dashboard's Recent section (a "Show only open payments" filter on the strung-but-not-paid stripe).
  2. A standalone /orders listing (cross-client open-payments view).
  3. The per-client /clients/{id} order history (per-client open-payments — already handled by the open-balance pill).

Locked decision (Mira, 2026-05-10): /orders standalone listing is out of scope for V2 per epic #153 non-goals; the open-payments filter lives on the dashboard's Recent section as a single-toggle filter chip.

Rationale:

  1. Stefan's primary "who owes me?" view is the dashboard. That's where he goes first; adding a filter chip is a 1-line schema-and-template change vs building a whole /orders listing.
  2. Per-client open balance is already on /clients/{id} (open-balance pill in the order-history summary chip per client-management-v2 § order history). Cross-client + dashboard-recent covers the remaining slice.
  3. A /orders listing is plausibly a V3 surface when Stefan eventually wants more sophisticated filtering (date ranges, racket type, client cohort). Building it now for one filter is over-spec.
  4. The dashboard already exists. Adding a filter chip there is a known surface; building /orders is a new surface with its own list/sort/pagination/empty-state design — out-of-budget for V2 launch.

The dashboard filter is not in this spec — Mira flags it as a follow-up patch to stringer-dashboard.md § Recent section (one-paragraph addendum, not a full re-spec).

State-machine UI

The state machine is implicit in the lifecycle dates (per state_of in order.py). The detail page doesn't show a separate "State: Strung" line — the state badge in the identity strip + the lifecycle table together communicate it.

  Draft                Ordered            Strung         Returned           Paid
  ─────                ────────           ──────         ────────           ────
  ⚪ Draft             🟡 Ordered         🟢 Strung      🔵 Returned        💵 Paid
                                                                            (independent
                                                                             flag — paid_at
                                                                             can co-exist
                                                                             with any state
                                                                             ≥ Strung;
                                                                             higher-state
                                                                             wins display)

Paid as independent flag. Per state_of, when both paid_at and strung_at are set, the derived state is paid. The detail page renders the highest state badge (Paid), but the Paid line in the lifecycle table shows the actual date. This matches the user model: "this is paid, AND was strung on date X."

Returned similarly. A Strung+Returned+Paid order is paid per the derivation; the lifecycle table shows all three dates. The badge collapses to the highest state.

Un-Strung (T2-r). When the user clears strung_at via the edit modal, the page re-renders in Ordered. Per order-lifecycle § Un-Strung re-emit, this does NOT silently "un-email" the previous receipt; the audit log shows the un-Strung event; if/when the user re-Strung-s, a fresh initial-kind emit is written. The detail page's Receipt section badges the order with a "re-emitted (un-strung)" cue when the latest emit's kind is auto_re_emit from a re-Strung after T2-r.

DE width budget (designer note)

DE labels here are typically 20–30% longer than EN. Specific watch-outs:

  • "Order #{N}" → "Auftrag #{N}" (similar — actually shorter).
  • "Mark paid" → "Als bezahlt markieren" (~1.7×) — fits as the 48-px-tall full-width CTA.
  • "Mark strung" → "Als bespannt markieren" (~1.6×) — fits.
  • "Re-email receipt" → "Quittung erneut senden" (~1.3×) — fits.
  • "Save & re-email" → "Speichern & erneut senden" (~1.5×) — wraps to two lines on sm modal CTA; the wireframe reserves the height (the Save button is 48 px tall on sm and can comfortably hold two lines of 13 px text).
  • "Saving will re-email Lukas with a new receipt PDF." → "Beim Speichern wird Lukas eine neue Quittung per E-Mail zugesandt." (~1.4×) — wraps to two lines in the banner; reserve the height.
  • "Unmark paid" → "Bezahlung zurücksetzen" (~1.7×).
  • "Backdate to a different date" → "Auf ein anderes Datum zurückdatieren" (~1.4×) — wraps on sm radio; the wireframe reserves the row.
  • "Already paid" (modal title) → "Bereits bezahlt" (similar).
  • "Clear paid date" → "Bezahldatum löschen" (~1.4×) — fits the secondary button width.
  • "Visible to the client on the receipt." → "Auf der Quittung für den Kunden sichtbar." (~1.2×) — fits.
  • "open" (lifecycle paid line) → "offen" (similar).
  • "(open)" → "(offen)" (similar).

The single-column layout naturally handles wrapping; no DE-specific layout tweaks needed beyond reserving the vertical height for the Save-CTA's two-line label.

Validation rules (UI surface; canonical server-side)

Rule Inline message
Labor, prices, sub-total < 0 "Price can't be negative."
Tension out of 12–35 kg range "Tension must be between 12 and 35 kg."
ordered_at after strung_at "Ordered date can't be after Strung date."
ordered_at in the future "Ordered date can't be in the future."
paid_at before strung_at "Paid date can't be before the strung date." (Picker disables earlier dates; this fires only on direct entry / power-user URL.)
paid_at in the future "Paid date can't be in the future."
Comments > 2000 chars "Comments are too long (max 2000)."
Manual re-email when Person.email IS NULL (Modal blocks Send) "This client doesn't have an email on file."
Edit submitted with no field changes (silent — no-op; toast "Nothing changed.")

Server-side validation is canonical (per Theo's chokepoint model). The UI strings above are the user-facing message; the Pydantic / handler validators on Pax's side return matching error keys.

Interaction states

State What renders
Detail — initial load Server-rendered HTML; identity strip + lifecycle + strings/pricing + receipt section (or placeholder) + edit CTA.
Detail — post-edit-save (re-emit) Banner: "Receipt re-emitted to {client first name}. New version sent {hh:mm}." Auto-dismiss after 6 s; manual ✕ available.
Detail — post-mark-strung Banner: "Marked strung. Receipt sent to {client first name}." Same dismiss behaviour.
Detail — post-mark-paid Banner: "Marked paid on {date}." (No receipt mention — paid is silent vis-à-vis receipts.)
Detail — post-unmark-paid Banner: "Cleared paid date. Order is back to open."
Detail — post-manual-re-email Banner: "Receipt re-emailed to {email}."
Detail — re-email recipient bounced (V2.x polish) Banner: "Receipt sent — but the previous send to {email} bounced. [Edit client email]"
Edit modal — open Pre-filled with current values; re-emit warning on Strung+; cross-side mode reflects current data (collapsed if same-as-main).
Edit modal — validation error Inline red-700 per affected field; modal stays open; Save button re-enabled.
Edit modal — saving (in-flight) Save button disabled with spinner; label "Saving…" or "Saving & re-emailing…" on Strung+.
Paid modal — open (open order) Today radio pre-selected; Backdate radio collapsed.
Paid modal — open (already paid) "Already paid" view with Edit/Clear CTAs; date prominent.
Re-email confirmation — open Recipient + receipt number visible; Send / Cancel CTAs.
Re-email — recipient missing Send disabled; "client doesn't have an email" hint + link to /clients/{id}.
Server error (5xx) Toast: "Something went wrong on our side. Try again." Form values preserved.
Network drop on save HTMX swap fails → toast: "You appear to be offline. Try again when you reconnect." Form values held in localStorage (V2.x polish; Round-1 leaves the form re-populated server-side after retry).

Accessibility

  • Heading order: <h1> per page (the page title — "Order #{N}" or "Order (Draft)") → <h2> per section (Lifecycle, Strings, Pricing, Comments, Receipt, More).
  • State badges: include color + icon + label (per the stringer-dashboard § state badge legend). Color-blind users read the icon + label.
  • Lifecycle "—" placeholder: announced by screen reader as "{label}: not set" via aria-label (the visible "—" alone is not enough).
  • "Open" amber-700 chip on Paid line: announced as "Paid: open — needs attention" via aria-label. Color-only signal supplemented.
  • State-transition CTAs: the inline sub-label ("Sends the receipt to Lukas") is aria-describedby-linked to the button so screen readers announce the side-effect with the action.
  • Re-emit warning banner: role="alert" + aria-live="polite" so screen readers announce the consent surface when the modal opens.
  • Save button label change ("Save" → "Save & re-email"): announced on focus per the live-region update.
  • Calendar picker: native <input type="date"> on sm for OS-level a11y; on lg the custom inline picker has full keyboard support (arrow keys to move dates; Enter to select; Esc to close).
  • Modals: role="dialog" aria-modal="true" aria-labelledby="modal-title". Focus moves to the first input on open; ESC dismisses; focus trap.
  • Hit targets: every interactive element ≥ 44 × 44 px. State-transition CTAs are 48 px tall (the most-pressed).
  • Color contrast: every text/background pair meets WCAG 2.1 AA. Open-balance amber-700 on white = 4.83:1.
  • prefers-reduced-motion: disables HTMX swap fades; banners appear instantly.
  • Without JS: every link works; modals become full pages (/orders/{id}/edit, /orders/{id}/paid, /orders/{id}/re-email); the calendar picker falls back to the native <input type="date"> even on lg.

HTMX / progressive-enhancement seams

  • Detail page initial load: GET /orders/{id} server-rendered.
  • Edit modal open: hx-get="/orders/{id}/edit" hx-target="#modal-portal" hx-swap="innerHTML". Without JS: separate page.
  • Edit modal save: regular <form method="post" action="/orders/{id}/edit">. HTMX upgrade returns a 303 redirect to /orders/{id} with a hx-trigger-flagged banner; without JS the browser follows, banner renders from a flash-message session value.
  • State-transition CTAs (Mark strung / Mark paid / Unmark paid): <form method="post" action="/orders/{id}/transition"> with a hidden kind field (strung / paid / unpaid). HTMX swaps the page region; without JS, full reload.
  • Paid modal: hx-get="/orders/{id}/paid" hx-target="#modal-portal". Without JS: separate page.
  • Re-email confirmation: hx-get="/orders/{id}/re-email" hx-target="#modal-portal". Without JS: separate page.
  • Emit history <details> disclosure: native; no HTMX. The history rows are server-rendered in the initial response (small N, < 10 rows in 99% of cases).
  • Banners (post-save): server-rendered into the page response; on HTMX swap, the banner is included in the swapped HTML; without JS, it's flash-message-driven.

The page's fundamental contract is "regular form POSTs". HTMX swaps are surgical optimisations.

i18n affordance

String Type Catalogue key
Page title "Order #{N}" / "Order (Draft)" / "Order (Ordered)" {% trans %} (Babel) order.detail.title.{strung,draft,ordered}
Section H2s (Lifecycle / Strings / Pricing / Comments / Receipt / More) {% trans %} order.detail.section.{lifecycle,strings,pricing,comments,receipt,more}
Lifecycle field labels (Ordered / Strung / Returned / Paid) {% trans %} order.lifecycle.{ordered,strung,returned,paid}
"(open)" / "—" placeholders {% trans %} order.lifecycle.{open,unset}
State-transition CTAs (Mark strung / Mark paid / Unmark paid / Backdate…) {% trans %} order.action.{mark_strung,mark_paid,unmark_paid,backdate}
State-transition sub-label ("Sends the receipt to {first}") {% trans %} (Babel) order.action.mark_strung.side_effect
Edit-order CTA {% trans %} order.action.edit
Re-email receipt CTA {% trans %} order.action.re_email
Download PDF CTA {% trans %} order.action.download_pdf
Emit history disclosure label {% trans %} (Babel) order.detail.emit_history.{label,row}
"Receipt: not yet emitted…" placeholder {% trans %} order.detail.receipt.unemitted
Receipt sent-line "Sent to {email} on {date} — {status}" {% trans %} (Babel) order.detail.receipt.sent_line
Receipt #{N} {% trans %} (Babel) order.detail.receipt.number
Edit modal title "Edit order #{N}" {% trans %} (Babel) order.edit.title
Edit modal re-emit banner {% trans %} (Babel) order.edit.re_emit_warning
Edit modal save labels ("Save" / "Save & re-email" / "Saving…" / "Saving & re-emailing…") {% trans %} order.edit.save.{plain,with_re_emit,saving,saving_with_re_emit}
Edit-modal field labels (Labor / Main price / Cross price / Main tension / Cross tension / etc.) {% trans %} (re-used from add-stringjob) stringjob.field.{...}
BYO toggle {% trans %} (re-used) stringjob.field.byo
Paid modal title "Mark paid" / "Already paid" {% trans %} order.paid.title.{mark,already}
Paid modal "Today" / "Backdate to a different date" {% trans %} order.paid.{today,backdate}
Paid modal "Mark paid" / "Edit date" / "Clear paid" CTAs {% trans %} order.paid.action.{mark,edit,clear}
Re-email modal title + body + CTAs {% trans %} (Babel) order.re_email.{title,body,send,cancel,no_email_warning,no_email_link}
Banners (post-save / post-transition / post-re-email) {% trans %} (Babel) order.banner.{re_emitted,strung_emitted,paid,unpaid,manual_re_emailed,bounced}
State badge labels (re-used from dashboard) {% trans %} order.state.{...}
Comments quote-block (data) Data n/a
Receipt # value (data) Data n/a
Currency CHF X.XX Format (Babel format_currency) n/a
Tension X kg Format (Babel format_unit) n/a
Date YYYY-MM-DD Format ISO per receipt-content OQ-R-2 default n/a
Order id (data, monospace) Data n/a
Validation messages {% trans %} order.error.<rule>

DE strings are Iris's later pass — Mira commits the catalogue keys + EN values; DE stays as # fuzzy until Iris signs off.

Decisions Mira made on Stefan's behalf

Listed for the MR description so Stefan can reverse any of them on review:

  1. Single "Edit order" modal for all editable fields (price, tension, BYO, comments, ordered_at), vs. per-field inline or per-field modals. Symmetric with client-management-v2 prior pattern; one re-emit warning per save; matches the V1 XLSX edit-row mental model.
  2. State-transition CTAs (Mark strung / Mark paid / Unmark paid) use NO confirmation dialog; side-effects are described inline as a sub-label ("Sends the receipt to Lukas"). Reversibility (un-Strung is T2-r; un-Paid is paid_at=NULL) makes accidents recoverable; the only irreversible side-effect (initial receipt email at Mark-strung) is signalled with the explicit sub-label rather than a modal.
  3. Manual "Re-email receipt" DOES confirm with a one-step modal showing the recipient and receipt number. Asymmetric with state-transition CTAs because manual re-email is intentional and the recipient address is verifiable in-modal.
  4. Edit-driven re-emit is automatic on save (not user-triggered). Per order-lifecycle § Triggers that cause an automatic re-emit. The user is informed before save (modal banner + adapted "Save & re-email" button label) and after save (post-save confirmation banner).
  5. M12 paid-date toggle (Mark paid / Unmark paid / Backdate) lives on its own dedicated modal at /orders/{id}/paid, NOT inside the general edit modal. Acceptance criteria for #66 (calendar picker) deserves a purpose-built UX; bundling would obscure the M12 affordance.
  6. Paid-date does NOT trigger receipt re-emit. Per OQ-R-4 — paid_at is not on the receipt. Confirmed cross-spec; the Mark-paid flow is silent vis-à-vis receipts.
  7. Open-payments filter lives on the dashboard's Recent section as a filter chip, NOT on a standalone /orders listing (which is out of V2 scope per epic). Mira flags a one-paragraph addendum to stringer-dashboard.md § Recent section as the implementation seam.
  8. The detail page renders the highest-state badge (Paid > Returned > Strung > Ordered > Draft) per state_of, while the lifecycle table shows every set date. Matches the OrderState derivation; the user sees both "current state" and "full history" in one glance.
  9. Receipt section is omitted entirely on Draft / Ordered orders (a one-line placeholder replaces it: "Receipt: not yet emitted — will send when you mark this strung."). Pre-Strung orders have no receipt to manage; rendering empty buttons is noise.
  10. Comments section is omitted entirely when comments are empty. Reduces visual noise on the 70% of orders without comments. The edit modal still surfaces the comments field on every order.
  11. Emit history is collapsed-by-default behind a <details> disclosure. Useful for the "what was sent when" audit trail; not useful in the daily flow.
  12. Order id (UUID) is hidden-by-default behind a "Show technical details" link in the More section. Same posture as client-management-v2 § More footer.
  13. Re-email when recipient email is missing disables the Send button and links to /clients/{id} to fix the Person record. Avoids the "I sent a receipt to NULL" confusion.
  14. Racket + Client are read-only on this page; changes are admin-override-only per order-lifecycle § Edit matrix. The detail page makes this explicit (no "Edit racket" affordance) rather than offering an edit that fails server-side.
  15. Lifecycle returned_at row is hidden when NULL. Most stringers don't track it; surfacing the empty row clutters the section. Editable in the modal, displayed when set.

Open questions for Stefan (with proposed defaults)

  1. Mark-strung confirmation — should the "Mark strung" CTA add a one-time "Are you sure? This emails Lukas." modal? Proposed default: no. The inline sub-label warns; the action is reversible (T2-r); confirmation friction makes the 50+/year happy path painful. If the inline label is too easy to miss in practice, flag and Mira will re-spec.
  2. Auto-dismiss timeout for post-save banners — proposed 6 s; some banners (re-emitted) are higher-stakes than others (paid). Proposed default: 6 s for all, with manual ✕ always available. Alternative: longer timeout for re-emit banners (8 s) so a glance-away stringer sees the receipt-was-sent confirmation. Cheap to tune.
  3. Open-payments filter on dashboard's Recent section — Mira's lean is a single toggle chip ("Show only open payments"). Stefan to confirm; this is an addendum to stringer-dashboard.md, not in this spec. Proposed default: ship the chip alongside this surface's implementation MR.
  4. Should the "Edit order" CTA be hidden on Paid orders? Some stringers prefer "Paid = locked"; per OQ-L-1 (a) we explicitly chose always-editable. Proposed default: keep the Edit CTA enabled on Paid orders. Already-paid edits are rare and might still be needed (typo correction); the always-editable rule is intentional. Alternative: render the CTA but with a confirmation modal "This order is paid — edits will re-email Lukas. Continue?" — adds one extra friction point for the rare case.
  5. Re-email modal — should it offer "Send to a different address" (one-off override)? Stefan might want to send the receipt to himself for forwarding. Proposed default: no — V2 always sends to the Person.email on file. "Send to a different address" is a mild abuse vector + a confusing UX (the audit log records the override-recipient, not the canonical email). V2.x polish if Stefan asks.
  6. Calendar picker style on lg — native <input type="date"> (small, browser-default) vs custom inline calendar (larger, consistent across browsers). Proposed default: custom inline on lg, native on sm. The native iOS/Android pickers are ergonomically excellent; the desktop browser ones are mediocre. Stefan to confirm; cheap to flip either way.
  7. Re-emitted-after-un-Strung badge — when an order goes Strung → Ordered (T2-r) → Strung, the receipt sequence is initial, un-strung-event, initial (per order-lifecycle § Un-Strung re-emit). Should the detail page show a badge "this order has been re-emitted after an un-Strung"? Proposed default: yes, a small chip in the Receipt section. Rare case (Stefan estimates < 1%); cheap to surface.
  8. Comments visibility on Rule #1 grantee view — when a Rule #1 grantee opens this page, the comments section is rendered without the comment value (per order-lifecycle § Receipt re-emit triggers Rule #1 redaction). Proposed default: section heading still renders, with body "Hidden — owner-only." Alternative: omit the section entirely. Mira leans on showing the section so the receiving stringer knows comments exist (without seeing them); transparency-with-redaction.

Future surfaces

Out of V2 scope per epic #153, but flagged for V2.x / V3 design rounds:

  • /orders standalone listing — cross-client order list with date-range / state / racket-type filters. The open-payments use case is covered by the dashboard chip in V2; a richer listing belongs to a future round when Stefan asks for more sophisticated filtering.
  • Order soft-delete + restore — currently unspecified per order-lifecycle (deletion is admin-only via DSAR queue). A future ADR could specify per-order soft-delete with the receipt-number kept reserved (preserving monotonicity per ADR-0008).
  • Send-to-different-address on the re-email modal — see open question #5.
  • In-app print preview — the Download PDF link returns the PDF; a "preview before re-emailing" affordance would render the PDF inline. V2.x polish.
  • Re-emit batch trigger — "regenerate affected receipts" button on the stringer profile (per order-lifecycle § Triggers that cause an automatic re-emit — bulk per-order re-emit when stringer business-identity changes). Already flagged in lifecycle as Mira's V2.x design.

Cross-references