From 7f92e735fb646e9dd38f2a3551db37996ed9cc9e Mon Sep 17 00:00:00 2001 From: Admin Date: Wed, 2 Sep 2026 17:24:01 +0330 Subject: [PATCH] improvement: Some improvements done. some bugs fixed. --- .cursor/rules/lab-cases.mdc | 2 +- .cursor/rules/lab-tasks.mdc | 4 +- .cursor/rules/treatment-workspace.mdc | 4 +- .cursor/skills/lab-cases/SKILL.md | 5 +- .cursor/skills/lab-tasks/SKILL.md | 6 +- .cursor/skills/treatment-workspace/SKILL.md | 8 +- AGENTS.md | 4 +- CLAUDE.md | 1 + backend/prisma/catalog-seed-data.ts | 209 +++++++++---- backend/prisma/export-prosthesis-catalog.ts | 20 +- backend/src/common/prosthesis-group.ts | 64 ++-- .../cases/lab-case-task.generator.spec.ts | 172 +++++++++- .../modules/cases/lab-case-task.generator.ts | 160 +++++++++- .../prosthesis-catalog.service.ts | 2 +- .../modules/treatments/treatments.service.ts | 3 +- docs/prosthesis-catalog.xlsx | Bin 15648 -> 16393 bytes frontend/messages/en.json | 4 +- frontend/messages/fa.json | 4 +- frontend/messages/nl.json | 4 +- .../components/treatment/prosthesisTree.ts | 121 +++++-- .../treatment/prosthesisTypeDisplay.ts | 78 +++++ .../src/components/ui/lab/CaseCreatePanel.tsx | 5 +- .../src/components/ui/lab/CaseDetailPanel.tsx | 31 +- .../ui/lab/LabCaseProsthesisGroupsList.tsx | 24 +- .../ui/lab/ProsthesisStackedTypeLabel.tsx | 50 +++ .../ui/lab/TaskProsthesisGroupHeader.tsx | 17 +- frontend/src/components/ui/lab/TaskRow.tsx | 22 +- .../ui/treatment/LabCasesDispatchPanel.tsx | 7 +- .../ui/treatment/ProsthesisAssignChart.tsx | 42 ++- .../ui/treatment/ProsthesisJobPopover.tsx | 34 +- .../ui/treatment/TreatmentWorkspace.tsx | 294 +++++++++++++----- frontend/src/styles/globals.css | 6 + 32 files changed, 1092 insertions(+), 315 deletions(-) create mode 100644 frontend/src/components/ui/lab/ProsthesisStackedTypeLabel.tsx diff --git a/.cursor/rules/lab-cases.mdc b/.cursor/rules/lab-cases.mdc index ad0ba78..8b10c2e 100644 --- a/.cursor/rules/lab-cases.mdc +++ b/.cursor/rules/lab-cases.mdc @@ -8,7 +8,7 @@ alwaysApply: false - **Filter by prosthesis**, not treatment type — `prosthesisTypeCode`; matches cases with **any task** of that type. - **Auto-select** newest case on tab open; right panel never empty when list has items; `?caseId=` deep link overrides. -- **List cards:** `LabCaseProsthesisGroupsList` (catalog color + teeth); same component as Treatment shipments rail; no patient mobile on card. +- **List cards:** `LabCaseProsthesisGroupsList` (catalog color + teeth); same component as Treatment shipments rail; no patient mobile on card. Connected same-type teeth share a group; stacked types on one tooth split by `+` in the type key. - **Assignment** in detail only (`TAB_CASES_EDIT`); task status changes on Tasks tab. - **Share link:** QR + URL when case is sent; token API + focus page — see `.cursor/skills/lab-case-share-link/SKILL.md`. - **Case Sheet PDF:** client A4 via `jspdf` + `html2canvas`; print layout must stay **hex-only** (Tailwind `lab()`/`oklch` breaks capture). Optional `externalCode` replaces PDF order number when set. diff --git a/.cursor/rules/lab-tasks.mdc b/.cursor/rules/lab-tasks.mdc index 5f8114a..2a8b43d 100644 --- a/.cursor/rules/lab-tasks.mdc +++ b/.cursor/rules/lab-tasks.mdc @@ -7,8 +7,8 @@ alwaysApply: false # Lab Tasks - **Default sort:** newest `labCase.sentAt` first; `stepOrder` asc within prosthesis group (backend `buildOrderBy`). -- **Grouping:** only when `sortBy=date`; flat list + hint for other sorts. -- **Prosthesis colors:** `PROSTHESIS_TYPE_COLORS` + `prosthesisTypeBadgeStyleFromCatalog` — never row index. +- **Grouping:** only when `sortBy=date`; flat list + hint for other sorts. Unconnected teeth stay one pipeline; **connected** teeth that share a type share a pipeline; `partial_denture` is one pipeline for all selected teeth. +- **Prosthesis colors:** `PROSTHESIS_TYPE_COLORS` + `prosthesisTypeColorFromCatalog` as text (no type pills) — never row index. Stacked `code+code` labels use `ProsthesisStackedTypeLabel` (one color per type). - **Important first:** `pinImportant=true` (sort pin, not filter). - **Task assignment:** assign in Cases (`TAB_CASES_EDIT`); status edit on Tasks only for assignee or unassigned tasks; others see “Assigned to {name}”. - **Grouped comments:** when `sortBy=date`, one comments icon on **case header** (not per row); flat sort keeps per-row comments. diff --git a/.cursor/rules/treatment-workspace.mdc b/.cursor/rules/treatment-workspace.mdc index 8888012..2197a67 100644 --- a/.cursor/rules/treatment-workspace.mdc +++ b/.cursor/rules/treatment-workspace.mdc @@ -8,10 +8,10 @@ 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 (including Upper/Lower arch). 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, 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. - **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 **one row per tooth** (`LabCaseToothJobsList`) — types as colored catalog text. Content clinical field = **Notes** (not case comments). +- **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). - **Lab comments:** shared `LabCaseCommentsPanel` (newest-first; sent=`justify-start`, received=`justify-end`; `viewerSide`) across Treatment / Cases / Tasks / share. Use logical `text-start`/`text-end`, not left/right. - **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. diff --git a/.cursor/skills/lab-cases/SKILL.md b/.cursor/skills/lab-cases/SKILL.md index 1a627ea..7baf45e 100644 --- a/.cursor/skills/lab-cases/SKILL.md +++ b/.cursor/skills/lab-cases/SKILL.md @@ -12,9 +12,10 @@ description: Lab Cases tab — list, filters, detail panel, assignment, share QR ## List behavior - **Default sort:** `startedAt` / `sentAt` desc (newest first). Lab-origin drafts (`origin === LAB_INTERNAL && !startedAt`) show a **Draft** badge. All list/detail cards also show **Received** (`CLINIC_DISPATCH`) or **Generated** (`LAB_INTERNAL`) via `LabCaseOriginBadge`. +- **Task group headers:** prosthesis type is **colored catalog text** (`prosthesisTypeColorFromCatalog`), not a Badge pill. Same on Tasks rows/headers. - **Page size:** `PAGE_SIZE = 10` in `CasesPage.tsx`. - **Auto-select:** On tab open / after filter reload, select first list item if none selected; keep selection when still in list; `?caseId=` URL wins. -- **Add case:** `TAB_CASES_EDIT` — `POST /cases` then replace the **right pane** with `CaseCreatePanel` (inline wizard, not a modal). Prosthesis-only lines, `ProsthesisAssignChart` (same picker/stacking as Treatment). Summary rows: `LabCaseToothJobsList` (tooth · types). Header: referring clinic/dentist, patient name/mobile, optional ACTIVE partner clinic, due date (`AppDateInput`). **Start** (`POST /cases/:id/start`) generates tasks; no `LabCaseSend`, no clinic inbox/`CASE_SENT`. **Delete** (`DELETE /cases/:id`) only for unstarted lab-origin drafts (`LAB_INTERNAL` && `!startedAt`); confirm in the create-panel header next to Start. Received clinic cases and started generated cases cannot be deleted. +- **Add case:** `TAB_CASES_EDIT` — `POST /cases` then replace the **right pane** with `CaseCreatePanel` (inline wizard, not a modal). Prosthesis-only lines, `ProsthesisAssignChart` (same picker/stacking as Treatment). Summary rows: `LabCaseToothJobsList` (connected same-type teeth share a row; otherwise tooth · types). Header: referring clinic/dentist, patient name/mobile, optional ACTIVE partner clinic, due date (`AppDateInput`). **Start** (`POST /cases/:id/start`) generates tasks; no `LabCaseSend`, no clinic inbox/`CASE_SENT`. **Delete** (`DELETE /cases/:id`) only for unstarted lab-origin drafts (`LAB_INTERNAL` && `!startedAt`); confirm in the create-panel header next to Start. Received clinic cases and started generated cases cannot be deleted. - **Right panel:** Draft lab-origin → wizard; started/received → `CaseDetailPanel`. - **Left rail layout:** section is `flex flex-col` + `lg:min-h-[420px]` / `h-full` with `lg:items-stretch` on the grid; case list wrapper is `flex-1 min-h-0 overflow-y-auto` (do **not** use a fixed `max-h-[55vh]` — that leaves empty space above pagination). @@ -36,7 +37,7 @@ Filter options: `GET /cases/filter-options` → `clinics`, `prosthesisTypes` (di Match Treatment shipment cards: patient name, clinic, **colored prosthesis groups + teeth** (`LabCaseProsthesisGroupsList`), sent date, progress bar, due-date badge, unread dot. **No patient mobile** on list cards. Prosthesis row order: **type · teeth**, then Connected badge when that group is a bridge. -List item shape: `prosthesisGroups: { prosthesisTypeCode, teeth[] }[]` from task teeth aggregation. After send, each tooth is its own group (stacked types on that tooth share one `prosthesisTypeCode` key like `crown+abutment`). **Connected** means that tooth’s `selectionGroupId` is shared by more than one tooth — not `teeth.length > 1` on the row. +List item shape: `prosthesisGroups: { prosthesisTypeCode, teeth[] }[]` from task teeth aggregation. Unconnected teeth stay one group each (stacked types on that tooth share one `prosthesisTypeCode` key like `crown+abutment`). **Connected** teeth that share a type are one group whose `teeth` lists the bridge. **Partial denture** is one group whose `teeth` is every selected unit. ## Detail panel diff --git a/.cursor/skills/lab-tasks/SKILL.md b/.cursor/skills/lab-tasks/SKILL.md index 25b3408..db58b3a 100644 --- a/.cursor/skills/lab-tasks/SKILL.md +++ b/.cursor/skills/lab-tasks/SKILL.md @@ -13,7 +13,7 @@ Backend: [`backend/src/modules/tasks/`](backend/src/modules/tasks/) `sortBy=date` + `sortDir=desc`: 1. `labCase.sentAt` desc (newest case first) -2. `labCaseId`, `sourceKey`, `tooth`, `prosthesisTypeCode` asc (one pipeline per tooth) +2. `labCaseId`, `sourceKey`, `tooth`, `prosthesisTypeCode` asc (one pipeline per unconnected tooth; connected same-type teeth share a pipeline; `partial_denture` is one pipeline for all selected teeth) 3. `stepOrder` asc (steps 1→N within that tooth) 4. `id` asc @@ -21,7 +21,7 @@ Other sorts use flat list on the frontend; `stepOrder asc` is still a tiebreaker ## Case grouping (frontend) -- **`sortBy === 'date'`** → grouped view via [`taskListGrouping.ts`](frontend/src/components/lab/taskListGrouping.ts): case header → **per-tooth** prosthesis sub-header → task rows (`treatmentDetailId:teeth:prosthesisTypeCode`). +- **`sortBy === 'date'`** → grouped view via [`taskListGrouping.ts`](frontend/src/components/lab/taskListGrouping.ts): case header → prosthesis sub-header (one per pipeline: unconnected tooth, or connected same-type bridge) → task rows (`treatmentDetailId:teeth:prosthesisTypeCode`). - **Other sorts** → flat list; show muted hint (`groupingOff*` i18n keys). Each row keeps clinic/patient/teeth context. Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`. @@ -31,7 +31,7 @@ Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`. ## Prosthesis colors - Map: [`catalog-type-colors.ts`](frontend/src/components/shared/catalog-type-colors.ts) → `PROSTHESIS_TYPE_COLORS` (one hex per catalog code). -- Resolve with [`prosthesisTypeDisplay.ts`](frontend/src/components/treatment/prosthesisTypeDisplay.ts) — use `prosthesisTypeBadgeStyleFromCatalog(code, catalog)`, **not** list row index. +- Resolve with [`prosthesisTypeDisplay.ts`](frontend/src/components/treatment/prosthesisTypeDisplay.ts) — use `prosthesisTypeColorFromCatalog(code, catalog)` as **text color** (no type pills), **not** list row index. - Load catalog via `prosthesisCatalogApi.list()` on Tasks/Cases/Today dashboard. ## Filters diff --git a/.cursor/skills/treatment-workspace/SKILL.md b/.cursor/skills/treatment-workspace/SKILL.md index 91da2ab..676f03b 100644 --- a/.cursor/skills/treatment-workspace/SKILL.md +++ b/.cursor/skills/treatment-workspace/SKILL.md @@ -43,7 +43,7 @@ Right-column entry is **not** a three-step wizard. Type dropdown + `TreatmentDet | **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. | -- Prosthesis types are assigned on the chart (`ProsthesisAssignChart` + `ProsthesisJobPopover` in `prosthesisTree.ts`). One restoration slot per tooth (crown / indirect / screw-retained); implant abutments and post & core **stack**. Arch products (denture, night guard, digital) use **Upper arch / Lower arch** labels and store `UA`/`LA` rows — they do not select 16 FDI teeth. Lab dispatch is on the same screen (no Treatment → Lab stepper). +- 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). - 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`). @@ -55,14 +55,14 @@ Right-column entry is **not** a three-step wizard. Type dropdown + `TreatmentDet -Helpers: `frontend/src/components/treatment/toothSelectionGroups.ts`. Persisted as `toothSelectionGroups` on the detail. Lab **dispatch** lists **one row per tooth** (`LabCaseToothJobsList`, colored catalog types). After send, Cases/Tasks keep one item per tooth; stacked types on that tooth **union** their workflow steps (not merge teeth by type). `LabCaseTask` unique key includes `tooth`. +Helpers: `frontend/src/components/treatment/toothSelectionGroups.ts`. Persisted as `toothSelectionGroups` on the detail. Lab **dispatch** lists connected teeth that share a type as **one row** (`LabCaseToothJobsList`); unconnected teeth stay one row per tooth (stacked codes stay together). After send, Cases/Tasks match that: a connected same-type bridge is one pipeline; stacked types on a **single** tooth **union** their workflow steps; `partial_denture` is one task set per `sourceKey`. `LabCaseTask` unique key includes `tooth` (first tooth of a merged bridge). Chart hit-testing (`FdiToothChart.tsx`): the hit target is the **unrotated full cell** (`pointerdown` + keyboard). Do **not** bind both `pointerdown` and `click` (double-toggle looks like a miss). Visual glyph is `pointer-events-none`; tilt lives on the outer wrapper; **scale/hover is nested inside** so inline `transform` does not kill scale. Horizontal inset (~22%) leaves a dead zone between teeth; FDI numbers are also clickable (`ToothNumber`). - **Plain click:** 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. Arch products open from the Upper/Lower arch labels and store `UA`/`LA` (not 16 FDI teeth). `pruneToothProsthesisForGroups` must keep those sentinels. +- **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. - 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. @@ -80,7 +80,7 @@ Chart hit-testing (`FdiToothChart.tsx`): the hit target is the **unrotated full | **Preview** | `previewTreatment`; `selectedPreviewId !== null` = **browse mode** | History click updates preview only | -| **Form** | `details[]`, `labCaseDrafts[]` | Appointment change → draft API; **Load into workspace** → hydrate | +| **Form** | `details[]`, `labCaseDrafts[]` | Appointment change → draft API; **Load into workspace** → hydrate. Autosave and strip-switch **flush both** `saveDraft` then `saveLabCases`. Dirty includes the lab-case snapshot (jobs, dest, due date), not only details. Non-prosthesis plans keep empty drafts and do not create lab rows. | diff --git a/AGENTS.md b/AGENTS.md index dd3720a..74c40b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,8 +48,8 @@ frontend/src/ - **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`. After send, Cases/Tasks keep **one item per tooth** (stacked types on that tooth union their lab steps; `UA`/`LA` → Upper/Lower arch). -- **Lab dispatch layout:** due date beside title (`justify-between`, logical start/end for RTL); one row per tooth (`LabCaseToothJobsList`, colored catalog types). +- **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. +- **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). **Treatment lab rules (quick ref):** diff --git a/CLAUDE.md b/CLAUDE.md index 898a35c..e686f19 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,6 +25,7 @@ There is **no root `package.json`**. Every npm command runs inside `backend/` or | `npm run lint` | ESLint with `--fix` | | `docker compose -f docker-compose.postgres.yml up -d` | Dev Postgres (host port from `POSTGRES_PORT` in `.env`) | | `npm run prisma:generate` / `prisma:migrate` / `prisma:seed` | Client, dev migration, reference-data upsert (seed never wipes) | +| `npm run prisma:export-prosthesis-catalog` | Rewrite `docs/prosthesis-catalog.xlsx` from `catalog-seed-data.ts` | | `npx prisma migrate reset` | Dev clean slate — drop, re-migrate, re-seed. Never against staging/prod | | `npm run prisma:wipe-app-data` / `prisma:reset-treatment` / `prisma:regenerate-tasks` | Targeted dev data scripts | diff --git a/backend/prisma/catalog-seed-data.ts b/backend/prisma/catalog-seed-data.ts index 7ee9283..3d86d9a 100644 --- a/backend/prisma/catalog-seed-data.ts +++ b/backend/prisma/catalog-seed-data.ts @@ -53,19 +53,21 @@ export const LEGACY_TREATMENT_TYPES: readonly TreatmentTypeSeed[] = [ export const LAB_WORKFLOW_STEPS = [ { code: 'intraoral_scan', sortOrder: 1 }, - { code: 'design', sortOrder: 2 }, - { code: 'milling_dry', sortOrder: 3 }, - { code: 'milling_wet', sortOrder: 4 }, - { code: 'printer_resin', sortOrder: 5 }, - { code: 'printer_metal', sortOrder: 6 }, - { code: 'sinter', sortOrder: 7 }, - { code: 'build_up', sortOrder: 8 }, - { code: 'stain', sortOrder: 9 }, - { code: 'glaze', sortOrder: 10 }, - { code: 'polish_prep', sortOrder: 11 }, - { code: 'packing', sortOrder: 12 }, - { code: 'shipping', sortOrder: 13 }, - { code: 'pressing', sortOrder: 14 }, + { code: 'choosing_abutment', sortOrder: 2 }, + { code: 'design', sortOrder: 3 }, + { code: 'model_create', sortOrder: 4 }, + { code: 'milling_dry', sortOrder: 5 }, + { code: 'milling_wet', sortOrder: 6 }, + { code: 'printer_resin', sortOrder: 7 }, + { code: 'printer_metal', sortOrder: 8 }, + { code: 'pressing', sortOrder: 9 }, + { code: 'sinter', sortOrder: 10 }, + { code: 'build_up', sortOrder: 11 }, + { code: 'stain', sortOrder: 12 }, + { code: 'glaze', sortOrder: 13 }, + { code: 'polish_prep', sortOrder: 14 }, + { code: 'packing', sortOrder: 15 }, + { code: 'shipping', sortOrder: 16 }, ] as const; export type ProsthesisChartRegion = 'crown' | 'root' | 'arch'; @@ -82,15 +84,42 @@ export type ProsthesisTypeSeed = { chartRegion: ProsthesisChartRegion; stackGroup: ProsthesisStackGroup; addonKind?: ProsthesisAddonKind; - /** Manufacturing steps between design and packing (exclusive of universal scan/design/pack/ship). */ + /** Manufacturing sequence. Packing and shipping are appended except for smile_design. */ manufacturingSteps: readonly string[]; }; -const FULL_CONTOUR = ['milling_wet', 'stain', 'glaze', 'polish_prep'] as const; -const LAYERED = ['milling_dry', 'sinter', 'build_up', 'stain', 'glaze', 'polish_prep'] as const; -const DENTURE = ['milling_wet', 'polish_prep'] as const; -const APPLIANCE = ['printer_resin', 'polish_prep'] as const; -const POST_CORE = ['milling_wet', 'polish_prep'] as const; +const SCAN_DESIGN_MODEL = ['intraoral_scan', 'design', 'model_create'] as const; +const INDIRECT_FULL = [...SCAN_DESIGN_MODEL, 'milling_wet', 'sinter', 'stain', 'glaze'] as const; +const INDIRECT_LAYERED = [ + ...SCAN_DESIGN_MODEL, + 'milling_wet', + 'sinter', + 'build_up', + 'stain', + 'glaze', +] as const; +/** Unlisted types keep scan+design plus today’s manufacturing (no model-create). */ +const KEEP_FULL_CONTOUR = [ + 'intraoral_scan', + 'design', + 'milling_wet', + 'stain', + 'glaze', + 'polish_prep', +] as const; +const KEEP_LAYERED = [ + 'intraoral_scan', + 'design', + 'milling_dry', + 'sinter', + 'build_up', + 'stain', + 'glaze', + 'polish_prep', +] as const; +const DENTURE = ['intraoral_scan', 'design', 'printer_resin', 'polish_prep'] as const; +const APPLIANCE = ['intraoral_scan', 'design', 'printer_resin', 'polish_prep'] as const; +const POST_CORE = ['intraoral_scan', 'design', 'milling_wet', 'polish_prep'] as const; function crown( code: string, @@ -113,6 +142,7 @@ function implantAddon( code: string, sortOrder: number, manufacturingSteps: readonly string[], + extra?: Partial, ): ProsthesisTypeSeed { return { code, @@ -122,6 +152,7 @@ function implantAddon( stackGroup: 'implant', addonKind: 'implant', manufacturingSteps, + ...extra, }; } @@ -137,20 +168,54 @@ function indirect( subcategory: indication, chartRegion: 'crown', stackGroup: 'restoration', - manufacturingSteps: technique === 'layered' ? LAYERED : FULL_CONTOUR, + manufacturingSteps: technique === 'layered' ? INDIRECT_LAYERED : INDIRECT_FULL, }; } export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [ - crown('pfm_crown', 1, ['milling_wet', 'build_up', 'stain', 'glaze', 'polish_prep']), - crown('pfz_crown', 2, ['milling_dry', 'sinter', 'build_up', 'stain', 'glaze', 'polish_prep']), - crown('monolithic_zirconia', 3, ['milling_dry', 'sinter', 'stain', 'glaze', 'polish_prep']), - crown('glass_ceramic_crown', 4, FULL_CONTOUR), - crown('full_metal_crown', 5, ['milling_wet', 'polish_prep']), - crown('temporary_resin_crown', 6, ['milling_wet', 'polish_prep']), - crown('pmma', 7, ['milling_dry', 'polish_prep']), - crown('peek_crown', 8, ['milling_dry', 'polish_prep']), - crown('press_ceramic', 9, ['printer_resin', 'pressing', 'stain', 'glaze', 'polish_prep']), + crown('pfm_crown', 1, [ + ...SCAN_DESIGN_MODEL, + 'milling_wet', + 'sinter', + 'build_up', + 'stain', + 'glaze', + ]), + crown('pfz_crown', 2, [ + ...SCAN_DESIGN_MODEL, + 'milling_dry', + 'sinter', + 'build_up', + 'stain', + 'glaze', + ]), + crown('monolithic_zirconia', 3, [ + ...SCAN_DESIGN_MODEL, + 'milling_dry', + 'sinter', + 'stain', + 'glaze', + ]), + crown('glass_ceramic_crown', 4, [ + ...SCAN_DESIGN_MODEL, + 'milling_dry', + 'sinter', + 'stain', + 'glaze', + ]), + crown('full_metal_crown', 5, [...SCAN_DESIGN_MODEL, 'milling_wet', 'polish_prep']), + crown('temporary_resin_crown', 6, ['intraoral_scan', 'design', 'milling_wet', 'polish_prep']), + crown('pmma', 7, ['intraoral_scan', 'design', 'milling_dry', 'polish_prep']), + crown('peek_crown', 8, ['intraoral_scan', 'design', 'milling_dry', 'polish_prep']), + crown('press_ceramic', 9, [ + 'intraoral_scan', + 'design', + 'printer_resin', + 'pressing', + 'stain', + 'glaze', + 'polish_prep', + ]), indirect('veneer', 'full_contour', 20), indirect('veneer', 'layered', 21), indirect('inlay', 'full_contour', 22), @@ -177,19 +242,31 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [ addonKind: 'post_core', manufacturingSteps: POST_CORE, }, - implantAddon('prefabricated_abutment', 40, ['polish_prep']), - implantAddon('ti_base_abutment', 41, ['polish_prep']), - implantAddon('multi_unit_abutment', 42, ['polish_prep']), - implantAddon('customized_abutment', 43, ['milling_wet', 'polish_prep']), - implantAddon('zirconia_abutment', 44, ['milling_dry', 'sinter', 'polish_prep']), - { - code: 'screw_retained', - sortOrder: 45, - category: 'implant', - chartRegion: 'crown', - stackGroup: 'restoration', - manufacturingSteps: ['milling_wet', 'polish_prep'], - }, + implantAddon('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, [ + 'intraoral_scan', + 'choosing_abutment', + 'model_create', + 'polish_prep', + ]), + implantAddon('customized_abutment', 43, [ + ...SCAN_DESIGN_MODEL, + 'milling_wet', + 'polish_prep', + ]), + implantAddon('zirconia_abutment', 44, [...SCAN_DESIGN_MODEL, 'milling_dry', 'sinter']), + implantAddon( + 'screw_retained', + 45, + [...SCAN_DESIGN_MODEL, 'milling_wet', 'polish_prep'], + { chartRegion: 'crown' }, + ), { code: 'complete_denture', sortOrder: 50, @@ -202,7 +279,7 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [ code: 'partial_denture', sortOrder: 51, category: 'removable', - chartRegion: 'arch', + chartRegion: 'crown', stackGroup: 'arch', manufacturingSteps: DENTURE, }, @@ -263,7 +340,7 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [ category: 'appliance', chartRegion: 'arch', stackGroup: 'arch', - manufacturingSteps: ['milling_dry', 'sinter'], + manufacturingSteps: ['intraoral_scan', 'design', 'milling_dry', 'sinter'], }, { code: 'surgical_guide', @@ -280,7 +357,7 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [ chartRegion: 'arch', stackGroup: 'arch', skipPackingShipping: true, - manufacturingSteps: [], + manufacturingSteps: ['intraoral_scan', 'design'], }, { code: 'mockup', @@ -288,7 +365,7 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [ category: 'digital', chartRegion: 'arch', stackGroup: 'arch', - manufacturingSteps: ['printer_resin'], + manufacturingSteps: ['intraoral_scan', 'design', 'model_create'], }, // Legacy leaves — labels for historical cases; hidden from the picker. { @@ -299,7 +376,7 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [ subcategory: 'veneer', chartRegion: 'crown', stackGroup: 'restoration', - manufacturingSteps: LAYERED, + manufacturingSteps: KEEP_LAYERED, }, { code: 'veneer_ips_press', @@ -309,7 +386,16 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [ subcategory: 'veneer', chartRegion: 'crown', stackGroup: 'restoration', - manufacturingSteps: ['printer_resin', 'build_up', 'stain', 'glaze', 'polish_prep', 'pressing'], + manufacturingSteps: [ + 'intraoral_scan', + 'design', + 'printer_resin', + 'build_up', + 'stain', + 'glaze', + 'polish_prep', + 'pressing', + ], }, { code: 'veneer_ips_cad', @@ -319,7 +405,7 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [ subcategory: 'veneer', chartRegion: 'crown', stackGroup: 'restoration', - manufacturingSteps: FULL_CONTOUR, + manufacturingSteps: KEEP_FULL_CONTOUR, }, { code: 'zirconia_overlay', @@ -329,7 +415,15 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [ subcategory: 'overlay', chartRegion: 'crown', stackGroup: 'restoration', - manufacturingSteps: ['milling_dry', 'sinter', 'stain', 'glaze', 'polish_prep'], + manufacturingSteps: [ + 'intraoral_scan', + 'design', + 'milling_dry', + 'sinter', + 'stain', + 'glaze', + 'polish_prep', + ], }, { code: 'ips_overlay', @@ -339,17 +433,16 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [ subcategory: 'overlay', chartRegion: 'crown', stackGroup: 'restoration', - manufacturingSteps: FULL_CONTOUR, + manufacturingSteps: KEEP_FULL_CONTOUR, }, ]; -const UNIVERSAL_PREFIX = ['intraoral_scan', 'design'] as const; -const UNIVERSAL_SUFFIX = ['packing', 'shipping'] as const; +const PACK_SHIP = ['packing', 'shipping'] as const; export function buildProsthesisStepCodes(type: ProsthesisTypeSeed): string[] { - const steps = [...UNIVERSAL_PREFIX, ...type.manufacturingSteps]; + const steps = [...type.manufacturingSteps]; if (!type.skipPackingShipping) { - steps.push(...UNIVERSAL_SUFFIX); + steps.push(...PACK_SHIP); } return steps; } @@ -427,7 +520,13 @@ export const PROSTHESIS_LABELS: Record> = { export const WORKFLOW_STEP_LABELS: Record> = { intraoral_scan: { en: 'Intraoral Scan', fa: 'اسکن داخل دهان', nl: 'Intraorale Scan' }, + choosing_abutment: { + en: 'Choosing Abutment', + fa: 'انتخاب اباتمنت', + nl: 'Abutment kiezen', + }, design: { en: 'Design', fa: 'طراحی', nl: 'Ontwerp' }, + model_create: { en: 'Model Create', fa: 'ساخت مدل', nl: 'Model maken' }, milling_dry: { en: 'Milling Dry', fa: 'فرز خشک', nl: 'Droog Frezen' }, milling_wet: { en: 'Milling Wet', fa: 'فرز تر', nl: 'Nat Frezen' }, printer_resin: { en: 'Printer Resin', fa: 'پرینتر رزین', nl: 'Harsprinter' }, diff --git a/backend/prisma/export-prosthesis-catalog.ts b/backend/prisma/export-prosthesis-catalog.ts index 79afe42..650ae86 100644 --- a/backend/prisma/export-prosthesis-catalog.ts +++ b/backend/prisma/export-prosthesis-catalog.ts @@ -15,7 +15,7 @@ import { const CATEGORY_LABELS: Record = { crown: 'Crowns', - indirect: 'Indirect restorations', + indirect: 'Veneer/Inlay/Onlay/Overlay', implant: 'Implants', post_core: 'Post & core', removable: 'Removable', @@ -39,17 +39,25 @@ const STACK_RULES = [ ['Slot', 'Rule'], [ 'restoration', - 'One per tooth: crown, veneer, inlay, onlay, overlay, or screw-retained.', + 'One per tooth: crown, veneer, inlay, onlay, or overlay.', ], - ['implant', 'One abutment add-on per tooth (Ti-base, custom, prefab, multi-unit, zirconia).'], - ['post_core', 'One post & core, only if a restoration is already present.'], + ['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.'], [ 'arch', - 'One arch product (denture, night guard, digital). Most arch products block implant add-ons; overdenture may take an implant add-on.', + 'Complete denture, overdenture, appliances, and digital use Upper/Lower arch labels (UA/LA). Not 16 FDI teeth.', + ], + [ + 'partial_denture', + 'Tooth-selected (FDI), not the arch labels. All selected teeth become one lab job. May stack with implant add-ons.', ], [ 'screw_retained', - 'Fills the restoration slot and blocks implant + post & core (it is the restoration).', + 'Fills the implant slot (chart paints the crown) and stacks with a restoration. Blocks post & core.', + ], + [ + '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.'], diff --git a/backend/src/common/prosthesis-group.ts b/backend/src/common/prosthesis-group.ts index 14963f6..6b899ab 100644 --- a/backend/src/common/prosthesis-group.ts +++ b/backend/src/common/prosthesis-group.ts @@ -1,33 +1,57 @@ import type { Prisma } from '@prisma/client'; -/** Union several workflows: scan/design first, packing/shipping last, other steps first-seen. */ -export function unionWorkflowStepCodes(lists: readonly (readonly string[])[]): string[] { - const middle: string[] = []; - const seenMiddle = new Set(); - let hasScan = false; - let hasDesign = false; - let hasPacking = false; - let hasShipping = false; +const UNION_PINNED_PREFIX = [ + 'intraoral_scan', + 'choosing_abutment', + 'design', + 'model_create', +] as const; +const UNION_SUFFIX = ['packing', 'shipping'] as const; + +const UNION_PINNED = new Set(UNION_PINNED_PREFIX); +const UNION_TAIL = new Set(UNION_SUFFIX); + +/** Remaining manufacturing steps — matches catalog LAB_WORKFLOW_STEPS order. */ +const UNION_MIDDLE_ORDER: Record = { + milling_dry: 1, + milling_wet: 2, + printer_resin: 3, + printer_metal: 4, + pressing: 5, + sinter: 6, + build_up: 7, + stain: 8, + glaze: 9, + polish_prep: 10, +}; + +/** Union stacked workflows: pin scan → abutment → design → model, then catalog order, pack/ship last. */ +export function unionWorkflowStepCodes(lists: readonly (readonly string[])[]): string[] { + const present = new Set(); for (const list of lists) { for (const code of list) { - if (code === 'intraoral_scan') hasScan = true; - else if (code === 'design') hasDesign = true; - else if (code === 'packing') hasPacking = true; - else if (code === 'shipping') hasShipping = true; - else if (!seenMiddle.has(code)) { - seenMiddle.add(code); - middle.push(code); - } + if (code) present.add(code); } } const out: string[] = []; - if (hasScan) out.push('intraoral_scan'); - if (hasDesign) out.push('design'); + for (const code of UNION_PINNED_PREFIX) { + if (present.has(code)) out.push(code); + } + + const middle = [...present].filter((code) => !UNION_PINNED.has(code) && !UNION_TAIL.has(code)); + middle.sort((a, b) => { + const da = UNION_MIDDLE_ORDER[a] ?? 100; + const db = UNION_MIDDLE_ORDER[b] ?? 100; + if (da !== db) return da - db; + return a.localeCompare(b); + }); out.push(...middle); - if (hasPacking) out.push('packing'); - if (hasShipping) out.push('shipping'); + + for (const code of UNION_SUFFIX) { + if (present.has(code)) out.push(code); + } return out; } diff --git a/backend/src/modules/cases/lab-case-task.generator.spec.ts b/backend/src/modules/cases/lab-case-task.generator.spec.ts index 4622720..cc12156 100644 --- a/backend/src/modules/cases/lab-case-task.generator.spec.ts +++ b/backend/src/modules/cases/lab-case-task.generator.spec.ts @@ -192,10 +192,9 @@ describe('generateLabCaseTasks', () => { expect(stepCodes).toEqual(['intraoral_scan', 'design']); expect(stepCodes).not.toContain('packing'); expect(stepCodes).not.toContain('shipping'); - expect(stepCodes).not.toContain('printer_resin'); }); - it('keeps connected and single teeth as separate per-tooth task sets', async () => { + it('merges connected teeth that share a prosthesis type into one task set', async () => { const pfmSteps = stepsFromSeed('pfm_crown'); const { tx, created } = buildMockTx({ toothProsthesisRows: [ @@ -222,10 +221,59 @@ describe('generateLabCaseTasks', () => { }); const count = await generateLabCaseTasks(tx as never, 'lab-case-merge', 'en'); - expect(count).toBe(pfmSteps.length * 3); + expect(count).toBe(pfmSteps.length * 2); const rows = created as Array<{ teeth: string[]; prosthesisTypeCode: string }>; const teethSets = new Set(rows.map((r) => r.teeth.join(','))); - expect(teethSets).toEqual(new Set(['14', '15', '21'])); + expect(teethSets).toEqual(new Set(['14,15', '21'])); + }); + + it('keeps an extra type on one connected tooth as its own task set', async () => { + const pfmSteps = stepsFromSeed('pfm_crown'); + const postSteps = stepsFromSeed('fiber_post_core'); + const { tx, created } = buildMockTx({ + toothProsthesisRows: [ + { + treatmentDetailId: 'detail-1', + tooth: '14', + prosthesisTypeCode: 'pfm_crown', + selectionGroupId: 'connected-1', + }, + { + treatmentDetailId: 'detail-1', + tooth: '14', + prosthesisTypeCode: 'fiber_post_core', + selectionGroupId: 'connected-1', + }, + { + treatmentDetailId: 'detail-1', + tooth: '15', + prosthesisTypeCode: 'pfm_crown', + selectionGroupId: 'connected-1', + }, + { + treatmentDetailId: 'detail-1', + tooth: '16', + prosthesisTypeCode: 'pfm_crown', + selectionGroupId: 'connected-1', + }, + ], + prosthesisTypes: [ + { code: 'pfm_crown', steps: pfmSteps }, + { code: 'fiber_post_core', steps: postSteps }, + ], + }); + + await generateLabCaseTasks(tx as never, 'lab-case-bridge-addon', 'en'); + const rows = created as Array<{ + teeth: string[]; + prosthesisTypeCode: string; + }>; + const byType = new Map(); + for (const row of rows) { + byType.set(row.prosthesisTypeCode, row.teeth.join(',')); + } + expect(byType.get('pfm_crown')).toBe('14,15,16'); + expect(byType.get('fiber_post_core')).toBe('14'); }); it('skips generation when tasks already exist', async () => { @@ -313,5 +361,121 @@ describe('generateLabCaseTasks', () => { expect(stepCodes).toContain('milling_dry'); expect(stepCodes).toContain('sinter'); expect(stepCodes).toContain('polish_prep'); + expect(stepCodes).toContain('model_create'); + expect(stepCodes).toContain('design'); + expect(stepCodes.indexOf('model_create')).toBeGreaterThan(stepCodes.indexOf('design')); + }); + + it('omits design for prefab abutments and inserts choosing_abutment before model_create', async () => { + const prefabSteps = stepsFromSeed('prefabricated_abutment'); + const { tx, created } = buildMockTx({ + toothProsthesisRows: [ + { + treatmentDetailId: 'detail-1', + tooth: '36', + prosthesisTypeCode: 'prefabricated_abutment', + }, + ], + prosthesisTypes: [{ code: 'prefabricated_abutment', steps: prefabSteps }], + }); + + await generateLabCaseTasks(tx as never, 'lab-case-prefab', 'en'); + const stepCodes = (created as Array<{ workflowStepCode: string }>).map( + (row) => row.workflowStepCode, + ); + expect(stepCodes).toEqual([ + 'intraoral_scan', + 'choosing_abutment', + 'model_create', + 'polish_prep', + 'packing', + 'shipping', + ]); + expect(stepCodes).not.toContain('design'); + }); + + it('pins choosing_abutment before design when stacking prefab with a crown', async () => { + const prefabSteps = stepsFromSeed('prefabricated_abutment'); + const zrSteps = stepsFromSeed('monolithic_zirconia'); + const { tx, created } = buildMockTx({ + toothProsthesisRows: [ + { + treatmentDetailId: 'detail-1', + tooth: '46', + prosthesisTypeCode: 'prefabricated_abutment', + }, + { + treatmentDetailId: 'detail-1', + tooth: '46', + prosthesisTypeCode: 'monolithic_zirconia', + }, + ], + prosthesisTypes: [ + { code: 'prefabricated_abutment', steps: prefabSteps }, + { code: 'monolithic_zirconia', steps: zrSteps }, + ], + }); + + await generateLabCaseTasks(tx as never, 'lab-case-union-pin', 'en'); + const stepCodes = (created as Array<{ workflowStepCode: string }>).map( + (row) => row.workflowStepCode, + ); + expect(stepCodes[0]).toBe('intraoral_scan'); + expect(stepCodes.indexOf('choosing_abutment')).toBeLessThan(stepCodes.indexOf('design')); + expect(stepCodes.indexOf('design')).toBeLessThan(stepCodes.indexOf('model_create')); + expect(stepCodes.filter((c) => c === 'intraoral_scan')).toHaveLength(1); + }); + + it('merges partial_denture rows on the same sourceKey into one task set', async () => { + const partialSteps = stepsFromSeed('partial_denture'); + const { tx, created } = buildMockTx({ + toothProsthesisRows: [ + { treatmentDetailId: 'detail-1', tooth: '14', prosthesisTypeCode: 'partial_denture' }, + { treatmentDetailId: 'detail-1', tooth: '15', prosthesisTypeCode: 'partial_denture' }, + { treatmentDetailId: 'detail-1', tooth: '16', prosthesisTypeCode: 'partial_denture' }, + { treatmentDetailId: 'detail-1', tooth: '17', prosthesisTypeCode: 'partial_denture' }, + { treatmentDetailId: 'detail-1', tooth: '18', prosthesisTypeCode: 'partial_denture' }, + ], + prosthesisTypes: [{ code: 'partial_denture', steps: partialSteps }], + }); + + const count = await generateLabCaseTasks(tx as never, 'lab-case-partial', 'en'); + expect(count).toBe(partialSteps.length); + const rows = created as Array<{ + teeth: string[]; + tooth: string; + prosthesisTypeCode: string; + }>; + expect(rows.every((r) => r.prosthesisTypeCode === 'partial_denture')).toBe(true); + expect(rows.every((r) => r.teeth.join(',') === '14,15,16,17,18')).toBe(true); + expect(new Set(rows.map((r) => r.tooth)).size).toBe(1); + }); + + it('keeps crowns on abutment teeth as separate per-tooth jobs beside a merged partial', async () => { + const partialSteps = stepsFromSeed('partial_denture'); + const pfmSteps = stepsFromSeed('pfm_crown'); + const { tx, created } = buildMockTx({ + toothProsthesisRows: [ + { treatmentDetailId: 'detail-1', tooth: '14', prosthesisTypeCode: 'partial_denture' }, + { treatmentDetailId: 'detail-1', tooth: '15', prosthesisTypeCode: 'partial_denture' }, + { treatmentDetailId: 'detail-1', tooth: '14', prosthesisTypeCode: 'pfm_crown' }, + ], + prosthesisTypes: [ + { code: 'partial_denture', steps: partialSteps }, + { code: 'pfm_crown', steps: pfmSteps }, + ], + }); + + await generateLabCaseTasks(tx as never, 'lab-case-partial-crown', 'en'); + const rows = created as Array<{ + teeth: string[]; + prosthesisTypeCode: string; + }>; + const partialRows = rows.filter((r) => r.prosthesisTypeCode === 'partial_denture'); + const crownRows = rows.filter((r) => r.prosthesisTypeCode === 'pfm_crown'); + expect(partialRows).toHaveLength(partialSteps.length); + expect(partialRows.every((r) => r.teeth.join(',') === '14,15')).toBe(true); + expect(crownRows).toHaveLength(pfmSteps.length); + expect(crownRows.every((r) => r.teeth.join(',') === '14')).toBe(true); }); }); diff --git a/backend/src/modules/cases/lab-case-task.generator.ts b/backend/src/modules/cases/lab-case-task.generator.ts index af904f8..670c9a6 100644 --- a/backend/src/modules/cases/lab-case-task.generator.ts +++ b/backend/src/modules/cases/lab-case-task.generator.ts @@ -4,6 +4,9 @@ import { prosthesisGroupKey, unionWorkflowStepCodes, } from '../../common/prosthesis-group'; +import { sortProsthesisTeeth } from './lab-case-task.util'; + +const PARTIAL_DENTURE_CODE = 'partial_denture'; type TransactionClient = Prisma.TransactionClient; @@ -64,7 +67,10 @@ export async function generateLabCaseTasks( const stepLabels = await resolveStepLabels(tx, allStepCodes, locale); // Per tooth: stacked types become one group key; steps are the union. - // Teeth are not merged even when they share the same type-set. + // Unconnected teeth stay separate even when they share a type. + // Connected (bridge) teeth that share a type merge into one pipeline; an extra + // type on one unit of that bridge is its own pipeline. partial_denture is one + // lab item per sourceKey. type ToothBucket = { sourceKey: string; treatmentDetailId: string | null; @@ -104,14 +110,140 @@ export async function generateLabCaseTasks( byTooth.set(toothKey, bucket); } - // One lab item per tooth (or arch sentinel): stacked types on that - // tooth become one prosthesisTypeCode group key; steps are the union. - const taskRows: Prisma.LabCaseTaskCreateManyInput[] = []; + const teethByBridge = new Map>(); + for (const [toothKey, bucket] of byTooth) { + if (!bucket.selectionGroupId) continue; + const tooth = toothKey.slice(bucket.sourceKey.length + 2); + const bridgeKey = `${bucket.sourceKey}::${bucket.selectionGroupId}`; + const set = teethByBridge.get(bridgeKey) ?? new Set(); + set.add(tooth); + teethByBridge.set(bridgeKey, set); + } + const bridgeKeys = new Set( + [...teethByBridge.entries()] + .filter(([, teeth]) => teeth.size > 1) + .map(([key]) => key), + ); + + type Pipeline = { + sourceKey: string; + treatmentDetailId: string | null; + lineId: string | null; + treatmentType: string; + selectionGroupId: string; + tooth: string; + teeth: string[]; + codes: string[]; + }; + + type PartialMerge = { + sourceKey: string; + treatmentDetailId: string | null; + lineId: string | null; + treatmentType: string; + teeth: string[]; + groupIds: Set; + }; + + type ConnectedTypeMerge = { + sourceKey: string; + treatmentDetailId: string | null; + lineId: string | null; + treatmentType: string; + selectionGroupId: string; + code: string; + teeth: string[]; + }; + + const pipelines: Pipeline[] = []; + const partialMerges = new Map(); + const connectedTypeMerges = new Map(); for (const [toothKey, bucket] of byTooth) { const tooth = toothKey.slice(bucket.sourceKey.length + 2); - const codes = [...bucket.codes]; - const lists = codes.map((code) => + const codes = new Set(bucket.codes); + if (codes.has(PARTIAL_DENTURE_CODE)) { + codes.delete(PARTIAL_DENTURE_CODE); + const merge = partialMerges.get(bucket.sourceKey) ?? { + sourceKey: bucket.sourceKey, + treatmentDetailId: bucket.treatmentDetailId, + lineId: bucket.lineId, + treatmentType: bucket.treatmentType, + teeth: [], + groupIds: new Set(), + }; + merge.teeth.push(tooth); + if (bucket.selectionGroupId) merge.groupIds.add(bucket.selectionGroupId); + partialMerges.set(bucket.sourceKey, merge); + } + if (codes.size === 0) continue; + + const bridgeKey = bucket.selectionGroupId + ? `${bucket.sourceKey}::${bucket.selectionGroupId}` + : ''; + if (bridgeKey && bridgeKeys.has(bridgeKey)) { + for (const code of codes) { + const typeKey = `${bridgeKey}::${code}`; + const merge = connectedTypeMerges.get(typeKey) ?? { + sourceKey: bucket.sourceKey, + treatmentDetailId: bucket.treatmentDetailId, + lineId: bucket.lineId, + treatmentType: bucket.treatmentType, + selectionGroupId: bucket.selectionGroupId, + code, + teeth: [], + }; + if (!merge.teeth.includes(tooth)) merge.teeth.push(tooth); + connectedTypeMerges.set(typeKey, merge); + } + continue; + } + + pipelines.push({ + sourceKey: bucket.sourceKey, + treatmentDetailId: bucket.treatmentDetailId, + lineId: bucket.lineId, + treatmentType: bucket.treatmentType, + selectionGroupId: bucket.selectionGroupId, + tooth, + teeth: [tooth], + codes: [...codes], + }); + } + + for (const merge of connectedTypeMerges.values()) { + const teeth = sortProsthesisTeeth(merge.teeth); + pipelines.push({ + sourceKey: merge.sourceKey, + treatmentDetailId: merge.treatmentDetailId, + lineId: merge.lineId, + treatmentType: merge.treatmentType, + selectionGroupId: merge.selectionGroupId, + tooth: teeth[0] ?? '', + teeth, + codes: [merge.code], + }); + } + + for (const merge of partialMerges.values()) { + const teeth = sortProsthesisTeeth(merge.teeth); + const sharedGroup = merge.groupIds.size === 1 ? [...merge.groupIds][0] : ''; + pipelines.push({ + sourceKey: merge.sourceKey, + treatmentDetailId: merge.treatmentDetailId, + lineId: merge.lineId, + treatmentType: merge.treatmentType, + selectionGroupId: sharedGroup, + tooth: teeth[0] ?? '', + teeth, + codes: [PARTIAL_DENTURE_CODE], + }); + } + + const taskRows: Prisma.LabCaseTaskCreateManyInput[] = []; + + for (const pipeline of pipelines) { + const lists = pipeline.codes.map((code) => (stepsByProsthesisCode.get(code) ?? []).map((s) => s.workflowStepCode), ); const unioned = unionWorkflowStepCodes(lists); @@ -119,19 +251,19 @@ export async function generateLabCaseTasks( continue; } - const groupCode = prosthesisGroupKey(codes); + const groupCode = prosthesisGroupKey(pipeline.codes); unioned.forEach((workflowStepCode, index) => { taskRows.push({ labCaseId, - treatmentDetailId: bucket.treatmentDetailId, - lineId: bucket.lineId, - sourceKey: bucket.sourceKey, - tooth, - teeth: [tooth], - treatmentType: bucket.treatmentType, + treatmentDetailId: pipeline.treatmentDetailId, + lineId: pipeline.lineId, + sourceKey: pipeline.sourceKey, + tooth: pipeline.tooth, + teeth: pipeline.teeth, + treatmentType: pipeline.treatmentType, prosthesisTypeCode: groupCode, - selectionGroupId: bucket.selectionGroupId, + selectionGroupId: pipeline.selectionGroupId, workflowStepCode, stepOrder: index + 1, stepLabel: stepLabels.get(workflowStepCode) ?? workflowStepCode, diff --git a/backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts b/backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts index a0cc311..c35e119 100644 --- a/backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts +++ b/backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts @@ -74,7 +74,7 @@ export class ProsthesisCatalogService implements OnModuleInit { } async list(localeInput?: string | null): Promise { - this.ensureLoaded(); + await this.refresh(); const locale = normalizeCatalogLocale(localeInput); const codes = [...this.byCode.keys()]; const labels = await this.catalogLabels.resolveLabels( diff --git a/backend/src/modules/treatments/treatments.service.ts b/backend/src/modules/treatments/treatments.service.ts index 21be004..ff0ef68 100644 --- a/backend/src/modules/treatments/treatments.service.ts +++ b/backend/src/modules/treatments/treatments.service.ts @@ -785,7 +785,8 @@ export class TreatmentsService { throw new AppException(ErrorCode.TREATMENT_TOOTH_UNKNOWN_DETAIL, HttpStatus.BAD_REQUEST); } const teeth = normalizeTeeth(detail.teeth); - if (!teeth.includes(row.tooth)) { + const isArchSentinel = row.tooth === 'UA' || row.tooth === 'LA'; + if (!isArchSentinel && !teeth.includes(row.tooth)) { throw new AppException(ErrorCode.TREATMENT_TOOTH_NOT_ON_DETAIL, HttpStatus.BAD_REQUEST); } this.prosthesisCatalog.assertKnownProsthesisType(row.prosthesisTypeCode); diff --git a/docs/prosthesis-catalog.xlsx b/docs/prosthesis-catalog.xlsx index e71778e884c1931e2e36ee6d8d41fd8edadce456..5725461f314355dcfc306011dfbec55b9c771e1a 100644 GIT binary patch delta 14996 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 PD4Fybh?xEPzux}~ZGBx8 delta 14269 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 W;(Py}P>$$-gbt)>2*o7+wftX9Z>==| diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 4b1f0f1..a44d166 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -877,7 +877,7 @@ "labCommentsMovedHint": "Progress and comments are in Lab shipments.", "toothChartTitleCompact": "Tooth chart", "toothChartHint": "Click a tooth to select or deselect it. Hold Shift and click another tooth to select a range. Click the circle between neighbors to connect or disconnect a bridge.", - "toothChartHintAssign": "Click a tooth to assign work. Shift-click a second tooth to select a range, then click any tooth in that range to assign to all of them. Hold Ctrl or drag to copy the last type set. Click Upper arch or Lower arch to assign a jaw-level product. Click the circle between neighbors to connect or disconnect a bridge.", + "toothChartHintAssign": "Click a tooth to assign work. Shift-click a second tooth to select a range — the picker opens for that span. Click a selected tooth with no type to deselect it; Clear removes the whole range. Hold Ctrl or drag to copy the last type set. Click Upper arch or Lower arch to assign a jaw-level product. Click the circle between neighbors to connect or disconnect a bridge.", "toothChartHelpAria": "How to select teeth", "toothConnectedHint": "Connected selection", "toothLinkHint": "Connect these teeth", @@ -1104,7 +1104,7 @@ "arch_lower": "Lower", "arch_both": "Both", "category_crown": "Crowns", - "category_indirect": "Indirect restorations", + "category_indirect": "Veneer/Inlay/Onlay/Overlay", "category_implant": "Implants", "category_post_core": "Post & core", "category_removable": "Removable", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 7a4bb06..9ab95ed 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -872,7 +872,7 @@ "toothChartTitle": "نمودار دندان‌ها FDI", "toothChartTitleCompact": "نمودار دندان", "toothChartHint": "برای انتخاب یا لغو انتخاب روی دندان کلیک کنید. Shift را نگه دارید و روی دندان دیگر کلیک کنید تا یک بازه انتخاب شود. روی دایره بین همسایه‌ها کلیک کنید تا بریج وصل یا قطع شود.", - "toothChartHintAssign": "برای اختصاص کار روی دندان کلیک کنید. Shift را نگه دارید و روی دندان دوم کلیک کنید تا یک بازه انتخاب شود، سپس روی هر دندان آن بازه کلیک کنید تا به همه اختصاص داده شود. Ctrl را نگه دارید یا بکشید تا آخرین مجموعه نوع کپی شود. روی «قوس بالا» یا «قوس پایین» کلیک کنید تا محصول فکی اختصاص داده شود. روی دایره بین همسایه‌ها کلیک کنید تا بریج وصل یا قطع شود.", + "toothChartHintAssign": "برای اختصاص کار روی دندان کلیک کنید. Shift را نگه دارید و روی دندان دوم کلیک کنید تا یک بازه انتخاب شود — انتخاب‌گر برای همان بازه باز می‌شود. روی دندان انتخاب‌شدهٔ بدون نوع کلیک کنید تا از انتخاب خارج شود؛ پاک‌کردن کل بازه را برمی‌دارد. Ctrl را نگه دارید یا بکشید تا آخرین مجموعه نوع کپی شود. روی «قوس بالا» یا «قوس پایین» کلیک کنید تا محصول فکی اختصاص داده شود. روی دایره بین همسایه‌ها کلیک کنید تا بریج وصل یا قطع شود.", "toothChartHelpAria": "راهنمای انتخاب دندان", "toothConnectedHint": "انتخاب متصل", "toothLinkHint": "اتصال این دندان‌ها", @@ -1105,7 +1105,7 @@ "arch_lower": "فک پایین", "arch_both": "هر دو", "category_crown": "روکش‌ها", - "category_indirect": "ترمیم‌های غیرمستقیم", + "category_indirect": "ونیر/اینلی/آنلی/اورلی", "category_implant": "ایمپلنت", "category_post_core": "پست و کور", "category_removable": "متحرک", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 948416a..046fae0 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -871,7 +871,7 @@ "toothChartTitle": "FDI-tanddiagram", "toothChartTitleCompact": "Tanddiagram", "toothChartHint": "Klik op een tand om deze te selecteren of te deselecteren. Houd Shift ingedrukt en klik op een andere tand om een bereik te selecteren. Klik op de cirkel tussen buren om een brug te verbinden of te ontkoppelen.", - "toothChartHintAssign": "Klik op een tand om werk toe te wijzen. Shift-klik op een tweede tand om een bereik te selecteren; klik daarna op een tand in dat bereik om aan allemaal toe te wijzen. Houd Ctrl ingedrukt of sleep om de laatste typeset te kopiëren. Klik op Bovenboog of Onderboog voor een boogproduct. Klik op de cirkel tussen buren om een brug te verbinden of te ontkoppelen.", + "toothChartHintAssign": "Klik op een tand om werk toe te wijzen. Shift-klik op een tweede tand om een bereik te selecteren — de kiezer opent voor dat bereik. Klik op een geselecteerde tand zonder type om die te deselecteren; Wissen verwijdert het hele bereik. Houd Ctrl ingedrukt of sleep om de laatste typeset te kopiëren. Klik op Bovenboog of Onderboog voor een boogproduct. Klik op de cirkel tussen buren om een brug te verbinden of te ontkoppelen.", "toothChartHelpAria": "Tanden selecteren", "toothConnectedHint": "Verbonden selectie", "toothLinkHint": "Deze tanden verbinden", @@ -1104,7 +1104,7 @@ "arch_lower": "Onder", "arch_both": "Beide", "category_crown": "Kronen", - "category_indirect": "Indirecte restauraties", + "category_indirect": "Veneer/Inlay/Onlay/Overlay", "category_implant": "Implantaten", "category_post_core": "Stiftopbouw", "category_removable": "Uitneembaar", diff --git a/frontend/src/components/treatment/prosthesisTree.ts b/frontend/src/components/treatment/prosthesisTree.ts index fa73eda..0102883 100644 --- a/frontend/src/components/treatment/prosthesisTree.ts +++ b/frontend/src/components/treatment/prosthesisTree.ts @@ -14,7 +14,7 @@ const PROSTHESIS_CATEGORY_ORDER = [ 'digital', ] as const; -export const TOOTH_CATEGORIES = ['implant', 'crown', 'indirect', 'post_core'] as const; +export const TOOTH_CATEGORIES = ['implant', 'crown', 'indirect', 'post_core', 'removable'] as const; export const ARCH_CATEGORIES = ['removable', 'appliance', 'digital'] as const; /** Not FDI — jaw-level prosthesis rows on LabCaseToothProsthesis.tooth */ @@ -40,11 +40,32 @@ export function catalogByCode( return new Map(catalog.map((entry) => [entry.code, entry])); } +export const PARTIAL_DENTURE_CODE = 'partial_denture'; + +export function effectiveChartRegion( + entry: Pick, +): string { + if (entry.code === PARTIAL_DENTURE_CODE) return 'crown'; + return entry.chartRegion; +} + +export function matchesPickerScope( + entry: Pick, + scope?: PickerScope, +): boolean { + if (!scope) return true; + const region = effectiveChartRegion(entry); + if (scope === 'tooth') return region !== 'arch'; + return region === 'arch'; +} + export function categoryCodesInCatalog( catalog: readonly ProsthesisCatalogEntry[], scope?: PickerScope, ): string[] { - const present = new Set(catalog.map((e) => e.category)); + const present = new Set( + catalog.filter((e) => matchesPickerScope(e, scope)).map((e) => e.category), + ); const order = scope === 'tooth' ? TOOTH_CATEGORIES @@ -57,10 +78,16 @@ export function categoryCodesInCatalog( export function subcategoriesFor( catalog: readonly ProsthesisCatalogEntry[], category: string, + scope?: PickerScope, ): string[] { const set = new Set( catalog - .filter((e) => e.category === category && e.subcategory) + .filter( + (e) => + e.category === category && + e.subcategory && + matchesPickerScope(e, scope), + ) .map((e) => e.subcategory), ); return [...set].sort(); @@ -70,12 +97,14 @@ export function leavesFor( catalog: readonly ProsthesisCatalogEntry[], category: string, subcategory?: string, + scope?: PickerScope, ): ProsthesisCatalogEntry[] { return catalog .filter((e) => { if (e.category !== category) return false; + if (!matchesPickerScope(e, scope)) return false; if (subcategory) return e.subcategory === subcategory; - const subs = subcategoriesFor(catalog, category); + const subs = subcategoriesFor(catalog, category, scope); if (subs.length === 0) return true; return !e.subcategory; }) @@ -102,12 +131,6 @@ export function applyLeafToJobs( let jobs = existingCodes.filter((code) => { const cur = byCode.get(code); if (!cur) return false; - if (next.code === 'screw_retained') { - return cur.stackGroup !== 'restoration' && cur.stackGroup !== 'implant'; - } - if (cur.code === 'screw_retained' && (next.stackGroup === 'restoration' || next.stackGroup === 'implant')) { - return false; - } return cur.stackGroup !== next.stackGroup; }); @@ -119,10 +142,10 @@ export function jobsAllowImplantAddon( jobCodes: readonly string[], byCode: Map, ): boolean { - if (jobCodes.includes('screw_retained')) return false; const hasArchBlocking = jobCodes.some((code) => { const e = byCode.get(code); - return e?.stackGroup === 'arch' && e.code !== 'overdenture'; + if (!e || e.stackGroup !== 'arch') return false; + return e.code !== 'overdenture' && e.code !== PARTIAL_DENTURE_CODE; }); if (hasArchBlocking) return false; return true; @@ -225,8 +248,9 @@ export function toothRegionColors( const entry = byCode.get(row.prosthesisTypeCode); const color = prosthesisTypeColorFromCatalog(row.prosthesisTypeCode, catalog); const fdi = row.tooth as FdiToothId; - if (entry?.chartRegion === 'root') root[fdi] = color; - else if (entry?.chartRegion === 'arch') { + const region = entry ? effectiveChartRegion(entry) : 'crown'; + if (region === 'root') root[fdi] = color; + else if (region === 'arch') { crown[fdi] = crown[fdi] ?? color; root[fdi] = root[fdi] ?? color; } else { @@ -309,29 +333,80 @@ function sortProsthesisTeeth(teeth: readonly string[]): string[] { }); } +export type ProsthesisJobDisplayRow = { + teeth: string[]; + codes: string[]; + connected: boolean; +}; + +/** + * Dispatch / summary rows. + * Connected teeth that share a type collapse to one row (bridge). An extra type on + * one unit of that bridge is its own row. Unconnected teeth stay one row per tooth + * (stacked codes stay together). + */ export function toothJobRowsForDetail( rows: readonly Pick< LabCaseToothProsthesisDraft, 'tooth' | 'prosthesisTypeCode' | 'detailClientId' | 'selectionGroupId' >[], detailClientId: string, - connectedGroupIds: ReadonlySet = new Set(), -): Array<{ tooth: string; codes: string[]; connected: boolean }> { - const byTooth = new Map(); +): ProsthesisJobDisplayRow[] { + const byTooth = new Map(); for (const row of rows) { if (row.detailClientId !== detailClientId || !row.prosthesisTypeCode) continue; - const entry = byTooth.get(row.tooth) ?? { codes: [], connected: false }; + const entry = byTooth.get(row.tooth) ?? { codes: [], selectionGroupId: '' }; if (!entry.codes.includes(row.prosthesisTypeCode)) { entry.codes.push(row.prosthesisTypeCode); } - if (row.selectionGroupId && connectedGroupIds.has(row.selectionGroupId)) { - entry.connected = true; + const groupId = row.selectionGroupId?.trim() || ''; + if (!entry.selectionGroupId && groupId) { + entry.selectionGroupId = groupId; } byTooth.set(row.tooth, entry); } - return sortProsthesisTeeth([...byTooth.keys()]).map((tooth) => { - const entry = byTooth.get(tooth)!; - return { tooth, codes: entry.codes, connected: entry.connected }; + + const teethByGroup = new Map(); + for (const [tooth, entry] of byTooth) { + if (!entry.selectionGroupId) continue; + const list = teethByGroup.get(entry.selectionGroupId) ?? []; + list.push(tooth); + teethByGroup.set(entry.selectionGroupId, list); + } + const bridgeGroupIds = new Set( + [...teethByGroup.entries()] + .filter(([, teeth]) => teeth.length > 1) + .map(([groupId]) => groupId), + ); + + const connectedByType = new Map(); + const singles: ProsthesisJobDisplayRow[] = []; + + for (const [tooth, entry] of byTooth) { + if (entry.selectionGroupId && bridgeGroupIds.has(entry.selectionGroupId)) { + for (const code of entry.codes) { + const key = `${entry.selectionGroupId}::${code}`; + const group = connectedByType.get(key) ?? { code, teeth: [] }; + if (!group.teeth.includes(tooth)) group.teeth.push(tooth); + connectedByType.set(key, group); + } + continue; + } + singles.push({ teeth: [tooth], codes: entry.codes, connected: false }); + } + + const connectedRows: ProsthesisJobDisplayRow[] = [...connectedByType.values()].map((group) => { + const teeth = sortProsthesisTeeth(group.teeth); + return { teeth, codes: [group.code], connected: teeth.length > 1 }; + }); + + return [...connectedRows, ...singles].sort((a, b) => { + const toothA = a.teeth[0] ?? ''; + const toothB = b.teeth[0] ?? ''; + if (toothA !== toothB) { + return sortProsthesisTeeth([toothA, toothB])[0] === toothA ? -1 : 1; + } + return (a.codes[0] ?? '').localeCompare(b.codes[0] ?? ''); }); } diff --git a/frontend/src/components/treatment/prosthesisTypeDisplay.ts b/frontend/src/components/treatment/prosthesisTypeDisplay.ts index ab2fe0d..8fa8f98 100644 --- a/frontend/src/components/treatment/prosthesisTypeDisplay.ts +++ b/frontend/src/components/treatment/prosthesisTypeDisplay.ts @@ -49,6 +49,84 @@ export function prosthesisTypeColorFromCatalog( return prosthesisTypeColor(code, prosthesisCatalogColorIndex(code, catalog)); } +function hexToRgb(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 rgbToHsl(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 hslToHex(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('')}`; +} + +/** Darker same-hue tone for icons sitting on a pastel fill (readable in light and dark). */ +export function prosthesisTypeFillAccent(hex: string): string { + const rgb = hexToRgb(hex); + if (!rgb) return '#334155'; + const [h, s, l] = rgbToHsl(rgb[0], rgb[1], rgb[2]); + const nextS = Math.min(1, Math.max(s, 0.4) + 0.14); + const nextL = Math.min(0.38, Math.max(0.24, l * 0.42)); + return hslToHex(h, nextS, nextL); +} + +/** Boost washed catalog pastels so type-name text stays hued on page backgrounds. */ +function prosthesisTypeLabelSwatch(hex: string): string { + const rgb = hexToRgb(hex); + if (!rgb) return hex; + const [h, s, l] = rgbToHsl(rgb[0], rgb[1], rgb[2]); + const nextS = Math.min(1, Math.max(s, 0.42) + 0.1); + const nextL = l > 0.72 ? 0.58 : l < 0.38 ? 0.5 : l; + return hslToHex(h, nextS, nextL); +} + +/** + * Type-name color on page backgrounds. Mixes the catalog swatch with theme text + * so the hue stays recognizable in both light and dark. + */ +export function prosthesisTypeLabelStyleFromCatalog( + code: string, + catalog: readonly Pick[], +): CSSProperties { + const swatch = prosthesisTypeLabelSwatch(prosthesisTypeColorFromCatalog(code, catalog)); + return { + ['--prosthesis-swatch' as string]: swatch, + color: 'color-mix(in srgb, var(--prosthesis-swatch) 80%, var(--color-text-primary))', + }; +} + /** Filled swatch (small indicator dots). */ export function prosthesisTypeSwatchStyle(code: string, index = 0): CSSProperties { return { backgroundColor: prosthesisTypeColor(code, index), borderColor: 'rgba(0, 0, 0, 0.18)' }; diff --git a/frontend/src/components/ui/lab/CaseCreatePanel.tsx b/frontend/src/components/ui/lab/CaseCreatePanel.tsx index c1a4d63..0a6947d 100644 --- a/frontend/src/components/ui/lab/CaseCreatePanel.tsx +++ b/frontend/src/components/ui/lab/CaseCreatePanel.tsx @@ -170,11 +170,8 @@ export function CaseCreatePanel({ ? activeLine.toothSelectionGroups : groupsFromFlatTeeth(activeLine?.teeth ?? []); - const connectedGroupIds = new Set( - groups.filter((group) => group.kind === 'connected').map((group) => group.groupId), - ); const toothJobRows = activeLine - ? toothJobRowsForDetail(activeLine.toothProsthesis, activeLine.clientId, connectedGroupIds) + ? toothJobRowsForDetail(activeLine.toothProsthesis, activeLine.clientId) : []; const buildPayload = useCallback( diff --git a/frontend/src/components/ui/lab/CaseDetailPanel.tsx b/frontend/src/components/ui/lab/CaseDetailPanel.tsx index 7da3c26..b10a686 100644 --- a/frontend/src/components/ui/lab/CaseDetailPanel.tsx +++ b/frontend/src/components/ui/lab/CaseDetailPanel.tsx @@ -18,10 +18,8 @@ import { import { labTaskStatusVariant } from '@/components/lab/labTaskStatusDisplay'; import { LabCaseDueDateBadge } from '@/components/lab/LabCaseDueDateBadge'; import { LabCaseOriginBadge } from '@/components/ui/lab/LabCaseOriginBadge'; -import { - formatToothList, - prosthesisTypeBadgeStyleFromCatalog, -} from '@/components/treatment/prosthesisTypeDisplay'; +import { formatToothList } from '@/components/treatment/prosthesisTypeDisplay'; +import { ProsthesisStackedTypeLabel } from '@/components/ui/lab/ProsthesisStackedTypeLabel'; import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog'; import { buildCaseConnectedTeeth, @@ -313,23 +311,16 @@ export function CaseDetailPanel({ className="rounded-md border border-border p-3 space-y-2" >
- - {group.prosthesisTypeLabel} - - - {t('toothGroupTitle', { - teeth: formatToothList(group.teeth, { - UA: tTreatment('selectedArchUpper'), - LA: tTreatment('selectedArchLower'), - }), - prosthesis: group.prosthesisTypeLabel, + /> + + {formatToothList(group.teeth, { + UA: tTreatment('selectedArchUpper'), + LA: tTreatment('selectedArchLower'), })} {group.connected ? : null} diff --git a/frontend/src/components/ui/lab/LabCaseProsthesisGroupsList.tsx b/frontend/src/components/ui/lab/LabCaseProsthesisGroupsList.tsx index f0df065..e0f9eb0 100644 --- a/frontend/src/components/ui/lab/LabCaseProsthesisGroupsList.tsx +++ b/frontend/src/components/ui/lab/LabCaseProsthesisGroupsList.tsx @@ -5,8 +5,9 @@ import { ConnectedSelectionBadge } from '@/components/ui/treatment/ConnectedSele import { formatToothList, prosthesisGroupLabelFromCatalog, - prosthesisTypeColorFromCatalog, + prosthesisTypeLabelStyleFromCatalog, } from '@/components/treatment/prosthesisTypeDisplay'; +import { ProsthesisStackedTypeLabel } from '@/components/ui/lab/ProsthesisStackedTypeLabel'; import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog'; export type LabCaseProsthesisGroup = { @@ -23,7 +24,7 @@ interface LabCaseProsthesisGroupsListProps { } function prosthesisLabel(code: string, catalog: readonly ProsthesisCatalogEntry[]): string { - return prosthesisGroupLabelFromCatalog(code, catalog); + return catalog.find((entry) => entry.code === code)?.label ?? prosthesisGroupLabelFromCatalog(code, catalog); } export function LabCaseProsthesisGroupsList({ @@ -43,13 +44,12 @@ export function LabCaseProsthesisGroupsList({
  • - - {prosthesisLabel(group.prosthesisTypeCode, prosthesisCatalog)} - + {group.teeth.length > 0 ? ( {' · '} @@ -73,7 +73,7 @@ export function LabCaseProsthesisGroupsList({ } export type LabCaseToothJobRow = { - tooth: string; + teeth: string[]; codes: string[]; connected?: boolean; }; @@ -98,11 +98,11 @@ export function LabCaseToothJobsList({
      {rows.map((row) => (
    • - {formatToothList([row.tooth], archLabels)} + {formatToothList(row.teeth, archLabels)} {row.codes.map((code) => ( @@ -111,7 +111,7 @@ export function LabCaseToothJobsList({ {prosthesisLabel(code, prosthesisCatalog)} diff --git a/frontend/src/components/ui/lab/ProsthesisStackedTypeLabel.tsx b/frontend/src/components/ui/lab/ProsthesisStackedTypeLabel.tsx new file mode 100644 index 0000000..ce1daa4 --- /dev/null +++ b/frontend/src/components/ui/lab/ProsthesisStackedTypeLabel.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { + prosthesisGroupLabelFromCatalog, + prosthesisTypeLabelStyleFromCatalog, + splitProsthesisGroupCode, +} from '@/components/treatment/prosthesisTypeDisplay'; +import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog'; + +interface ProsthesisStackedTypeLabelProps { + code: string; + catalog: readonly ProsthesisCatalogEntry[]; + className?: string; + title?: string; +} + +export function ProsthesisStackedTypeLabel({ + code, + catalog, + className, + title, +}: ProsthesisStackedTypeLabelProps) { + const parts = splitProsthesisGroupCode(code); + const fullTitle = title ?? prosthesisGroupLabelFromCatalog(code, catalog); + if (parts.length <= 1) { + const part = parts[0] ?? code; + return ( + + {catalog.find((entry) => entry.code === part)?.label ?? part} + + ); + } + + return ( + + {parts.map((part, index) => ( + + {index > 0 ? + : null} + + {catalog.find((entry) => entry.code === part)?.label ?? part} + + + ))} + + ); +} diff --git a/frontend/src/components/ui/lab/TaskProsthesisGroupHeader.tsx b/frontend/src/components/ui/lab/TaskProsthesisGroupHeader.tsx index f9a9e40..ce97464 100644 --- a/frontend/src/components/ui/lab/TaskProsthesisGroupHeader.tsx +++ b/frontend/src/components/ui/lab/TaskProsthesisGroupHeader.tsx @@ -1,8 +1,8 @@ 'use client'; import { useTranslations } from 'next-intl'; -import { Badge } from '@/components/ui/shared/Badge'; -import { formatToothList, prosthesisTypeBadgeStyleFromCatalog } from '@/components/treatment/prosthesisTypeDisplay'; +import { formatToothList } from '@/components/treatment/prosthesisTypeDisplay'; +import { ProsthesisStackedTypeLabel } from '@/components/ui/lab/ProsthesisStackedTypeLabel'; import type { ProsthesisTaskGroup } from '@/components/lab/taskListGrouping'; import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog'; @@ -24,15 +24,12 @@ export function TaskProsthesisGroupHeader({ return (
      - - {group.prosthesisTypeLabel} - + /> {t('teethLabel', { teeth: formatToothList(group.teeth, archLabels) })} diff --git a/frontend/src/components/ui/lab/TaskRow.tsx b/frontend/src/components/ui/lab/TaskRow.tsx index f5190fe..8cdfe79 100644 --- a/frontend/src/components/ui/lab/TaskRow.tsx +++ b/frontend/src/components/ui/lab/TaskRow.tsx @@ -16,10 +16,8 @@ import { LabCaseOriginBadge } from '@/components/ui/lab/LabCaseOriginBadge'; import { isLabGeneratedCase } from '@/components/lab/labCaseOrigin'; import { formatAppDate, APP_DATE } from '@/lib/i18n/format'; import { notifyTabBadgesChanged } from '@/lib/tabBadgeUtils'; -import { - formatToothList, - prosthesisTypeBadgeStyleFromCatalog, -} from '@/components/treatment/prosthesisTypeDisplay'; +import { formatToothList } from '@/components/treatment/prosthesisTypeDisplay'; +import { ProsthesisStackedTypeLabel } from '@/components/ui/lab/ProsthesisStackedTypeLabel'; import { tasksApi } from '@/lib/api/tasks'; import type { LabTaskListItem, LabTaskStatus } from '@/types/cases'; import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog'; @@ -207,18 +205,12 @@ export function TaskRow({ {t('showInCase')} ) : null} - - {task.prosthesisTypeLabel} - + />
      ) : null}
  • diff --git a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx index cb200db..8bf2eb7 100644 --- a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx +++ b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx @@ -254,13 +254,8 @@ export function LabCasesDispatchPanel({ } const typeLabel = treatmentTypeLabelFromCatalog(activeDetail.treatmentType, treatmentCatalog); - const connectedGroupIds = new Set( - (activeDetail.toothSelectionGroups ?? []) - .filter((group) => group.kind === 'connected') - .map((group) => group.groupId), - ); const toothJobRows = activeLabCase - ? toothJobRowsForDetail(activeLabCase.toothProsthesis, activeDetailId, connectedGroupIds) + ? toothJobRowsForDetail(activeLabCase.toothProsthesis, activeDetailId) : []; const activeDetailAttachments = activeDetail.attachmentMetas ?? []; diff --git a/frontend/src/components/ui/treatment/ProsthesisAssignChart.tsx b/frontend/src/components/ui/treatment/ProsthesisAssignChart.tsx index 6e5df0f..de592bf 100644 --- a/frontend/src/components/ui/treatment/ProsthesisAssignChart.tsx +++ b/frontend/src/components/ui/treatment/ProsthesisAssignChart.tsx @@ -171,8 +171,31 @@ export function ProsthesisAssignChart({ 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; + } + openToothPicker(fdi); } @@ -256,11 +279,17 @@ export function ProsthesisAssignChart({ setOpen(false); return; } - const tooth = focusTooth; - if (!tooth) return; + const pending = pendingRangeRef.current; + const teeth = + pending && pending.length > 0 + ? pending + : focusTooth + ? [focusTooth] + : []; + if (teeth.length === 0) return; commit( - removeTeethFromGroups(groups, [tooth]), - clearJobsForTeeth(toothProsthesis, detailClientId, [tooth]), + removeTeethFromGroups(groups, teeth), + clearJobsForTeeth(toothProsthesis, detailClientId, teeth), ); brushRef.current = []; setOpen(false); @@ -336,7 +365,10 @@ export function ProsthesisAssignChart({ onPickAddon={handlePickAddon} onRemoveJob={handleRemoveJob} onClear={handleClear} - onClose={() => setOpen(false)} + onClose={() => { + setOpen(false); + setRange(null); + }} /> } onAssignPointer={handleAssignPointer} diff --git a/frontend/src/components/ui/treatment/ProsthesisJobPopover.tsx b/frontend/src/components/ui/treatment/ProsthesisJobPopover.tsx index 05ddf0c..27b312c 100644 --- a/frontend/src/components/ui/treatment/ProsthesisJobPopover.tsx +++ b/frontend/src/components/ui/treatment/ProsthesisJobPopover.tsx @@ -2,14 +2,14 @@ import type { ReactNode } from 'react'; import { useTranslations } from 'next-intl'; -import { X } from 'lucide-react'; +import { 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 { prosthesisTypeColorFromCatalog } from '@/components/treatment/prosthesisTypeDisplay'; +import { prosthesisTypeColorFromCatalog, prosthesisTypeFillAccent } from '@/components/treatment/prosthesisTypeDisplay'; import { addonLeaves, categoryCodesInCatalog, @@ -151,7 +151,7 @@ export function ProsthesisJobPopover({ const back = parentStep(step); function openCategory(code: string) { - const subs = subcategoriesFor(catalog, code); + const subs = subcategoriesFor(catalog, code, scope); const arch = scope === 'arch' ? lockedArch : undefined; if (subs.length > 0) { onStep({ kind: 'subcategory', category: code, arch }); @@ -264,7 +264,7 @@ export function ProsthesisJobPopover({ } 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).map(leafTile); + childTiles = leavesFor(catalog, step.category, step.subcategory, scope).map(leafTile); } else { selectedNode = { label: categoryLabel(step.category), @@ -274,7 +274,7 @@ export function ProsthesisJobPopover({ const arch = step.arch; const subs = step.kind === 'subcategory' - ? subcategoriesFor(catalog, step.category).map((sub) => ( + ? subcategoriesFor(catalog, step.category, scope).map((sub) => ( { 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?.category + ? t(`category_${entry.category}` as never) + : t('regionCrown')} + + {entry?.label ?? code} - {entry?.label ?? code} ); diff --git a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx index 71a3b92..ea88db5 100644 --- a/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx +++ b/frontend/src/components/ui/treatment/TreatmentWorkspace.tsx @@ -329,12 +329,70 @@ function mergeServerIdsIntoDetails( }); } +const PREVIEW_TREATMENT_ID = 'current-draft'; + +function isPreviewTreatment(treatment: PastTreatment): boolean { + return treatment.id === PREVIEW_TREATMENT_ID; +} + +function serializeLabCases(drafts: LabCaseDraft[]): string { + return JSON.stringify( + [...drafts] + .sort((a, b) => a.clientId.localeCompare(b.clientId)) + .map((lc) => ({ + clientId: lc.clientId, + id: lc.id ?? null, + destinationOrganizationId: lc.destinationOrganizationId ?? null, + detailClientId: lc.detailClientId, + dueDate: lc.dueDate ?? null, + attachmentIds: [...lc.attachmentIds].sort(), + toothProsthesis: [...lc.toothProsthesis] + .map((tp) => ({ + detailClientId: tp.detailClientId, + tooth: tp.tooth, + prosthesisTypeCode: tp.prosthesisTypeCode, + selectionGroupId: tp.selectionGroupId ?? '', + })) + .sort( + (a, b) => + a.tooth.localeCompare(b.tooth) || + a.prosthesisTypeCode.localeCompare(b.prosthesisTypeCode), + ), + })), + ); +} + +function isLabCasesDirty(drafts: LabCaseDraft[], savedSnapshot: string | null): boolean { + if (savedSnapshot === null) { + return drafts.some( + (lc) => + lc.toothProsthesis.length > 0 || + Boolean(lc.destinationOrganizationId) || + Boolean(lc.dueDate) || + lc.attachmentIds.length > 0, + ); + } + return serializeLabCases(drafts) !== savedSnapshot; +} + +function mergeServerIdsIntoLabCases( + local: LabCaseDraft[], + fromServer: LabCaseDraft[], +): LabCaseDraft[] { + const byClientId = new Map(fromServer.map((lc) => [lc.clientId, lc])); + return local.map((lc) => { + const s = byClientId.get(lc.clientId); + if (!s) return lc; + return { ...lc, id: s.id ?? lc.id }; + }); +} + function detailsToPreviewTreatment( details: TreatmentDetailDraft[], meta: { title: string; patientId: string; treatmentAt: string; id?: string }, ): PastTreatment { return { - id: meta.id ?? 'current-draft', + id: meta.id ?? PREVIEW_TREATMENT_ID, patientId: meta.patientId, title: meta.title, treatmentAt: meta.treatmentAt, @@ -434,6 +492,7 @@ export function TreatmentWorkspace({ const [activeDetailId, setActiveDetailId] = useState(() => details[0].clientId); const [activeLabCaseId, setActiveLabCaseId] = useState(null); const [savedSnapshot, setSavedSnapshot] = useState(null); + const [savedLabCasesSnapshot, setSavedLabCasesSnapshot] = useState(null); const [saveStatus, setSaveStatus] = useState<'idle' | 'dirty' | 'saving' | 'saved' | 'error'>('idle'); const [selectedPreviewId, setSelectedPreviewId] = useState(null); const [workspaceMode, setWorkspaceMode] = useState('live'); @@ -445,6 +504,8 @@ export function TreatmentWorkspace({ detailsRef.current = details; const savedSnapshotRef = useRef(savedSnapshot); savedSnapshotRef.current = savedSnapshot; + const savedLabCasesSnapshotRef = useRef(savedLabCasesSnapshot); + savedLabCasesSnapshotRef.current = savedLabCasesSnapshot; const autosaveTimerRef = useRef | null>(null); const saveInFlightRef = useRef(false); const saveQueuedRef = useRef(false); @@ -498,8 +559,10 @@ export function TreatmentWorkspace({ }, [labCaseDrafts, activeDetailId]); const isDirty = useMemo( - () => isDetailsDirty(details, savedSnapshot), - [details, savedSnapshot], + () => + isDetailsDirty(details, savedSnapshot) || + isLabCasesDirty(labCaseDrafts, savedLabCasesSnapshot), + [details, savedSnapshot, labCaseDrafts, savedLabCasesSnapshot], ); const AUTOSAVE_DEBOUNCE_MS = 600; @@ -799,7 +862,11 @@ export function TreatmentWorkspace({ const mappedLabCases = withoutEmptyLabCaseDrafts( (treatment.labCases ?? []).map(mapLabCaseDraftFromApi), ); + const labSnap = serializeLabCases(mappedLabCases); + labCaseDraftsRef.current = mappedLabCases; + savedLabCasesSnapshotRef.current = labSnap; setLabCaseDrafts(mappedLabCases); + setSavedLabCasesSnapshot(labSnap); setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null); setOrganizationSearch(''); setSaveStatus('idle'); @@ -1158,7 +1225,11 @@ export function TreatmentWorkspace({ const mappedLabCases = withoutEmptyLabCaseDrafts( (response.data?.labCases ?? []).map(mapLabCaseDraftFromApi), ); + const labSnap = serializeLabCases(mappedLabCases); + labCaseDraftsRef.current = mappedLabCases; + savedLabCasesSnapshotRef.current = labSnap; setLabCaseDrafts(mappedLabCases); + setSavedLabCasesSnapshot(labSnap); setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null); setOrganizationSearch(''); setSaveStatus('idle'); @@ -1241,12 +1312,15 @@ export function TreatmentWorkspace({ // If the user changed details while this save was in flight (e.g. removed a // detail), do not clobber local state with the stale response. if (serializeDetails(localNow) === sentSnapshot) { + detailsRef.current = mapped; setDetails(mapped); setActiveDetailId((prev) => { const stillExists = mapped.some((d) => d.clientId === prev); return stillExists ? prev : (mapped[0]?.clientId ?? ''); }); - setSavedSnapshot(serializeDetails(mapped)); + const snap = serializeDetails(mapped); + savedSnapshotRef.current = snap; + setSavedSnapshot(snap); } else { const merged = mergeServerIdsIntoDetails(localNow, mapped); detailsRef.current = merged; @@ -1262,6 +1336,111 @@ export function TreatmentWorkspace({ [selectedAppointment, selectedStandalone, t], ); + const persistLabCases = useCallback( + async (savedTreatment: PastTreatment, draftsOverride?: LabCaseDraft[]) => { + if (!selectedAppointment && !selectedStandalone) throw new Error('No visit selected'); + if (isPreviewTreatment(savedTreatment)) { + return savedTreatment; + } + + const drafts = draftsOverride ?? labCaseDraftsRef.current; + const detailIdByClientId = new Map( + savedTreatment.details.map((d) => [d.clientId, d.id]), + ); + + const payload = drafts + .map((lc) => { + if (!lc.detailClientId) return null; + const treatmentDetailId = detailIdByClientId.get(lc.detailClientId); + if (!treatmentDetailId) return null; + + return { + clientId: lc.clientId, + id: lc.id, + destinationOrganizationId: lc.destinationOrganizationId ?? undefined, + treatmentDetailId, + toothProsthesis: lc.toothProsthesis + .map((tp) => { + const detailId = detailIdByClientId.get(tp.detailClientId); + if (!detailId) return null; + return { + treatmentDetailId: detailId, + tooth: tp.tooth, + prosthesisTypeCode: tp.prosthesisTypeCode, + selectionGroupId: tp.selectionGroupId ?? '', + }; + }) + .filter( + ( + row, + ): row is { + treatmentDetailId: string; + tooth: string; + prosthesisTypeCode: string; + selectionGroupId: string; + } => row !== null, + ), + attachmentIds: lc.attachmentIds, + dueDate: lc.dueDate ?? null, + }; + }) + .filter((row): row is NonNullable => row !== null); + + const localJobCount = drafts.reduce((n, lc) => n + lc.toothProsthesis.length, 0); + const payloadJobCount = payload.reduce((n, lc) => n + lc.toothProsthesis.length, 0); + if (payloadJobCount < localJobCount) { + throw new Error('Lab case jobs could not be mapped to saved details'); + } + + const applySavedDrafts = (mapped: LabCaseDraft[]) => { + const sent = serializeLabCases(drafts); + const localNow = labCaseDraftsRef.current; + if (serializeLabCases(localNow) === sent) { + const snap = serializeLabCases(mapped); + labCaseDraftsRef.current = mapped; + savedLabCasesSnapshotRef.current = snap; + setLabCaseDrafts(mapped); + setSavedLabCasesSnapshot(snap); + setActiveLabCaseId((prev) => { + if (prev && mapped.some((lc) => lc.clientId === prev)) return prev; + return mapped[0]?.clientId ?? null; + }); + return; + } + const merged = mergeServerIdsIntoLabCases(localNow, mapped); + labCaseDraftsRef.current = merged; + setLabCaseDrafts(merged); + }; + + const emptySnap = serializeLabCases([]); + if (payload.length === 0) { + if (drafts.length > 0) { + throw new Error('Lab case drafts could not be mapped to saved details'); + } + if ( + !savedLabCasesSnapshotRef.current || + savedLabCasesSnapshotRef.current === emptySnap + ) { + savedLabCasesSnapshotRef.current = emptySnap; + setSavedLabCasesSnapshot(emptySnap); + return savedTreatment; + } + } + + const response = selectedAppointment + ? await treatmentsApi.saveLabCases(selectedAppointment.id, { + labCases: payload, + }) + : await treatmentsApi.saveLabCasesByTreatment(selectedStandalone!.id, { + labCases: payload, + }); + const mapped = withoutEmptyLabCaseDrafts(response.data.labCases.map(mapLabCaseDraftFromApi)); + applySavedDrafts(mapped); + return response.data; + }, + [selectedAppointment, selectedStandalone], + ); + const refreshHistory = useCallback(async (patientId: string, options?: { silentLabCases?: boolean }) => { const requestId = ++historyRequestRef.current; try { @@ -1281,17 +1460,28 @@ export function TreatmentWorkspace({ return; } - if ( - !isDetailsDirty(detailsRef.current, savedSnapshotRef.current) || - !areDetailsPersistable(detailsRef.current) - ) { + const detailsDirty = isDetailsDirty(detailsRef.current, savedSnapshotRef.current); + const labDirty = isLabCasesDirty( + labCaseDraftsRef.current, + savedLabCasesSnapshotRef.current, + ); + if (!detailsDirty && !labDirty) { + return; + } + if (!areDetailsPersistable(detailsRef.current)) { return; } saveInFlightRef.current = true; setSaveStatus('saving'); + const draftsAtSave = labCaseDraftsRef.current; try { - await persistDraft(); + const saved = await persistDraft({ force: labDirty && !detailsDirty }); + if (isPreviewTreatment(saved)) { + setSaveStatus('dirty'); + return; + } + await persistLabCases(saved, draftsAtSave); setSaveStatus('saved'); if (historyPatientId) { await refreshHistory(historyPatientId); @@ -1304,12 +1494,15 @@ export function TreatmentWorkspace({ saveInFlightRef.current = false; if (saveQueuedRef.current) { saveQueuedRef.current = false; - if (isDetailsDirty(detailsRef.current, savedSnapshotRef.current)) { + if ( + isDetailsDirty(detailsRef.current, savedSnapshotRef.current) || + isLabCasesDirty(labCaseDraftsRef.current, savedLabCasesSnapshotRef.current) + ) { void runDraftSave(); } } } - }, [hasLiveContext, persistDraft, showError, t, historyPatientId, refreshHistory]); + }, [hasLiveContext, persistDraft, persistLabCases, showError, t, historyPatientId, refreshHistory]); const flushDraftSave = useCallback(async (): Promise => { if (autosaveTimerRef.current) { @@ -1325,7 +1518,10 @@ export function TreatmentWorkspace({ await new Promise((resolve) => setTimeout(resolve, 50)); } - if (!isDetailsDirty(detailsRef.current, savedSnapshotRef.current)) { + if ( + !isDetailsDirty(detailsRef.current, savedSnapshotRef.current) && + !isLabCasesDirty(labCaseDraftsRef.current, savedLabCasesSnapshotRef.current) + ) { return true; } @@ -1850,79 +2046,11 @@ export function TreatmentWorkspace({ [canEditTreatmentForDay, isDetailLocked, hasLiveContext, selectedAppointment, selectedStandalone, showSuccess, showError, t, tErrors], ); - const persistLabCases = useCallback( - async (savedTreatment: PastTreatment, draftsOverride?: LabCaseDraft[]) => { - if (!selectedAppointment && !selectedStandalone) throw new Error('No visit selected'); - - const drafts = draftsOverride ?? labCaseDrafts; - const detailIdByClientId = new Map( - savedTreatment.details.map((d) => [d.clientId, d.id]), - ); - - const payload = drafts - .map((lc) => { - if (!lc.detailClientId) return null; - const treatmentDetailId = detailIdByClientId.get(lc.detailClientId); - if (!treatmentDetailId) return null; - - return { - clientId: lc.clientId, - id: lc.id, - destinationOrganizationId: lc.destinationOrganizationId ?? undefined, - treatmentDetailId, - toothProsthesis: lc.toothProsthesis - .map((tp) => { - const detailId = detailIdByClientId.get(tp.detailClientId); - if (!detailId) return null; - return { - treatmentDetailId: detailId, - tooth: tp.tooth, - prosthesisTypeCode: tp.prosthesisTypeCode, - selectionGroupId: tp.selectionGroupId ?? '', - }; - }) - .filter( - ( - row, - ): row is { - treatmentDetailId: string; - tooth: string; - prosthesisTypeCode: string; - selectionGroupId: string; - } => row !== null, - ), - attachmentIds: lc.attachmentIds, - dueDate: lc.dueDate ?? null, - }; - }) - .filter((row): row is NonNullable => row !== null); - - if (payload.length === 0) { - return savedTreatment; - } - - const response = selectedAppointment - ? await treatmentsApi.saveLabCases(selectedAppointment.id, { - labCases: payload, - }) - : await treatmentsApi.saveLabCasesByTreatment(selectedStandalone!.id, { - labCases: payload, - }); - const mapped = withoutEmptyLabCaseDrafts(response.data.labCases.map(mapLabCaseDraftFromApi)); - setLabCaseDrafts(mapped); - setActiveLabCaseId((prev) => { - if (prev && mapped.some((lc) => lc.clientId === prev)) return prev; - return mapped[0]?.clientId ?? null; - }); - return response.data; - }, - [labCaseDrafts, selectedAppointment, selectedStandalone], - ); - const handleLabCasesChange = useCallback( (next: LabCaseDraft[]) => { const prevCleaned = withoutEmptyLabCaseDrafts(labCaseDrafts); const cleaned = withoutEmptyLabCaseDrafts(next); + labCaseDraftsRef.current = cleaned; setLabCaseDrafts(cleaned); if (!cleaned.some((lc) => lc.detailClientId === activeDetailId)) { @@ -2036,9 +2164,7 @@ export function TreatmentWorkspace({ const updatedLabCases = [...labCaseDrafts, draft]; setLabCaseDrafts(updatedLabCases); - // Autosave only watches `details`, so a lab draft left in state alone loses the - // lab, the due date and the prosthesis map on reload — silently, because the - // detail itself survives. + // Persist the new detail first so lab-case rows can use real treatmentDetailIds. void (async () => { try { const saved = await persistDraft({ force: true }); diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index b2d6689..7e7f5bd 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -388,6 +388,12 @@ select option { stroke: currentColor; } +/* Inherit the parent color (job-chip trash, catalog-tinted controls). */ +.lucide.lucide-inherit { + color: inherit; + stroke: currentColor; +} + @keyframes picker-expand { from { opacity: 0;