Skip to content

Client Management (V2) — /clients + /clients/{id}

The standalone client-management surface — list, detail, edit. Owned by Mira. Cross-cuts: V2 scope M5 + M9 + M12, client-identity-and-sharing, data-model § ClientProfile + Person, add-stringjob § new-client mini-flow (the inline-create that this surface complements), stringer-dashboard, settings-v2 (stringer-default settings, NOT here), v3-vision § sign-off (the V3 hooks live on this page).

Source requirement

  • V2 Client Management epic — racket-book#141 — V2 today regresses XLSX capability: no list-all-clients, no per-client history, no edit, no per-client defaults. This spec closes the gap.
  • M5 + M9 — Person + ClientProfile entities; the add-stringjob flow is the only V2 surface that touches them so far (inline new-client mini-flow). M5 is incomplete without a list / detail / edit surface.
  • M12 — manual paid-date toggle. Epic explicitly asks Mira to decide whether the M12 toggle lives on the order-detail surface or on the client-detail surface; resolution below.
  • client-identity-and-sharing § identity matching — verified-email-only auto-match. The edit-email path here applies the same rule.
  • client-identity-and-sharing § privacy invariantPerson carries platform-public data; ClientProfile carries stringer-private notes. The edit surfaces respect this split.
  • v3-vision § V2 hooks + issue #51 / D5Player.signoff_pref (client-side, V3-portal) + Player.stringer_signoff_override (stringer-side, V3-UI) + Stringer.signoff_default (stringer-default, settings). The stringer-side override surface lands on this page in V3 — the design must reserve a slot today.

Goal

Three commitments, in tension-resolution order:

  1. Restore XLSX parity. Stefan's V1 XLSX gives him a single sheet of all clients with full per-client order history. V2's list + detail surfaces must match that capability — searchable, sortable, mobile-friendly. Without it, V2 cannot replace V1.
  2. The detail page is the V3 anchor. The same page hosts (V2) edit-name/email/phone + (V2) order history; in V3 it gains the sign-off override + notification opt-out tri-state without re-architecting. Mira reserves space and surfaces today.
  3. Privacy invariant is structural, not opt-in. Person.email edit goes through the verified-email match flow (add-stringjob § new-client mini-flow rule); ClientProfile.nickname edits stay stringer-private; never the twain shall meet. The page surfaces are organised by which entity they touch.

M12 paid-date toggle — decision

Epic #141 explicitly asks Mira to pick one of: order-detail vs client-detail.

Decision: M12 paid-date toggle lives on the order-detail page, NOT on the client-detail page.

Rationale

  1. The toggle is per-order, not per-client. Order.paid_at is a column on Order, not on ClientProfile. A client with five outstanding orders has five separate paid_at decisions; surfacing them on the client-detail page would require a per-row toggle inside the order-history list — which means building the order-detail surface inline anyway, just on a different page.
  2. The "open payments" filter Stefan asked for (M12 acceptance criteria) has its natural home on the order list (or the dashboard's "Recent" section with a filter), not on a client. "Show me all open payments" crosses clients; a client-detail-anchored toggle hides that view.
  3. Late payments months after pickup are the common case (per UC-8). Stefan flips paid_at on an order context — "did this specific stringjob get paid?" — not on a client context — "is this client paid up across all jobs?". The mental model is order-shaped.
  4. The order-detail surface already exists in the queue (out of Round-1, but stringer-dashboard § future flags it). M12 is one tap on that page; putting it on client-detail forks the surface unnecessarily.
  5. Backdating support (M12 acceptance: calendar picker for backdating) is per-order data, not per-client. A client-detail backdate-picker would force a "which order?" picker first — extra friction.

The client-detail page does show payment status per order in the order-history list (a chip / icon — "paid" vs "open"), and does include an "open balance" summary line at the top of the list (sum of total_chf where paid_at IS NULL) — but the toggle itself is on the order-detail page. The client-detail order list lets Stefan see "this client owes me X across N open jobs" at a glance without owning the write affordance.

Locked decision (Stefan, 2026-05-09): M12 paid-date toggle lives on the order-detail page. Stefan: "order-detail." The five-point rationale above stands; the client-detail order list shows payment status (chip + open-balance summary) but does not own the write affordance.

Surfaces

1. /clients                — list (search, sort, recent activity)
2. /clients/{id}           — detail (profile + edit + order history + V3 hooks)
3. /clients/{id}/edit-…    — edit modals (name / email / phone) keyed off detail
4. /clients/new            — standalone create (ClientProfile + Person, no order)

The /clients "+ New" CTA routes to /clients/new — a standalone create-a-client flow that produces a ClientProfile + Person row and lands the user on the new client's /clients/{id} detail page. New-client creation also stays inline in the add-stringjob mini-flow for the common "I'm starting a new order for someone new" path; the two flows reuse the same identity-match logic but /clients/new does NOT create an order at the end.

Locked decision (Stefan, 2026-05-09): standalone /clients/new is offered. Stefan: "+ New Client should be available, unless that is really complex to implement." Orphan ClientProfile rows (no orders ever attached) are allowed; the rare-case "client added but never strung for" is not enough reason to deny the affordance.

Surface 1 — /clients (list)

Viewport sm 375 px (mobile-first)

┌───────────────────────────────────────┐
│ ← Clients                  [+ New]    │  Header — back + new-client CTA
├───────────────────────────────────────┤
│                                       │
│ ┌───────────────────────────────────┐ │
│ │ 🔍 Search clients                 │ │  Search input
│ └───────────────────────────────────┘ │
│                                       │
│  Sort:  ◉ Recent activity             │  Sort selector
│         ◯ Name (A→Z)                  │
│                                       │
│  All clients                  47      │  H2 + count
│                                       │
│ ┌───────────────────────────────────┐ │
│ │ 👤 Lukas Müller                   │ │
│ │     Last strung 2026-04-30        │ │
│ │     12 orders · CHF 84 open       │ │
│ │                          Open  →  │ │
│ └───────────────────────────────────┘ │
│ ┌───────────────────────────────────┐ │
│ │ 👤 Anna Bauer                     │ │
│ │     Last strung 2026-04-28        │ │
│ │     8 orders · paid up            │ │
│ │                          Open  →  │ │
│ └───────────────────────────────────┘ │
│ ┌───────────────────────────────────┐ │
│ │ 👤 Stefan (myself)        Self    │ │  Self-row pinned to top
│ │     Last strung 2026-04-30        │ │
│ │     416 orders                    │ │
│ │                          Open  →  │ │
│ └───────────────────────────────────┘ │
│ ⋯                                     │
│                                       │
│  Show:  [50 ▾]   (20/50/100/200/      │  Page-size selector
│                   500/1000)           │
│                                       │
│ ┌───────────────────────────────────┐ │
│ │  Show more (50 of 150)            │ │  Pagination
│ └───────────────────────────────────┘ │
│                                       │
└───────────────────────────────────────┘

Viewport lg 1280 px

┌───────────────────────────────────────────────────────────────────┐
│ ← Clients                                            [+ New]      │
├───────────────────────────────────────────────────────────────────┤
│                                                                   │
│  [🔍 Search clients                  ]   Sort: [Recent ▾]         │
│                                                                   │
│  All clients   47                                                 │
│                                                                   │
│  ┌─────────┬───────────────┬────────┬───────────┬───────────────┐ │
│  │ Name    │ Last strung   │ Orders │ Open bal. │               │ │
│  ├─────────┼───────────────┼────────┼───────────┼───────────────┤ │
│  │ Lukas M.│ 2026-04-30    │ 12     │ CHF 84    │  Open  →      │ │
│  │ Anna B. │ 2026-04-28    │  8     │ paid up   │  Open  →      │ │
│  │ Stefan  │ 2026-04-30    │ 416    │ self      │  Open  →      │ │
│  │ ⋯       │               │        │           │               │ │
│  └─────────┴───────────────┴────────┴───────────┴───────────────┘ │
│                                                                   │
│  Show: [50 ▾]                              [ Show more (50/150) ] │
└───────────────────────────────────────────────────────────────────┘

The two columns "Last strung" + "Orders" + "Open balance" are mobile-stacked-into-the-card on sm; on lg they're a real table with sortable column headers (<th> is the click target — clicking sorts by that column).

Component breakdown

Component Notes
Header back-arrow Returns to dashboard.
+ New CTA Routes to /clients/new — the standalone create-client flow. Creates a ClientProfile + Person and routes to the new client's detail page; does NOT create an order. (For the "new client + new order in one go" path, the entry point is the add-stringjob mini-flow — used by the dashboard "Add stringjob" CTA.)
Search input Placeholder "Search clients" — matches against Person.display_first_name + display_last_name (case-insensitive substring) AND against ClientProfile.nickname (stringer-private label, e.g. "the lefty kid") per the same rule as add-stringjob § Stage 1. HTMX live-filter (hx-trigger="keyup changed delay:200ms").
Sort selector Two options on sm (radio pills): "Recent activity" (ORDER BY MAX(orders.strung_at) DESC NULLS LAST) and "Name (A→Z)" (ORDER BY display_last_name, display_first_name). On lg, every column header is sort-clickable; the radio collapses into a <select>.
Self-row pin If is_self_for_stringer = TRUE exists, the self-ClientProfile renders pinned to the top with a "Self" chip — same treatment as add-stringjob § Stage 1 recent list.
Client row Per-row content: avatar circle (initials), full name (display_first_name + display_last_name), metadata line "Last strung YYYY-MM-DD" (or "No previous jobs"), and a third metadata line "N orders · CHF X open" (or "paid up" / "self"). Whole row is the tap target.
Open-balance pill Computed: SUM(total_chf) WHERE paid_at IS NULL for this client's orders. Shown in slate-500 when zero ("paid up"); amber-700 when non-zero. Stringer-internal — never on a client receipt.
Pagination "Show more (N of M)" loads the next page (default page size 50). Adjacent page-size selector dropdown (<select>) offers 20 / 50 / 100 / 200 / 500 / 1000; selection persists per-stringer via a cookie (see page-size persistence). HTMX-driven; without JS, links to ?page=2&size=N.
Empty state "You don't have any clients yet. Add a stringjob to create your first one." + CTA "Add stringjob" routing to /orders/new. Should fire on a fresh stringer's first visit.

Sort decision

Default sort: "Recent activity" (most-recently-strung first). Rationale: Stefan's most-frequent client-list use is "find the person I just strung for" — same rationale as the dashboard's Recent section. Alphabetical is the secondary sort.

The list is not filtered by default; "All clients" includes inactive clients (those with no recent orders). A "Hide inactive" filter (e.g. "no orders in 6 months") is flagged for V2.x polish, not in this spec.

TODO(stefan): confirm the default sort. Alternative: alphabetical first, with recent-activity as the secondary option. Mira leans recent-first based on the "find the person I just strung for" use case.

Search behaviour

  • Match scope: Person.display_first_name, Person.display_last_name, ClientProfile.nickname — same as add-stringjob.
  • Match type: case-insensitive substring (ILIKE %q%). No fuzzy matching; no Soundex; no ranked relevance. Per client-identity-and-sharing § identity matching, fuzzy matching is rejected platform-wide.
  • Debounce: 200 ms (matches add-stringjob).
  • Empty results: "No clients match 'foo'." + a "+ New client" inline action.

Pagination

Server-side, page-based. Default page size 50; "Show more" loads the next page and appends.

Locked decision (Stefan, 2026-05-09): page size = 50 default, with a dropdown selector offering 20 / 50 / 100 / 200 / 500 / 1000. Stefan: "50. And have a dropdown to adjust the page size: 20, 50, 100, 200, 500, 1'000".

Page-size persistence

The page-size selector value persists per-stringer via cookie (rb_pagesize_clients=<n>, scoped to /clients, lax samesite, 1-year max-age). On each request, the server reads the cookie; if absent or invalid, falls back to 50. Updating the selector triggers an HTMX swap of the row region with the new size and writes the cookie via Set-Cookie on the response.

Why a cookie, not a Stringer column: page size is an ephemeral browsing preference, not stringer-published config. It changes per-device (Stefan on his phone wants 20; Stefan on his laptop wants 200). A column would force a single value across devices and bloat the schema for low-value data. A cookie lives at the right scope (per-device, per-browser).

Without JS: the selector becomes a <form method="get" action="/clients"> with a <select name="size"> and an explicit submit; the server still writes the cookie on response. The ?size=N query param overrides the cookie for that request, and persists into the cookie for next time.

Surface 2 — /clients/{id} (detail)

The page that hosts (V2) profile + edit + order history, and (V3) sign-off override + notification opt-out.

Viewport sm 375 px (mobile-first)

┌───────────────────────────────────────┐
│ ← Lukas Müller                        │  Header — back + page title
├───────────────────────────────────────┤
│                                       │
│  ┌─────────────────────────────────┐  │
│  │ 👤  Lukas Müller                │  │  Avatar + display name
│  │     lukas.m@example.com  ✓      │  │  Email + verified-check
│  │     +41 79 123 45 67            │  │  Phone (if set)
│  │                                 │  │
│  │     [ Edit profile ]            │  │  Single edit CTA → modal
│  └─────────────────────────────────┘  │
│                                       │
│  Stringer-private notes               │  ClientProfile fields
│  Nickname:        the lefty kid       │
│  Default tension: 25 / 24             │
│  Notes:           dislikes Solinco    │
│                                       │
│      [ Edit notes ]                   │  Separate edit CTA
│                                       │
│  ─── Orders ───   12 total ·          │  H2
│                   2 open ·            │
│                   CHF 84 outstanding  │
│                                       │
│ ┌───────────────────────────────────┐ │
│ │ 🟡 Ordered  · 2026-05-02          │ │  Order card
│ │ Pure Aero 98 · 24 kg · CHF 43     │ │
│ │ #2026-0042 · open payment         │ │
│ │                          Open  →  │ │
│ └───────────────────────────────────┘ │
│ ┌───────────────────────────────────┐ │
│ │ ✅ Done  · 2026-04-30             │ │
│ │ Pure Aero 98 · 24 kg · CHF 48     │ │
│ │ #2026-0040 · paid 2026-05-01      │ │
│ │                          Open  →  │ │
│ └───────────────────────────────────┘ │
│ ⋯                                     │
│                                       │
│ ┌───────────────────────────────────┐ │
│ │  Show more (10 of 12)             │ │  Pagination
│ └───────────────────────────────────┘ │
│                                       │
│  ─── More ───                         │  Footer section
│                                       │
│  Client added 2024-08-15              │  Created-at line
│  ClientProfile id 7f2a-…              │  Internal id (debug; toggleable)
│                                       │
└───────────────────────────────────────┘

Viewport lg 1280 px

Two-column with profile on the left (~40%, sticky), order history on the right (~60%).

┌───────────────────────────────────────────────────────────────────┐
│ ← Lukas Müller                                                    │
├──────────────────────────┬────────────────────────────────────────┤
│                          │                                        │
│  👤  Lukas Müller        │  Orders   12 total · 2 open · CHF 84   │
│  lukas.m@…  ✓            │                                        │
│  +41 79 …                │  ┌────────────────────────────────────┐│
│                          │  │ 🟡 #2026-0042 · 2026-05-02         ││
│  [ Edit profile ]        │  │   Pure Aero 98 · 24 kg · CHF 43    ││
│                          │  │   open payment        Open  →      ││
│  Stringer-private        │  └────────────────────────────────────┘│
│  Nickname:    the lefty… │  ┌────────────────────────────────────┐│
│  Tension:     25 / 24    │  │ ✅ #2026-0040 · 2026-04-30         ││
│  Notes:       dislikes…  │  │   Pure Aero 98 · 24 kg · CHF 48    ││
│                          │  │   paid 2026-05-01     Open  →      ││
│  [ Edit notes ]          │  └────────────────────────────────────┘│
│                          │  ⋯                                     │
│  More                    │                                        │
│  Added 2024-08-15        │  [ Show more (10 of 12) ]              │
│  ClientProfile 7f2a-…    │                                        │
│                          │                                        │
└──────────────────────────┴────────────────────────────────────────┘

Component breakdown

Profile panel (top-of-page on sm, left-rail on lg)

Public profile fields — backed by Person. Edit goes through a single "Edit profile" modal that handles all three (name / email / phone) per the privacy split below.

Component Notes
Avatar circle Initials on slate-200 background; same shape as the list page. 96 × 96 px on sm profile; 64 × 64 px on lg left-rail.
Display name Person.display_first_name + display_last_name. text-h2 slate-900.
Email + verified-check Person.email (or "(no email)" when NULL). Adjacent ✓ icon when Person.email_verified_at IS NOT NULL. Hint on hover/focus: "Verified by magic-link click."
Phone Not rendered in the public-profile panel. Phone lives on ClientProfile.phone (stringer-private) per the privacy invariant + Stefan's 2026-05-09 lock. The number renders inside the stringer-private notes block (and inside the edit-notes modal), not next to the verified email.
"Edit profile" CTA Opens the edit-profile modal. Single button covers name + email; phone is in the stringer-private section.

Stringer-private notes — backed by ClientProfile. Per the privacy invariant, these never leak to other stringers.

Component Notes
Nickname ClientProfile.nickname (e.g. "the lefty kid"). Optional.
Default tension memo ClientProfile.default_tension_memo (free-text, e.g. "always 25/24, dislikes Solinco"). Optional.
Internal notes ClientProfile.internal_notes (free-text). Optional.
Phone ClientProfile.phone (text, ≤40 chars, lenient validation). Stringer-private per Stefan's 2026-05-09 lock. Optional.
Address ClientProfile.address (text, ≤500 chars, multi-line). Stringer-private — V1 XLSX parity; never on receipts. Optional.
"Edit notes" CTA Opens the edit-notes modal.

Phone + address placement — there is no Person.phone column in V2 (identity.py shows phone on Stringer only — for the stringer's own business-identity, not for clients). Same situation for address: Person carries platform-public match keys (name + email), nothing else. The choices were:

  1. ClientProfile.phone + ClientProfile.address (new columns on ClientProfile). Stringer-private; never leak via Rule #1 share. Matches V1 XLSX behaviour (Stefan keeps client phone + address on his side, in his book).
  2. Person.phone + Person.address (new columns on Person, like Person.email). Platform-public; visible to other stringers under share rules; visible to the client themselves in V3 portal.

Locked decision (Stefan, 2026-05-09): phone lands on ClientProfile.phone — Stefan: "Person.email is public, ClientProfile.phone is private." The phone column is stringer-private and lives in the edit-notes modal, alongside the other ClientProfile fields. There is no Person.phone in V2.

Mira's lean (locked, no-objection-from-Stefan path): address also lands on ClientProfile.address (text, ≤500 chars) — same posture as phone. Address is notebook data, not a platform-public match key; receipts don't show client addresses (only the stringer's business address per receipt-content F-2); V1 XLSX has it. Lives in the edit-notes modal.

TODO(stefan): if you don't want client address stored at all (e.g. data-minimisation per FADP), drop the ClientProfile.address column from the spec and from the edit-notes modal. The default is to ship it.

Edit-profile modal

Triggered by "Edit profile" CTA. Edits Person.display_first_name, Person.display_last_name, Person.email. (Phone is in the edit-notes modal by default, per the open question above.)

   ╔═══════════════════════════════╗
   ║  Edit profile                 ║
   ║                               ║
   ║  First name *                 ║
   ║  [Lukas                  ]    ║
   ║                               ║
   ║  Last name *                  ║
   ║  [Müller                 ]    ║
   ║                               ║
   ║  Email                        ║
   ║  [lukas.m@example.com    ]    ║
   ║   Verified ✓                  ║
   ║                               ║
   ║  ⓘ Changing the email          ║
   ║  removes the verification     ║
   ║  ✓ until the new address is   ║
   ║  re-verified by magic-link.   ║
   ║                               ║
   ║  ┌───────────┐ ┌────────────┐ ║
   ║  │ Cancel    │ │ Save       │ ║
   ║  └───────────┘ └────────────┘ ║
   ╚═══════════════════════════════╝

Verified-email-match rule (per add-stringjob § new-client mini-flow):

When the user changes the email and submits, the server runs the same identity-match logic as new-client creation:

  • If the new email matches an existing verified Person (different Person.id), surface a confirmation dialog: "A platform-verified client with this email already exists. Re-link this ClientProfile to that Person?"
  • Yes: the ClientProfile's person_id is updated to point at the existing Person; the original Person is left in place (it may have other ClientProfiles via other stringers' relationships). The current ClientProfile's nickname / notes / default_tension_memo remain (they're stringer-private, attached to the ClientProfile, not the Person).
  • No: cancel — the email change is not committed.
  • If no match, the email is saved on the current Person row, email_verified_at is cleared (the new address must re-verify), and a magic-link verification email is sent to the new address. The ✓ disappears until the recipient clicks the link.

Validation:

Rule Inline message
First name empty "First name is required."
Last name empty "Last name is required."
Email format invalid "Please enter a valid email address."
Email matches a different verified Person (Dialog) "A platform-verified client with this email already exists. Re-link this ClientProfile to that Person?"

Why a single edit modal for all three fields? Person fields cluster naturally — they're the platform-public identity. Splitting into three separate per-field modals would be friction; bundling matches the V1 XLSX edit-row mental model.

Edit-notes modal

Triggered by "Edit notes" CTA. Edits ClientProfile.nickname, ClientProfile.default_tension_memo, ClientProfile.internal_notes, ClientProfile.phone, ClientProfile.address.

   ╔═══════════════════════════════╗
   ║  Stringer-private notes       ║
   ║                               ║
   ║  Nickname                     ║
   ║  [the lefty kid           ]   ║
   ║   Optional. Only you see this.║
   ║                               ║
   ║  Default tension memo         ║
   ║  [25/24, dislikes Solinco ]   ║
   ║                               ║
   ║  Phone                        ║
   ║  [+41 79 …               ]    ║
   ║                               ║
   ║  Address                      ║
   ║  [Bahnhofstrasse 1        ]   ║  textarea, multi-line
   ║  [8001 Zürich             ]   ║
   ║                               ║
   ║  Internal notes               ║
   ║  [free-form notes…        ]   ║  textarea
   ║                               ║
   ║  ⓘ These notes never leave    ║
   ║  your account, even when you  ║
   ║  share a job with another     ║
   ║  stringer.                    ║
   ║                               ║
   ║  ┌───────────┐ ┌────────────┐ ║
   ║  │ Cancel    │ │ Save       │ ║
   ║  └───────────┘ └────────────┘ ║
   ╚═══════════════════════════════╝

The privacy reassurance line at the bottom of the modal (load-bearing copy) restates the privacy invariant: these notes live on ClientProfile and are NEVER exposed via Rule #1 share or any other cross-stringer mechanism.

Order history

Per-client order list, paginated.

Component Notes
Section H2 "Orders" + a summary chip "N total · M open · CHF X outstanding" — same shape as the list-page's open-balance pill aggregated for this client.
Order card Same shape as the dashboard queue / recent cards (per stringer-dashboard § queue card). State badge + date + racket + tension + total + receipt number + payment status. Tap → order-detail page.
Sort strung_at DESC NULLS LAST (most-recent-first); secondary created_at DESC for unsaved-strung orders (Draft, Ordered). Stefan sees the freshest activity at the top.
Pagination "Show more (N of M)". Default page size 50 orders per page (per Stefan's 2026-05-09 lock — 21 pages for the 416-order self-row at 20/page is too many). Adjacent page-size selector (<select>) offers 20 / 50 / 100 / 200 / 500 / 1000 — same options as the client-list selector. Persists per-stringer via cookie (rb_pagesize_orders=<n>, scoped to /clients/{id}).
Empty state "No orders for this client yet." + "Add stringjob" CTA routing to /orders/new with the client pre-selected.

Locked decision (Stefan, 2026-05-09): order-history default page size = 50, with the same dropdown selector as the client list (20 / 50 / 100 / 200 / 500 / 1000). Stefan: "21 pages for self-row at 416 orders is too many."

Low-priority metadata, kept out of the main flow.

Component Notes
Client added ClientProfile.created_at formatted ISO. Useful for "when did I first meet this client?"
ClientProfile id UUID, displayed in monospace. Toggleable — collapsed by default; tapping a small "Show technical details" link reveals the id. Useful for support / debugging. Stefan rarely needs it; Mira chose collapsed-by-default to reduce visual noise.

Surface 3 — Edit flows (modals)

The two edit modals are described in detail above (edit-profile, edit-notes). They open from the detail page, not from the list page — list-row taps always open the detail view.

Modal vs. inline editing: Mira chose modals over inline editing because:

  1. The Person/ClientProfile privacy split is easier to communicate via two distinct modals — the modal title ("Edit profile" vs "Stringer-private notes") tells the stringer which side they're touching.
  2. The verified-email-match dialog (if the email change triggers a match) is a modal-on-modal flow that's cleanly handled when the parent surface is itself a modal.
  3. Inline edit on sm would push other content out of view; modals stay anchored.

HTMX seam: hx-get="/clients/{id}/edit-profile" hx-target="#modal-portal" opens; hx-post="/clients/{id}/edit-profile" saves. Without JS: a separate /clients/{id}/edit-profile page renders the modal-as-page; on save, redirect back to /clients/{id}.

Surface 4 — /clients/new (standalone create)

The standalone create-a-client surface, locked into the spec on 2026-05-09. Reuses the add-stringjob § new-client mini-flow identity-match logic (verified-email-match per client-identity-and-sharing + ADR-0004 R-FADP-4) — but does NOT create an order at the end. On save, the new ClientProfile + Person row exists; the user is redirected to the new client's /clients/{id} detail page.

Viewport sm 375 px (mobile-first)

┌───────────────────────────────────────┐
│ ← New client                          │  Header — back to /clients
├───────────────────────────────────────┤
│                                       │
│  First name *                         │
│ ┌───────────────────────────────────┐ │
│ │                                   │ │
│ └───────────────────────────────────┘ │
│                                       │
│  Last name *                          │
│ ┌───────────────────────────────────┐ │
│ │                                   │ │
│ └───────────────────────────────────┘ │
│                                       │
│  Email                                │
│ ┌───────────────────────────────────┐ │
│ │                                   │ │
│ └───────────────────────────────────┘ │
│  Optional. If they sign in to the     │
│  V3 portal later, this is how we'll   │
│  match them.                          │
│                                       │
│  Phone                                │
│ ┌───────────────────────────────────┐ │
│ │                                   │ │
│ └───────────────────────────────────┘ │
│  Optional. Stringer-private — only    │
│  you see this.                        │
│                                       │
│  Address                              │
│ ┌───────────────────────────────────┐ │
│ │                                   │ │  textarea, multi-line
│ │                                   │ │
│ └───────────────────────────────────┘ │
│  Optional. Stringer-private — only    │
│  you see this.                        │
│                                       │
│  ┌──────────┐  ┌─────────────────┐    │
│  │ Cancel   │  │ Create client   │    │
│  └──────────┘  └─────────────────┘    │
│                                       │
└───────────────────────────────────────┘

Viewport lg 1280 px

Same single-column form, centered at 720 px max width — same as the stringer-onboarding form layout. The two CTAs render inline-right at lg.

┌───────────────────────────────────────────────────────────────────┐
│ ← New client                                                      │
├───────────────────────────────────────────────────────────────────┤
│                                                                   │
│   First name *                  Last name *                       │
│   [                       ]     [                          ]      │
│                                                                   │
│   Email                                                           │
│   [                                              ]                │
│   Optional. Match key for the V3 portal.                          │
│                                                                   │
│   Phone                                                           │
│   [                                              ]                │
│   Optional. Stringer-private.                                     │
│                                                                   │
│   Address                                                         │
│   [                                              ]                │
│   [                                              ]                │
│   Optional. Stringer-private.                                     │
│                                                                   │
│                                       [ Cancel ] [ Create client ]│
└───────────────────────────────────────────────────────────────────┘

Component breakdown

Component Notes
Header back-arrow Returns to /clients.
First name <input type="text" maxlength="80" required> — same shape as add-stringjob § new-client mini-flow. Writes to Person.display_first_name.
Last name <input type="text" maxlength="80" required>. Writes to Person.display_last_name.
Email <input type="email" maxlength="254">. Optional. Writes to Person.email. Triggers the verified-email-match dialog if the address matches an existing verified Person — same flow as the edit-profile modal: "A platform-verified client with this email already exists. Re-link this new ClientProfile to that Person?" Per ADR-0004 R-FADP-4.
Phone <input type="tel" maxlength="40">. Optional. Writes to ClientProfile.phone (stringer-private). Same lenient validation as the edit-notes modal (warn on <4 digits, don't block).
Address <textarea rows="3" maxlength="500">. Optional. Writes to ClientProfile.address (stringer-private). Multi-line.
Cancel Returns to /clients without writes.
Create client Submits the form. On success: server creates a new Person (or re-uses one via the verified-email-match path) + a new ClientProfile linked to the current stringer; redirects to /clients/{id} for the new ClientProfile.

Validation

Same rules as the edit modals (see Validation rules) — first/last name required, email format if present, phone lenient, address ≤500 chars. Server-side is canonical.

Verified-email-match flow

Identical to the edit-profile modal match flow:

  • New email + match found (existing verified Person, different Person.id): dialog "A platform-verified client with this email already exists. Re-link this new ClientProfile to that Person?"
  • Yes: the new ClientProfile is created with person_id pointing at the existing Person; the form's name fields are ignored (the existing Person carries the canonical name); the new ClientProfile carries phone + address as stringer-private notes.
  • No: cancel — the create is not committed; the form re-renders so the user can change the email.
  • No match: a new Person + a new ClientProfile are created; the email is unverified (email_verified_at IS NULL); a magic-link verification email is sent to the address. The /clients/{id} page shows the email without the verified ✓ until the recipient clicks the link.
  • No email: a new Person + a new ClientProfile are created; no email-verification flow runs.

HTMX / progressive-enhancement seams

  • Form submit: regular <form method="post" action="/clients/new">. HTMX upgrade returns a 303 redirect; without JS the browser follows.
  • Verified-email-match dialog: server returns a separate page (/clients/new/confirm-rematch) without JS, or an hx-target="#modal-portal" swap with HTMX. Same pattern as the edit-profile match-dialog.

Why the form has only 5 fields, not the full notes set

The new-client surface is deliberately leaner than the edit-notes modal. Nickname / default-tension-memo / internal-notes are all enrichment fields — the stringer adds them once they know the client. At create-time, the bare minimum for a useful client row is name + (optional) email + (optional) phone + (optional) address. The stringer can fill in the rest from /clients/{id} -> "Edit notes".

V3 forward-compat hooks

Per epic #141: surface where V3 features will land on /clients/{id} without re-architecting. Three known V3 hooks:

Hook 1 — Sign-off override (per-client tri-state)

Per v3-vision § sign-off § Settings UI / D5 + issue #51, the V3 client-detail page hosts a tri-state control:

Sign-off requirement
  ◯ Use my default (require)
  ◯ Always require sign-off for this client
  ◯ Skip sign-off for this client

V2 placement: new "Sign-off" section between the "Stringer-private notes" panel and the "Orders" history. In V2 the section is NOT rendered — the Player.stringer_signoff_override column ships per v3-vision V2 hooks, but the UI lights up only when V3 ships the C1 portal that consumes the OR-logic.

The slot is reserved in the wireframe by leaving an explicit <!-- v3-signoff-override --> HTML comment in the template (Mira's note for Pax + Juno: render the section conditionally on a feature flag once V3 ships).

Hook 2 — Notification opt-out (#51 / V3)

Per issue #51: the V3 per-client notification-channel opt-outs (e.g. "skip the auto-pickup-ready email for this client" per channel) live in the same Sign-off + Notifications section as Hook 1. Same V2 posture: column ships (Person.notification_prefs JSONB already exists per identity.py L345; per-stringer-overlay column is a future Player-side column TBD), no V2 UI.

The slot is the same reserved section as Hook 1 — V3 splits the section into "Sign-off" + "Notifications" subsections when both light up.

Hook 3 — Per-client price override (S2 from V2 scope)

Per V2 scope § Should: "S2 — Per-client notification overrides — Template + channel preferences per client." This is a V3-promotion candidate. Same placement: the reserved section.

If S2 promotes into V2 late (Stefan flips the should-list), the section structure is already there — just light up the relevant rows.

V3 reserved-section wireframe

  ─── Sign-off & notifications ───        (V3, hidden in V2)
  Sign-off requirement
    ◯ Use my default (require)
    ◯ Always require sign-off
    ◯ Skip sign-off for this client

  Notification channels (per-client overrides)
    Email     [Use stringer default ▾]
    SMS       [...]
    Telegram  [...]

  Per-client price override
    Default labor: CHF 25 (your stringer default)
    [⌃ Override]

The V3 section sits between "Stringer-private notes" and "Orders" — visible after the public + private profile, before the historical activity. In V2 the section is collapsed-to-zero-height — the page renders without it.

Order history — pagination strategy

The order-history list on /clients/{id} is the largest data surface on this page. Stefan's self-ClientProfile carries ~416 orders post-M15.

Strategy:

  • Server-side pagination (?page=N&size=N). Page size 50 default; "Show more" appends. Adjacent dropdown selector offers 20 / 50 / 100 / 200 / 500 / 1000 (same options as the client-list selector). Selection persists per-stringer via cookie (rb_pagesize_orders=<n>, scoped to /clients/{id}).
  • HTMX append, not replace. hx-target="#order-list" hx-swap="beforeend" — the new page appends to the visible list without flicker.
  • Without JS: "Show more" is a regular <a href="?page=2&size=N#order-list"> link; the server re-renders the page with both pages stitched. Anchor scrolls to the newly-appended section. Page-size selector becomes a <form method="get">.
  • No infinite scroll. Explicit "Show more" tap reduces accidental data fetches and is more accessible.
  • No virtualisation. 416 rows of HTML is fine for <table> rendering; virtual lists are SPA territory.

The "summary chip" at the top of the section ("12 total · 2 open · CHF 84") is computed server-side at page load — it's NOT updated as pages append (the totals are global, not per-page).

Non-goals

Per epic #141 + Mira's sweep:

  • Client deletion. Per stringer-lifecycle § cascade rules, ClientProfile rows are RESTRICT-on-delete (FK-protected by Order rows). FADP-driven Person erasure is admin-only via the DSAR queue. A "delete client" button on /clients/{id} is misleading at best and FADP-violating at worst.
  • Person-merge UI. Per admin-person-merge, merging two Person records is admin-only — listed in Round 5 admin tooling. A stringer-side merge is structurally unsafe (cross-tenant data; non-admin lacks the privilege). Out of V2 client-management scope.
  • V3 client portal (#46 / C1). The client-side surfaces (client logs in, sees their own history, downloads receipts, sets prefs) are V3. This page has no client-portal affordances; the V2 stringer view is the only writer.
  • Bulk actions (e.g. "tag all clients with X", "export CSV of selected clients"). Out of V2; the M20 export (settings-v2 § Your data) covers full-data export. Per-client CSV/XLSX is a V2.x polish.
  • Cross-stringer client search / discovery. A stringer cannot search "is Anna Bauer also a client of another stringer" — that's the privacy-invariant. The Rule #1 share UI (share-management) covers explicit cross-tenant grants.
  • Client deduplication suggestions. Per client-identity-and-sharing § identity matching, only verified-email match triggers any cross-record action. No "did you mean Anna B.?" suggestions.

Interaction states

State What renders
List — initial load Server-rendered list with the first 50 rows + the search box + sort selector.
List — searching HTMX swap of the row region as the user types. 200 ms debounce.
List — show more New 50 rows append; "Show more" CTA refreshes with new "(N of M)" label.
List — empty (no clients) "You don't have any clients yet. Add a stringjob to create your first one." + CTA.
Detail — initial load Profile panel + stringer-private notes + first 20 orders + summary chip.
Detail — order pagination "Show more" appends 20 more orders below.
Edit-profile modal — open Modal renders pre-filled with current Person values.
Edit-profile — verified-email match Match dialog overlays the modal: "Re-link this ClientProfile…?"
Edit-profile — save success Modal closes; profile panel re-renders with the new values; toast at top: "Profile updated." If email changed, an additional toast: "We sent a verification email to {new}."
Edit-notes modal — open / save Same shape as edit-profile; no cross-record dialog.
Validation error Inline red-700 per affected field; modal stays open.
Server error (5xx) Toast at top: "Something went wrong on our side. Try again."
No-email client edit The email field is empty + the verified ✓ is absent. The hint reads "Add an email so this client can sign up later for the V3 portal (when it ships)."

Validation rules (UI surface; canonical server-side)

Rule Inline message
First name empty "First name is required."
First name > 80 chars "First name is too long (max 80)."
Last name empty "Last name is required."
Last name > 80 chars "Last name is too long (max 80)."
Email format invalid "Please enter a valid email address."
Email matches another verified Person (Dialog) "A platform-verified client with this email already exists. Re-link this ClientProfile to that Person?"
Nickname > 120 chars "Nickname is too long (max 120)."
Default tension memo > 200 chars "Tension memo is too long (max 200)."
Internal notes > 2000 chars "Notes are too long (max 2000)."
Phone < 4 digits AND not empty "That phone number looks short. Save it anyway, or fix and re-save." (Permissive.)
Phone > 40 chars "Phone number is too long (max 40)."
Address > 500 chars "Address is too long (max 500)."

Accessibility

  • Heading order: <h1> per page (list: "Clients"; detail: client display name) → <h2> per section ("All clients" / "Orders" / "Stringer-private notes" / "More"). The visually-hidden H1 on the list page reads "Clients"; on detail, the H1 IS the client name (visible as the page header).
  • Search input: <input type="search">, aria-label="Search clients".
  • Sort selector: <fieldset> with two radios on sm; <select> with <label> on lg table view. Column headers on the lg-table are <button> inside <th>aria-sort flips between ascending, descending, none.
  • Client row / order row tap targets: whole row is a single focusable region; aria-label describes the row, e.g. "Open Lukas Müller — last strung 2026-04-30, 12 orders, 84 francs open."
  • Modals: role="dialog" aria-modal="true" aria-labelledby="modal-title". Focus moves to the first input on open; ESC dismisses (equivalent to "Cancel"); focus trap inside the modal until close.
  • Verified-email check icon: aria-label="Email verified by magic-link". Decorative when the email is unverified.
  • Stringer-private notes box: <aside aria-label="Stringer-private notes — only you see these"> so screen readers announce the privacy boundary.
  • Hit targets: every interactive element ≥ 44 × 44 px; row hit targets are full-row.
  • Color contrast: every text/background pair meets WCAG 2.1 AA. Open-balance amber-700 on white = 4.83:1 (above 4.5:1).
  • prefers-reduced-motion: disables HTMX swap fades; "Show more" appends instantly.
  • Without JS: every link still works; the modals become full pages; the search reverts to a <form method="get" action="/clients"> that re-renders the list with the filter applied.

HTMX / progressive-enhancement seams

  • List search: hx-get="/clients?q=..." hx-trigger="keyup changed delay:200ms" hx-target="#client-list" hx-swap="innerHTML". Without JS: regular <form method="get"> submit.
  • List sort: column-header click on lghx-get="/clients?sort=..." hx-target="#client-list". Without JS: <a href="/clients?sort=..."> link.
  • List pagination: hx-get="/clients?page=N" hx-target="#client-list" hx-swap="beforeend". Without JS: <a href="/clients?page=N#client-list"> link.
  • Detail page initial load: server-rendered GET /clients/{id}.
  • Detail order pagination: hx-get="/clients/{id}?page=N" hx-target="#order-list" hx-swap="beforeend". Without JS: anchor link.
  • Edit-profile modal: hx-get="/clients/{id}/edit-profile" hx-target="#modal-portal" hx-swap="innerHTML". Without JS: separate /clients/{id}/edit-profile page.
  • Edit-profile save: regular <form method="post" action="/clients/{id}/edit-profile">. HTMX upgrade returns a 303 redirect; without JS the browser follows.
  • Verified-email match dialog: modal-on-modal — server returns the dialog HTML; HTMX swaps it into the modal portal. Without JS: a confirmation page (/clients/{id}/edit-profile/confirm-rematch) replaces the modal-as-page.
  • Edit-notes modal: same pattern as edit-profile.

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

i18n affordance

String Type Catalogue key
"Clients" (list page H1) {% trans %} clients.list.title
"Search clients" placeholder {% trans %} clients.list.search.placeholder
"Sort:" + "Recent activity" / "Name (A→Z)" {% trans %} clients.list.sort.{label,recent,name}
"All clients" + "{N} clients" count {% trans %} (Babel format_decimal) clients.list.section_title / clients.list.count
"Show more ({N} of {M})" {% trans %} (Babel) clients.list.show_more
"Last strung {date}" / "No previous jobs" {% trans %} (Babel) clients.list.row.last_strung / clients.list.row.no_previous
"{N} orders · CHF {X} open" / "paid up" / "self" {% trans %} (Babel) clients.list.row.activity
"+ New" CTA {% trans %} clients.list.cta.new
"Self" chip {% trans %} clients.list.row.self_chip (re-used from add-stringjob)
"You don't have any clients yet…" empty state {% trans %} clients.list.empty
"Edit profile" CTA {% trans %} clients.detail.cta.edit_profile
"Stringer-private notes" section H2 {% trans %} clients.detail.section.notes
"Edit notes" CTA {% trans %} clients.detail.cta.edit_notes
"Orders" section H2 + summary {% trans %} (Babel) clients.detail.section.orders / clients.detail.orders.summary
"More" section H2 {% trans %} clients.detail.section.more
"Client added {date}" {% trans %} (Babel) clients.detail.more.created_at
"Show technical details" / ClientProfile id label {% trans %} clients.detail.more.technical
Edit-profile modal: "Edit profile" / "First name" / "Last name" / "Email" / verified-text / save / cancel {% trans %} clients.edit_profile.{title,first,last,email,verified,save,cancel}
Email-change verification hint {% trans %} clients.edit_profile.email_change_hint
Verified-email-match dialog body + Yes/No labels {% trans %} clients.edit_profile.match_dialog.{body,yes,no}
Edit-notes modal: title / "Nickname" / "Default tension memo" / "Phone" / "Address" / "Internal notes" / privacy line {% trans %} clients.edit_notes.{title,nickname,tension_memo,phone,address,notes,privacy}
/clients/new form labels + CTAs {% trans %} clients.new.{title,first,last,email,email_hint,phone,phone_hint,address,address_hint,cancel,submit}
Page-size selector "Show:" label + option labels {% trans %} clients.list.page_size.{label,option_20,option_50,option_100,option_200,option_500,option_1000}
Order card state badge labels {% trans %} (re-used from dashboard) order.state.{...}
Open-balance amount "CHF 84" Format (Babel format_currency) n/a
Tension "24 kg" Format (Babel format_unit) n/a
Date "2026-04-30" Format ISO YYYY-MM-DD n/a
Client name (data) Data n/a
Email value (data) Data n/a

DE strings are Iris's later pass.

DE width budget (designer note)

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

  • "Clients" → "Kunden" (similar — actually shorter).
  • "Search clients" → "Kunden suchen" (similar).
  • "Recent activity" → "Letzte Aktivität" (~1.1×).
  • "Last strung {date}" → "Zuletzt bespannt {date}" (~1.5×) — the row metadata wraps to two lines on sm for some clients; the wireframe reserves the height.
  • "Stringer-private notes" → "Interne Notizen" (~1.0×) — fits.
  • "Edit profile" → "Profil bearbeiten" (~1.4×) — fits the inline button at all viewports.
  • "Edit notes" → "Notizen bearbeiten" (~1.5×) — fits.
  • "A platform-verified client with this email already exists. Re-link this ClientProfile to that Person?" → "Ein plattform-verifizierter Kunde mit dieser E-Mail-Adresse existiert bereits. Diese ClientProfile-Verknüpfung auf diese Person umstellen?" (~1.5×) — wraps to four lines on sm; the modal reserves the height.
  • "Show more ({N} of {M})" → "Mehr anzeigen ({N} von {M})" (~1.2×) — fits the button.

The single-column scroll naturally handles wrapping; no DE-specific layout tweaks needed.

Decisions Mira made on Stefan's behalf

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

  1. M12 paid-date toggle lives on the order-detail page, NOT on the client-detail page (Stefan locked 2026-05-09 — "order-detail"). Five-point rationale above stands.
  2. Orphan-client creation IS allowed via /clients/new (Stefan locked 2026-05-09). The + New button on /clients routes to /clients/new, a standalone create flow that produces a ClientProfile + Person row without an order. The add-stringjob mini-flow remains the entry point for "new client + new order in one go".
  3. Default sort = "Recent activity" on the client list (vs alphabetical). Matches the dashboard's recent section pattern.
  4. List page size = 50 default, dropdown selector 20/50/100/200/500/1000, persisted per-stringer via cookie (Stefan locked 2026-05-09). Same selector pattern applies to the order-history list on /clients/{id}, also defaulting to 50 (bumped from 20).
  5. Phone goes on ClientProfile.phone (stringer-private), not on Person.phone (Stefan locked 2026-05-09 — "Person.email is public, ClientProfile.phone is private."). Privacy-restrictive default; matches the V1 XLSX pattern.
  6. Address goes on ClientProfile.address (stringer-private) — Mira's lean, unobjected by Stefan on 2026-05-09. Same posture as phone: notebook data, not a platform-public match key, never on receipts. Stefan can drop the column if he wants no client-address storage at all (TODO callout in profile-panel section).
  7. Single "Edit profile" modal handles name + email together (vs three separate per-field edit modals). Bundles the platform-public-identity edits.
  8. Email-edit triggers the verified-email-match flow (same as add-stringjob, also reused on /clients/new). Re-link-to-existing-Person path is offered explicitly.
  9. No client deletion in V2. FADP / FK-integrity / cascade reasons; admin-only via the DSAR queue.
  10. V3 hooks are placement-only — no V2 UI. A "Sign-off & notifications" section is reserved between notes and orders; rendered conditionally on a V3 feature flag.
  11. The "More" footer section hides the ClientProfile id by default (toggleable). Reduces visual noise; debug-info available on demand.

Open questions for Stefan (with proposed defaults)

Items locked by Stefan on 2026-05-09 are marked Locked below; the remainder are still open.

  1. ~~Phone column placement: ClientProfile.phone or Person.phone?~~ Locked 2026-05-09: ClientProfile.phone.
  2. Default sort on /clients — recent activity or name? Proposed default: recent activity. See sort-decision sub-section.
  3. ~~List page size (50) — too aggressive, just right, or too conservative?~~ Locked 2026-05-09: 50 default + dropdown 20/50/100/200/500/1000.
  4. ~~Order-history page size on detail (20) — Stefan's self-row at 416 orders = 21 pages. Acceptable, or bump to 50?~~ Locked 2026-05-09: 50 default + same dropdown as the client list.
  5. Open-balance pill on the list rows — useful, or visual noise? Proposed default: show. Per UC-8 (late payments are normal), Stefan tracks who owes him; pill at-a-glance saves him drilling into client-detail.
  6. The detail page's "More" section ClientProfile id — show by default, hide by default, or remove entirely? Proposed default: hide-by-default behind a "Show technical details" toggle. Useful for support / cross-tenant debugging; not useful for daily flow.
  7. ~~"+ New" routing — to /orders/new (Mira's lean) or to a standalone /clients/new page?~~ Locked 2026-05-09: standalone /clients/new is offered. See Surface 4.
  8. Search match scope — does Stefan want the search to also match Person.email? Proposed default: no — search matches name + nickname only. Email is a privacy-sensitive identifier; matching it from a free-text search box is mildly hostile. Stefan can search by name and find the email on the detail page.
  9. Self-row pin to top of list — keep, or sort the self-row alongside everyone else by activity? Proposed default: keep pinned. Same posture as the dashboard's add-stringjob picker.
  10. Verified-email-match Yes/No copy — the Yes branch wording: "Re-link this ClientProfile to that Person?" Mira leans on the same vague-on-other-stringer-identity wording from add-stringjob (we don't reveal which other stringer has the verified Person). Proposed default: keep the vague wording. Stefan to confirm.
  11. V3 hook section title — "Sign-off & notifications" or "Preferences" or "Per-client overrides"? Proposed default: "Sign-off & notifications" when V3 lights up — explicit about what's there. The slot title is moot in V2 since the section is hidden.
  12. Address column on ClientProfile.address — Mira's lean (locked into the spec) is to add it, ≤500 chars, stringer-private alongside phone. TODO(stefan): if you don't want client address stored at all (data-minimisation per FADP), drop the column from the spec; otherwise the default is to ship it.

Cross-references