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 invariant —
Personcarries platform-public data;ClientProfilecarries stringer-private notes. The edit surfaces respect this split. - v3-vision § V2 hooks + issue #51 / D5 —
Player.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:
- 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.
- 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.
- Privacy invariant is structural, not opt-in.
Person.emailedit goes through the verified-email match flow (add-stringjob § new-client mini-flow rule);ClientProfile.nicknameedits 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¶
- The toggle is per-order, not per-client.
Order.paid_atis 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. - 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.
- 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.
- 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.
- 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/newis 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:
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).Person.phone+Person.address(new columns on Person, likePerson.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 noPerson.phonein 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.addresscolumn 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_idis 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'snickname/notes/default_tension_memoremain (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_atis 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."
"More" footer section¶
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:
- 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.
- 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.
- Inline edit on
smwould 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. |
<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
ClientProfileis created withperson_idpointing 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 anhx-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 onsm;<select>with<label>onlgtable view. Column headers on the lg-table are<button>inside<th>—aria-sortflips betweenascending,descending,none. - Client row / order row tap targets: whole row is a single focusable region;
aria-labeldescribes 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
lg→hx-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-profilepage. - 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
smfor 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:
- 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.
- Orphan-client creation IS allowed via
/clients/new(Stefan locked 2026-05-09). The+ Newbutton on/clientsroutes 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". - Default sort = "Recent activity" on the client list (vs alphabetical). Matches the dashboard's recent section pattern.
- 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). - Phone goes on
ClientProfile.phone(stringer-private), not onPerson.phone(Stefan locked 2026-05-09 — "Person.email is public, ClientProfile.phone is private."). Privacy-restrictive default; matches the V1 XLSX pattern. - 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). - Single "Edit profile" modal handles name + email together (vs three separate per-field edit modals). Bundles the platform-public-identity edits.
- 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. - No client deletion in V2. FADP / FK-integrity / cascade reasons; admin-only via the DSAR queue.
- 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.
- 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.
- ~~Phone column placement:
ClientProfile.phoneorPerson.phone?~~ Locked 2026-05-09:ClientProfile.phone. - Default sort on
/clients— recent activity or name? Proposed default: recent activity. See sort-decision sub-section. - ~~List page size (50) — too aggressive, just right, or too conservative?~~ Locked 2026-05-09: 50 default + dropdown 20/50/100/200/500/1000.
- ~~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.
- 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.
- 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.
- ~~"+ New" routing — to
/orders/new(Mira's lean) or to a standalone/clients/newpage?~~ Locked 2026-05-09: standalone/clients/newis offered. See Surface 4. - 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. - 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.
- 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.
- 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.
- 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¶
- Source requirement: V2 Client Management epic — racket-book#141, V2 scope M5 + M9 + M12, issue #66 — M12.
- Schema:
app/db/models/identity.py(Person+ClientProfile),app/db/models/order.py(paid_at), data-model § ClientProfile. - Privacy + identity: client-identity-and-sharing § privacy invariant + identity matching, ADR-0004 § Person/ClientProfile split.
- V3 hooks: v3-vision § sign-off, issue #51 / D5, issue #46 / C1 client portal.
- Linked design surfaces: add-stringjob § new-client mini-flow (the inline-create complement), stringer-dashboard (the entry point), settings-v2 (stringer-default settings; client-overrides land here, not there), share-management (cross-stringer per-job share UI).
- Future surfaces (out of V2 scope):
- Order detail / edit page — hosts the M12 paid-date toggle per the decision above.
- V3 client portal screens — surfaces the client-side of
Player.signoff_pref+Person.notification_prefs. - Admin person-merge — covered by admin-person-merge, Round 5.
- i18n strategy: i18n architecture.
- Issue tracking: racket-book#141 (closes on merge).