15 KiB
name, description
| name | description |
|---|---|
| dyolink-treatment-workspace | 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)
-
Day strip —
AppointmentsStrip.tsxrendersDayStripItem[](appointment|unscheduled) viaDayStripCard. Header usesScheduleDayPickercompact: date is centered in a 3-col grid; no “Schedule date” label; Today sits on the navigator (CalendarDaySelectwhen 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;draftHydratingRefmust 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 whenareUnscheduledDetailsStripDeletable(no type/teeth/notes/attachments, including[]). Workspace fetchesGET /appointmentsandGET /treatments/day. Patient search (PatientSearchCombobox) sits in the page header (workspace-wide). New treatment is one sharedButtonat the top of the left rail, with the selected-patient card under it: it opensNewTreatmentPatientPicker(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 (noDetailscopy — not the type-first overlay). Last-line chip delete confirms the plan will be empty until Add. -
Treatment preview —
TreatmentPreviewCard.tsx(history browse only; omitted for the live draft) -
Treatment history —
PastTreatmentsPanel.tsx(past saved plans for patient; client-side filters intreatmentHistoryFilters.ts) -
Editor — detail chips + Add (
TreatmentDetailsEditor); type-first form with chart + notes; lab send sheet only for prosthesis (LabCasesDispatchPanel)
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). Compact FDI chart (same scale as Cases), 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 tab.
| Stage | UI | When |
|---|---|---|
| Treatment | Type dropdown + TreatmentDetailAttachmentsStrip, FdiToothChart (Cases scale), full-width Notes |
Always |
| Lab | LabCasesDispatchPanel |
Only when active detail type is lab-dependent. Entering Lab auto-ensures a shipment 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. |
- Prosthesis uses
WizardStepper(Treatment → Lab) with Back/Next. Lab dispatch keeps comments. - 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. - Switching
activeDetailIdresets to the treatment form, unlesspendingEntryStepRefis set tolabfirst (lab shipments rail / “Go to dispatch” / load-with-focus). - 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 rows are 1:1 with selection groups; after send, Cases/Tasks merge teeth by prosthesis type (not by selection group).
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: 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: 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.
- Prevent browser selection artifacts (
select-none, ShiftpreventDefaulton mousedown). - On group change, prune/remap
labCase.toothProsthesisviapruneToothProsthesisForGroups. - Connected UI label:
ConnectedSelectionBadge(sharedBadge+ 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); requireviewerSide: 'LAB' | 'CLINIC'. Used in Cases, Tasks, share focus, and Treatment (DetailLabCaseCommentsSection). Composer:h-9input + 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 |
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
treatmentAtmatching 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
Saved lab-dependent detail with no teeth can autosave but cannot create a lab shipment.
- Inline amber banner in
TreatmentDetailsEditoron both Treatment and Lab steps when the active detail qualifies (isLabDependentDetailMissingTeeth). Do not use a separate notice card. handleAddLabCaseshows toast withlabShipmentBlockedBody.- Dispatch panel only appears when a detail passes
isDetailReadyForLabDispatch(persisted + lab-dependent + teeth).
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). - UI:
DetailLabCaseCommentsSection→LabCaseCommentsPanel+treatmentsApicomment endpoints (viewerSide="CLINIC"). - Backend includes
tasks: { select: { id, status } }on lab cases;mapDetailexposestaskProgress: { 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 and prosthesis type (apply-all and per-tooth) stay empty until the user chooses. 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 } |
| 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. 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
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 (
TreatmentDetailsEditorshowChrome), including the last remaining line. Disabled when!canEdit, day/workspacedisabled, detail sent (isDetailLocked), oruploadBusy. Confirm viaconfirmRemoveDetail. Drop linked unsent lab drafts with the detail. Empty details persist as[](SaveTreatmentDraftDtohas no@ArrayMinSize;areDetailsPersistableisdetails.every(isDetailTypeSelected)so[]saves). Unscheduled strip-card trash usesareUnscheduledDetailsStripDeletable(blank lines), thenPUT{ details: [] }andDELETE /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@Transformempty string →undefinedbefore@IsEmail(see patients DTO). -
Form validation → inline errors; transient feedback → global
useToast()viaToastProvider.
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.