Files
dyolink/.cursor/skills/treatment-workspace/SKILL.md

15 KiB
Raw Blame History

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)

  1. Day stripAppointmentsStrip.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 details type only (unscheduledStripColorCode; live draft for the open card; draftHydratingRef must be set before strip/appointment pick so overlay does not paint the previous cards 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 previewTreatmentPreviewCard.tsx (history browse only; omitted for the live draft)

  3. Treatment historyPastTreatmentsPanel.tsx (past saved plans for patient; client-side filters in treatmentHistoryFilters.ts)

  4. 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 activeDetailId resets to the treatment form, unless pendingEntryStepRef is set to lab first (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 AB + BC = one bridge.
  • Shift+click: inclusive same-arch range → all selected as singles (empty circles); overlapping existing bridges are absorbed as singles too. Midline neighbors (1121, 4131) allowed.
  • 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 |

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

Saved lab-dependent detail with no teeth 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).

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: DetailLabCaseCommentsSectionLabCaseCommentsPanel + 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.creatednotifyTabBadgesChanged() → 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 details 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 todays 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 (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.