diff --git a/.cursor/rules/treatment-workspace.mdc b/.cursor/rules/treatment-workspace.mdc index 2197a67..b2622b9 100644 --- a/.cursor/rules/treatment-workspace.mdc +++ b/.cursor/rules/treatment-workspace.mdc @@ -8,7 +8,7 @@ alwaysApply: false - **Browse mode** (`selectedPreviewId` set): preview only; banner + **Load into workspace**; form unchanged until load. - **Current draft** preview: omitted in live editing (form is the source). History browse still uses preview + **Load into workspace**. -- **Entry:** Type dropdown + `TreatmentDetailAttachmentsStrip` on one row, bordered chart (Cases chrome), then full-width auto-growing Notes. Prosthesis types live on the chart (teeth, or Upper/Lower arch for jaw products). Screw-retained stacks with a crown; partial denture is selected on FDI teeth. Lab dispatch is always under chart+notes when the active type is lab-dependent — no Treatment→Lab stepper. Lab-rail / Go to dispatch scrolls to the panel (`pendingScrollToLabRef`). Lab-dependent chips use colored sent/unsent text; sent date is on Lab dispatch. Lab dispatch keeps comments. +- **Entry:** Type dropdown + `TreatmentDetailAttachmentsStrip` on one row (plus **Lab dispatch** toggle when the type is lab-dependent), bordered chart (Cases chrome), then full-width auto-growing Notes. Prosthesis types live on the chart (teeth, or Upper/Lower arch for jaw products). Picker: `4fr` wrapping category grid / `1fr` add-ons; empty crown slot is plus + dashed chip in a full-height cell; after a crown is picked the slot stays as the filled type chip (so it can be changed or cleared). Children stay a wrapping grid with an L-rail. Category parent is the heaviest family tone, children lighter. Lab dispatch is primary, Chart is outline, same width. Chart header: Full View + Reset as shared `Button` `sm`. Arch labels: dashed outline + plus. Picker Upper/Lower/Both remaps `UA`/`LA` (`retargetArchJobs`: Both→Upper drops lower). Implant or post & core suggests a **crown** slot; a veneer/inlay/onlay/overlay hides that slot; Post & core is disabled after a crown/implant; Implant is disabled after post & core; screw-retained does not suggest another crown and disables Crown / Indirect. Partial denture is selected on FDI teeth. Lab dispatch **replaces the chart** in the same slot (toggle beside attachments) — not stacked underneath. Rail / Go to dispatch opens that view (`pendingScrollToLabRef`). Prosthesis teeth **always have jobs**; hydrate/save prune selections without `toothProsthesis`. Lab-dependent chips use colored sent/unsent text; sent date is on Lab dispatch. Lab dispatch keeps comments. - **Tooth hits:** unrotated full cell, `pointerdown` only (not `click` too — double-toggles). Glyph `pointer-events-none`; nest scale/hover inside the rotate wrapper. Groups: `toothSelectionGroups.ts` (never 1-tooth connected); prune lab `toothProsthesis` on change. - **Day strip:** `ScheduleDayPicker` `compact` centered in the header. Timed cards use purpose banners. Unscheduled cards use the same banner from the **first detail’s type only** (live draft for the open card); empty first line keeps chip theming. Trash inherits banner ink. Strip-delete only when `areUnscheduledDetailsStripDeletable` (blank lines or `[]`); persist `[]` then `DELETE /treatments/:id`. - **Lab dispatch UI:** due date end-aligned beside title (`sm:flex-row` + `justify-between`; stacks on mobile). Job list is `LabCaseToothJobsList`: connected same-type teeth share a row; unconnected teeth stay one row (stacked types as colored catalog text). Content clinical field = **Notes** (not case comments). @@ -16,7 +16,7 @@ alwaysApply: false - **Detail chrome:** chips (type + teeth) + Add at top; **Remove** = trash on chip (unsent, including last line; disabled when day-locked / no edit / uploading). Last-line confirm: plan will be empty until Add. Empty `[]` shows `noDetails` (not the type-first overlay). New treatment seeds one blank detail. No delete in type/notes fields. - **Patient search:** `PatientSearchCombobox` in the **page header** (workspace-wide). Opens today’s strip visit if any, else latest history. No visit/history → inline editor empty state pointing to **New treatment** in the rail (never a dialog, never auto-create). **New treatment** stays at the top of the left rail; selected-patient card sits under it. Picker: Walk-in first, then a matching full-width card for the current named patient (name + mobile/email, else hint), then search. Never auto-create from the appointment card. - **Lab dispatch attention:** `LabDispatchAttentionPanel` — unsent lab-dependent details; quick jump to dispatch. -- **History API:** patient-scoped; non-owners filtered by provider on treatment or appointment; org owners see all. +- **History API:** patient-scoped; filtered by provider on treatment or appointment — owners are not exempt. - **History filters (client-side):** `PastTreatmentsPanel` — “Not shipped to lab” + single date; helpers in `treatmentHistoryFilters.ts`. - **Lab shipment block:** lab-dependent detail with no teeth and no arch job (`UA`/`LA`) saves but cannot ship — inline amber banner (`labBlockedBannerClass`). - **Edit gating:** `canEditTreatmentForDay` = permission + appointment + not past day + `live` mode. Sent detail locks that line; Add still OK same day. Upload rejects sent (`TREATMENT_DETAIL_SENT`). diff --git a/.cursor/skills/treatment-workspace/SKILL.md b/.cursor/skills/treatment-workspace/SKILL.md index 676f03b..f62b0dd 100644 --- a/.cursor/skills/treatment-workspace/SKILL.md +++ b/.cursor/skills/treatment-workspace/SKILL.md @@ -28,7 +28,7 @@ Thin route: `app/[locale]/(dashboard)/treatment/page.tsx` (supports `?appointmen 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 + notes; lab send sheet under the form when the active type is prosthesis (`LabCasesDispatchPanel`) +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) @@ -36,17 +36,17 @@ Thin route: `app/[locale]/(dashboard)/treatment/page.tsx` (supports `?appointmen -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 panel. +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 | Always | -| **Lab** | `LabCasesDispatchPanel` under the form | When active detail type is lab-dependent. 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. | +| **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. | -- Prosthesis types are assigned on the chart (`ProsthesisAssignChart` + `ProsthesisJobPopover` in `prosthesisTree.ts`). One **restoration** slot per tooth (crown / veneer-inlay-onlay-overlay); **screw-retained** is an implant add-on (`stackGroup: implant`, paints the crown) and stacks with a restoration. Implant abutments and post & core also stack (post & core stays off when the implant is screw-retained). 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. Lab dispatch is on the same screen (no Treatment → Lab stepper). +- Prosthesis types are assigned on the chart (`ProsthesisAssignChart` + `ProsthesisJobPopover` in `prosthesisTree.ts`). The picker is two columns (`4fr` wrapping category grid / `1fr` add-ons). Category tiles keep the wrapping `minmax(8rem, 1fr)` grid and stretch to fill the overlay; expanded children use that same wrapping grid with a parent-colored **L** rail (not a per-card tree). 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 **scrolls** to the dispatch panel (`pendingScrollToLabRef` + `labPanelRef`). +- 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. @@ -59,10 +59,10 @@ Helpers: `frontend/src/components/treatment/toothSelectionGroups.ts`. Persisted 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). +- **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:** 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:** Shift = range then assign to the span; Ctrl/Cmd or drag copies the last type-set. Click a selected tooth **opens the picker**; click drops a tooth only from an in-progress Shift range that still has no jobs. 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. +- **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. @@ -115,15 +115,15 @@ Helpers: `frontend/src/components/treatment/treatmentHistoryFilters.ts`. -## Lab shipment without teeth +## Lab shipment without teeth or arch jobs -Saved lab-dependent detail with **no teeth** can autosave but **cannot** create a lab shipment. +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). +- Dispatch panel only appears when a detail passes `isDetailReadyForLabDispatch` (persisted + lab-dependent + teeth **or** arch jobs). diff --git a/AGENTS.md b/AGENTS.md index 74c40b5..4267220 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,8 +47,8 @@ frontend/src/ - Day/mode gate: editable only for live draft on today/future (`canEditTreatmentForDay`). Past day / historical load → read-only form. Patient search sits in the **page header** (workspace-wide). If the patient has a visit on the day strip, that visit opens; else the latest history plan loads. If neither exists, the editor shows an inline empty state (no dialog) pointing to **New treatment** in the rail — do not auto-create. **New treatment** is at the top of the left rail (selected-patient card below it) and opens a picker: Walk-in first, then a matching card for the current named patient (name + mobile/email), then search. It does not auto-copy the appointment card’s patient. New treatment seeds one blank detail; a persisted empty plan loads as `[]` until Add. - **Unscheduled strip cards** use the same treatment-type banner as appointments from the **first detail’s type only** (`unscheduledStripColorCode`; live draft for the open card). Empty first line → chip theming even if later lines are typed. Strip trash only when every line is blank (no type/teeth/notes/attachments, including `[]`); typed cards need chip-delete first. Backend `DELETE /treatments/:id` is empty-only (`TREATMENT_HAS_DETAILS`). - Sent-to-lab detail locks that line; **Add detail** still OK same day; **Remove detail** = trash on chip (unsent, including last line; day/edit gates apply). Empty details persist as `[]`. Attachment upload blocked when sent (`TREATMENT_DETAIL_SENT`). -- **Entry:** Type dropdown + compact attachments strip (`TreatmentDetailAttachmentsStrip`) on one row, then bordered FDI chart (Cases chrome/scale), then full-width auto-growing **Notes**. Prosthesis types are assigned on the chart (teeth, or Upper/Lower arch labels for jaw products). Lab dispatch sits under chart+notes when the active type is lab-dependent — no Treatment→Lab stepper. Detail chips show type + teeth; lab-dependent chips use colored sent/unsent text (sent date on dispatch). Lab-rail / Go to dispatch **scrolls** to the panel (`pendingScrollToLabRef`). Lab dispatch keeps comments. -- **Tooth selection:** Hit target is the unrotated cell (`pointerdown` only — do not also bind `click`). Glyph is `pointer-events-none`; nest hover/selected scale inside the rotate wrapper. Neighbor empty/filled circles between selected adjacent teeth connect/disconnect bridges; Shift+range selects only (empty circles; overlap absorbs as singles); midline 11–21 / 41–31 allowed. Plain click selects/deselects (deselect splits bridges). Never a 1-tooth connected. Prosthesis assign chart: Shift = range then assign; Ctrl/Cmd or drag = copy last type-set. Helpers: `toothSelectionGroups.ts`. Connected label: `ConnectedSelectionBadge`. Unconnected teeth stay **one item per tooth** (stacked types on that tooth union their lab steps). **Connected** teeth that share a prosthesis type are **one job** (teeth listed together); an extra type on one unit of the bridge is its own row. `UA`/`LA` → Upper/Lower arch. **Partial denture** is tooth-selected and becomes **one lab job** for all those teeth. +- **Entry:** Type dropdown + compact attachments strip (`TreatmentDetailAttachmentsStrip`) on one row (plus **Lab dispatch** / **Chart** toggle when the type is lab-dependent; dispatch is primary, Chart is outline, same width), then bordered FDI chart (Cases chrome/scale) **or** the lab dispatch panel in that same slot, then full-width auto-growing **Notes**. Prosthesis types are assigned on the chart (teeth, or Upper/Lower arch labels for jaw products — dashed outline + plus). Arch Upper/Lower/Both remaps existing `UA`/`LA` jobs (Both→Upper clears lower). Picker: `4fr` wrapping category grid / `1fr` add-ons; empty crown slot is plus + dashed “Crown can be added”; filled crown slot stays visible so it can be changed. Implant/post & core show a crown suggestion slot; a veneer/inlay/onlay/overlay hides it; Post & core is disabled after crown or implant; Implant is disabled after post & core; screw-retained does not suggest another crown. Prosthesis teeth always have jobs (hydrate/save prune). Lab-rail / Go to dispatch **opens the dispatch view** (`pendingScrollToLabRef`). Detail chips show type + teeth; lab-dependent chips use colored sent/unsent text (sent date on dispatch). Lab dispatch keeps comments. +- **Tooth selection:** Hit target is the unrotated cell (`pointerdown` only — do not also bind `click`). Glyph is `pointer-events-none`; nest hover/selected scale inside the rotate wrapper. Neighbor empty/filled circles between selected adjacent teeth connect/disconnect bridges; midline 11–21 / 41–31 allowed. Never a 1-tooth connected. On the non-prosthesis chart, Shift+range selects as singles. **Prosthesis assign chart (Exocad-style):** plain click opens the picker (tooth is not selected until a type is picked); **Done** stores that tooth’s jobs as the copy brush; **Ctrl/Cmd+click** or drag copies those jobs onto one tooth; **Shift+click** copies onto the inclusive same-arch range as **singles** (connect with neighbor circles if it is a bridge). Helpers: `toothSelectionGroups.ts`. Connected label: `ConnectedSelectionBadge`. Unconnected teeth stay **one item per tooth** (stacked types on that tooth union their lab steps). **Connected** teeth that share a prosthesis type are **one job** (teeth listed together); an extra type on one unit of the bridge is its own row. `UA`/`LA` → Upper/Lower arch. **Partial denture** is tooth-selected and becomes **one lab job** for all those teeth. - **Lab dispatch layout:** due date beside title (`justify-between`, logical start/end for RTL); job rows from `LabCaseToothJobsList` (connected same-type teeth share a row; stacked types on a single tooth stay on that tooth). - **Schedule date:** Treatment strip and Appointments page headers use `ScheduleDayPicker` `compact` (date centered in a 3-col header; no “Schedule date” label; Today on the navigator). @@ -57,7 +57,7 @@ frontend/src/ - New prosthesis dispatch lines start **empty** (no default lab). Types are assigned only on the chart (`ProsthesisAssignChart` / `ProsthesisJobPopover`). Recent-lab chips are the last **3 sent** destinations — pick is explicit, never auto-selected. - Detail treatment type need **not** match appointment purpose — purpose only seeds the **first** line of an empty **appointment** draft (first open, and **Add detail** when the plan is `[]`). Further **Add detail** starts with an empty type. Unscheduled / New treatment still seeds a blank first line. - **History filters** are client-side only (`treatmentHistoryFilters.ts`): “Not shipped to lab” + single date on already-fetched patient history; includes live current draft when filtering. -- **Lab shipments rail**: unified list with scope toggle **This patient** vs **All updates** (unread across org for **this clinician's cases only**, includes patient name). Opening a case from the rail **scrolls** to the lab dispatch panel. +- **Lab shipments rail**: unified list with scope toggle **This patient** vs **All updates** (unread across org for **this clinician's cases only**, includes patient name). Opening a case from the rail **opens the lab dispatch view** (same slot as the chart). - **Unread semantics**: Treatment tab badge = count of unread cases **for the user's own treatment plans** (per-case read cursor) and clears when a case is opened/marked read (not on tab visit). - **Live lab rail**: `notification.created` → `notifyTabBadgesChanged()` silently refreshes patient lab cases + unread rail (does **not** clear draft/form state). - **Lab shipment progress + comments**: shown in **Lab dispatch panel** for the active shipment; expanding activity / opening comments marks that case read. Shared UI: `LabCaseCommentsPanel` — newest first; sent = start / received = end (`text-start`/`justify-start`, RTL-safe); pass `viewerSide`. diff --git a/backend/prisma/catalog-seed-data.ts b/backend/prisma/catalog-seed-data.ts index 3d86d9a..796ee8e 100644 --- a/backend/prisma/catalog-seed-data.ts +++ b/backend/prisma/catalog-seed-data.ts @@ -72,7 +72,6 @@ export const LAB_WORKFLOW_STEPS = [ export type ProsthesisChartRegion = 'crown' | 'root' | 'arch'; export type ProsthesisStackGroup = 'restoration' | 'implant' | 'post_core' | 'arch'; -export type ProsthesisAddonKind = '' | 'implant' | 'post_core'; export type ProsthesisTypeSeed = { code: string; @@ -83,7 +82,6 @@ export type ProsthesisTypeSeed = { subcategory?: string; chartRegion: ProsthesisChartRegion; stackGroup: ProsthesisStackGroup; - addonKind?: ProsthesisAddonKind; /** Manufacturing sequence. Packing and shipping are appended except for smile_design. */ manufacturingSteps: readonly string[]; }; @@ -138,7 +136,7 @@ function crown( }; } -function implantAddon( +function implant( code: string, sortOrder: number, manufacturingSteps: readonly string[], @@ -150,7 +148,6 @@ function implantAddon( category: 'implant', chartRegion: 'root', stackGroup: 'implant', - addonKind: 'implant', manufacturingSteps, ...extra, }; @@ -230,7 +227,6 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [ category: 'post_core', chartRegion: 'root', stackGroup: 'post_core', - addonKind: 'post_core', manufacturingSteps: POST_CORE, }, { @@ -239,29 +235,28 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [ category: 'post_core', chartRegion: 'root', stackGroup: 'post_core', - addonKind: 'post_core', manufacturingSteps: POST_CORE, }, - implantAddon('prefabricated_abutment', 40, [ + implant('prefabricated_abutment', 40, [ 'intraoral_scan', 'choosing_abutment', 'model_create', 'polish_prep', ]), - implantAddon('ti_base_abutment', 41, ['intraoral_scan', 'model_create', 'polish_prep']), - implantAddon('multi_unit_abutment', 42, [ + implant('ti_base_abutment', 41, ['intraoral_scan', 'model_create', 'polish_prep']), + implant('multi_unit_abutment', 42, [ 'intraoral_scan', 'choosing_abutment', 'model_create', 'polish_prep', ]), - implantAddon('customized_abutment', 43, [ + implant('customized_abutment', 43, [ ...SCAN_DESIGN_MODEL, 'milling_wet', 'polish_prep', ]), - implantAddon('zirconia_abutment', 44, [...SCAN_DESIGN_MODEL, 'milling_dry', 'sinter']), - implantAddon( + implant('zirconia_abutment', 44, [...SCAN_DESIGN_MODEL, 'milling_dry', 'sinter']), + implant( 'screw_retained', 45, [...SCAN_DESIGN_MODEL, 'milling_wet', 'polish_prep'], diff --git a/backend/prisma/export-prosthesis-catalog.ts b/backend/prisma/export-prosthesis-catalog.ts index 650ae86..4cd531e 100644 --- a/backend/prisma/export-prosthesis-catalog.ts +++ b/backend/prisma/export-prosthesis-catalog.ts @@ -41,25 +41,31 @@ const STACK_RULES = [ 'restoration', 'One per tooth: crown, veneer, inlay, onlay, or overlay.', ], - ['implant', 'One abutment add-on per tooth (Ti-base, custom, prefab, multi-unit, zirconia, screw-retained).'], - ['post_core', 'One post & core, only if a restoration is already present. Blocked when screw-retained is on the tooth.'], + ['implant', 'One implant type per tooth. Disabled when post & core is on the tooth.'], + [ + 'post_core', + 'One post & core. Disabled when a restoration or implant is already on the tooth.', + ], [ 'arch', - 'Complete denture, overdenture, appliances, and digital use Upper/Lower arch labels (UA/LA). Not 16 FDI teeth.', + 'Complete denture, overdenture, appliances, and digital use Upper/Lower/Both (UA/LA). The picker control remaps jobs (Both→Upper drops lower).', ], [ 'partial_denture', - 'Tooth-selected (FDI), not the arch labels. All selected teeth become one lab job. May stack with implant add-ons.', + 'Tooth-selected (FDI), not the arch labels. All selected teeth become one lab job.', ], [ 'screw_retained', - 'Fills the implant slot (chart paints the crown) and stacks with a restoration. Blocks post & core.', + 'Implant that paints the crown. No crown suggestion slot; Crown and Indirect are disabled. Does not stack with another restoration.', + ], + [ + 'crown_suggestion', + 'Implant or post & core (not screw-retained) shows a crown suggestion slot. A veneer/inlay/onlay/overlay hides it. After a crown is picked the slot stays filled so it can be changed.', ], [ 'connected', 'Connected teeth that share a type are one lab job. An extra type on one unit of the bridge is its own job. Unconnected teeth stay separate even if they share a type.', ], - ['maximum', 'Usual maximum is 3 types per tooth (e.g. zirconia crown + Ti-base + fiber post).'], ['two_crowns', 'Two crowns cannot stack.'], ]; @@ -198,7 +204,6 @@ function buildWorkbook(): Buffer { 'label_nl', 'chartRegion', 'stackGroup', - 'addonKind', 'active', ], ]; @@ -215,7 +220,6 @@ function buildWorkbook(): Buffer { labels.nl ?? '', type.chartRegion, type.stackGroup, - type.addonKind ?? '', type.isActive === false ? 'false' : 'true', ]); } diff --git a/backend/prisma/migrations/20260904120000_drop_prosthesis_addon_kind/migration.sql b/backend/prisma/migrations/20260904120000_drop_prosthesis_addon_kind/migration.sql new file mode 100644 index 0000000..a15e74f --- /dev/null +++ b/backend/prisma/migrations/20260904120000_drop_prosthesis_addon_kind/migration.sql @@ -0,0 +1,3 @@ +-- Crown suggestion slot replaced catalog addonKind (implant / post_core add-on lists). + +ALTER TABLE "prosthesis_types" DROP COLUMN IF EXISTS "addonKind"; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 170874b..f80558f 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -359,8 +359,6 @@ model ProsthesisType { chartRegion String @default("crown") /// Mutual exclusion group: restoration | implant | post_core | arch stackGroup String @default("restoration") - /// Also listed as an add-on: implant | post_core | empty - addonKind String @default("") steps ProsthesisTypeStep[] diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index 0845457..74e7c4a 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -191,7 +191,6 @@ async function main() { subcategory: type.subcategory ?? '', chartRegion: type.chartRegion, stackGroup: type.stackGroup, - addonKind: type.addonKind ?? '', }; const prosthesisType = await prisma.prosthesisType.upsert({ where: { code: type.code }, diff --git a/backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts b/backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts index c35e119..be8ecc2 100644 --- a/backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts +++ b/backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts @@ -16,7 +16,6 @@ export type ProsthesisTypeCatalogEntry = { subcategory: string; chartRegion: string; stackGroup: string; - addonKind: string; }; type CatalogRow = { @@ -25,7 +24,6 @@ type CatalogRow = { subcategory: string; chartRegion: string; stackGroup: string; - addonKind: string; }; @Injectable() @@ -53,7 +51,6 @@ export class ProsthesisCatalogService implements OnModuleInit { subcategory: true, chartRegion: true, stackGroup: true, - addonKind: true, }, }); @@ -66,7 +63,6 @@ export class ProsthesisCatalogService implements OnModuleInit { subcategory: row.subcategory, chartRegion: row.chartRegion, stackGroup: row.stackGroup, - addonKind: row.addonKind, }, ]), ); @@ -94,7 +90,6 @@ export class ProsthesisCatalogService implements OnModuleInit { subcategory: row.subcategory, chartRegion: row.chartRegion, stackGroup: row.stackGroup, - addonKind: row.addonKind, }; }) .sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code)); diff --git a/docs/prosthesis-catalog.xlsx b/docs/prosthesis-catalog.xlsx index 5725461..e3d4f21 100644 Binary files a/docs/prosthesis-catalog.xlsx and b/docs/prosthesis-catalog.xlsx differ diff --git a/frontend/messages/en.json b/frontend/messages/en.json index a44d166..238b9c2 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -757,6 +757,8 @@ "noOrgInvitePermission": "You do not have permission to invite labs. Contact your organization owner.", "sendThisCase": "Send this case", "labDispatchTitle": "Lab dispatch", + "showLabDispatch": "Lab dispatch", + "showChart": "Chart", "labDispatchSubtitle": "Choose a lab and send.", "labDispatchSendHint": "Shipment grouping is saved when you send to the lab.", "addLabShipment": "Add lab shipment", @@ -898,6 +900,7 @@ "selectedArchLower": "Lower arch", "upperArch": "Upper arch", "lowerArch": "Lower arch", + "archAssignHint": "Click to assign a jaw-level product", "toothAria": "FDI tooth {fdi}", "toothSelectedSuffix": ", selected", "sentToAt": "Sent to {orgName} at {datetime}", @@ -1120,6 +1123,7 @@ "sub_partial_denture": "Partial denture", "sub_night_guard": "Night guard", "addonNone": "None", + "addonCrownCanBeAdded": "Crown can be added", "clear": "Clear this tooth", "done": "Done", "pickerTeeth": "Teeth {teeth}", @@ -1133,8 +1137,10 @@ "removeJob": "Remove this type", "regionCrown": "Crown", "pickerTooth": "Tooth {tooth}", - "slotImplant": "Implant add-on", - "slotPostCore": "Post & core" + "slotCrown": "Crown", + "suggestionsLabel": "Suggested add-ons", + "pickerBack": "Back", + "categoryDisabledHint": "This category cannot be combined with the types already on this tooth." }, "errors": { "GENERIC": "Something went wrong. Please try again.", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 9ab95ed..7e6fcb0 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -758,6 +758,8 @@ "noOrgInvitePermission": "شما مجوز دعوت از آزمایشگاه را ندارید. با مالک سازمان تماس بگیرید.", "sendThisCase": "ارسال این پرونده", "labDispatchTitle": "ارسال به لابراتوار", + "showLabDispatch": "ارسال به لابراتوار", + "showChart": "نمودار", "labDispatchSubtitle": "لابراتوار را انتخاب کنید و ارسال کنید.", "labDispatchSendHint": "گروه‌بندی محموله هنگام ارسال به لاب ذخیره می‌شود.", "addLabShipment": "افزودن محموله لاب", @@ -899,6 +901,7 @@ "selectedArchLower": "قوس پایین", "upperArch": "قوس بالا", "lowerArch": "قوس پایین", + "archAssignHint": "برای اختصاص محصول فکی کلیک کنید", "toothAria": "دندان FDI {fdi}", "toothSelectedSuffix": "، انتخاب شده", "sentToAt": "ارسال به {orgName} در {datetime}", @@ -1121,6 +1124,7 @@ "sub_partial_denture": "پارسیل", "sub_night_guard": "نایت گارد", "addonNone": "هیچ‌کدام", + "addonCrownCanBeAdded": "روکش می‌تواند افزوده شود", "clear": "پاک کردن این دندان", "done": "تمام", "pickerTeeth": "دندان‌های {teeth}", @@ -1134,8 +1138,10 @@ "removeJob": "حذف این نوع", "regionCrown": "تاج", "pickerTooth": "دندان {tooth}", - "slotImplant": "افزونه ایمپلنت", - "slotPostCore": "پست و کور" + "slotCrown": "روکش", + "suggestionsLabel": "افزودنی‌های پیشنهادی", + "pickerBack": "بازگشت", + "categoryDisabledHint": "این دسته با انواعی که روی این دندان هستند قابل ترکیب نیست." }, "errors": { "GENERIC": "مشکلی پیش آمد. لطفاً دوباره تلاش کنید.", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 046fae0..80d78c0 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -757,6 +757,8 @@ "noOrgInvitePermission": "U heeft geen toestemming om labs uit te nodigen. Neem contact op met de organisatie-eigenaar.", "sendThisCase": "Verzend deze case", "labDispatchTitle": "Lab-dispatch", + "showLabDispatch": "Lab-dispatch", + "showChart": "Gebitskaart", "labDispatchSubtitle": "Kies een lab en verstuur.", "labDispatchSendHint": "Groepering wordt opgeslagen wanneer u naar het lab verzendt.", "addLabShipment": "Labzending toevoegen", @@ -898,6 +900,7 @@ "selectedArchLower": "Onderboog", "upperArch": "Bovenboog", "lowerArch": "Onderboog", + "archAssignHint": "Klik om een boogproduct toe te wijzen", "toothAria": "FDI-tand {fdi}", "toothSelectedSuffix": ", geselecteerd", "sentToAt": "Verzonden naar {orgName} op {datetime}", @@ -1120,6 +1123,7 @@ "sub_partial_denture": "Partiële prothese", "sub_night_guard": "Nachtbeugel", "addonNone": "Geen", + "addonCrownCanBeAdded": "Kroon kan worden toegevoegd", "clear": "Deze tand wissen", "done": "Klaar", "pickerTeeth": "Tanden {teeth}", @@ -1133,8 +1137,10 @@ "removeJob": "Dit type verwijderen", "regionCrown": "Kroon", "pickerTooth": "Tand {tooth}", - "slotImplant": "Implantaat-add-on", - "slotPostCore": "Stiftopbouw" + "slotCrown": "Kroon", + "suggestionsLabel": "Voorgestelde toevoegingen", + "pickerBack": "Terug", + "categoryDisabledHint": "Deze categorie kan niet worden gecombineerd met de types die al op deze tand staan." }, "errors": { "GENERIC": "Er is iets misgegaan. Probeer het opnieuw.", diff --git a/frontend/src/components/shared/catalog-type-colors.ts b/frontend/src/components/shared/catalog-type-colors.ts index d3f4120..a15d29b 100644 --- a/frontend/src/components/shared/catalog-type-colors.ts +++ b/frontend/src/components/shared/catalog-type-colors.ts @@ -20,61 +20,124 @@ export const TREATMENT_TYPE_COLORS: Record = { continue_treatment: '#99f6e4', }; -/** Dedicated prosthesis palette — one distinct pastel per catalog code. */ -export const PROSTHESIS_TYPE_COLORS: Record = { - pfm_crown: '#e2e8f0', - pfz_crown: '#bbf7d0', - monolithic_zirconia: '#bae6fd', - glass_ceramic_crown: '#fef08a', - full_metal_crown: '#d4d4d8', - temporary_resin_crown: '#ddd6fe', - pmma: '#fcd34d', - peek_crown: '#fda4af', - veneer_zirconia: '#86efac', - veneer_ips_press: '#fed7aa', - veneer_ips_cad: '#fdba74', - soft_structure: '#e9d5ff', - customized_abutment: '#a5b4fc', - prefabricated_abutment: '#c7d2fe', - ti_base_abutment: '#93c5fd', - multi_unit_abutment: '#818cf8', - zirconia_abutment: '#4ade80', - screw_retained: '#f0abfc', - zirconia_overlay: '#2dd4bf', - ips_overlay: '#fef3c7', - smile_design: '#f9a8d4', - mockup: '#fbcfe8', - press_ceramic: '#fde68a', - veneer_full_contour: '#fdba74', - veneer_layered: '#86efac', - inlay_full_contour: '#fcd34d', - inlay_layered: '#bbf7d0', - onlay_full_contour: '#fed7aa', - onlay_layered: '#a7f3d0', - overlay_full_contour: '#fef3c7', - overlay_layered: '#2dd4bf', - cast_post_core: '#d6d3d1', - fiber_post_core: '#e7e5e4', - complete_denture: '#c4b5fd', - partial_denture: '#ddd6fe', - overdenture: '#a78bfa', - night_guard_soft: '#e9d5ff', - night_guard_hard: '#d8b4fe', - night_guard_dual: '#c084fc', - bleaching_tray: '#a5f3fc', - clear_aligner: '#67e8f9', - surgical_guide: '#94a3b8', +/** Category tiles — heaviest (darkest) pastel of each family; leaves lighten from here. */ +export const PROSTHESIS_CATEGORY_COLORS: Record = { + crown: '#e6c84f', + indirect: '#f59e4c', + implant: '#38bdf8', + post_core: '#a8a29e', + removable: '#a78bfa', + appliance: '#d8b4fe', + digital: '#f472b6', }; -/** Category tiles in the assign overlay (not per-leaf). */ -export const PROSTHESIS_CATEGORY_COLORS: Record = { - crown: '#bae6fd', - indirect: '#fdba74', - implant: '#a5b4fc', - post_core: '#d6d3d1', - removable: '#c4b5fd', - appliance: '#e9d5ff', - digital: '#f9a8d4', +function hexToRgbChannels(hex: string): [number, number, number] | null { + const raw = hex.replace('#', '').trim(); + if (raw.length !== 6) return null; + const n = Number.parseInt(raw, 16); + if (Number.isNaN(n)) return null; + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} + +function rgbToHslChannels(r: number, g: number, b: number): [number, number, number] { + const rr = r / 255; + const gg = g / 255; + const bb = b / 255; + const max = Math.max(rr, gg, bb); + const min = Math.min(rr, gg, bb); + const l = (max + min) / 2; + if (max === min) return [0, 0, l]; + const d = max - min; + const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + let h = 0; + if (max === rr) h = ((gg - bb) / d + (gg < bb ? 6 : 0)) / 6; + else if (max === gg) h = ((bb - rr) / d + 2) / 6; + else h = ((rr - gg) / d + 4) / 6; + return [h, s, l]; +} + +function hslToHexChannels(h: number, s: number, l: number): string { + const hue2rgb = (p: number, q: number, t: number) => { + let tt = t; + if (tt < 0) tt += 1; + if (tt > 1) tt -= 1; + if (tt < 1 / 6) return p + (q - p) * 6 * tt; + if (tt < 1 / 2) return q; + if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6; + return p; + }; + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + const r = Math.round(hue2rgb(p, q, h + 1 / 3) * 255); + const g = Math.round(hue2rgb(p, q, h) * 255); + const b = Math.round(hue2rgb(p, q, h - 1 / 3) * 255); + return `#${[r, g, b].map((c) => c.toString(16).padStart(2, '0')).join('')}`; +} + +function clamp01(n: number): number { + return Math.min(1, Math.max(0, n)); +} + +/** Same-hue sibling of a category pastel. Index 0 is the parent (heaviest); higher indices only lighten. */ +export function prosthesisFamilyTone(parentHex: string, siblingIndex: number): string { + if (siblingIndex <= 0) return parentHex; + const rgb = hexToRgbChannels(parentHex); + if (!rgb) return parentHex; + const [h, s, l] = rgbToHslChannels(rgb[0], rgb[1], rgb[2]); + const nextL = clamp01(Math.min(0.9, l + siblingIndex * 0.038)); + const nextS = clamp01(s - siblingIndex * 0.025); + return hslToHexChannels(h, nextS, nextL); +} + +export function prosthesisCategoryFamilyColor(category: string, siblingIndex = 0): string { + const parent = PROSTHESIS_CATEGORY_COLORS[category] ?? '#e2e8f0'; + return prosthesisFamilyTone(parent, siblingIndex); +} + +/** Dedicated prosthesis palette — family tones (legacy map kept as fallback). */ +export const PROSTHESIS_TYPE_COLORS: Record = { + pfm_crown: prosthesisCategoryFamilyColor('crown', 1), + pfz_crown: prosthesisCategoryFamilyColor('crown', 2), + monolithic_zirconia: prosthesisCategoryFamilyColor('crown', 3), + glass_ceramic_crown: prosthesisCategoryFamilyColor('crown', 4), + full_metal_crown: prosthesisCategoryFamilyColor('crown', 5), + temporary_resin_crown: prosthesisCategoryFamilyColor('crown', 6), + pmma: prosthesisCategoryFamilyColor('crown', 7), + peek_crown: prosthesisCategoryFamilyColor('crown', 8), + press_ceramic: prosthesisCategoryFamilyColor('crown', 9), + veneer_zirconia: prosthesisCategoryFamilyColor('indirect', 1), + veneer_ips_press: prosthesisCategoryFamilyColor('indirect', 2), + veneer_ips_cad: prosthesisCategoryFamilyColor('indirect', 3), + zirconia_overlay: prosthesisCategoryFamilyColor('indirect', 4), + ips_overlay: prosthesisCategoryFamilyColor('indirect', 5), + veneer_full_contour: prosthesisCategoryFamilyColor('indirect', 1), + veneer_layered: prosthesisCategoryFamilyColor('indirect', 2), + inlay_full_contour: prosthesisCategoryFamilyColor('indirect', 3), + inlay_layered: prosthesisCategoryFamilyColor('indirect', 4), + onlay_full_contour: prosthesisCategoryFamilyColor('indirect', 5), + onlay_layered: prosthesisCategoryFamilyColor('indirect', 6), + overlay_full_contour: prosthesisCategoryFamilyColor('indirect', 7), + overlay_layered: prosthesisCategoryFamilyColor('indirect', 8), + customized_abutment: prosthesisCategoryFamilyColor('implant', 1), + prefabricated_abutment: prosthesisCategoryFamilyColor('implant', 2), + ti_base_abutment: prosthesisCategoryFamilyColor('implant', 3), + multi_unit_abutment: prosthesisCategoryFamilyColor('implant', 4), + zirconia_abutment: prosthesisCategoryFamilyColor('implant', 5), + screw_retained: prosthesisCategoryFamilyColor('implant', 6), + cast_post_core: prosthesisCategoryFamilyColor('post_core', 1), + fiber_post_core: prosthesisCategoryFamilyColor('post_core', 2), + complete_denture: prosthesisCategoryFamilyColor('removable', 1), + partial_denture: prosthesisCategoryFamilyColor('removable', 2), + overdenture: prosthesisCategoryFamilyColor('removable', 3), + night_guard_soft: prosthesisCategoryFamilyColor('appliance', 1), + night_guard_hard: prosthesisCategoryFamilyColor('appliance', 2), + night_guard_dual: prosthesisCategoryFamilyColor('appliance', 3), + bleaching_tray: prosthesisCategoryFamilyColor('appliance', 4), + clear_aligner: prosthesisCategoryFamilyColor('appliance', 5), + soft_structure: prosthesisCategoryFamilyColor('appliance', 6), + smile_design: prosthesisCategoryFamilyColor('digital', 1), + mockup: prosthesisCategoryFamilyColor('digital', 2), + surgical_guide: prosthesisCategoryFamilyColor('digital', 3), }; export const CATALOG_FALLBACK_COLORS = [ diff --git a/frontend/src/components/treatment/prosthesisTree.ts b/frontend/src/components/treatment/prosthesisTree.ts index 0102883..320450d 100644 --- a/frontend/src/components/treatment/prosthesisTree.ts +++ b/frontend/src/components/treatment/prosthesisTree.ts @@ -2,7 +2,11 @@ import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog'; import type { FdiToothId, LabCaseToothProsthesisDraft, ToothSelectionGroup } from '@/types/treatment'; import { PROSTHESIS_CATEGORY_COLORS } from '@/components/shared/catalog-type-colors'; import { prosthesisTypeColorFromCatalog } from '@/components/treatment/prosthesisTypeDisplay'; -import { groupsFromFlatTeeth, newGroupId } from '@/components/treatment/toothSelectionGroups'; +import { + deriveTeethFromGroups, + groupsFromFlatTeeth, + newGroupId, +} from '@/components/treatment/toothSelectionGroups'; const PROSTHESIS_CATEGORY_ORDER = [ 'implant', @@ -111,13 +115,35 @@ export function leavesFor( .sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code)); } -export function addonLeaves( - catalog: readonly ProsthesisCatalogEntry[], - kind: 'implant' | 'post_core', -): ProsthesisCatalogEntry[] { - return catalog - .filter((e) => e.addonKind === kind) - .sort((a, b) => a.sortOrder - b.sortOrder); +export const SCREW_RETAINED_CODE = 'screw_retained'; + +export type AddonSlot = 'crown'; + +export function jobsHaveStackGroup( + jobCodes: readonly string[], + byCode: Map, + group: string, +): boolean { + return jobCodes.some((code) => byCode.get(code)?.stackGroup === group); +} + +export function canStackLeaf( + existingCodes: readonly string[], + nextCode: string, + byCode: Map, +): boolean { + const next = byCode.get(nextCode); + if (!next) return false; + const hasRestoration = jobsHaveStackGroup(existingCodes, byCode, 'restoration'); + const hasImplant = jobsHaveStackGroup(existingCodes, byCode, 'implant'); + const hasPost = jobsHaveStackGroup(existingCodes, byCode, 'post_core'); + if (next.stackGroup === 'post_core') return !hasRestoration && !hasImplant; + if (next.stackGroup === 'implant') return !hasPost; + // Screw-retained already paints the crown — do not stack a second restoration. + if (next.stackGroup === 'restoration' && existingCodes.includes(SCREW_RETAINED_CODE)) { + return false; + } + return true; } export function applyLeafToJobs( @@ -127,36 +153,115 @@ export function applyLeafToJobs( ): string[] { const next = byCode.get(nextCode); if (!next) return [...existingCodes]; + if (!canStackLeaf(existingCodes, nextCode, byCode)) return [...existingCodes]; - let jobs = existingCodes.filter((code) => { + const jobs = existingCodes.filter((code) => { const cur = byCode.get(code); if (!cur) return false; - return cur.stackGroup !== next.stackGroup; + if (cur.stackGroup === next.stackGroup) return false; + if (nextCode === SCREW_RETAINED_CODE && cur.stackGroup === 'restoration') return false; + return true; }); jobs.push(nextCode); return jobs; } -export function jobsAllowImplantAddon( +export function crownRestorationCode( jobCodes: readonly string[], byCode: Map, -): boolean { - const hasArchBlocking = jobCodes.some((code) => { - const e = byCode.get(code); - if (!e || e.stackGroup !== 'arch') return false; - return e.code !== 'overdenture' && e.code !== PARTIAL_DENTURE_CODE; +): string | undefined { + return jobCodes.find((code) => { + const entry = byCode.get(code); + return entry?.category === 'crown' && entry.stackGroup === 'restoration'; }); - if (hasArchBlocking) return false; - return true; } -export function jobsAllowPostCoreAddon( +/** + * Crown suggestion slot after implant or post & core. + * Stays visible when the restoration is a crown (filled chip). Hidden for + * screw-retained and for non-crown restorations (veneer / inlay / onlay / overlay). + */ +export function jobsAllowCrownAddon( jobCodes: readonly string[], byCode: Map, ): boolean { - if (jobCodes.includes('screw_retained')) return false; - return jobCodes.some((code) => byCode.get(code)?.stackGroup === 'restoration'); + if (jobCodes.length === 0) return false; + if (jobCodes.includes(SCREW_RETAINED_CODE)) return false; + if ( + !jobsHaveStackGroup(jobCodes, byCode, 'implant') && + !jobsHaveStackGroup(jobCodes, byCode, 'post_core') + ) { + return false; + } + const restoration = jobCodes.find((code) => byCode.get(code)?.stackGroup === 'restoration'); + if (!restoration) return true; + return byCode.get(restoration)?.category === 'crown'; +} + +export function categoryDisabledForJobs( + category: string, + jobCodes: readonly string[], + byCode: Map, +): boolean { + const hasRestoration = jobsHaveStackGroup(jobCodes, byCode, 'restoration'); + const hasImplant = jobsHaveStackGroup(jobCodes, byCode, 'implant'); + const hasPost = jobsHaveStackGroup(jobCodes, byCode, 'post_core'); + if (category === 'post_core') return hasRestoration || hasImplant; + if (category === 'implant') return hasPost; + if ( + jobCodes.includes(SCREW_RETAINED_CODE) && + (category === 'crown' || category === 'indirect') + ) { + return true; + } + return false; +} + +export function fdiTeethWithJobs( + rows: readonly Pick[], + detailClientId: string, +): Set { + const teeth = new Set(); + for (const row of rows) { + if ( + row.detailClientId === detailClientId && + row.prosthesisTypeCode && + !isArchSentinel(row.tooth) + ) { + teeth.add(row.tooth); + } + } + return teeth; +} + +export function pruneGroupsToJobTeeth( + groups: ToothSelectionGroup[], + jobTeeth: Set, +): ToothSelectionGroup[] { + const extra = groups.flatMap((g) => g.teeth).filter((tooth) => !jobTeeth.has(tooth)); + if (extra.length === 0) return groups; + return removeTeethFromGroups(groups, extra); +} + +export function pruneDetailTeethToJobs< + T extends { + clientId: string; + teeth: readonly string[]; + toothSelectionGroups: ToothSelectionGroup[]; + }, +>(detail: T, jobs: readonly LabCaseToothProsthesisDraft[]): T { + const jobTeeth = fdiTeethWithJobs(jobs, detail.clientId); + const groups = + detail.toothSelectionGroups.length > 0 + ? detail.toothSelectionGroups + : groupsFromFlatTeeth(detail.teeth as FdiToothId[]); + const nextGroups = pruneGroupsToJobTeeth(groups, jobTeeth); + return { + ...detail, + toothSelectionGroups: nextGroups, + teeth: deriveTeethFromGroups(nextGroups), + }; } export function codesOnTooth( @@ -295,9 +400,8 @@ export function writeArchJobs( arch: ArchTarget, code: string, ): LabCaseToothProsthesisDraft[] { - const replace = new Set(archSentinels(arch)); const rest = rows.filter( - (row) => !(row.detailClientId === detailClientId && replace.has(row.tooth)), + (row) => !(row.detailClientId === detailClientId && isArchSentinel(row.tooth)), ); const next = archSentinels(arch).map((tooth) => ({ detailClientId, @@ -308,6 +412,23 @@ export function writeArchJobs( return [...rest, ...next]; } +/** Move / copy / drop UA·LA rows so they match the segmented arch control. */ +export function retargetArchJobs( + rows: LabCaseToothProsthesisDraft[], + detailClientId: string, + from: ArchTarget, + to: ArchTarget, +): LabCaseToothProsthesisDraft[] { + if (from === to) return rows; + const keep = archSentinels(to).filter((tooth) => archSentinels(from).includes(tooth)); + const code = + uniqueCodesOnGroup(rows, detailClientId, keep)[0] ?? + uniqueCodesOnGroup(rows, detailClientId, archSentinels(from))[0] ?? + uniqueCodesOnGroup(rows, detailClientId, [ARCH_TOOTH_UPPER, ARCH_TOOTH_LOWER])[0]; + if (!code) return rows; + return writeArchJobs(rows, detailClientId, to, code); +} + export function uniqueCodesOnGroup( rows: readonly LabCaseToothProsthesisDraft[], detailClientId: string, diff --git a/frontend/src/components/treatment/prosthesisTypeDisplay.ts b/frontend/src/components/treatment/prosthesisTypeDisplay.ts index 8fa8f98..52f56bf 100644 --- a/frontend/src/components/treatment/prosthesisTypeDisplay.ts +++ b/frontend/src/components/treatment/prosthesisTypeDisplay.ts @@ -3,15 +3,19 @@ import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog'; import { PROSTHESIS_FALLBACK_COLORS, PROSTHESIS_TYPE_COLORS, + prosthesisCategoryFamilyColor, resolveCatalogTypeColor, } from '@/components/shared/catalog-type-colors'; /** * Prosthesis-type colors for Cases, Tasks, Treatment chart/dispatch, and Today. - * Uses a dedicated pastel map (unique per prosthesis code). + * Uses a category family pastel; sibling types are stepped tones of that hue. */ -/** Dark ink that stays readable on every pastel in the palette. */ +type ProsthesisColorCatalog = readonly Pick< + ProsthesisCatalogEntry, + 'code' | 'sortOrder' | 'category' +>[]; const BADGE_INK = '#14253d'; export function primaryProsthesisCode(code: string): string { @@ -25,7 +29,7 @@ export function splitProsthesisGroupCode(code: string): string[] { /** Stable catalog index for fallback colors — use sortOrder, not list row position. */ export function prosthesisCatalogColorIndex( code: string, - catalog: readonly Pick[], + catalog: ProsthesisColorCatalog, ): number { const entry = catalog.find((e) => e.code === primaryProsthesisCode(code)); if (entry) return Math.max(0, entry.sortOrder - 1); @@ -44,9 +48,18 @@ export function prosthesisTypeColor(code: string, index = 0): string { export function prosthesisTypeColorFromCatalog( code: string, - catalog: readonly Pick[], + catalog: ProsthesisColorCatalog, ): string { - return prosthesisTypeColor(code, prosthesisCatalogColorIndex(code, catalog)); + const primary = primaryProsthesisCode(code); + const entry = catalog.find((e) => e.code === primary); + if (!entry?.category) { + return prosthesisTypeColor(primary, prosthesisCatalogColorIndex(code, catalog)); + } + const siblings = catalog + .filter((e) => e.category === entry.category) + .sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code)); + const idx = siblings.findIndex((e) => e.code === primary); + return prosthesisCategoryFamilyColor(entry.category, Math.max(0, idx) + 1); } function hexToRgb(hex: string): [number, number, number] | null { @@ -118,7 +131,7 @@ function prosthesisTypeLabelSwatch(hex: string): string { */ export function prosthesisTypeLabelStyleFromCatalog( code: string, - catalog: readonly Pick[], + catalog: ProsthesisColorCatalog, ): CSSProperties { const swatch = prosthesisTypeLabelSwatch(prosthesisTypeColorFromCatalog(code, catalog)); return { @@ -134,9 +147,12 @@ export function prosthesisTypeSwatchStyle(code: string, index = 0): CSSPropertie export function prosthesisTypeSwatchStyleFromCatalog( code: string, - catalog: readonly Pick[], + catalog: ProsthesisColorCatalog, ): CSSProperties { - return prosthesisTypeSwatchStyle(code, prosthesisCatalogColorIndex(code, catalog)); + return { + backgroundColor: prosthesisTypeColorFromCatalog(code, catalog), + borderColor: 'rgba(0, 0, 0, 0.18)', + }; } /** Pastel pill / banner fill with readable dark text (group headers, badges). */ @@ -150,9 +166,13 @@ export function prosthesisTypeBadgeStyle(code: string, index = 0): CSSProperties export function prosthesisTypeBadgeStyleFromCatalog( code: string, - catalog: readonly Pick[], + catalog: ProsthesisColorCatalog, ): CSSProperties { - return prosthesisTypeBadgeStyle(code, prosthesisCatalogColorIndex(code, catalog)); + return { + backgroundColor: prosthesisTypeColorFromCatalog(code, catalog), + borderColor: 'rgba(0, 0, 0, 0.16)', + color: BADGE_INK, + }; } export function formatToothList( diff --git a/frontend/src/components/ui/treatment/FdiToothChart.tsx b/frontend/src/components/ui/treatment/FdiToothChart.tsx index 4389c9d..2a23e7d 100644 --- a/frontend/src/components/ui/treatment/FdiToothChart.tsx +++ b/frontend/src/components/ui/treatment/FdiToothChart.tsx @@ -2,7 +2,7 @@ import { useEffect, useId, useRef, useState, type CSSProperties, type ReactNode } from 'react'; import { useTranslations } from 'next-intl'; -import { Info } from 'lucide-react'; +import { Info, Plus } from 'lucide-react'; import { Button } from '@/components/ui/shared/Button'; import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton'; import { @@ -117,6 +117,76 @@ function ToothNumber({ ); } +const CHART_TOOL_BTN_CLASS = 'h-8'; + +function ArchAssignControl({ + label, + hint, + highlighted, + disabled, + onClick, +}: { + label: string; + hint: string; + highlighted: boolean; + disabled: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function ArchLabelRow({ + placement, + interactive, + label, + hint, + highlighted, + disabled, + onClick, +}: { + placement: 'upper' | 'lower'; + interactive: boolean; + label: string; + hint: string; + highlighted: boolean; + disabled: boolean; + onClick: () => void; +}) { + const spacing = placement === 'upper' ? 'mb-1' : 'mt-1'; + if (!interactive) { + return ( +

+ {label} +

+ ); + } + return ( +
+ +
+ ); +} + interface FdiToothChartProps { selected: ReadonlySet; /** @@ -129,7 +199,7 @@ interface FdiToothChartProps { linkedEdges?: ReadonlySet; onToggle?: (fdi: FdiToothId, event: { shiftKey: boolean }) => void; /** - * Click-to-assign (Exocad): click opens the job picker; shift/drag paints. + * Click-to-assign (Exocad): click opens the job picker; Ctrl/drag or Shift copies the last jobs. * When set, this replaces `onToggle` for tooth hits. */ onAssignPointer?: ( @@ -148,7 +218,7 @@ interface FdiToothChartProps { crownColors?: Partial>; rootColors?: Partial>; archHighlight?: 'upper' | 'lower' | 'both' | null; - /** Extra control rendered in the chart header (e.g. whole-plan checkbox). */ + /** Extra control rendered next to Reset in the chart header (e.g. Full View). */ headerControl?: ReactNode; /** Centered overlay on the odontogram (prosthesis picker). */ overlay?: ReactNode; @@ -540,22 +610,15 @@ export function FdiToothChart({ const chartBody = ( <> - {onArchClick && !readOnly ? ( - - ) : ( -

{t('upperArch')}

- )} + onArchClick?.('upper')} + />
@@ -657,28 +720,21 @@ export function FdiToothChart({
- {onArchClick && !readOnly ? ( - - ) : ( -

{t('lowerArch')}

- )} + onArchClick?.('lower')} + /> ); return (
-
+

{compact || embedded ? t('toothChartTitleCompact') : t('toothChartTitle')} @@ -694,18 +750,24 @@ export function FdiToothChart({ ) : null}

-
{headerControl}
-
- {onReset && !readOnly ? ( - +
+ {headerControl || (onReset && !readOnly) ? ( +
+ {headerControl} + {onReset && !readOnly ? ( + + ) : null} +
) : null}

{t('selectedLabel')}{' '} diff --git a/frontend/src/components/ui/treatment/ProsthesisAssignChart.tsx b/frontend/src/components/ui/treatment/ProsthesisAssignChart.tsx index de592bf..e54b2ff 100644 --- a/frontend/src/components/ui/treatment/ProsthesisAssignChart.tsx +++ b/frontend/src/components/ui/treatment/ProsthesisAssignChart.tsx @@ -19,22 +19,22 @@ import { ensureTeethInGroups, hasArchJobs, isArchSentinel, - jobsAllowImplantAddon, - jobsAllowPostCoreAddon, + jobsAllowCrownAddon, removeTeethFromGroups, + retargetArchJobs, toothRegionColors, uniqueCodesOnGroup, writeArchJobs, writeJobsForTeeth, + type AddonSlot, type ArchTarget, type PickerScope, } from '@/components/treatment/prosthesisTree'; import { - applyShiftRange, deriveTeethFromGroups, groupsFromFlatTeeth, linkedEdgesFromGroups, - shiftRangeTeeth, + teethBetweenInclusive, } from '@/components/treatment/toothSelectionGroups'; interface ProsthesisAssignChartProps { @@ -64,7 +64,6 @@ export function ProsthesisAssignChart({ onChange, }: ProsthesisAssignChartProps) { const tTreatment = useTranslations('treatment'); - const tProsthesis = useTranslations('prosthesis'); const [open, setOpen] = useState(false); const [mobile, setMobile] = useState(false); const [anchor, setAnchor] = useState(null); @@ -73,8 +72,6 @@ export function ProsthesisAssignChart({ const [lockedArch, setLockedArch] = useState('upper'); const brushRef = useRef([]); const rangeAnchorRef = useRef(null); - const pendingRangeRef = useRef(null); - const [pendingRange, setPendingRange] = useState(null); const byCode = useMemo(() => catalogByCode(catalog), [catalog]); useEffect(() => { @@ -102,12 +99,11 @@ export function ProsthesisAssignChart({ const archHighlight = open && scope === 'arch' ? lockedArch : persistedArch; const archWork = hasArchJobs(toothProsthesis, detailClientId); - const focusTooth = anchor ?? [...selected][0] ?? null; const jobCodes = scope === 'arch' ? uniqueCodesOnGroup(toothProsthesis, detailClientId, archSentinels(lockedArch)) - : focusTooth - ? codesOnTooth(toothProsthesis, detailClientId, focusTooth) + : anchor + ? codesOnTooth(toothProsthesis, detailClientId, anchor) : []; function groupIdForTooth(tooth: string, nextGroups: ToothSelectionGroup[]): string { @@ -119,6 +115,7 @@ export function ProsthesisAssignChart({ } function applyCodes(teeth: FdiToothId[], codes: string[]) { + if (codes.length === 0 || teeth.length === 0) return; const nextGroups = ensureTeethInGroups(groups, teeth); const nextRows = writeJobsForTeeth( toothProsthesis, @@ -127,24 +124,21 @@ export function ProsthesisAssignChart({ codes, (tooth) => groupIdForTooth(tooth, nextGroups), ); - brushRef.current = codes; commit(nextGroups, nextRows); } - function setRange(next: FdiToothId[] | null) { - pendingRangeRef.current = next; - setPendingRange(next); + function rememberBrushFromTooth(tooth: FdiToothId | null) { + if (!tooth) return; + const codes = codesOnTooth(toothProsthesis, detailClientId, tooth); + if (codes.length === 0) return; + brushRef.current = codes; + rangeAnchorRef.current = tooth; } function openToothPicker(fdi: FdiToothId) { - const pending = pendingRangeRef.current; - if (pending && !pending.includes(fdi)) { - setRange(null); - } setScope('tooth'); setAnchor(fdi); setStep({ kind: 'category' }); - rangeAnchorRef.current = fdi; setOpen(true); } @@ -155,44 +149,21 @@ export function ProsthesisAssignChart({ if (disabled) return; if (event.paint || event.ctrlKey) { if (brushRef.current.length === 0) return; - setRange(null); applyCodes([fdi], brushRef.current); return; } if (event.shiftKey) { + if (brushRef.current.length === 0) return; const start = rangeAnchorRef.current; if (!start || start === fdi) { rangeAnchorRef.current = fdi; + applyCodes([fdi], brushRef.current); return; } - const nextGroups = applyShiftRange(groups, start, fdi); - const union = shiftRangeTeeth(groups, start, fdi); + const span = teethBetweenInclusive(start, fdi); + if (!span || span.length === 0) return; + applyCodes(span, brushRef.current); rangeAnchorRef.current = fdi; - if (!nextGroups || !union) return; - setRange(union); - commit(nextGroups, toothProsthesis); - setScope('tooth'); - setAnchor(fdi); - setStep({ kind: 'category' }); - setOpen(true); - return; - } - - const isSelected = selected.has(fdi); - const hasJobs = codesOnTooth(toothProsthesis, detailClientId, fdi).length > 0; - const inPendingRange = Boolean(pendingRangeRef.current?.includes(fdi)); - // Drop a tooth from an in-progress Shift range. A selected tooth with no jobs - // after hydrate must open the picker — otherwise reload looks like a deselect. - if (isSelected && !hasJobs && inPendingRange) { - const nextGroups = removeTeethFromGroups(groups, [fdi]); - const nextRows = clearJobsForTeeth(toothProsthesis, detailClientId, [fdi]); - const remainingRange = (pendingRangeRef.current ?? []).filter((tooth) => tooth !== fdi); - setRange(remainingRange.length > 1 ? remainingRange : null); - commit(nextGroups, nextRows); - if (anchor === fdi) { - setOpen(false); - setAnchor(null); - } return; } @@ -201,45 +172,55 @@ export function ProsthesisAssignChart({ function handleArchClick(arch: 'upper' | 'lower') { if (disabled) return; - setRange(null); setScope('arch'); - setLockedArch(arch); + setLockedArch(archTargetFromJobs(toothProsthesis, detailClientId) ?? arch); setStep({ kind: 'category' }); setOpen(true); } - function handlePickLeaf(code: string, arch?: ArchTarget) { - if (scope === 'arch' || arch) { - const target = arch ?? lockedArch; - commit(groups, writeArchJobs(toothProsthesis, detailClientId, target, code)); - brushRef.current = [code]; + function handleLockedArchChange(next: ArchTarget) { + if (next === lockedArch) return; + const previous = lockedArch; + setLockedArch(next); + setStep((current) => + current.kind === 'subcategory' || current.kind === 'leaves' + ? { ...current, arch: next } + : current, + ); + const nextRows = retargetArchJobs(toothProsthesis, detailClientId, previous, next); + if (nextRows !== toothProsthesis) { + commit(groups, nextRows); + } + } + + function handlePickLeaf(code: string) { + if (scope === 'arch') { + commit(groups, writeArchJobs(toothProsthesis, detailClientId, lockedArch, code)); setStep({ kind: 'category' }); return; } - const pending = pendingRangeRef.current; - const targets = - pending && pending.length > 0 && anchor && pending.includes(anchor) - ? pending - : anchor - ? [anchor] - : []; - if (targets.length === 0) return; - const base = codesOnTooth(toothProsthesis, detailClientId, targets[0]); + if (!anchor) return; + const base = codesOnTooth(toothProsthesis, detailClientId, anchor); const nextCodes = applyLeafToJobs(base, code, byCode); - applyCodes(targets, nextCodes); - setRange(null); + applyCodes([anchor], nextCodes); setStep({ kind: 'category' }); } - function handlePickAddon(code: string | null, kind: 'implant' | 'post_core') { - const tooth = focusTooth; - if (!tooth || scope === 'arch') return; - const current = codesOnTooth(toothProsthesis, detailClientId, tooth).filter((c) => { - const entry = byCode.get(c); - return entry?.addonKind !== kind; + function handlePickAddon(code: string | null, kind: AddonSlot) { + if (!anchor || scope === 'arch' || kind !== 'crown') return; + const current = codesOnTooth(toothProsthesis, detailClientId, anchor).filter((c) => { + return byCode.get(c)?.stackGroup !== 'restoration'; }); const next = code ? applyLeafToJobs(current, code, byCode) : current; - applyCodes([tooth], next); + if (next.length === 0) { + commit( + removeTeethFromGroups(groups, [anchor]), + clearJobsForTeeth(toothProsthesis, detailClientId, [anchor]), + ); + setStep({ kind: 'category' }); + return; + } + applyCodes([anchor], next); setStep({ kind: 'category' }); } @@ -249,14 +230,14 @@ export function ProsthesisAssignChart({ if (rest.length === 0) { commit( groups, - clearJobsForTeeth(toothProsthesis, detailClientId, archSentinels(lockedArch)), + clearJobsForTeeth(toothProsthesis, detailClientId, archSentinels('both')), ); return; } commit(groups, writeArchJobs(toothProsthesis, detailClientId, lockedArch, rest[0])); return; } - const tooth = focusTooth; + const tooth = anchor; if (!tooth) return; const next = codesOnTooth(toothProsthesis, detailClientId, tooth).filter((c) => c !== code); if (next.length === 0) { @@ -264,7 +245,10 @@ export function ProsthesisAssignChart({ removeTeethFromGroups(groups, [tooth]), clearJobsForTeeth(toothProsthesis, detailClientId, [tooth]), ); - brushRef.current = []; + if (rangeAnchorRef.current === tooth) { + brushRef.current = []; + rangeAnchorRef.current = null; + } return; } applyCodes([tooth], next); @@ -274,27 +258,28 @@ export function ProsthesisAssignChart({ if (scope === 'arch') { commit( groups, - clearJobsForTeeth(toothProsthesis, detailClientId, archSentinels(lockedArch)), + clearJobsForTeeth(toothProsthesis, detailClientId, archSentinels('both')), ); setOpen(false); return; } - const pending = pendingRangeRef.current; - const teeth = - pending && pending.length > 0 - ? pending - : focusTooth - ? [focusTooth] - : []; + const teeth = anchor ? [anchor] : []; if (teeth.length === 0) return; commit( removeTeethFromGroups(groups, teeth), clearJobsForTeeth(toothProsthesis, detailClientId, teeth), ); brushRef.current = []; + rangeAnchorRef.current = null; setOpen(false); setAnchor(null); - setRange(null); + } + + function handleClosePicker() { + if (scope === 'tooth') { + rememberBrushFromTooth(anchor); + } + setOpen(false); } function handleReset() { @@ -307,9 +292,9 @@ export function ProsthesisAssignChart({ ); commit(groupsFromFlatTeeth([]), nextRows); brushRef.current = []; + rangeAnchorRef.current = null; setOpen(false); setAnchor(null); - setRange(null); } const selectedSummary = (() => { @@ -349,26 +334,15 @@ export function ProsthesisAssignChart({ scope={scope} lockedArch={scope === 'arch' ? lockedArch : undefined} jobCodes={jobCodes} - tooth={focusTooth} - rangeLabel={ - pendingRange && pendingRange.length > 1 ? pendingRange.join(', ') : null - } - allowImplantAddon={ - scope === 'tooth' && jobsAllowImplantAddon(jobCodes, byCode) - } - allowPostCoreAddon={ - scope === 'tooth' && jobsAllowPostCoreAddon(jobCodes, byCode) - } + tooth={anchor} + allowCrownAddon={scope === 'tooth' && jobsAllowCrownAddon(jobCodes, byCode)} onStep={setStep} - onLockedArchChange={setLockedArch} + onLockedArchChange={handleLockedArchChange} onPickLeaf={handlePickLeaf} onPickAddon={handlePickAddon} onRemoveJob={handleRemoveJob} onClear={handleClear} - onClose={() => { - setOpen(false); - setRange(null); - }} + onClose={handleClosePicker} /> } onAssignPointer={handleAssignPointer} diff --git a/frontend/src/components/ui/treatment/ProsthesisJobPopover.tsx b/frontend/src/components/ui/treatment/ProsthesisJobPopover.tsx index 27b312c..2641abf 100644 --- a/frontend/src/components/ui/treatment/ProsthesisJobPopover.tsx +++ b/frontend/src/components/ui/treatment/ProsthesisJobPopover.tsx @@ -2,36 +2,48 @@ import type { ReactNode } from 'react'; import { useTranslations } from 'next-intl'; -import { Trash2 } from 'lucide-react'; +import { ArrowLeft, Plus, Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/shared/Button'; import { ResponsiveDialogOverlay, ResponsiveDialogPanel, } from '@/components/ui/shared/ResponsiveDialog'; import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog'; +import { prosthesisFamilyTone } from '@/components/shared/catalog-type-colors'; import { prosthesisTypeColorFromCatalog, prosthesisTypeFillAccent } from '@/components/treatment/prosthesisTypeDisplay'; import { - addonLeaves, + catalogByCode, categoryCodesInCatalog, + categoryDisabledForJobs, categoryTileColor, + crownRestorationCode, leavesFor, subcategoriesFor, + type AddonSlot, type ArchTarget, type PickerScope, } from '@/components/treatment/prosthesisTree'; const CHIP_INK = '#14253d'; const TILE_CLASS = - 'flex h-full min-h-0 w-[3.6rem] shrink-0 flex-row items-center justify-center gap-0.5 rounded-[var(--radius-md)] border px-0.5 py-2 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40'; + 'flex min-h-[3.25rem] w-full flex-col items-center justify-center gap-0.5 rounded-[var(--radius-md)] border px-2.5 py-2 text-center transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:cursor-not-allowed disabled:opacity-45'; const TILE_TEXT_CLASS = - 'max-h-full overflow-hidden [writing-mode:vertical-rl] [text-orientation:mixed] whitespace-normal break-words text-center text-xs font-medium leading-tight sm:text-[13px]'; -const HEADER_BTN_CLASS = 'h-auto min-h-8 px-2.5 py-1 text-xs leading-tight'; + 'w-full whitespace-normal break-words text-center text-xs font-medium leading-snug sm:text-[13px]'; +const TILE_GRID_CLASS = + 'grid gap-2 [grid-template-columns:repeat(auto-fill,minmax(8rem,1fr))]'; +const HEADER_BTN_CLASS = 'h-auto min-h-8 w-full px-2 py-1 text-xs leading-tight whitespace-normal'; + +function tileSurfaceClass(active?: boolean) { + return active + ? 'border-primary ring-1 ring-primary/30' + : 'border-border/70 hover:border-border hover:bg-background-card/70'; +} export type PickerStep = | { kind: 'category' } | { kind: 'subcategory'; category: string; arch?: ArchTarget } | { kind: 'leaves'; category: string; subcategory?: string; arch?: ArchTarget } - | { kind: 'addon'; slot: 'implant' | 'post_core' }; + | { kind: 'addon'; slot: AddonSlot }; interface ProsthesisJobPopoverProps { open: boolean; @@ -42,13 +54,11 @@ interface ProsthesisJobPopoverProps { lockedArch?: ArchTarget; jobCodes: string[]; tooth: string | null; - rangeLabel?: string | null; - allowImplantAddon: boolean; - allowPostCoreAddon: boolean; + allowCrownAddon: boolean; onStep: (step: PickerStep) => void; onLockedArchChange?: (arch: ArchTarget) => void; onPickLeaf: (code: string, arch?: ArchTarget) => void; - onPickAddon: (code: string | null, kind: 'implant' | 'post_core') => void; + onPickAddon: (code: string | null, kind: AddonSlot) => void; onRemoveJob: (code: string) => void; onClear: () => void; onClose: () => void; @@ -56,54 +66,47 @@ interface ProsthesisJobPopoverProps { function Tile({ label, - caption, color, active, + disabled, + hint, + className = TILE_CLASS, onClick, }: { label: string; - caption?: string; color?: string; active?: boolean; + disabled?: boolean; + hint?: string; + className?: string; onClick: () => void; }) { return ( ); } -function VRule() { - return

; -} - function parentStep(step: PickerStep): PickerStep | null { - if (step.kind === 'category') return null; - if (step.kind === 'addon') return { kind: 'category' }; - if (step.kind === 'leaves') { - if (step.subcategory) { - return { kind: 'subcategory', category: step.category, arch: step.arch }; - } - return { kind: 'category' }; + switch (step.kind) { + case 'category': + return null; + case 'addon': + case 'subcategory': + return { kind: 'category' }; + case 'leaves': + return step.subcategory + ? { kind: 'subcategory', category: step.category, arch: step.arch } + : { kind: 'category' }; } - if (step.kind === 'subcategory') return { kind: 'category' }; - return { kind: 'category' }; } export function ProsthesisJobPopover({ @@ -115,9 +118,7 @@ export function ProsthesisJobPopover({ lockedArch, jobCodes, tooth, - rangeLabel, - allowImplantAddon, - allowPostCoreAddon, + allowCrownAddon, onStep, onLockedArchChange, onPickLeaf, @@ -130,14 +131,10 @@ export function ProsthesisJobPopover({ if (!open) return null; const categories = categoryCodesInCatalog(catalog, scope); - const implantAddons = addonLeaves(catalog, 'implant'); - const postCoreAddons = addonLeaves(catalog, 'post_core'); - const currentImplant = - jobCodes.find((c) => catalog.find((e) => e.code === c)?.addonKind === 'implant') ?? ''; - const currentPost = - jobCodes.find((c) => catalog.find((e) => e.code === c)?.addonKind === 'post_core') ?? ''; + const byCode = catalogByCode(catalog); + const crownLeaves = leavesFor(catalog, 'crown', undefined, 'tooth'); + const currentCrown = crownRestorationCode(jobCodes, byCode) ?? ''; const hasLeaf = jobCodes.length > 0; - const byCode = new Map(catalog.map((e) => [e.code, e])); const archForLeaves = step.kind === 'leaves' || step.kind === 'subcategory' ? step.arch : lockedArch; @@ -151,6 +148,7 @@ export function ProsthesisJobPopover({ const back = parentStep(step); function openCategory(code: string) { + if (categoryDisabledForJobs(code, jobCodes, byCode)) return; const subs = subcategoriesFor(catalog, code, scope); const arch = scope === 'arch' ? lockedArch : undefined; if (subs.length > 0) { @@ -173,53 +171,38 @@ export function ProsthesisJobPopover({ } const addonTiles: ReactNode[] = []; - if (scope === 'tooth' && hasLeaf) { - if (allowImplantAddon && implantAddons.length > 0) { + if (scope === 'tooth' && allowCrownAddon && crownLeaves.length > 0) { + const addonActive = step.kind === 'addon' && step.slot === 'crown'; + const openAddon = () => + onStep(addonActive ? { kind: 'category' } : { kind: 'addon', slot: 'crown' }); + if (currentCrown) { addonTiles.push( - onStep( - step.kind === 'addon' && step.slot === 'implant' - ? { kind: 'category' } - : { kind: 'addon', slot: 'implant' }, - ) - } + key="slot-crown" + className={`${TILE_CLASS} h-full`} + label={byCode.get(currentCrown)?.label ?? currentCrown} + color={prosthesisTypeColorFromCatalog(currentCrown, catalog)} + active={addonActive} + onClick={openAddon} />, ); - } - if (allowPostCoreAddon && postCoreAddons.length > 0) { + } else { addonTiles.push( - - onStep( - step.kind === 'addon' && step.slot === 'post_core' - ? { kind: 'category' } - : { kind: 'addon', slot: 'post_core' }, - ) - } - />, + , ); } } @@ -228,103 +211,155 @@ export function ProsthesisJobPopover({ let childTiles: ReactNode[] = []; let childrenKey = 'root'; - if (step.kind === 'category') { - childTiles = categories.map((code) => ( - openCategory(code)} - /> - )); - } else if (step.kind === 'addon') { - selectedNode = { - label: step.slot === 'implant' ? t('slotImplant') : t('slotPostCore'), - }; - childrenKey = `addon-${step.slot}`; - const options = step.slot === 'implant' ? implantAddons : postCoreAddons; - const current = step.slot === 'implant' ? currentImplant : currentPost; - childTiles = [ - onPickAddon(null, step.slot)} - />, - ...options.map((opt) => ( + switch (step.kind) { + case 'category': + childTiles = categories.map((code) => { + const disabled = categoryDisabledForJobs(code, jobCodes, byCode); + return ( + openCategory(code)} + /> + ); + }); + break; + case 'addon': + selectedNode = { label: t('slotCrown'), color: categoryTileColor('crown') }; + childrenKey = 'addon-crown'; + childTiles = [ onPickAddon(opt.code, step.slot)} - /> - )), - ]; - } else if (step.kind === 'leaves' && step.subcategory) { - selectedNode = { label: subLabel(step.subcategory) }; - childrenKey = `leaves-${step.category}-${step.subcategory}`; - childTiles = leavesFor(catalog, step.category, step.subcategory, scope).map(leafTile); - } else { - selectedNode = { - label: categoryLabel(step.category), - color: categoryTileColor(step.category), - }; - childrenKey = `${step.kind}-${step.category}`; - const arch = step.arch; - const subs = - step.kind === 'subcategory' - ? subcategoriesFor(catalog, step.category, scope).map((sub) => ( - - onStep({ - kind: 'leaves', - category: step.category, - subcategory: sub, - arch, - }) - } - /> - )) - : []; - const leaves = leavesFor( - catalog, - step.category, - step.kind === 'leaves' ? step.subcategory : undefined, - scope, - ).map(leafTile); - childTiles = [...subs, ...leaves]; + key="none" + label={t('addonNone')} + active={!currentCrown} + onClick={() => onPickAddon(null, 'crown')} + />, + ...crownLeaves.map((opt) => ( + onPickAddon(opt.code, 'crown')} + /> + )), + ]; + break; + case 'subcategory': { + const parentFill = categoryTileColor(step.category); + selectedNode = { label: categoryLabel(step.category), color: parentFill }; + childrenKey = `sub-${step.category}`; + childTiles = [ + ...subcategoriesFor(catalog, step.category, scope).map((sub, i) => ( + + onStep({ + kind: 'leaves', + category: step.category, + subcategory: sub, + arch: step.arch, + }) + } + /> + )), + ...leavesFor(catalog, step.category, undefined, scope).map(leafTile), + ]; + break; + } + case 'leaves': + if (step.subcategory) { + selectedNode = { + label: subLabel(step.subcategory), + color: prosthesisFamilyTone(categoryTileColor(step.category), 1), + }; + childrenKey = `leaves-${step.category}-${step.subcategory}`; + childTiles = leavesFor(catalog, step.category, step.subcategory, scope).map(leafTile); + } else { + selectedNode = { + label: categoryLabel(step.category), + color: categoryTileColor(step.category), + }; + childrenKey = `leaves-${step.category}`; + childTiles = leavesFor(catalog, step.category, undefined, scope).map(leafTile); + } + break; } - const tileRow = ( -
- {selectedNode ? ( -
- back && onStep(back)} - /> - -
- {childTiles} -
-
- ) : ( - childTiles - )} + const tileGrid = ( +
+ {childTiles}
); - const title = rangeLabel - ? t('pickerTeeth', { teeth: rangeLabel }) - : scope === 'arch' + const branchColor = selectedNode?.color ?? 'var(--color-border-strong)'; + const tileRow = selectedNode ? ( +
+ +
+
+ + +
+ {tileGrid} +
+
+ ) : ( + tileGrid + ); + + const title = + scope === 'arch' ? lockedArch === 'both' ? t('pickerArchBoth') : lockedArch === 'lower' @@ -334,11 +369,90 @@ export function ProsthesisJobPopover({ ? t('pickerTooth', { tooth }) : t('pickerTitle'); - const header = ( -
-
-

{title}

-
+ const jobChips = + jobCodes.length === 0 ? ( +

{t('noneYet')}

+ ) : ( +
+ {jobCodes.map((code) => { + const entry = byCode.get(code); + const color = prosthesisTypeColorFromCatalog(code, catalog); + const trashColor = prosthesisTypeFillAccent(color); + return ( + + + + {entry?.category + ? t(`category_${entry.category}` as never) + : t('regionCrown')} + + {entry?.label ?? code} + + + + ); + })} +
+ ); + + const body = ( +
+
+

{title}

+ {scope === 'arch' && onLockedArchChange ? ( +
+ {(['upper', 'lower', 'both'] as const).map((arch) => ( + + ))} +
+ ) : null} +
{jobChips}
+

+ {scope === 'arch' ? t('clearHintArch') : t('clearHint')} +

+
+ {tileRow} +
+
+
+
-
- {scope === 'arch' && onLockedArchChange ? ( -
- {(['upper', 'lower', 'both'] as const).map((arch) => ( - - ))} -
- ) : null} - {jobCodes.length === 0 ? ( -

{t('noneYet')}

- ) : ( -
- {jobCodes.map((code) => { - const entry = byCode.get(code); - const color = prosthesisTypeColorFromCatalog(code, catalog); - const trashColor = prosthesisTypeFillAccent(color); - return ( - - - - {entry?.category - ? t(`category_${entry.category}` as never) - : t('regionCrown')} - - {entry?.label ?? code} - - - - ); - })} -
- )} -

- {scope === 'arch' ? t('clearHintArch') : t('clearHint')} -

-
- ); - - const addonRail = - addonTiles.length > 0 ? ( -
- {addonTiles} -
- ) : null; - - const body = ( -
- {header} -
- {tileRow} - {addonRail} -
+ {addonTiles.length > 0 ? ( +
+

+ {t('suggestionsLabel')} +

+
{addonTiles}
+
+ ) : null} +
); @@ -453,7 +491,7 @@ export function ProsthesisJobPopover({

{t('pickerTitle')}

@@ -472,7 +510,7 @@ export function ProsthesisJobPopover({ onClick={onClose} />
e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()} diff --git a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx index d46aa33..60bef3f 100644 --- a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx +++ b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx @@ -53,6 +53,9 @@ interface TreatmentDetailsEditorProps { onTreatmentTypeChange?: (nextType: string) => void; /** Arch-level jobs count as work for the missing-teeth lab banner. */ hasArchJobs?: boolean; + showLabDispatchToggle?: boolean; + labWorkspaceView?: 'chart' | 'dispatch'; + onLabWorkspaceViewChange?: (view: 'chart' | 'dispatch') => void; } export function TreatmentDetailsEditor({ @@ -79,6 +82,9 @@ export function TreatmentDetailsEditor({ chartLockMessage, onTreatmentTypeChange, hasArchJobs = false, + showLabDispatchToggle = false, + labWorkspaceView = 'chart', + onLabWorkspaceViewChange, }: TreatmentDetailsEditorProps) { const t = useTranslations('treatment'); const tCommon = useTranslations('common'); @@ -229,7 +235,7 @@ export function TreatmentDetailsEditor({ {showFields && activeDetail ? (
-
+
+ {showLabDispatchToggle ? ( + + ) : null}
{chart ? ( diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index ea88db5..f28a072 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -60,7 +60,7 @@ import { toggleToothInGroups, unlinkAdjacentTeeth, } from '@/components/treatment/toothSelectionGroups'; -import { hasArchJobs } from '@/components/treatment/prosthesisTree'; +import { hasArchJobs, pruneDetailTeethToJobs } from '@/components/treatment/prosthesisTree'; import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention'; import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention'; import { @@ -114,6 +114,19 @@ function labCaseDraftForDetail( return rows.find((lc) => !lc.sentAt) ?? rows[0]; } +function pruneLabDependentDetailsToJobs( + details: TreatmentDetailDraft[], + drafts: LabCaseDraft[], + labDependentCodes: Set, +): TreatmentDetailDraft[] { + if (labDependentCodes.size === 0) return details; + return details.map((detail) => { + if (!labDependentCodes.has(detail.treatmentType)) return detail; + const jobs = labCaseDraftForDetail(drafts, detail.clientId)?.toothProsthesis ?? []; + return pruneDetailTeethToJobs(detail, jobs); + }); +} + function labCaseDraftsToPast( labCaseDrafts: LabCaseDraft[], details: TreatmentDetailDraft[], @@ -422,6 +435,29 @@ interface TreatmentWorkspaceProps { initialLabCaseId?: string | null; } +function WholePlanToggle({ + pressed, + label, + onToggle, +}: { + pressed: boolean; + label: string; + onToggle: () => void; +}) { + return ( + + ); +} + export function TreatmentWorkspace({ userId, currentOrganization, @@ -518,6 +554,7 @@ export function TreatmentWorkspace({ const pendingAppointmentIdRef = useRef(initialAppointmentId); const pendingLabCaseIdRef = useRef(initialLabCaseId); const labPanelRef = useRef(null); + const [labWorkspaceView, setLabWorkspaceView] = useState<'chart' | 'dispatch'>('chart'); const historyRequestRef = useRef(0); /** When set, switching the active detail scrolls to the lab dispatch panel. */ const pendingScrollToLabRef = useRef(false); @@ -704,6 +741,20 @@ export function TreatmentWorkspace({ [activeDetail, labDependentCodes], ); + useEffect(() => { + if (labDependentCodes.size === 0) return; + setDetails((prev) => { + const next = pruneLabDependentDetailsToJobs( + prev, + labCaseDraftsRef.current, + labDependentCodes, + ); + if (serializeDetails(next) === serializeDetails(prev)) return prev; + detailsRef.current = next; + return next; + }); + }, [labDependentCodes]); + const activeTypeSelected = Boolean(activeDetail && isDetailTypeSelected(activeDetail)); const activeLocked = Boolean(activeDetail && isDetailLocked(activeDetail)); const activeHasArchJobs = hasArchJobs( @@ -846,7 +897,14 @@ export function TreatmentWorkspace({ treatment: PastTreatment, options?: { seedBlankIfEmpty?: boolean }, ) => { - const mapped = treatment.details.map(mapDetailFromApi); + const mappedLabCases = withoutEmptyLabCaseDrafts( + (treatment.labCases ?? []).map(mapLabCaseDraftFromApi), + ); + const mapped = pruneLabDependentDetailsToJobs( + treatment.details.map(mapDetailFromApi), + mappedLabCases, + labDependentCodes, + ); const nextDetails = mapped.length > 0 ? mapped @@ -859,9 +917,6 @@ export function TreatmentWorkspace({ return stillExists ? prev : (nextDetails[0]?.clientId ?? ''); }); setSavedSnapshot(serializeDetails(nextDetails)); - const mappedLabCases = withoutEmptyLabCaseDrafts( - (treatment.labCases ?? []).map(mapLabCaseDraftFromApi), - ); const labSnap = serializeLabCases(mappedLabCases); labCaseDraftsRef.current = mappedLabCases; savedLabCasesSnapshotRef.current = labSnap; @@ -870,7 +925,7 @@ export function TreatmentWorkspace({ setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null); setOrganizationSearch(''); setSaveStatus('idle'); - }, []); + }, [labDependentCodes]); const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]); const connectedSelectedTeeth = useMemo( @@ -929,12 +984,19 @@ export function TreatmentWorkspace({ setOrganizationSearch(''); if (pendingScrollToLabRef.current) { pendingScrollToLabRef.current = false; + setLabWorkspaceView('dispatch'); requestAnimationFrame(() => { scrollWithinMainScrollContainer(labPanelRef.current); }); + } else { + setLabWorkspaceView('chart'); } }, [activeDetailId]); + useEffect(() => { + if (!showLabDispatch) setLabWorkspaceView('chart'); + }, [showLabDispatch]); + // Sync active lab shipment when the selected treatment detail changes. useEffect(() => { const match = labCaseDraftForDetail(labCaseDrafts, activeDetailId); @@ -1201,8 +1263,16 @@ export function TreatmentWorkspace({ : await treatmentsApi.getDraftByTreatment(treatmentId!); if (cancelled) return; + const mappedLabCases = withoutEmptyLabCaseDrafts( + (response.data?.labCases ?? []).map(mapLabCaseDraftFromApi), + ); + if (response.data?.details?.length) { - const mapped = response.data.details.map(mapDetailFromApi); + const mapped = pruneLabDependentDetailsToJobs( + response.data.details.map(mapDetailFromApi), + mappedLabCases, + labDependentCodes, + ); setDetails(mapped); setActiveDetailId((prev) => { const stillExists = mapped.some((d) => d.clientId === prev); @@ -1222,9 +1292,6 @@ export function TreatmentWorkspace({ setSavedSnapshot(serializeDetails([first])); } - const mappedLabCases = withoutEmptyLabCaseDrafts( - (response.data?.labCases ?? []).map(mapLabCaseDraftFromApi), - ); const labSnap = serializeLabCases(mappedLabCases); labCaseDraftsRef.current = mappedLabCases; savedLabCasesSnapshotRef.current = labSnap; @@ -1253,6 +1320,7 @@ export function TreatmentWorkspace({ selectedStandalone?.id, workspaceMode, treatmentCatalog, + labDependentCodes, showError, t, tErrors, @@ -1270,7 +1338,15 @@ export function TreatmentWorkspace({ const patientId = selectedAppointment?.patientId ?? selectedStandalone!.patientId; const treatmentAt = selectedAppointment?.startAt ?? selectedStandalone!.treatmentAt; - const currentDetails = detailsRef.current; + const currentDetails = pruneLabDependentDetailsToJobs( + detailsRef.current, + labCaseDraftsRef.current, + labDependentCodes, + ); + if (serializeDetails(currentDetails) !== serializeDetails(detailsRef.current)) { + detailsRef.current = currentDetails; + setDetails(currentDetails); + } const dirty = isDetailsDirty(currentDetails, savedSnapshotRef.current); if (!options?.force && !dirty) { @@ -1333,7 +1409,7 @@ export function TreatmentWorkspace({ } return response.data; }, - [selectedAppointment, selectedStandalone, t], + [labDependentCodes, selectedAppointment, selectedStandalone, t], ); const persistLabCases = useCallback( @@ -1881,8 +1957,11 @@ export function TreatmentWorkspace({ (item: LabDispatchAttentionItem) => { if (item.isCurrentDraft) { exitBrowse(); - pendingScrollToLabRef.current = true; - setActiveDetailId(item.detailClientId); + setLabWorkspaceView('dispatch'); + if (item.detailClientId !== activeDetailId) { + pendingScrollToLabRef.current = true; + setActiveDetailId(item.detailClientId); + } const linked = labCaseDrafts.find( (lc) => !lc.sentAt && lc.detailClientId === item.detailClientId, ); @@ -1901,7 +1980,7 @@ export function TreatmentWorkspace({ if (!treatment) return; void loadTreatmentIntoWorkspace(treatment, item.detailClientId); }, - [exitBrowse, history, historyPanelItems, labCaseDrafts, loadTreatmentIntoWorkspace], + [exitBrowse, activeDetailId, history, historyPanelItems, labCaseDrafts, loadTreatmentIntoWorkspace], ); const handleSelectPatientLabCase = useCallback( @@ -1949,8 +2028,11 @@ export function TreatmentWorkspace({ workspaceMode === 'live' && !isBrowsing ) { - pendingScrollToLabRef.current = true; - setActiveDetailId(item.detailClientId); + setLabWorkspaceView('dispatch'); + if (item.detailClientId !== activeDetailId) { + pendingScrollToLabRef.current = true; + setActiveDetailId(item.detailClientId); + } const matchingDraft = labCaseDrafts.find((lc) => lc.id === item.labCaseId); if (matchingDraft) { setActiveLabCaseId(matchingDraft.clientId); @@ -1967,6 +2049,7 @@ export function TreatmentWorkspace({ })(); }, [ + activeDetailId, activePatientId, currentDraftPreview, handleLabCaseMarkedRead, @@ -2244,6 +2327,7 @@ export function TreatmentWorkspace({ if (detail.treatmentType === nextType) return; const leavingLab = labDependentCodes.has(detail.treatmentType); + const enteringLab = labDependentCodes.has(nextType); const jobs = labCaseDraftForDetail(labCaseDrafts, activeDetailId)?.toothProsthesis ?? []; const hasWork = @@ -2257,7 +2341,7 @@ export function TreatmentWorkspace({ const nextDetails = details.map((d) => { if (d.clientId !== activeDetailId) return d; - if (leavingLab) { + if (leavingLab || enteringLab) { return { ...d, treatmentType: nextType, @@ -2495,6 +2579,39 @@ export function TreatmentWorkspace({ ], ); + const labDispatchPanel = ( + { + if (historyPatientId) { + void refreshPatientLabCases(historyPatientId, { silent: true }); + } + }} + activeLabCaseId={activeLabCaseId} + onLabCasesChange={handleLabCasesChange} + disabled={!canEditTreatmentForDay} + canEdit={canEdit} + orgs={orgs} + organizationSearch={organizationSearch} + onOrganizationSearchChange={setOrganizationSearch} + recentOrganizationIds={recentOrganizationIds} + sendBusyId={sendBusyId} + onSendLabCase={(lc, comment) => handleSendLabCase(lc, comment)} + onCommentError={showError} + canInviteLab={canAccessOrganizations} + onInviteLab={() => router.push('/organizations?action=invite-lab')} + /> + ); + if (!canView) { return (
@@ -2784,10 +2901,19 @@ export function TreatmentWorkspace({ showFields hasArchJobs={activeHasArchJobs} voice={voiceForEditor} - chartLocked={!activeTypeSelected && !showWholeTreatmentPlan} + chartLocked={!activeTypeSelected && !showWholeTreatmentPlan && labWorkspaceView !== 'dispatch'} chartLockMessage={t('selectTypeBeforeTeeth')} + showLabDispatchToggle={showLabDispatch} + labWorkspaceView={labWorkspaceView} + onLabWorkspaceViewChange={(view) => { + setShowWholeTreatmentPlan(false); + setLabWorkspaceView(view); + }} chart={ - prosthesisAssignActive ? ( +
+ {showLabDispatch && labWorkspaceView === 'dispatch' ? ( + labDispatchPanel + ) : prosthesisAssignActive ? ( setShowWholeTreatmentPlan((open) => !open)} - aria-pressed={showWholeTreatmentPlan} - className={` - rounded-[var(--radius-md)] border px-2.5 py-1 text-[11px] leading-none transition-colors - focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45 - ${ - showWholeTreatmentPlan - ? 'border-primary bg-primary-soft font-medium text-text-primary' - : 'border-border/70 text-text-secondary hover:border-border hover:bg-background-card/50' - } - `} - > - {t('toothChartWholePlan')} - + setShowWholeTreatmentPlan((open) => !open)} + /> } onToggleLink={(a, b) => { if ( @@ -2868,35 +2983,34 @@ export function TreatmentWorkspace({ ); }} onChange={({ groups, toothProsthesis }) => { - setDetails((prev) => - prev.map((d) => - d.clientId !== activeDetailId - ? d - : { - ...d, - toothSelectionGroups: groups, - teeth: deriveTeethFromGroups(groups), - }, - ), + const nextDetails = details.map((d) => + d.clientId !== activeDetailId + ? d + : { + ...d, + toothSelectionGroups: groups, + teeth: deriveTeethFromGroups(groups), + }, ); - setLabCaseDrafts((prev) => { - const existing = prev.find( - (lc) => lc.detailClientId === activeDetailId && !lc.sentAt, - ); - if (existing) { - return prev.map((lc) => + detailsRef.current = nextDetails; + setDetails(nextDetails); + const prev = labCaseDraftsRef.current; + const existing = prev.find( + (lc) => lc.detailClientId === activeDetailId && !lc.sentAt, + ); + const nextDrafts = existing + ? prev.map((lc) => lc.clientId === existing.clientId ? { ...lc, toothProsthesis } : lc, - ); - } - return [ - ...prev, - { - ...newLabCaseDraft(), - detailClientId: activeDetailId, - toothProsthesis, - }, - ]; - }); + ) + : [ + ...prev, + { + ...newLabCaseDraft(), + detailClientId: activeDetailId, + toothProsthesis, + }, + ]; + handleLabCasesChange(nextDrafts); }} /> ) : ( @@ -2908,22 +3022,11 @@ export function TreatmentWorkspace({ toothColors={chartToothColors} readOnly={showWholeTreatmentPlan} headerControl={ - + setShowWholeTreatmentPlan((open) => !open)} + /> } onToggle={(fdi, event) => { if ( @@ -3084,43 +3187,10 @@ export function TreatmentWorkspace({ }} /> ) + } +
} /> - - {showLabDispatch ? ( -
- { - if (historyPatientId) { - void refreshPatientLabCases(historyPatientId, { silent: true }); - } - }} - activeLabCaseId={activeLabCaseId} - onLabCasesChange={handleLabCasesChange} - disabled={!canEditTreatmentForDay} - canEdit={canEdit} - orgs={orgs} - organizationSearch={organizationSearch} - onOrganizationSearchChange={setOrganizationSearch} - recentOrganizationIds={recentOrganizationIds} - sendBusyId={sendBusyId} - onSendLabCase={(lc, comment) => handleSendLabCase(lc, comment)} - onCommentError={showError} - canInviteLab={canAccessOrganizations} - onInviteLab={() => router.push('/organizations?action=invite-lab')} - /> -
- ) : null} )}
diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index 7e7f5bd..cda40ac 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -6,10 +6,10 @@ --radius-md: 6px; --radius-lg: 8px; - --color-background-primary: #f6f9fc; - --color-background-secondary: #ffffff; - --color-background-card: #ffffff; - --color-surface-elevated: #f8fbff; + --color-background-primary: #f2f3f5; + --color-background-secondary: #f7f8f9; + --color-background-card: #f7f8f9; + --color-surface-elevated: #eceef1; --color-text-primary: #0f172a; --color-text-secondary: #475569; @@ -18,7 +18,7 @@ --color-border: #d2dcec; --color-border-strong: #aec0de; - --color-card-background: #ffffff; + --color-card-background: #f7f8f9; --color-card-foreground: #0f172a; --color-card-muted: #475569; --color-card-border: #d2dcec; @@ -397,7 +397,7 @@ select option { @keyframes picker-expand { from { opacity: 0; - transform: scaleX(0.88); + transform: translateY(4px) scale(0.98); } to { opacity: 1; @@ -406,7 +406,7 @@ select option { } .picker-expand { - transform-origin: inline-start; + transform-origin: top center; animation: picker-expand 180ms ease-out; } diff --git a/frontend/src/types/treatment-catalog.ts b/frontend/src/types/treatment-catalog.ts index 3c594e9..8ba1929 100644 --- a/frontend/src/types/treatment-catalog.ts +++ b/frontend/src/types/treatment-catalog.ts @@ -16,5 +16,4 @@ export interface ProsthesisCatalogEntry { subcategory: string; chartRegion: 'crown' | 'root' | 'arch' | string; stackGroup: 'restoration' | 'implant' | 'post_core' | 'arch' | string; - addonKind: '' | 'implant' | 'post_core' | string; }