From 72f885d9dd61f8de16f42d99af40dd3cf2916200 Mon Sep 17 00:00:00 2001 From: Admin Date: Fri, 4 Sep 2026 17:04:48 +0330 Subject: [PATCH] improvement: all confirmed suggestions implemented --- .cursor/rules/treatment-workspace.mdc | 4 +- .cursor/skills/treatment-workspace/SKILL.md | 24 +- AGENTS.md | 6 +- backend/prisma/catalog-seed-data.ts | 19 +- backend/prisma/export-prosthesis-catalog.ts | 20 +- .../migration.sql | 3 + backend/prisma/schema.prisma | 2 - backend/prisma/seed.ts | 1 - .../prosthesis-catalog.service.ts | 5 - docs/prosthesis-catalog.xlsx | Bin 16393 -> 16270 bytes frontend/messages/en.json | 10 +- frontend/messages/fa.json | 10 +- frontend/messages/nl.json | 10 +- .../components/shared/catalog-type-colors.ts | 169 +++-- .../components/treatment/prosthesisTree.ts | 167 ++++- .../treatment/prosthesisTypeDisplay.ts | 40 +- .../components/ui/treatment/FdiToothChart.tsx | 158 +++-- .../ui/treatment/ProsthesisAssignChart.tsx | 174 +++--- .../ui/treatment/ProsthesisJobPopover.tsx | 580 ++++++++++-------- .../ui/treatment/TreatmentDetailsEditor.tsx | 34 +- .../ui/treatment/TreatmentWorkspace.tsx | 298 +++++---- frontend/src/styles/globals.css | 14 +- frontend/src/types/treatment-catalog.ts | 1 - 23 files changed, 1070 insertions(+), 679 deletions(-) create mode 100644 backend/prisma/migrations/20260904120000_drop_prosthesis_addon_kind/migration.sql 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 5725461f314355dcfc306011dfbec55b9c771e1a..e3d4f2170cc651337e38d643ffb9965c716d49e3 100644 GIT binary patch delta 4516 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 = { 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; }