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

13 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 once a treatment type is selected (live draft for the open card; draftHydratingRef must be set before strip/appointment pick so overlay does not paint the previous cards type). Until typed they keep chip theming. 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. New treatment is one shared Button: it opens NewTreatmentPatientPicker (Walk-in always first, 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.

  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. Last-used lab and prosthesis type are remembered. 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 only defaults new details).
  • 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 banner in TreatmentDetailsEditor + LabShipmentBlockedNotice above dispatch when active detail qualifies (isLabDependentDetailMissingTeeth).
  • 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). 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 → no-op; else load latest history into the editor; no history → do not auto-create (history rail empties; dentist uses New treatment and picks a patient, including Walk-in).

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.