---

name: dyolink-treatment-workspace

description: Treatment tab workspace — appointments strip, preview vs form, history, load flow, draft autosave, type-first entry, connected teeth, lab dispatch. Use when changing treatment UX, preview/history, or lab dispatch in TreatmentWorkspace.

---



# Treatment workspace



Main orchestrator: `frontend/src/components/ui/treatment/TreatmentWorkspace.tsx`  

Thin route: `app/[locale]/(dashboard)/treatment/page.tsx` (supports `?appointmentId=`).



## Layout (top → bottom)



1. **Day strip** — `AppointmentsStrip.tsx` renders `DayStripItem[]` (`appointment` | `unscheduled`) via `DayStripCard`. Header uses **`ScheduleDayPicker` `compact`**: date is centered in a 3-col grid; no “Schedule date” label; **Today** sits on the navigator (`CalendarDaySelect` when the label row is hidden). Timed appointments keep treatment-type pastel banners. Unscheduled cards use the same banner from the **first detail’s type only** (`unscheduledStripColorCode`; live draft for the open card; `draftHydratingRef` must be set **before** strip/appointment pick so overlay does not paint the previous card’s type). Empty first line → chip theming even if later lines are typed. Trash inherits banner ink on typed cards. Strip trash only when `areUnscheduledDetailsStripDeletable` (no type/teeth/notes/attachments, including `[]`). Workspace fetches `GET /appointments` **and** `GET /treatments/day`. Patient search (`PatientSearchCombobox`) sits in the **page header** (workspace-wide). **New treatment** is one shared `Button` at the **top of the left rail**, with the selected-patient card under it: it opens `NewTreatmentPatientPicker` (Walk-in always first, then a matching full-width card for the current named patient with name + mobile/email or hint, then search). Creating happens only after an explicit patient choice — never from the selected appointment card. New treatment seeds one blank detail so the type field is ready; a persisted empty plan hydrates as `[]` until Add (`noDetails` copy — not the type-first overlay). Last-line chip delete confirms the plan will be empty until Add.

2. **Treatment preview** — `TreatmentPreviewCard.tsx` (history browse only; omitted for the live draft)

3. **Treatment history** — `PastTreatmentsPanel.tsx` (past saved plans for patient; **client-side** filters in `treatmentHistoryFilters.ts`)

4. **Editor** — detail chips + Add (`TreatmentDetailsEditor`); type-first form with chart **or** lab dispatch in the same slot (`LabCasesDispatchPanel` replaces the chart; not stacked underneath)



## Entry (type-first form / optional lab sheet)



Right-column entry is **not** a three-step wizard. Type dropdown + `TreatmentDetailAttachmentsStrip` on one row (same height as `Dropdown`; paperclip | divider | thumbs grouped image → pdf → other; upload progress in one square; click thumb → preview/remove dialog). When the active type is lab-dependent, a **Lab dispatch** / **Chart** button sits beside attachments. Compact FDI chart (same scale as Cases) **or** `LabCasesDispatchPanel` in that same slot, then full-width auto-growing **Notes** (`rows={1}`). Chart is dimmed until a type is chosen. Lab-dependent chips show colored sent/unsent **text** (not badge pills); sent date stays on the Lab dispatch panel.

| Stage | UI | When |
|-------|-----|------|
| **Treatment** | Type dropdown + `TreatmentDetailAttachmentsStrip`, `FdiToothChart` / `ProsthesisAssignChart`, full-width Notes | Default |
| **Lab** | `LabCasesDispatchPanel` in the chart slot | Lab-dependent type + user clicks **Lab dispatch** (or rail / Go to dispatch). Auto-ensures a shipment draft (teeth/arch not required to create the draft). **No default lab or prosthesis type** on a new detail (including siblings in the same plan). Last **3 sent** labs appear as chips under search — pick is explicit. Comments stay on the dispatch panel. After send, the same case QR as lab Cases is shown (`shareUrl`): dest/jobs share a row with the thumb; tracker + comments are full width below. |

- Prosthesis types are assigned on the chart (`ProsthesisAssignChart` + `ProsthesisJobPopover` in `prosthesisTree.ts`). The picker is two columns (`4fr` wrapping category grid / `1fr` add-ons) with a vertical `border-e` divider. Category and subcategory tiles (and matching leaves) show SVGs from `src/assets/prosthesis-catalog` (served from `public/prosthesis-catalog`) via `prosthesisCatalogIcons.ts`. Production Docker must copy `public/` into the standalone image (see `.cursor/rules/frontend-assets.mdc`). Category tiles keep the wrapping `minmax(8rem, 1fr)` grid and stretch to fill the overlay; expanded children use `minmax(10.2rem, 1fr)` with a parent-colored **L** rail sized to the first child card (not a per-card tree). Parent-bar back arrow is black. Child labels stay one line and ellipsize (`…`) when they overflow. **Add detail**, Lab dispatch, and Chart share one control width (`WorkspaceActionLabel` in `TreatmentDetailsEditor`). Indirect children are **Veneer → Inlay → Onlay → Overlay** (same order as the category title). Crown leaves without a dedicated SVG use the monolithic zirconia drawing, not the Crown parent icon. Empty crown suggestion: plus + dashed chip (`addonCrownCanBeAdded`) inside a full-height slot; after a crown is picked the slot stays as the filled type chip. Arch Upper/Lower/Both is a compact `h-8` segmented control at half the tree column width — it **is** the assignment (`retargetArchJobs`): Both→Upper/Lower drops the other jaw; Upper/Lower→Both copies the type onto the empty jaw; Upper↔Lower moves the job. Chart Upper/Lower arch labels are dashed outline buttons with plus (open the picker; control shows current jobs, or the clicked jaw if none). Category parents use the heaviest family pastel; children only lighten. One **restoration** per tooth (crown / veneer-inlay-onlay-overlay). **Screw-retained** is implant (`stackGroup: implant`, paints the crown) and is itself the restoration — no crown suggestion slot, and Crown / Indirect are disabled. Implant or post & core (without a non-crown restoration) shows a **crown** suggestion slot. A veneer/inlay/onlay/overlay hides the suggestion slot. **Post & core** category is visible but disabled when a restoration or implant is on the tooth. **Implant** category is disabled when post & core is on the tooth. Complete denture / overdenture / appliances / digital use **Upper arch / Lower arch** (`UA`/`LA`). **Partial denture** is tooth-level (select FDI teeth, Removable in the tooth picker); after send it is **one lab job** for all those teeth. Picker leaves are filtered by `chartRegion` so Removable appears in both tooth and arch pickers. Prosthesis FDI teeth **must** have jobs — never persist selected teeth without `toothProsthesis` (`pruneDetailTeethToJobs`). Catalog has no `addonKind` — stacking uses `stackGroup` plus the crown suggestion slot.
- Detail chips show **type + teeth**, not “Detail N”. Lab-dependent chips use colored sent/unsent text (same size as the label); sent date stays on Lab dispatch.
- Detail type may differ from appointment purpose. Purpose seeds the first line of an empty **appointment** draft (first open, and **Add detail** when the plan is `[]`). Later **Add detail** starts with an empty type. Unscheduled / New treatment still seeds a blank first line.
- Lab shipments rail / “Go to dispatch” / load-with-focus **opens the dispatch view** in the chart slot (`pendingScrollToLabRef` + `labPanelRef`).
- Live draft is **not** duplicated in the left rail preview; preview is for history browse only.



## Tooth selection groups



Helpers: `frontend/src/components/treatment/toothSelectionGroups.ts`. Persisted as `toothSelectionGroups` on the detail. Lab **dispatch** lists connected teeth that share a type as **one row** (`LabCaseToothJobsList`); unconnected teeth stay one row per tooth (stacked codes stay together). After send, Cases/Tasks match that: a connected same-type bridge is one pipeline; stacked types on a **single** tooth **union** their workflow steps; `partial_denture` is one task set per `sourceKey`. `LabCaseTask` unique key includes `tooth` (first tooth of a merged bridge).

Chart hit-testing (`FdiToothChart.tsx`): the hit target is the **unrotated full cell** (`pointerdown` + keyboard). Do **not** bind both `pointerdown` and `click` (double-toggle looks like a miss). Visual glyph is `pointer-events-none`; tilt lives on the outer wrapper; **scale/hover is nested inside** so inline `transform` does not kill scale. Horizontal inset (~22%) leaves a dead zone between teeth; FDI numbers are also clickable (`ToothNumber`).

- **Plain click (non-prosthesis chart):** add/remove single; deselecting a tooth in a bridge removes it and splits/shrinks remaining sides (never a 1-tooth connected group).
- **Neighbor circles:** when two arch-adjacent teeth are both selected, an empty circle appears **between** them (not per-tooth). Click empty → connect; click filled → disconnect (teeth stay selected). Transitive A–B + B–C = one bridge.
- **Shift+click (non-prosthesis chart):** inclusive same-arch range → all selected as **singles** (empty circles); overlapping existing bridges are absorbed as singles too. Midline neighbors (11–21, 41–31) allowed.
- **Prosthesis assign chart (Exocad-style):** plain click **opens the picker** and does not select the tooth until a type is picked. **Done** stores that tooth’s job set as the copy brush. **Ctrl/Cmd+click** or drag copies the brush onto one tooth. **Shift+click** copies the brush onto the inclusive same-arch range as **singles** (no auto-connect; neighbor circles still make bridges). Arch products open from the Upper/Lower arch labels and store `UA`/`LA` (not 16 FDI teeth). Partial denture is assigned from selected teeth (not the arch labels). `pruneToothProsthesisForGroups` must keep those sentinels. Hydrate/save prune FDI teeth that have no job (`pruneDetailTeethToJobs`).
- Prevent browser selection artifacts (`select-none`, Shift `preventDefault` on mousedown).
- On group change, prune/remap `labCase.toothProsthesis` via `pruneToothProsthesisForGroups`.
- Connected UI label: `ConnectedSelectionBadge` (shared `Badge` + primary tint) in dispatch + lab case lists.
- Lab case comments: shared `LabCaseCommentsPanel` — newest-first; **sent** = `justify-start` / `text-start`, **received** = `justify-end` / `text-end` (RTL-safe); require `viewerSide: 'LAB' | 'CLINIC'`. Used in Cases, Tasks, share focus, and Treatment (`DetailLabCaseCommentsSection`). Composer: `h-9` input + primary send / visibility buttons (white icons; Send mirrored in RTL).



## Two layers of state (critical)



| Layer | State | Updated when |

|-------|--------|--------------|

| **Preview** | `previewTreatment`; `selectedPreviewId !== null` = **browse mode** | History click updates preview only |

| **Form** | `details[]`, `labCaseDrafts[]` | Appointment change → draft API; **Load into workspace** → hydrate. Autosave and strip-switch **flush both** `saveDraft` then `saveLabCases`. Dirty includes the lab-case snapshot (jobs, dest, due date), not only details. Non-prosthesis plans keep empty drafts and do not create lab rows. |



**Browse mode:** banner + “Load into workspace” / “Back to current draft”. No Open button on preview card.



**Lab dispatch attention:** `LabDispatchAttentionPanel` lists lab-dependent unsent details (current draft + user history). “Go to dispatch” / “Load & dispatch”.



## History API



`GET /treatments/patients/:id/history` returns saved treatments for **that patient** scoped to the **logged-in clinician** (`Treatment.providerUserId` or linked `Appointment.providerUserId`). **Owners are not exempt** — each user only sees plans they created or own via their appointments.



## History filters (client-side only)



`PastTreatmentsPanel` filters **already-fetched** history — no extra API params.

- **Not shipped to lab** — show treatments that have at least one lab-dependent detail (prosthesis via `labDependentCodes`) with `!sentAt`.
- **Date** — filter on `treatmentAt` matching that local calendar day.
- When **not shipped** is on, filters **already-fetched** history only (excludes the active appointment row).
- **Previous treatments rail** excludes the active appointment; live draft stays in the editor only. Refresh history after lab send.

Helpers: `frontend/src/components/treatment/treatmentHistoryFilters.ts`.



## Lab shipment without teeth or arch jobs



Saved lab-dependent detail with **no FDI teeth and no arch jobs** (`UA`/`LA`) can autosave but **cannot** create a lab shipment.

- Inline amber banner in `TreatmentDetailsEditor` on **both** Treatment and Lab steps when the active detail qualifies (`isLabDependentDetailMissingTeeth`). Do not use a separate notice card.
- `handleAddLabCase` shows toast with `labShipmentBlockedBody`.
- Dispatch panel only appears when a detail passes `isDetailReadyForLabDispatch` (persisted + lab-dependent + teeth **or** arch jobs).



## Lab case comments on details

Comments for a shipment live in the Lab dispatch panel (and Cases/Tasks/share), not on Content notes.

- Unsent: deferred composer in `DetailLabCaseCommentsSection` (posts with Send to lab).
- Sent / in progress: live composer until tasks complete (`canPostComments`). Same-card share QR sits beside dest/jobs only; tracker + comments stay full width below (`LabCasesDispatchPanel`).
- UI: `DetailLabCaseCommentsSection` → `LabCaseCommentsPanel` + `treatmentsApi` comment endpoints (`viewerSide="CLINIC"`).
- Backend includes `tasks: { select: { id, status } }` on lab cases; `mapDetail` exposes `taskProgress: { completed, total }`.

## Live lab rail refresh

Inbox Socket.IO `notification.created` → `notifyTabBadgesChanged()` → silent refresh of patient lab cases + unread rail. Does **not** remount the workspace or clear draft/form state. See `.cursor/skills/notifications-inbox/SKILL.md`.

## Appointments default selection



On today: in-progress slot first, else nearest start time to `now`. Other days: first appointment. Re-runs every 60s on today unless `selectionLocked`. Frontend filters appointments to `providerUserId === userId`.



**Today checkbox** (`ScheduleDayPicker`, including `compact` on the Treatment strip and Appointments page headers): unchecked when `selectedDay` is not today (e.g. after loading a historical treatment). Checking Today calls `onSelectDay(today)` which unlocks selection (`selectionLocked = false`) and resets browse/historical context; appointments reload and auto-select nearest to now.



## Lab org search (dispatch)



`LinkedOrganizationSearchCombobox` in `LabCasesDispatchPanel` — search-only results (no dropdown). Chips under the search are the last **3 labs this clinic sent a case to** (`rememberRecentLab` after successful send). They are shortcuts, not defaults: a new detail’s lab stays empty until the user chooses (types stay on the chart). Switching details clears the search box. No match + org tab access → **Invite a lab** navigates to `/organizations?action=invite-lab`. No org access → show permission message; dispatch stops.



Pattern mirrors `PatientSearchCombobox` in appointments.



## Scroll



Use `scrollWithinMainScrollContainer()` (not raw `scrollIntoView`) when jumping to lab dispatch panel — dashboard `<main>` is the scroll container; document scroll conflicts with `.app-web-bg { overflow: hidden }`.



Use shared `Checkbox` (not native `<input type="checkbox">`) to avoid focus-driven scroll jumps.



## Backend APIs



| Endpoint | Purpose |

|----------|---------|

| `GET /appointments?from&to` | Timed strip cards |

| `GET /treatments/day?from&to` | Standalone (unscheduled) strip cards |

| `POST /treatments` | Create standalone `{ patientId?, walkIn?, treatmentAt }`. Named `patientId` must be this org (`ensurePatientInOrg`). |

| `DELETE /treatments/:id` | Empty standalone only (`appointmentId` null, no detail rows). UI may `PUT` `{ details: [] }` first when the strip looks blank but autosave has not finished. |

| `GET /treatments/patients/:patientId/history` | History (patient + org; filtered by provider) |

| `GET /treatments/appointments/:id/draft` | Load form on appointment select |

| `GET/PUT /treatments/:treatmentId/draft` | Load/save when there is no appointment |

| `PUT .../lab-cases` | Autosave (600ms debounce) — appointment or treatment id |



Walk-in uses one sentinel `Patient` per clinic (`isWalkIn`, hidden from Patients/search/booking). Display via i18n, never the stored name. Named patients are this-org only (`createdByOrganizationId`); another clinic’s mobile is `PATIENT_MOBILE_UNAVAILABLE`, not a shared row. Patient search: same workspace patient with a live visit → no-op; else open today’s strip visit if any; else load latest history into the editor; **no history and no strip visit → do not auto-create**. Detach the previous visit, keep the searched patient, and show an inline editor empty state (`noTreatmentFoundTitle` / `noTreatmentFoundBody`) that points to **New treatment** in the rail (Walk-in, current named patient card, or search).

Draft writes for appointments require provider match (`ensureAppointmentProvider`). Standalone requires `treatment.providerUserId === actor`.



## Edit gating

```typescript
canEditTreatmentForDay = canEdit && hasLiveContext && !isViewingPastDay && workspaceMode === 'live'
```

`hasLiveContext` is a selected live appointment **or** standalone treatment. Past day or `historical` workspace mode freezes the treatment form + most lab-dispatch fields.

### Per-detail sent lock

`isDetailLocked` = linked lab case has `sentAt`. Locked details: type, comment, teeth, attachments are read-only (UI + draft save skips updates/deletes; attachment upload returns `TREATMENT_DETAIL_SENT`).

### Add / remove details

- **Add detail** stays enabled whenever `canEditTreatmentForDay` — even if sibling details are already sent.
- **Remove detail:** trash icon on each **detail chip** in chrome (`TreatmentDetailsEditor` `showChrome`), including the last remaining line. Disabled when `!canEdit`, day/workspace `disabled`, detail sent (`isDetailLocked`), or `uploadBusy`. Confirm via `confirmRemoveDetail`. Drop linked unsent lab drafts with the detail. Empty details persist as `[]` (`SaveTreatmentDraftDto` has no `@ArrayMinSize`; `areDetailsPersistable` is `details.every(isDetailTypeSelected)` so `[]` saves). Unscheduled strip-card trash uses `areUnscheduledDetailsStripDeletable` (blank lines), then `PUT` `{ details: [] }` and `DELETE /treatments/:id` (backend still refuses when any detail row remains — `TREATMENT_HAS_DETAILS`). **Do not** put a delete control in the type/notes fields.

### Sent lab shipment fields (vs detail)

After send, destination / prosthesis / shipment attachments are frozen. **Due date** stays editable until all lab tasks complete (UI still respects day/mode `disabled`). **Comments** stay editable until tasks complete and intentionally ignore the day/mode gate.

Full map helpers: `treatmentDetailRules.ts`, `LabCasesDispatchPanel.tsx`.




- `@IsOptional()` email: use `@Transform` empty string → `undefined` before `@IsEmail` (see patients DTO).

- Form validation → inline errors; transient feedback → global `useToast()` via `ToastProvider`.



## When changing history scope



Filter in **backend** `listPatientHistory` / lab-case lists on patient + org + **provider scope** (`common/treatment-provider-scope.ts`). History is **per selected patient and per clinician**, not per day or all org plans.

**UI filters** (not shipped, date) are client-side only — do not add API params unless product explicitly requires server-side filtering.

