19 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 or lab dispatch in the same slot (LabCasesDispatchPanelreplaces 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+ProsthesisJobPopoverinprosthesisTree.ts). The picker is two columns (4frwrapping category grid /1fradd-ons) with a verticalborder-edivider. Category and subcategory tiles (and matching leaves) show SVGs fromsrc/assets/prosthesis-catalog(served frompublic/prosthesis-catalog) viaprosthesisCatalogIcons.ts. Category tiles keep the wrappingminmax(8rem, 1fr)grid and stretch to fill the overlay; expanded children useminmax(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 (WorkspaceActionLabelinTreatmentDetailsEditor). 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 compacth-8segmented 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 bychartRegionso Removable appears in both tooth and arch pickers. Prosthesis FDI teeth must have jobs — never persist selected teeth withouttoothProsthesis(pruneDetailTeethToJobs). Catalog has noaddonKind— stacking usesstackGroupplus 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).pruneToothProsthesisForGroupsmust keep those sentinels. Hydrate/save prune FDI teeth that have no job (pruneDetailTeethToJobs). - 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. 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
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 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
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 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+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 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 } |
| 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.