From a3c14a18c15395403cbd643fa8f2650156041f56 Mon Sep 17 00:00:00 2001 From: Admin Date: Tue, 1 Sep 2026 21:03:04 +0330 Subject: [PATCH] improvement: new prosthesis type data structure implemented and finally working! --- .cursor/rules/treatment-workspace.mdc | 8 +- .cursor/skills/lab-cases/SKILL.md | 4 +- .cursor/skills/lab-tasks/SKILL.md | 6 +- .cursor/skills/treatment-workspace/SKILL.md | 17 +- AGENTS.md | 12 +- backend/package.json | 3 +- backend/prisma/catalog-seed-data.ts | 357 +++++++++---- backend/prisma/export-prosthesis-catalog.ts | 286 +++++++++++ .../migration.sql | 26 + .../migration.sql | 12 + backend/prisma/schema.prisma | 16 +- backend/prisma/seed.ts | 35 +- backend/src/common/prosthesis-group.ts | 78 +++ backend/src/modules/cases/cases.service.ts | 63 +-- .../modules/cases/lab-case-access.service.ts | 62 +-- .../cases/lab-case-task.generator.spec.ts | 81 ++- .../modules/cases/lab-case-task.generator.ts | 98 ++-- .../src/modules/cases/lab-case-task.util.ts | 84 +++ .../prosthesis-catalog.service.ts | 58 ++- backend/src/modules/tasks/tasks.service.ts | 33 +- .../lab-case-send.validation.spec.ts | 30 ++ .../treatments/lab-case-send.validation.ts | 11 + docs/prosthesis-catalog.xlsx | Bin 0 -> 15648 bytes frontend/messages/en.json | 63 ++- frontend/messages/fa.json | 63 ++- frontend/messages/nl.json | 63 ++- frontend/package-lock.json | 109 ++-- .../src/components/lab/taskListGrouping.ts | 2 +- .../components/shared/catalog-type-colors.ts | 31 ++ .../treatment/labDispatchAttention.ts | 22 +- .../components/treatment/prosthesisTree.ts | 340 +++++++++++++ .../treatment/prosthesisTypeDisplay.ts | 45 +- .../treatment/toothSelectionGroups.ts | 38 +- .../treatment/treatmentDetailRules.ts | 12 +- .../src/components/ui/lab/CaseCreatePanel.tsx | 199 ++------ .../src/components/ui/lab/CaseDetailPanel.tsx | 7 +- .../ui/lab/CaseSheetPrintLayout.tsx | 4 +- .../components/ui/lab/CaseToothChartPanel.tsx | 26 +- .../ui/lab/LabCaseProsthesisGroupsList.tsx | 67 ++- .../ui/lab/TaskProsthesisGroupHeader.tsx | 7 +- frontend/src/components/ui/lab/TaskRow.tsx | 7 +- .../components/ui/shared/WizardStepper.tsx | 100 ---- .../components/ui/treatment/FdiToothChart.tsx | 237 +++++++-- .../ui/treatment/LabCasesDispatchPanel.tsx | 256 +++------- .../ui/treatment/ProsthesisAssignChart.tsx | 349 +++++++++++++ .../ui/treatment/ProsthesisJobPopover.tsx | 477 ++++++++++++++++++ .../components/ui/treatment/ToothGlyph.tsx | 79 +-- .../ui/treatment/TreatmentDetailsEditor.tsx | 24 +- .../ui/treatment/TreatmentWorkspace.tsx | 389 +++++++++----- frontend/src/styles/globals.css | 22 + frontend/src/types/treatment-catalog.ts | 5 + 51 files changed, 3306 insertions(+), 1117 deletions(-) create mode 100644 backend/prisma/export-prosthesis-catalog.ts create mode 100644 backend/prisma/migrations/20260901120000_prosthesis_tree_multi_type/migration.sql create mode 100644 backend/prisma/migrations/20260901130000_lab_case_task_per_tooth/migration.sql create mode 100644 backend/src/common/prosthesis-group.ts create mode 100644 docs/prosthesis-catalog.xlsx create mode 100644 frontend/src/components/treatment/prosthesisTree.ts delete mode 100644 frontend/src/components/ui/shared/WizardStepper.tsx create mode 100644 frontend/src/components/ui/treatment/ProsthesisAssignChart.tsx create mode 100644 frontend/src/components/ui/treatment/ProsthesisJobPopover.tsx diff --git a/.cursor/rules/treatment-workspace.mdc b/.cursor/rules/treatment-workspace.mdc index dbc5ecd..8888012 100644 --- a/.cursor/rules/treatment-workspace.mdc +++ b/.cursor/rules/treatment-workspace.mdc @@ -8,19 +8,19 @@ 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. No stepper unless prosthesis — then `WizardStepper` Treatment → Lab with Back/Next. Chip switches reset to the treatment form unless `pendingEntryStepRef` requests Lab (shipments rail open). 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 (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. - **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). Prosthesis type: stacked below `md`, 50/50 same-row from `md`. 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 **one row per tooth** (`LabCaseToothJobsList`) — 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. - **Lab dispatch attention:** `LabDispatchAttentionPanel` — unsent lab-dependent details; quick jump to dispatch. - **History API:** patient-scoped; non-owners filtered by provider on treatment or appointment; org owners see all. - **History filters (client-side):** `PastTreatmentsPanel` — “Not shipped to lab” + single date; helpers in `treatmentHistoryFilters.ts`. -- **Lab shipment block:** lab-dependent detail with no teeth saves but cannot ship — same inline amber banner on Treatment and Lab (`labBlockedBannerClass`); toast on add shipment. +- **Lab shipment block:** lab-dependent detail with no teeth and no arch job (`UA`/`LA`) saves but cannot ship — inline amber banner (`labBlockedBannerClass`). - **Edit gating:** `canEditTreatmentForDay` = permission + appointment + not past day + `live` mode. Sent detail locks that line; Add still OK same day. Upload rejects sent (`TREATMENT_DETAIL_SENT`). -- **Lab search:** `LinkedOrganizationSearchCombobox`; invite lab via `/organizations?action=invite-lab` when permitted. New dispatch lines start empty (lab + apply-all / per-tooth prosthesis type). Chips = last 3 **sent** labs (`labDispatchDefaults.ts`) — never auto-select. Clear the search box when switching details. +- **Lab search:** `LinkedOrganizationSearchCombobox`; invite lab via `/organizations?action=invite-lab` when permitted. New dispatch lines start empty (lab only — types live on the chart). Chips = last 3 **sent** labs (`labDispatchDefaults.ts`) — never auto-select. Clear the search box when switching details. - **Scroll:** `scrollWithinMainScrollContainer`; shared `Checkbox` only. Full map: `.cursor/skills/treatment-workspace/SKILL.md` diff --git a/.cursor/skills/lab-cases/SKILL.md b/.cursor/skills/lab-cases/SKILL.md index 397e030..1a627ea 100644 --- a/.cursor/skills/lab-cases/SKILL.md +++ b/.cursor/skills/lab-cases/SKILL.md @@ -14,7 +14,7 @@ description: Lab Cases tab — list, filters, detail panel, assignment, share QR - **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`. - **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, interactive `FdiToothChart` (same connect/Shift rules as Treatment). 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` (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 +36,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. +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. ## Detail panel diff --git a/.cursor/skills/lab-tasks/SKILL.md b/.cursor/skills/lab-tasks/SKILL.md index 53489bb..25b3408 100644 --- a/.cursor/skills/lab-tasks/SKILL.md +++ b/.cursor/skills/lab-tasks/SKILL.md @@ -13,15 +13,15 @@ Backend: [`backend/src/modules/tasks/`](backend/src/modules/tasks/) `sortBy=date` + `sortDir=desc`: 1. `labCase.sentAt` desc (newest case first) -2. `labCaseId`, `treatmentDetailId`, `prosthesisTypeCode` asc (stable grouping) -3. `stepOrder` asc (steps 1→N within prosthesis group) +2. `labCaseId`, `sourceKey`, `tooth`, `prosthesisTypeCode` asc (one pipeline per tooth) +3. `stepOrder` asc (steps 1→N within that tooth) 4. `id` asc 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 → prosthesis sub-header → task rows. +- **`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`). - **Other sorts** → flat list; show muted hint (`groupingOff*` i18n keys). Each row keeps clinic/patient/teeth context. Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`. diff --git a/.cursor/skills/treatment-workspace/SKILL.md b/.cursor/skills/treatment-workspace/SKILL.md index c0ba28b..91da2ab 100644 --- a/.cursor/skills/treatment-workspace/SKILL.md +++ b/.cursor/skills/treatment-workspace/SKILL.md @@ -28,7 +28,7 @@ Thin route: `app/[locale]/(dashboard)/treatment/page.tsx` (supports `?appointmen 3. **Treatment history** — `PastTreatmentsPanel.tsx` (past saved plans for patient; **client-side** filters in `treatmentHistoryFilters.ts`) -4. **Editor** — detail chips + Add (`TreatmentDetailsEditor`); type-first form with chart + notes; lab send sheet only for prosthesis (`LabCasesDispatchPanel`) +4. **Editor** — detail chips + Add (`TreatmentDetailsEditor`); type-first form with chart + notes; lab send sheet under the form when the active type is prosthesis (`LabCasesDispatchPanel`) @@ -36,17 +36,17 @@ Thin route: `app/[locale]/(dashboard)/treatment/page.tsx` (supports `?appointmen -Right-column entry is **not** a three-step wizard. Type dropdown + `TreatmentDetailAttachmentsStrip` on one row (same height as `Dropdown`; paperclip | divider | thumbs grouped image → pdf → other; upload progress in one square; click thumb → preview/remove dialog). Compact FDI chart (same scale as Cases), then full-width auto-growing **Notes** (`rows={1}`). Chart is dimmed until a type is chosen. Lab-dependent chips show colored sent/unsent **text** (not badge pills); sent date stays on the Lab dispatch tab. +Right-column entry is **not** a three-step wizard. Type dropdown + `TreatmentDetailAttachmentsStrip` on one row (same height as `Dropdown`; paperclip | divider | thumbs grouped image → pdf → other; upload progress in one square; click thumb → preview/remove dialog). Compact FDI chart (same scale as Cases), then full-width auto-growing **Notes** (`rows={1}`). Chart is dimmed until a type is chosen. Lab-dependent chips show colored sent/unsent **text** (not badge pills); sent date stays on the Lab dispatch panel. | Stage | UI | When | |-------|-----|------| -| **Treatment** | Type dropdown + `TreatmentDetailAttachmentsStrip`, `FdiToothChart` (Cases scale), full-width Notes | Always | -| **Lab** | `LabCasesDispatchPanel` | Only when active detail type is lab-dependent. Entering Lab auto-ensures a shipment draft. **No default lab or prosthesis type** on a new detail (including siblings in the same plan). Last **3 sent** labs appear as chips under search — pick is explicit. Comments stay on the dispatch panel. | +| **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 uses `WizardStepper` (Treatment → Lab) with Back/Next. Lab dispatch keeps comments. +- 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). - Detail chips show **type + teeth**, not “Detail N”. Lab-dependent chips use colored sent/unsent text (same size as the label); sent date stays on Lab dispatch. - Detail type may differ from appointment purpose. Purpose seeds the first line of an empty **appointment** draft (first open, and **Add detail** when the plan is `[]`). Later **Add detail** starts with an empty type. Unscheduled / New treatment still seeds a blank first line. -- Switching `activeDetailId` resets to the treatment form, unless `pendingEntryStepRef` is set to `lab` first (lab shipments rail / “Go to dispatch” / load-with-focus). +- Lab shipments rail / “Go to dispatch” / load-with-focus **scrolls** to the dispatch panel (`pendingScrollToLabRef` + `labPanelRef`). - Live draft is **not** duplicated in the left rail preview; preview is for history browse only. @@ -55,13 +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** rows are 1:1 with selection groups; after send, Cases/Tasks **merge** teeth by prosthesis type (not by selection group). +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`. 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. - 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. @@ -155,7 +156,7 @@ On today: in-progress slot first, else nearest start time to `now`. Other days: -`LinkedOrganizationSearchCombobox` in `LabCasesDispatchPanel` — search-only results (no dropdown). Chips under the search are the last **3 labs this clinic sent a case to** (`rememberRecentLab` after successful send). They are shortcuts, not defaults: a new detail’s lab and prosthesis type (apply-all and per-tooth) stay empty until the user chooses. Switching details clears the search box. No match + org tab access → **Invite a lab** navigates to `/organizations?action=invite-lab`. No org access → show permission message; dispatch stops. +`LinkedOrganizationSearchCombobox` in `LabCasesDispatchPanel` — search-only results (no dropdown). Chips under the search are the last **3 labs this clinic sent a case to** (`rememberRecentLab` after successful send). They are shortcuts, not defaults: a new detail’s lab stays empty until the user chooses (types stay on the chart). Switching details clears the search box. No match + org tab access → **Invite a lab** navigates to `/organizations?action=invite-lab`. No org access → show permission message; dispatch stops. diff --git a/AGENTS.md b/AGENTS.md index 048e3f7..dd3720a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,17 +47,17 @@ frontend/src/ - Day/mode gate: editable only for live draft on today/future (`canEditTreatmentForDay`). Past day / historical load → read-only form. Patient search sits in the **page header** (workspace-wide). If the patient has a visit on the day strip, that visit opens; else the latest history plan loads. If neither exists, the editor shows an inline empty state (no dialog) pointing to **New treatment** in the rail — do not auto-create. **New treatment** is at the top of the left rail (selected-patient card below it) and opens a picker: Walk-in first, then a matching card for the current named patient (name + mobile/email), then search. It does not auto-copy the appointment card’s patient. New treatment seeds one blank detail; a persisted empty plan loads as `[]` until Add. - **Unscheduled strip cards** use the same treatment-type banner as appointments from the **first detail’s type only** (`unscheduledStripColorCode`; live draft for the open card). Empty first line → chip theming even if later lines are typed. Strip trash only when every line is blank (no type/teeth/notes/attachments, including `[]`); typed cards need chip-delete first. Backend `DELETE /treatments/:id` is empty-only (`TREATMENT_HAS_DETAILS`). - Sent-to-lab detail locks that line; **Add detail** still OK same day; **Remove detail** = trash on chip (unsent, including last line; day/edit gates apply). Empty details persist as `[]`. Attachment upload blocked when sent (`TREATMENT_DETAIL_SENT`). -- **Entry:** Type dropdown + compact attachments strip (`TreatmentDetailAttachmentsStrip`) on one row, then bordered FDI chart (Cases chrome/scale), then full-width auto-growing **Notes**. No wizard for non-lab types. Prosthesis: `WizardStepper` Treatment → Lab with Back/Next. Detail chips show type + teeth; lab-dependent chips use colored sent/unsent text (sent date on Lab tab). Switching chips resets to the treatment form unless `pendingEntryStepRef` requests Lab (shipments rail). 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. Helpers: `toothSelectionGroups.ts`. Connected label: `ConnectedSelectionBadge`. After send, Cases/Tasks merge teeth by prosthesis type. -- **Lab dispatch layout:** due date beside title (`justify-between`, logical start/end for RTL); prosthesis type on same row as teeth from `md:` up (stacked on mobile). +- **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). - **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):** -- Lab-dependent details (e.g. prosthesis) **without teeth** can save but **cannot ship** — same inline amber banner (`labShipmentBlockedBody`) on Treatment **and** Lab steps; toast on dispatch add. -- New prosthesis dispatch lines start **empty** (no default lab, no apply-all / per-tooth prosthesis type), even for siblings in the same plan. Recent-lab chips are the last **3 sent** destinations — pick is explicit, never auto-selected. +- Lab-dependent details (e.g. prosthesis) **without teeth or an arch product** can save but **cannot ship** — same inline amber banner (`labShipmentBlockedBody`). Arch jobs store as `UA`/`LA` on `LabCaseToothProsthesis.tooth` (not on `detail.teeth`). +- New prosthesis dispatch lines start **empty** (no default lab). Types are assigned only on the chart (`ProsthesisAssignChart` / `ProsthesisJobPopover`). Recent-lab chips are the last **3 sent** destinations — pick is explicit, never auto-selected. - Detail treatment type need **not** match appointment purpose — purpose only seeds the **first** line of an empty **appointment** draft (first open, and **Add detail** when the plan is `[]`). Further **Add detail** starts with an empty type. Unscheduled / New treatment still seeds a blank first line. - **History filters** are client-side only (`treatmentHistoryFilters.ts`): “Not shipped to lab” + single date on already-fetched patient history; includes live current draft when filtering. -- **Lab shipments rail**: unified list with scope toggle **This patient** vs **All updates** (unread across org for **this clinician's cases only**, includes patient name). Opening a case from the rail jumps to the **Lab** send sheet. +- **Lab shipments rail**: unified list with scope toggle **This patient** vs **All updates** (unread across org for **this clinician's cases only**, includes patient name). Opening a case from the rail **scrolls** to the lab dispatch panel. - **Unread semantics**: Treatment tab badge = count of unread cases **for the user's own treatment plans** (per-case read cursor) and clears when a case is opened/marked read (not on tab visit). - **Live lab rail**: `notification.created` → `notifyTabBadgesChanged()` silently refreshes patient lab cases + unread rail (does **not** clear draft/form state). - **Lab shipment progress + comments**: shown in **Lab dispatch panel** for the active shipment; expanding activity / opening comments marks that case read. Shared UI: `LabCaseCommentsPanel` — newest first; sent = start / received = end (`text-start`/`justify-start`, RTL-safe); pass `viewerSide`. diff --git a/backend/package.json b/backend/package.json index d58c282..329ed25 100644 --- a/backend/package.json +++ b/backend/package.json @@ -24,7 +24,8 @@ "prisma:seed": "prisma db seed", "prisma:reset-treatment": "ts-node prisma/reset-treatment-data.ts", "prisma:wipe-app-data": "ts-node prisma/wipe-app-data.ts", - "prisma:regenerate-tasks": "ts-node prisma/regenerate-lab-tasks.ts" + "prisma:regenerate-tasks": "ts-node prisma/regenerate-lab-tasks.ts", + "prisma:export-prosthesis-catalog": "ts-node --transpile-only prisma/export-prosthesis-catalog.ts" }, "prisma": { "seed": "ts-node prisma/seed.ts" diff --git a/backend/prisma/catalog-seed-data.ts b/backend/prisma/catalog-seed-data.ts index bdf5508..7ee9283 100644 --- a/backend/prisma/catalog-seed-data.ts +++ b/backend/prisma/catalog-seed-data.ts @@ -29,7 +29,6 @@ export const TREATMENT_TYPES: readonly TreatmentTypeSeed[] = [ { code: 'perio', labDependent: false, sortOrder: 9 }, { code: 'pediatrics', labDependent: false, sortOrder: 10 }, { code: 'extraction', labDependent: false, sortOrder: 11 }, - // Appointment-only: not real treatment plan details. { code: 'clinic_visit', labDependent: false, @@ -69,140 +68,278 @@ export const LAB_WORKFLOW_STEPS = [ { code: 'pressing', sortOrder: 14 }, ] as const; +export type ProsthesisChartRegion = 'crown' | 'root' | 'arch'; +export type ProsthesisStackGroup = 'restoration' | 'implant' | 'post_core' | 'arch'; +export type ProsthesisAddonKind = '' | 'implant' | 'post_core'; + export type ProsthesisTypeSeed = { code: string; sortOrder: number; skipPackingShipping?: boolean; + isActive?: boolean; + category: string; + subcategory?: string; + chartRegion: ProsthesisChartRegion; + stackGroup: ProsthesisStackGroup; + addonKind?: ProsthesisAddonKind; /** Manufacturing steps between design and packing (exclusive of universal scan/design/pack/ship). */ 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; + +function crown( + code: string, + sortOrder: number, + manufacturingSteps: readonly string[], + extra?: Partial, +): ProsthesisTypeSeed { + return { + code, + sortOrder, + category: 'crown', + chartRegion: 'crown', + stackGroup: 'restoration', + manufacturingSteps, + ...extra, + }; +} + +function implantAddon( + code: string, + sortOrder: number, + manufacturingSteps: readonly string[], +): ProsthesisTypeSeed { + return { + code, + sortOrder, + category: 'implant', + chartRegion: 'root', + stackGroup: 'implant', + addonKind: 'implant', + manufacturingSteps, + }; +} + +function indirect( + indication: string, + technique: 'full_contour' | 'layered', + sortOrder: number, +): ProsthesisTypeSeed { + return { + code: `${indication}_${technique}`, + sortOrder, + category: 'indirect', + subcategory: indication, + chartRegion: 'crown', + stackGroup: 'restoration', + manufacturingSteps: technique === 'layered' ? LAYERED : FULL_CONTOUR, + }; +} + 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']), + indirect('veneer', 'full_contour', 20), + indirect('veneer', 'layered', 21), + indirect('inlay', 'full_contour', 22), + indirect('inlay', 'layered', 23), + indirect('onlay', 'full_contour', 24), + indirect('onlay', 'layered', 25), + indirect('overlay', 'full_contour', 26), + indirect('overlay', 'layered', 27), { - code: 'pfm_crown', - sortOrder: 1, - manufacturingSteps: ['milling_wet', 'build_up', 'stain', 'glaze', 'polish_prep'], + code: 'cast_post_core', + sortOrder: 30, + category: 'post_core', + chartRegion: 'root', + stackGroup: 'post_core', + addonKind: 'post_core', + manufacturingSteps: POST_CORE, }, { - code: 'pfz_crown', - sortOrder: 2, - manufacturingSteps: ['milling_dry', 'sinter', 'build_up', 'stain', 'glaze', 'polish_prep'], + code: 'fiber_post_core', + sortOrder: 31, + category: 'post_core', + chartRegion: 'root', + stackGroup: 'post_core', + 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: 'monolithic_zirconia', - sortOrder: 3, - manufacturingSteps: ['milling_dry', 'sinter', 'stain', 'glaze', 'polish_prep'], - }, - { - code: 'glass_ceramic_crown', - sortOrder: 4, - manufacturingSteps: ['milling_wet', 'stain', 'glaze', 'polish_prep'], - }, - { - code: 'full_metal_crown', - sortOrder: 5, + code: 'screw_retained', + sortOrder: 45, + category: 'implant', + chartRegion: 'crown', + stackGroup: 'restoration', manufacturingSteps: ['milling_wet', 'polish_prep'], }, { - code: 'temporary_resin_crown', - sortOrder: 6, - manufacturingSteps: ['milling_wet', 'polish_prep'], + code: 'complete_denture', + sortOrder: 50, + category: 'removable', + chartRegion: 'arch', + stackGroup: 'arch', + manufacturingSteps: DENTURE, }, { - code: 'pmma', - sortOrder: 7, - manufacturingSteps: ['milling_dry', 'polish_prep'], + code: 'partial_denture', + sortOrder: 51, + category: 'removable', + chartRegion: 'arch', + stackGroup: 'arch', + manufacturingSteps: DENTURE, }, { - code: 'peek_crown', - sortOrder: 8, - manufacturingSteps: ['milling_dry', 'polish_prep'], + code: 'overdenture', + sortOrder: 52, + category: 'removable', + chartRegion: 'arch', + stackGroup: 'arch', + manufacturingSteps: DENTURE, }, { - code: 'veneer_zirconia', - sortOrder: 9, - manufacturingSteps: ['milling_dry', 'sinter', 'build_up', 'stain', 'glaze', 'polish_prep'], + code: 'night_guard_soft', + sortOrder: 60, + category: 'appliance', + subcategory: 'night_guard', + chartRegion: 'arch', + stackGroup: 'arch', + manufacturingSteps: APPLIANCE, }, { - code: 'veneer_ips_press', - sortOrder: 10, - manufacturingSteps: [ - 'printer_resin', - 'build_up', - 'stain', - 'glaze', - 'polish_prep', - 'pressing', - ], + code: 'night_guard_hard', + sortOrder: 61, + category: 'appliance', + subcategory: 'night_guard', + chartRegion: 'arch', + stackGroup: 'arch', + manufacturingSteps: APPLIANCE, }, { - code: 'veneer_ips_cad', - sortOrder: 11, - manufacturingSteps: ['milling_wet', 'stain', 'glaze', 'polish_prep'], + code: 'night_guard_dual', + sortOrder: 62, + category: 'appliance', + subcategory: 'night_guard', + chartRegion: 'arch', + stackGroup: 'arch', + manufacturingSteps: APPLIANCE, + }, + { + code: 'bleaching_tray', + sortOrder: 63, + category: 'appliance', + chartRegion: 'arch', + stackGroup: 'arch', + manufacturingSteps: APPLIANCE, + }, + { + code: 'clear_aligner', + sortOrder: 64, + category: 'appliance', + chartRegion: 'arch', + stackGroup: 'arch', + manufacturingSteps: APPLIANCE, }, { code: 'soft_structure', - sortOrder: 12, + sortOrder: 65, + category: 'appliance', + chartRegion: 'arch', + stackGroup: 'arch', manufacturingSteps: ['milling_dry', 'sinter'], }, { - code: 'customized_abutment', - sortOrder: 13, - manufacturingSteps: ['milling_wet', 'polish_prep'], + code: 'surgical_guide', + sortOrder: 70, + category: 'digital', + chartRegion: 'arch', + stackGroup: 'arch', + manufacturingSteps: APPLIANCE, }, { - code: 'prefabricated_abutment', - sortOrder: 14, - manufacturingSteps: ['polish_prep'], + code: 'smile_design', + sortOrder: 71, + category: 'digital', + chartRegion: 'arch', + stackGroup: 'arch', + skipPackingShipping: true, + manufacturingSteps: [], }, { - code: 'ti_base_abutment', - sortOrder: 15, - manufacturingSteps: ['polish_prep'], + code: 'mockup', + sortOrder: 72, + category: 'digital', + chartRegion: 'arch', + stackGroup: 'arch', + manufacturingSteps: ['printer_resin'], + }, + // Legacy leaves — labels for historical cases; hidden from the picker. + { + code: 'veneer_zirconia', + sortOrder: 200, + isActive: false, + category: 'indirect', + subcategory: 'veneer', + chartRegion: 'crown', + stackGroup: 'restoration', + manufacturingSteps: LAYERED, }, { - code: 'multi_unit_abutment', - sortOrder: 16, - manufacturingSteps: ['polish_prep'], + code: 'veneer_ips_press', + sortOrder: 201, + isActive: false, + category: 'indirect', + subcategory: 'veneer', + chartRegion: 'crown', + stackGroup: 'restoration', + manufacturingSteps: ['printer_resin', 'build_up', 'stain', 'glaze', 'polish_prep', 'pressing'], }, { - code: 'zirconia_abutment', - sortOrder: 17, - manufacturingSteps: ['milling_dry', 'sinter', 'polish_prep'], - }, - { - code: 'screw_retained', - sortOrder: 18, - manufacturingSteps: [ - 'milling_wet', - 'printer_metal', - 'sinter', - 'build_up', - 'stain', - 'glaze', - 'polish_prep', - ], + code: 'veneer_ips_cad', + sortOrder: 202, + isActive: false, + category: 'indirect', + subcategory: 'veneer', + chartRegion: 'crown', + stackGroup: 'restoration', + manufacturingSteps: FULL_CONTOUR, }, { code: 'zirconia_overlay', - sortOrder: 19, + sortOrder: 203, + isActive: false, + category: 'indirect', + subcategory: 'overlay', + chartRegion: 'crown', + stackGroup: 'restoration', manufacturingSteps: ['milling_dry', 'sinter', 'stain', 'glaze', 'polish_prep'], }, { code: 'ips_overlay', - sortOrder: 20, - manufacturingSteps: ['milling_wet', 'stain', 'glaze', 'polish_prep'], - }, - { - code: 'smile_design', - sortOrder: 21, - skipPackingShipping: true, - manufacturingSteps: ['printer_resin'], - }, - { - code: 'mockup', - sortOrder: 22, - manufacturingSteps: ['printer_resin'], + sortOrder: 204, + isActive: false, + category: 'indirect', + subcategory: 'overlay', + chartRegion: 'crown', + stackGroup: 'restoration', + manufacturingSteps: FULL_CONTOUR, }, ]; @@ -217,6 +354,12 @@ export function buildProsthesisStepCodes(type: ProsthesisTypeSeed): string[] { return steps; } +export { + unionWorkflowStepCodes, + prosthesisGroupKey, + splitProsthesisGroupKey, +} from '../src/common/prosthesis-group'; + const TREATMENT_LABELS: Record> = { restoration: { en: 'Restoration', fa: 'ترمیم', nl: 'Restauratie' }, specialized_restoration: { en: 'Specialized Restoration', fa: 'ترمیم تخصصی', nl: 'Gespecialiseerde Restauratie' }, @@ -237,7 +380,7 @@ const TREATMENT_LABELS: Record> = { hygiene: { en: 'Hygiene', fa: 'بهداشت', nl: 'Hygiëne' }, }; -const PROSTHESIS_LABELS: Record> = { +export const PROSTHESIS_LABELS: Record> = { pfm_crown: { en: 'PFM Crown', fa: 'روکش PFM', nl: 'PFM Kroon' }, pfz_crown: { en: 'PFZ Crown', fa: 'روکش PFZ', nl: 'PFZ Kroon' }, monolithic_zirconia: { en: 'Monolithic Zirconia', fa: 'زیرکونیا مونولیتیک', nl: 'Monolithisch Zirconia' }, @@ -246,23 +389,43 @@ const PROSTHESIS_LABELS: Record> = { temporary_resin_crown: { en: 'Temporary Resin Crown', fa: 'روکش موقت رزینی', nl: 'Tijdelijke Harskroon' }, pmma: { en: 'PMMA', fa: 'PMMA', nl: 'PMMA' }, peek_crown: { en: 'PEEK Crown', fa: 'روکش PEEK', nl: 'PEEK Kroon' }, - veneer_zirconia: { en: 'Veneer Zirconia', fa: 'ونیر زیرکونیا', nl: 'Veneer Zirconia' }, - veneer_ips_press: { en: 'Veneer IPS Press', fa: 'ونیر IPS پرس', nl: 'Veneer IPS Press' }, - veneer_ips_cad: { en: 'Veneer IPS CAD', fa: 'ونیر IPS CAD', nl: 'Veneer IPS CAD' }, - soft_structure: { en: 'Soft Structure', fa: 'ساختار نرم', nl: 'Zachte Structuur' }, - customized_abutment: { en: 'Customized Abutment', fa: 'اباتمنت سفارشی', nl: 'Aangepast Abutment' }, + press_ceramic: { en: 'Pressed Ceramic / IPS e.max', fa: 'سرامیک پرس / IPS e.max', nl: 'Perskeramiek / IPS e.max' }, + veneer_full_contour: { en: 'Veneer · Full contour', fa: 'ونیر · تمام‌کانتور', nl: 'Veneer · Full contour' }, + veneer_layered: { en: 'Veneer · Layered ceramic', fa: 'ونیر · سرامیک لایه‌ای', nl: 'Veneer · Gelaagd keramiek' }, + inlay_full_contour: { en: 'Inlay · Full contour', fa: 'اینلی · تمام‌کانتور', nl: 'Inlay · Full contour' }, + inlay_layered: { en: 'Inlay · Layered ceramic', fa: 'اینلی · سرامیک لایه‌ای', nl: 'Inlay · Gelaagd keramiek' }, + onlay_full_contour: { en: 'Onlay · Full contour', fa: 'آنلی · تمام‌کانتور', nl: 'Onlay · Full contour' }, + onlay_layered: { en: 'Onlay · Layered ceramic', fa: 'آنلی · سرامیک لایه‌ای', nl: 'Onlay · Gelaagd keramiek' }, + overlay_full_contour: { en: 'Overlay · Full contour', fa: 'اورلی · تمام‌کانتور', nl: 'Overlay · Full contour' }, + overlay_layered: { en: 'Overlay · Layered ceramic', fa: 'اورلی · سرامیک لایه‌ای', nl: 'Overlay · Gelaagd keramiek' }, + cast_post_core: { en: 'Cast Post & Core', fa: 'پست و کور ریختگی', nl: 'Gegoten Stiftopbouw' }, + fiber_post_core: { en: 'Fiber Post & Core', fa: 'پست و کور فایبر', nl: 'Fiber Stiftopbouw' }, + customized_abutment: { en: 'Custom Abutment (Titanium)', fa: 'اباتمنت سفارشی (تیتانیوم)', nl: 'Aangepast abutment (titanium)' }, prefabricated_abutment: { en: 'Prefabricated Abutment', fa: 'اباتمنت آماده', nl: 'Prefab Abutment' }, ti_base_abutment: { en: 'Ti Base Abutment', fa: 'اباتمنت پایه تیتانیوم', nl: 'Ti Basis Abutment' }, multi_unit_abutment: { en: 'Multi Unit Abutment', fa: 'اباتمنت مولتی یونیت', nl: 'Multi Unit Abutment' }, - zirconia_abutment: { en: 'Zirconia Abutment', fa: 'اباتمنت زیرکونیا', nl: 'Zirconia Abutment' }, + zirconia_abutment: { en: 'Custom Abutment (Zirconia)', fa: 'اباتمنت سفارشی (زیرکونیا)', nl: 'Aangepast abutment (zirconia)' }, screw_retained: { en: 'Screw Retained', fa: 'پیچی', nl: 'Schroefgehouden' }, - zirconia_overlay: { en: 'Zirconia Overlay', fa: 'اورلی زیرکونیا', nl: 'Zirconia Overlay' }, - ips_overlay: { en: 'IPS Overlay', fa: 'اورلی IPS', nl: 'IPS Overlay' }, + complete_denture: { en: 'Complete Denture', fa: 'دنچر کامل', nl: 'Volledige prothese' }, + partial_denture: { en: 'Partial Denture', fa: 'دنچر پارسیل', nl: 'Partiële prothese' }, + overdenture: { en: 'Overdenture', fa: 'اوردنچر', nl: 'Overkappingsprothese' }, + night_guard_soft: { en: 'Night Guard · Soft', fa: 'نایت گارد · نرم', nl: 'Nachtbeugel · Zacht' }, + night_guard_hard: { en: 'Night Guard · Hard', fa: 'نایت گارد · سخت', nl: 'Nachtbeugel · Hard' }, + night_guard_dual: { en: 'Night Guard · Dual laminate', fa: 'نایت گارد · دو لایه', nl: 'Nachtbeugel · Dual laminate' }, + bleaching_tray: { en: 'Bleaching Tray', fa: 'تری بلیچینگ', nl: 'Bleeklepel' }, + clear_aligner: { en: 'Clear Aligner', fa: 'الاینر شفاف', nl: 'Clear aligner' }, + soft_structure: { en: 'Soft Structure', fa: 'ساختار نرم', nl: 'Zachte Structuur' }, + surgical_guide: { en: 'Surgical Guide', fa: 'گاید جراحی', nl: 'Chirurgische mal' }, smile_design: { en: 'Smile Design', fa: 'طراحی لبخند', nl: 'Smile Design' }, mockup: { en: 'Mockup', fa: 'ماکاپ', nl: 'Mockup' }, + veneer_zirconia: { en: 'Veneer Zirconia', fa: 'ونیر زیرکونیا', nl: 'Veneer Zirconia' }, + veneer_ips_press: { en: 'Veneer IPS Press', fa: 'ونیر IPS پرس', nl: 'Veneer IPS Press' }, + veneer_ips_cad: { en: 'Veneer IPS CAD', fa: 'ونیر IPS CAD', nl: 'Veneer IPS CAD' }, + zirconia_overlay: { en: 'Zirconia Overlay', fa: 'اورلی زیرکونیا', nl: 'Zirconia Overlay' }, + ips_overlay: { en: 'IPS Overlay', fa: 'اورلی IPS', nl: 'IPS Overlay' }, }; -const WORKFLOW_STEP_LABELS: Record> = { +export const WORKFLOW_STEP_LABELS: Record> = { intraoral_scan: { en: 'Intraoral Scan', fa: 'اسکن داخل دهان', nl: 'Intraorale Scan' }, design: { en: 'Design', fa: 'طراحی', nl: 'Ontwerp' }, milling_dry: { en: 'Milling Dry', fa: 'فرز خشک', nl: 'Droog Frezen' }, diff --git a/backend/prisma/export-prosthesis-catalog.ts b/backend/prisma/export-prosthesis-catalog.ts new file mode 100644 index 0000000..79afe42 --- /dev/null +++ b/backend/prisma/export-prosthesis-catalog.ts @@ -0,0 +1,286 @@ +/** + * Regenerates docs/prosthesis-catalog.xlsx from catalog-seed-data.ts. + * + * cd backend && npx ts-node --transpile-only prisma/export-prosthesis-catalog.ts + */ +import * as fs from 'fs'; +import * as path from 'path'; +import * as zlib from 'zlib'; +import { + PROSTHESIS_TYPES, + PROSTHESIS_LABELS, + WORKFLOW_STEP_LABELS, + buildProsthesisStepCodes, +} from './catalog-seed-data'; + +const CATEGORY_LABELS: Record = { + crown: 'Crowns', + indirect: 'Indirect restorations', + implant: 'Implants', + post_core: 'Post & core', + removable: 'Removable', + appliance: 'Appliances', + digital: 'Digital', +}; + +const SUBCATEGORY_LABELS: Record = { + full_contour: 'Full contour', + layered: 'Layered', + veneer: 'Veneer', + inlay: 'Inlay', + onlay: 'Onlay', + overlay: 'Overlay', + complete_denture: 'Complete denture', + partial_denture: 'Partial denture', + night_guard: 'Night guard', +}; + +const STACK_RULES = [ + ['Slot', 'Rule'], + [ + 'restoration', + 'One per tooth: crown, veneer, inlay, onlay, overlay, or screw-retained.', + ], + ['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.'], + [ + 'arch', + 'One arch product (denture, night guard, digital). Most arch products block implant add-ons; overdenture may take an implant add-on.', + ], + [ + 'screw_retained', + 'Fills the restoration slot and blocks implant + post & core (it is the restoration).', + ], + ['maximum', 'Usual maximum is 3 types per tooth (e.g. zirconia crown + Ti-base + fiber post).'], + ['two_crowns', 'Two crowns cannot stack.'], +]; + +function xmlEscape(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function colLetter(index: number): string { + let n = index + 1; + let s = ''; + while (n > 0) { + const r = (n - 1) % 26; + s = String.fromCharCode(65 + r) + s; + n = Math.floor((n - 1) / 26); + } + return s; +} + +function sheetXml(rows: string[][]): string { + const cells = rows + .map((row, r) => { + const rowNum = r + 1; + const inner = row + .map((value, c) => { + const ref = `${colLetter(c)}${rowNum}`; + return `${xmlEscape(value)}`; + }) + .join(''); + return `${inner}`; + }) + .join(''); + const dim = `A1:${colLetter(Math.max(0, ...(rows.map((r) => r.length - 1))))}${rows.length || 1}`; + return ( + `` + + `` + + `` + + `${cells}` + + `` + ); +} + +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let i = 0; i < 256; i++) { + let c = i; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table[i] = c >>> 0; + } + return table; +})(); + +function crc32(buf: Buffer): number { + let crc = 0xffffffff; + for (let i = 0; i < buf.length; i++) { + crc = CRC_TABLE[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +} + +function zipStore(files: Array<{ name: string; data: Buffer }>): Buffer { + const locals: Buffer[] = []; + const centrals: Buffer[] = []; + let offset = 0; + + for (const file of files) { + const name = Buffer.from(file.name, 'utf8'); + const compressed = zlib.deflateRawSync(file.data); + const crc = crc32(file.data); + const local = Buffer.alloc(30 + name.length); + local.writeUInt32LE(0x04034b50, 0); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(0, 6); + local.writeUInt16LE(8, 8); + local.writeUInt16LE(0, 10); + local.writeUInt16LE(0, 12); + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(compressed.length, 18); + local.writeUInt32LE(file.data.length, 22); + local.writeUInt16LE(name.length, 26); + local.writeUInt16LE(0, 28); + name.copy(local, 30); + locals.push(local, compressed); + + const central = Buffer.alloc(46 + name.length); + central.writeUInt32LE(0x02014b50, 0); + central.writeUInt16LE(20, 4); + central.writeUInt16LE(20, 6); + central.writeUInt16LE(0, 8); + central.writeUInt16LE(8, 10); + central.writeUInt16LE(0, 12); + central.writeUInt16LE(0, 14); + central.writeUInt32LE(crc, 16); + central.writeUInt32LE(compressed.length, 20); + central.writeUInt32LE(file.data.length, 24); + central.writeUInt16LE(name.length, 28); + central.writeUInt16LE(0, 30); + central.writeUInt16LE(0, 32); + central.writeUInt16LE(0, 34); + central.writeUInt16LE(0, 36); + central.writeUInt32LE(0, 38); + central.writeUInt32LE(offset, 42); + name.copy(central, 46); + centrals.push(central); + + offset += local.length + compressed.length; + } + + const centralDir = Buffer.concat(centrals); + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); + eocd.writeUInt16LE(0, 4); + eocd.writeUInt16LE(0, 6); + eocd.writeUInt16LE(files.length, 8); + eocd.writeUInt16LE(files.length, 10); + eocd.writeUInt32LE(centralDir.length, 12); + eocd.writeUInt32LE(offset, 16); + eocd.writeUInt16LE(0, 20); + + return Buffer.concat([...locals, centralDir, eocd]); +} + +function buildWorkbook(): Buffer { + const typeRows: string[][] = [ + [ + 'category', + 'category_label', + 'subcategory', + 'subcategory_label', + 'code', + 'label_en', + 'label_fa', + 'label_nl', + 'chartRegion', + 'stackGroup', + 'addonKind', + 'active', + ], + ]; + for (const type of PROSTHESIS_TYPES) { + const labels = PROSTHESIS_LABELS[type.code] ?? {}; + typeRows.push([ + type.category, + CATEGORY_LABELS[type.category] ?? type.category, + type.subcategory ?? '', + type.subcategory ? SUBCATEGORY_LABELS[type.subcategory] ?? type.subcategory : '', + type.code, + labels.en ?? type.code, + labels.fa ?? '', + labels.nl ?? '', + type.chartRegion, + type.stackGroup, + type.addonKind ?? '', + type.isActive === false ? 'false' : 'true', + ]); + } + + const stepRows: string[][] = [ + ['code', 'label_en', 'step_order', 'step_code', 'step_en', 'step_fa', 'step_nl'], + ]; + for (const type of PROSTHESIS_TYPES) { + if (type.isActive === false) continue; + const labels = PROSTHESIS_LABELS[type.code] ?? {}; + const steps = buildProsthesisStepCodes(type); + steps.forEach((stepCode, index) => { + const stepLabels = WORKFLOW_STEP_LABELS[stepCode] ?? {}; + stepRows.push([ + type.code, + labels.en ?? type.code, + String(index + 1), + stepCode, + stepLabels.en ?? stepCode, + stepLabels.fa ?? '', + stepLabels.nl ?? '', + ]); + }); + } + + const contentTypes = + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + ``; + + const rels = + `` + + `` + + `` + + ``; + + const workbook = + `` + + `` + + `` + + `` + + `` + + `` + + `` + + ``; + + const workbookRels = + `` + + `` + + `` + + `` + + `` + + ``; + + return zipStore([ + { name: '[Content_Types].xml', data: Buffer.from(contentTypes, 'utf8') }, + { name: '_rels/.rels', data: Buffer.from(rels, 'utf8') }, + { name: 'xl/workbook.xml', data: Buffer.from(workbook, 'utf8') }, + { name: 'xl/_rels/workbook.xml.rels', data: Buffer.from(workbookRels, 'utf8') }, + { name: 'xl/worksheets/sheet1.xml', data: Buffer.from(sheetXml(typeRows), 'utf8') }, + { name: 'xl/worksheets/sheet2.xml', data: Buffer.from(sheetXml(stepRows), 'utf8') }, + { name: 'xl/worksheets/sheet3.xml', data: Buffer.from(sheetXml(STACK_RULES), 'utf8') }, + ]); +} + +const outPath = path.resolve(__dirname, '../../docs/prosthesis-catalog.xlsx'); +fs.mkdirSync(path.dirname(outPath), { recursive: true }); +fs.writeFileSync(outPath, buildWorkbook()); +console.log(`Wrote ${outPath}`); diff --git a/backend/prisma/migrations/20260901120000_prosthesis_tree_multi_type/migration.sql b/backend/prisma/migrations/20260901120000_prosthesis_tree_multi_type/migration.sql new file mode 100644 index 0000000..3c344e1 --- /dev/null +++ b/backend/prisma/migrations/20260901120000_prosthesis_tree_multi_type/migration.sql @@ -0,0 +1,26 @@ +-- Prosthesis type tree fields + multiple work items per tooth. + +ALTER TABLE "prosthesis_types" + ADD COLUMN IF NOT EXISTS "category" TEXT NOT NULL DEFAULT 'crown', + ADD COLUMN IF NOT EXISTS "subcategory" TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS "chartRegion" TEXT NOT NULL DEFAULT 'crown', + ADD COLUMN IF NOT EXISTS "stackGroup" TEXT NOT NULL DEFAULT 'restoration', + ADD COLUMN IF NOT EXISTS "addonKind" TEXT NOT NULL DEFAULT ''; + +ALTER TABLE "lab_case_tooth_prosthesis" + DROP CONSTRAINT IF EXISTS "lab_case_tooth_prosthesis_labCaseId_sourceKey_tooth_key"; + +DROP INDEX IF EXISTS "lab_case_tooth_prosthesis_labCaseId_sourceKey_tooth_key"; + +-- Deduplicate stacked rows that would collide after adding prosthesisTypeCode to the unique key +-- (legacy unique already forbade two types per tooth, so this is a no-op for current data). +DELETE FROM "lab_case_tooth_prosthesis" AS a + USING "lab_case_tooth_prosthesis" AS b +WHERE a."id" > b."id" + AND a."labCaseId" = b."labCaseId" + AND a."sourceKey" = b."sourceKey" + AND a."tooth" = b."tooth" + AND a."prosthesisTypeCode" = b."prosthesisTypeCode"; + +CREATE UNIQUE INDEX IF NOT EXISTS "lab_case_tooth_prosthesis_case_source_tooth_type_key" + ON "lab_case_tooth_prosthesis"("labCaseId", "sourceKey", "tooth", "prosthesisTypeCode"); diff --git a/backend/prisma/migrations/20260901130000_lab_case_task_per_tooth/migration.sql b/backend/prisma/migrations/20260901130000_lab_case_task_per_tooth/migration.sql new file mode 100644 index 0000000..71f4ae3 --- /dev/null +++ b/backend/prisma/migrations/20260901130000_lab_case_task_per_tooth/migration.sql @@ -0,0 +1,12 @@ +-- One LabCaseTask pipeline per tooth: unique must include tooth. +ALTER TABLE "lab_case_tasks" ADD COLUMN IF NOT EXISTS "tooth" TEXT NOT NULL DEFAULT ''; + +UPDATE "lab_case_tasks" +SET "tooth" = COALESCE("teeth"->>0, '') +WHERE "tooth" = ''; + +ALTER TABLE "lab_case_tasks" DROP CONSTRAINT IF EXISTS "lab_case_tasks_case_source_prosthesis_step_key"; +DROP INDEX IF EXISTS "lab_case_tasks_case_source_prosthesis_step_key"; + +CREATE UNIQUE INDEX IF NOT EXISTS "lab_case_tasks_case_source_tooth_prosthesis_step_key" + ON "lab_case_tasks"("labCaseId", "sourceKey", "tooth", "prosthesisTypeCode", "stepOrder"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index b1508d2..170874b 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -351,6 +351,16 @@ model ProsthesisType { sortOrder Int @default(0) isActive Boolean @default(true) skipPackingShipping Boolean @default(false) + /// Picker column: crown | indirect | implant | post_core | removable | appliance | digital + category String @default("crown") + /// Optional second drill (veneer/inlay/onlay/overlay, night_guard, …). Empty if none. + subcategory String @default("") + /// Chart tint: crown | root | arch + chartRegion String @default("crown") + /// Mutual exclusion group: restoration | implant | post_core | arch + stackGroup String @default("restoration") + /// Also listed as an add-on: implant | post_core | empty + addonKind String @default("") steps ProsthesisTypeStep[] @@ -398,7 +408,7 @@ model LabCaseToothProsthesis { detail TreatmentDetail? @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade) line LabCaseLine? @relation(fields: [lineId], references: [id], onDelete: Cascade) - @@unique([labCaseId, sourceKey, tooth]) + @@unique([labCaseId, sourceKey, tooth, prosthesisTypeCode], map: "lab_case_tooth_prosthesis_case_source_tooth_type_key") @@map("lab_case_tooth_prosthesis") } @@ -409,6 +419,8 @@ model LabCaseTask { lineId String? /// treatmentDetailId (clinic) or lineId (lab-origin) — required unique grouping key. sourceKey String + /// Single FDI tooth or arch sentinel (UA/LA). One task pipeline per tooth. + tooth String @default("") teeth Json treatmentType String prosthesisTypeCode String @@ -433,7 +445,7 @@ model LabCaseTask { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@unique([labCaseId, sourceKey, prosthesisTypeCode, stepOrder], map: "lab_case_tasks_case_source_prosthesis_step_key") + @@unique([labCaseId, sourceKey, tooth, prosthesisTypeCode, stepOrder], map: "lab_case_tasks_case_source_tooth_prosthesis_step_key") @@index([labCaseId, status]) @@index([assigneeUserId]) @@map("lab_case_tasks") diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index 024d958..0845457 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -183,22 +183,30 @@ async function main() { ); for (const type of PROSTHESIS_TYPES) { + const treeFields = { + sortOrder: type.sortOrder, + skipPackingShipping: type.skipPackingShipping ?? false, + isActive: type.isActive ?? true, + category: type.category, + subcategory: type.subcategory ?? '', + chartRegion: type.chartRegion, + stackGroup: type.stackGroup, + addonKind: type.addonKind ?? '', + }; const prosthesisType = await prisma.prosthesisType.upsert({ where: { code: type.code }, - update: { - sortOrder: type.sortOrder, - skipPackingShipping: type.skipPackingShipping ?? false, - isActive: true, - }, + update: treeFields, create: { id: randomUUID(), code: type.code, - sortOrder: type.sortOrder, - skipPackingShipping: type.skipPackingShipping ?? false, - isActive: true, + ...treeFields, }, }); + await prisma.prosthesisTypeStep.deleteMany({ + where: { prosthesisTypeId: prosthesisType.id }, + }); + const stepCodes = buildProsthesisStepCodes(type); for (const [index, stepCode] of stepCodes.entries()) { const labWorkflowStepId = workflowStepByCode.get(stepCode); @@ -206,15 +214,8 @@ async function main() { throw new Error(`Unknown workflow step code: ${stepCode}`); } - await prisma.prosthesisTypeStep.upsert({ - where: { - prosthesisTypeId_stepOrder: { - prosthesisTypeId: prosthesisType.id, - stepOrder: index + 1, - }, - }, - update: { labWorkflowStepId }, - create: { + await prisma.prosthesisTypeStep.create({ + data: { id: randomUUID(), prosthesisTypeId: prosthesisType.id, labWorkflowStepId, diff --git a/backend/src/common/prosthesis-group.ts b/backend/src/common/prosthesis-group.ts new file mode 100644 index 0000000..14963f6 --- /dev/null +++ b/backend/src/common/prosthesis-group.ts @@ -0,0 +1,78 @@ +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; + + 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); + } + } + } + + const out: string[] = []; + if (hasScan) out.push('intraoral_scan'); + if (hasDesign) out.push('design'); + out.push(...middle); + if (hasPacking) out.push('packing'); + if (hasShipping) out.push('shipping'); + return out; +} + +export function prosthesisGroupKey(codes: readonly string[]): string { + return [...new Set(codes.filter(Boolean))].sort().join('+'); +} + +export function splitProsthesisGroupKey(key: string): string[] { + return key.split('+').map((part) => part.trim()).filter(Boolean); +} + +export function prosthesisTypeTaskWhere(code: string): Prisma.LabCaseTaskWhereInput { + return { + OR: [ + { prosthesisTypeCode: code }, + { prosthesisTypeCode: { startsWith: `${code}+` } }, + { prosthesisTypeCode: { endsWith: `+${code}` } }, + { prosthesisTypeCode: { contains: `+${code}+` } }, + ], + }; +} + +export function prosthesisTypeCaseWhere(code: string): Prisma.LabCaseWhereInput { + return { + OR: [ + { toothProsthesis: { some: { prosthesisTypeCode: code } } }, + { tasks: { some: prosthesisTypeTaskWhere(code) } }, + ], + }; +} + +export function atomicProsthesisCodes(codes: Iterable): string[] { + const out = new Set(); + for (const code of codes) { + if (!code) continue; + for (const part of splitProsthesisGroupKey(code)) out.add(part); + } + return [...out]; +} + +export function prosthesisGroupLabel( + groupKey: string, + labels: ReadonlyMap, +): string { + const parts = splitProsthesisGroupKey(groupKey); + if (parts.length === 0) return groupKey; + return parts.map((code) => labels.get(code) ?? code).join(' + '); +} diff --git a/backend/src/modules/cases/cases.service.ts b/backend/src/modules/cases/cases.service.ts index 93028e7..ea23ce0 100644 --- a/backend/src/modules/cases/cases.service.ts +++ b/backend/src/modules/cases/cases.service.ts @@ -20,12 +20,21 @@ import { generateLabCaseTasks } from './lab-case-task.generator'; import { isLabCaseOverdue, } from '../../common/lab-case-due-date'; -import { normalizeTaskTeeth } from './lab-case-task.util'; +import { + aggregateProsthesisGroupsFromTasks, + connectedSelectionGroupIds, + normalizeTaskTeeth, +} from './lab-case-task.util'; import { hasEffectivePermission } from '../../common/membership-permissions'; import { LAB_CASES_TAB_ACTIVITY_TYPES } from '../../common/lab-case-activity'; import { LabCaseActivityService } from '../notifications/lab-case-activity.service'; import { UserNotificationService } from '../notifications/user-notification.service'; import { LabCaseAccessService } from './lab-case-access.service'; +import { + atomicProsthesisCodes, + prosthesisGroupLabel, + prosthesisTypeCaseWhere, +} from '../../common/prosthesis-group'; const labCaseListInclude = { treatment: { @@ -215,7 +224,7 @@ export class CasesService { } } - const typeCodes = new Set(taskRows.map((row) => row.prosthesisTypeCode)); + const typeCodes = new Set(atomicProsthesisCodes(taskRows.map((row) => row.prosthesisTypeCode))); const catalog = await this.prosthesisCatalog.list(); const prosthesisTypes = catalog .filter((entry) => typeCodes.has(entry.code)) @@ -984,13 +993,7 @@ export class CasesService { AND: [ { OR: [received, ownedInternal] }, ...(query.prosthesisTypeCode - ? [ - { - tasks: { - some: { prosthesisTypeCode: query.prosthesisTypeCode }, - }, - } satisfies Prisma.LabCaseWhereInput, - ] + ? [prosthesisTypeCaseWhere(query.prosthesisTypeCode)] : []), ...(query.q?.trim() ? [this.buildSearchWhere(query.q.trim())] : []), ], @@ -1111,7 +1114,7 @@ export class CasesService { lc.details[0]?.detail?.treatmentType ?? lc.lines[0]?.treatmentType ?? null; const link = lc.details[0]; const { clinic, patient } = this.resolveClinicAndPatient(lc); - const prosthesisCodes = [...new Set(lc.tasks.map((t) => t.prosthesisTypeCode).filter(Boolean))]; + const prosthesisCodes = atomicProsthesisCodes(lc.tasks.map((t) => t.prosthesisTypeCode)); const prosthesisLabels = await this.catalogLabels.resolveLabels( CatalogEntityKind.PROSTHESIS_TYPE, prosthesisCodes, @@ -1208,35 +1211,7 @@ export class CasesService { connected?: boolean; selectionGroupId?: string; }> { - const prosthesisByGroup = new Map< - string, - { prosthesisTypeCode: string; teeth: string[]; selectionGroupId: string } - >(); - - for (const task of tasks) { - if (!task.prosthesisTypeCode) continue; - const selectionGroupId = task.selectionGroupId ?? ''; - const key = `${selectionGroupId}::${task.prosthesisTypeCode}`; - const teeth = normalizeTaskTeeth(task.teeth); - const entry = prosthesisByGroup.get(key) ?? { - prosthesisTypeCode: task.prosthesisTypeCode, - teeth: [], - selectionGroupId, - }; - entry.teeth.push(...teeth); - prosthesisByGroup.set(key, entry); - } - - return [...prosthesisByGroup.values()] - .map((group) => ({ - prosthesisTypeCode: group.prosthesisTypeCode, - teeth: [...new Set(group.teeth)].sort((a, b) => - a.localeCompare(b, undefined, { numeric: true }), - ), - selectionGroupId: group.selectionGroupId || undefined, - connected: Boolean(group.selectionGroupId) && [...new Set(group.teeth)].length > 1, - })) - .sort((a, b) => a.prosthesisTypeCode.localeCompare(b.prosthesisTypeCode)); + return aggregateProsthesisGroupsFromTasks(tasks); } private groupTasks( @@ -1256,16 +1231,18 @@ export class CasesService { } >(); + const connectedIds = connectedSelectionGroupIds(tasks); + for (const task of tasks) { const selectionGroupId = task.selectionGroupId ?? ''; - const key = `${task.sourceKey ?? task.treatmentDetailId ?? task.lineId}:${selectionGroupId}:${task.prosthesisTypeCode}`; + const key = `${task.sourceKey ?? task.treatmentDetailId ?? task.lineId}:${normalizeTaskTeeth(task.teeth).join(',')}:${task.prosthesisTypeCode}`; const entry = groups.get(key) ?? { treatmentDetailId: task.treatmentDetailId ?? task.sourceKey ?? task.lineId ?? '', teeth: normalizeTaskTeeth(task.teeth), treatmentType: task.treatmentType, prosthesisTypeCode: task.prosthesisTypeCode, prosthesisTypeLabel: - prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode, + prosthesisGroupLabel(task.prosthesisTypeCode, prosthesisLabels), selectionGroupId, tasks: [], }; @@ -1280,7 +1257,7 @@ export class CasesService { prosthesisTypeCode: group.prosthesisTypeCode, prosthesisTypeLabel: group.prosthesisTypeLabel, selectionGroupId: group.selectionGroupId || undefined, - connected: Boolean(group.selectionGroupId) && [...new Set(group.teeth)].length > 1, + connected: Boolean(group.selectionGroupId) && connectedIds.has(group.selectionGroupId), tasks: group.tasks, })); } @@ -1296,7 +1273,7 @@ export class CasesService { treatmentType: task.treatmentType, prosthesisTypeCode: task.prosthesisTypeCode, prosthesisTypeLabel: - prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode, + prosthesisGroupLabel(task.prosthesisTypeCode, prosthesisLabels), workflowStepCode: task.workflowStepCode ?? '', stepOrder: task.stepOrder, stepLabel: task.stepLabel, diff --git a/backend/src/modules/cases/lab-case-access.service.ts b/backend/src/modules/cases/lab-case-access.service.ts index 2e681fa..9d21c20 100644 --- a/backend/src/modules/cases/lab-case-access.service.ts +++ b/backend/src/modules/cases/lab-case-access.service.ts @@ -15,8 +15,12 @@ import { normalizeCatalogLocale, } from '../catalog/catalog-label.service'; import { isActorTreatmentProvider } from '../../common/treatment-provider-scope'; -import { normalizeTaskTeeth } from './lab-case-task.util'; +import { aggregateProsthesisGroupsFromTasks } from './lab-case-task.util'; import { isLabCaseOverdue } from '../../common/lab-case-due-date'; +import { + atomicProsthesisCodes, + prosthesisGroupLabel, +} from '../../common/prosthesis-group'; import { TasksService } from '../tasks/tasks.service'; import { LabCaseCommentsService } from '../lab-case-comments/lab-case-comments.service'; import { CreateLabCaseCommentDto } from '../lab-case-comments/dto/lab-case-comment.dto'; @@ -95,18 +99,14 @@ export class LabCaseAccessService { } const locale = normalizeCatalogLocale(localeInput); - const prosthesisCodes = [ - ...new Set( - ( - await this.prisma.labCaseTask.findMany({ - where: { labCaseId: labCase.id }, - select: { prosthesisTypeCode: true }, - }) - ) - .map((t) => t.prosthesisTypeCode) - .filter(Boolean), - ), - ]; + const prosthesisCodes = atomicProsthesisCodes( + ( + await this.prisma.labCaseTask.findMany({ + where: { labCaseId: labCase.id }, + select: { prosthesisTypeCode: true }, + }) + ).map((t) => t.prosthesisTypeCode), + ); const prosthesisLabels = await this.catalogLabels.resolveLabels( CatalogEntityKind.PROSTHESIS_TYPE, prosthesisCodes, @@ -303,37 +303,11 @@ export class LabCaseAccessService { orderBy: [{ prosthesisTypeCode: 'asc' }], }); - const byGroup = new Map< - string, - { prosthesisTypeCode: string; teeth: string[]; selectionGroupId: string } - >(); - for (const task of tasks) { - if (!task.prosthesisTypeCode) continue; - const selectionGroupId = task.selectionGroupId ?? ''; - const key = `${selectionGroupId}::${task.prosthesisTypeCode}`; - const teeth = normalizeTaskTeeth(task.teeth); - const entry = byGroup.get(key) ?? { - prosthesisTypeCode: task.prosthesisTypeCode, - teeth: [], - selectionGroupId, - }; - entry.teeth.push(...teeth); - byGroup.set(key, entry); - } - - return [...byGroup.values()].map((group) => { - const teeth = [...new Set(group.teeth)].sort((a, b) => - a.localeCompare(b, undefined, { numeric: true }), - ); - return { - prosthesisTypeCode: group.prosthesisTypeCode, - prosthesisTypeLabel: - prosthesisLabels.get(group.prosthesisTypeCode) ?? group.prosthesisTypeCode, - teeth, - selectionGroupId: group.selectionGroupId || undefined, - connected: Boolean(group.selectionGroupId) && teeth.length > 1, - }; - }); + return aggregateProsthesisGroupsFromTasks(tasks).map((group) => ({ + ...group, + prosthesisTypeLabel: + prosthesisGroupLabel(group.prosthesisTypeCode, prosthesisLabels), + })); } private async getMembership(userId: string, organizationId: string) { 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 99b114f..4622720 100644 --- a/backend/src/modules/cases/lab-case-task.generator.spec.ts +++ b/backend/src/modules/cases/lab-case-task.generator.spec.ts @@ -133,7 +133,7 @@ describe('generateLabCaseTasks', () => { expect(stepCodes).toContain('milling_wet'); }); - it('groups teeth sharing a prosthesis in one detail, and keeps other prosthesis separate', async () => { + it('keeps one task set per tooth even when they share a prosthesis', async () => { const pfmSteps = stepsFromSeed('pfm_crown'); const zirconiaSteps = stepsFromSeed('monolithic_zirconia'); const { tx, created } = buildMockTx({ @@ -150,16 +150,24 @@ describe('generateLabCaseTasks', () => { const count = await generateLabCaseTasks(tx as never, 'lab-case-group', 'en'); - expect(count).toBe(pfmSteps.length + zirconiaSteps.length); + expect(count).toBe(pfmSteps.length * 2 + zirconiaSteps.length); const rows = created as Array<{ teeth: string[]; prosthesisTypeCode: string }>; - const pfmRows = rows.filter((r) => r.prosthesisTypeCode === 'pfm_crown'); + const pfm14 = rows.filter( + (r) => r.prosthesisTypeCode === 'pfm_crown' && r.teeth.join() === '14', + ); + const pfm15 = rows.filter( + (r) => r.prosthesisTypeCode === 'pfm_crown' && r.teeth.join() === '15', + ); const zirconiaRows = rows.filter((r) => r.prosthesisTypeCode === 'monolithic_zirconia'); - expect(pfmRows).toHaveLength(pfmSteps.length); + expect(pfm14).toHaveLength(pfmSteps.length); + expect(pfm15).toHaveLength(pfmSteps.length); expect(zirconiaRows).toHaveLength(zirconiaSteps.length); - // Teeth sharing the prosthesis in the same detail are merged and sorted. - expect(pfmRows.every((r) => JSON.stringify(r.teeth) === JSON.stringify(['14', '15']))).toBe(true); expect(zirconiaRows.every((r) => JSON.stringify(r.teeth) === JSON.stringify(['16']))).toBe(true); + const uniqueKeys = new Set( + rows.map((r) => `${r.teeth.join()}:${r.prosthesisTypeCode}`), + ); + expect(uniqueKeys.size).toBe(3); }); it('omits packing and shipping for smile_design', async () => { @@ -181,12 +189,13 @@ describe('generateLabCaseTasks', () => { const stepCodes = (created as Array<{ workflowStepCode: string }>).map( (row) => row.workflowStepCode, ); + expect(stepCodes).toEqual(['intraoral_scan', 'design']); expect(stepCodes).not.toContain('packing'); expect(stepCodes).not.toContain('shipping'); - expect(stepCodes).toContain('printer_resin'); + expect(stepCodes).not.toContain('printer_resin'); }); - it('merges teeth with the same prosthesis type across selection groups', async () => { + it('keeps connected and single teeth as separate per-tooth task sets', async () => { const pfmSteps = stepsFromSeed('pfm_crown'); const { tx, created } = buildMockTx({ toothProsthesisRows: [ @@ -213,12 +222,10 @@ describe('generateLabCaseTasks', () => { }); const count = await generateLabCaseTasks(tx as never, 'lab-case-merge', 'en'); - expect(count).toBe(pfmSteps.length); + expect(count).toBe(pfmSteps.length * 3); const rows = created as Array<{ teeth: string[]; prosthesisTypeCode: string }>; - expect(rows).toHaveLength(pfmSteps.length); - expect(rows.every((r) => JSON.stringify(r.teeth) === JSON.stringify(['14', '15', '21']))).toBe( - true, - ); + const teethSets = new Set(rows.map((r) => r.teeth.join(','))); + expect(teethSets).toEqual(new Set(['14', '15', '21'])); }); it('skips generation when tasks already exist', async () => { @@ -255,12 +262,56 @@ describe('generateLabCaseTasks', () => { }); const count = await generateLabCaseTasks(tx as never, 'lab-internal-1', 'en'); - expect(count).toBe(pfmSteps.length); + expect(count).toBe(pfmSteps.length * 2); + const rows = created as Array<{ teeth: string[]; lineId: string }>; + const teethSets = new Set(rows.map((r) => r.teeth.join(','))); + expect(teethSets).toEqual(new Set(['11', '21'])); expect(created[0]).toMatchObject({ lineId: 'line-1', sourceKey: 'line-1', treatmentDetailId: null, - teeth: ['11', '21'], }); }); + + it('unions stacked types on the same tooth and de-dupes scan/pack/ship', async () => { + const tiSteps = stepsFromSeed('ti_base_abutment'); + const zrSteps = stepsFromSeed('monolithic_zirconia'); + const { tx, created } = buildMockTx({ + toothProsthesisRows: [ + { + treatmentDetailId: 'detail-1', + tooth: '46', + prosthesisTypeCode: 'ti_base_abutment', + }, + { + treatmentDetailId: 'detail-1', + tooth: '46', + prosthesisTypeCode: 'monolithic_zirconia', + }, + ], + prosthesisTypes: [ + { code: 'ti_base_abutment', steps: tiSteps }, + { code: 'monolithic_zirconia', steps: zrSteps }, + ], + }); + + const count = await generateLabCaseTasks(tx as never, 'lab-case-stack', 'en'); + const rows = created as Array<{ + workflowStepCode: string; + prosthesisTypeCode: string; + teeth: string[]; + }>; + expect(count).toBe(rows.length); + expect(rows.every((r) => r.teeth.join() === '46')).toBe(true); + expect(rows[0].prosthesisTypeCode).toBe('monolithic_zirconia+ti_base_abutment'); + const stepCodes = rows.map((r) => r.workflowStepCode); + expect(stepCodes.filter((c) => c === 'intraoral_scan')).toHaveLength(1); + expect(stepCodes.filter((c) => c === 'packing')).toHaveLength(1); + expect(stepCodes.filter((c) => c === 'shipping')).toHaveLength(1); + expect(stepCodes[0]).toBe('intraoral_scan'); + expect(stepCodes[stepCodes.length - 1]).toBe('shipping'); + expect(stepCodes).toContain('milling_dry'); + expect(stepCodes).toContain('sinter'); + expect(stepCodes).toContain('polish_prep'); + }); }); diff --git a/backend/src/modules/cases/lab-case-task.generator.ts b/backend/src/modules/cases/lab-case-task.generator.ts index 5caa5c0..af904f8 100644 --- a/backend/src/modules/cases/lab-case-task.generator.ts +++ b/backend/src/modules/cases/lab-case-task.generator.ts @@ -1,5 +1,9 @@ import { CatalogEntityKind, LabTaskStatus, Prisma } from '@prisma/client'; import { normalizeCatalogLocale } from '../catalog/catalog-label.service'; +import { + prosthesisGroupKey, + unionWorkflowStepCodes, +} from '../../common/prosthesis-group'; type TransactionClient = Prisma.TransactionClient; @@ -30,7 +34,7 @@ export async function generateLabCaseTasks( const prosthesisCodes = [...new Set(toothProsthesisRows.map((r) => r.prosthesisTypeCode))]; const prosthesisTypes = await tx.prosthesisType.findMany({ - where: { code: { in: prosthesisCodes }, isActive: true }, + where: { code: { in: prosthesisCodes } }, include: { steps: { orderBy: { stepOrder: 'asc' }, @@ -59,21 +63,18 @@ export async function generateLabCaseTasks( const stepLabels = await resolveStepLabels(tx, allStepCodes, locale); - // Merge all teeth that share the same prosthesis type on a detail into one - // task set (pre–selection-group behavior). selectionGroupId is stored for - // UI/history only — not part of the grouping key. - const groups = new Map< - string, - { - sourceKey: string; - treatmentDetailId: string | null; - lineId: string | null; - treatmentType: string; - prosthesisTypeCode: string; - selectionGroupId: string; - teeth: string[]; - } - >(); + // Per tooth: stacked types become one group key; steps are the union. + // Teeth are not merged even when they share the same type-set. + type ToothBucket = { + sourceKey: string; + treatmentDetailId: string | null; + lineId: string | null; + treatmentType: string; + selectionGroupId: string; + codes: Set; + }; + + const byTooth = new Map(); for (const row of toothProsthesisRows) { const sourceKey = @@ -82,9 +83,9 @@ export async function generateLabCaseTasks( row.lineId || ''; if (!sourceKey) continue; - const key = `${sourceKey}::${row.prosthesisTypeCode}`; + const toothKey = `${sourceKey}::${row.tooth}`; const selectionGroupId = row.selectionGroupId?.trim() || ''; - const group = groups.get(key) ?? { + const bucket = byTooth.get(toothKey) ?? { sourceKey, treatmentDetailId: row.treatmentDetailId, lineId: row.lineId, @@ -93,44 +94,50 @@ export async function generateLabCaseTasks( row.line?.treatmentType ?? row.treatmentType ?? 'prosthesis', - prosthesisTypeCode: row.prosthesisTypeCode, selectionGroupId, - teeth: [], + codes: new Set(), }; - // Keep first non-empty selectionGroupId for persistence; grouping ignores it. - if (!group.selectionGroupId && selectionGroupId) { - group.selectionGroupId = selectionGroupId; + if (!bucket.selectionGroupId && selectionGroupId) { + bucket.selectionGroupId = selectionGroupId; } - group.teeth.push(row.tooth); - groups.set(key, group); + bucket.codes.add(row.prosthesisTypeCode); + 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[] = []; - for (const group of groups.values()) { - const typeSteps = stepsByProsthesisCode.get(group.prosthesisTypeCode) ?? []; - if (typeSteps.length === 0) { + for (const [toothKey, bucket] of byTooth) { + const tooth = toothKey.slice(bucket.sourceKey.length + 2); + const codes = [...bucket.codes]; + const lists = codes.map((code) => + (stepsByProsthesisCode.get(code) ?? []).map((s) => s.workflowStepCode), + ); + const unioned = unionWorkflowStepCodes(lists); + if (unioned.length === 0) { continue; } - const teeth = sortTeeth(group.teeth); + const groupCode = prosthesisGroupKey(codes); - for (const step of typeSteps) { + unioned.forEach((workflowStepCode, index) => { taskRows.push({ labCaseId, - treatmentDetailId: group.treatmentDetailId, - lineId: group.lineId, - sourceKey: group.sourceKey, - teeth, - treatmentType: group.treatmentType, - prosthesisTypeCode: group.prosthesisTypeCode, - selectionGroupId: group.selectionGroupId, - workflowStepCode: step.workflowStepCode, - stepOrder: step.stepOrder, - stepLabel: stepLabels.get(step.workflowStepCode) ?? step.workflowStepCode, + treatmentDetailId: bucket.treatmentDetailId, + lineId: bucket.lineId, + sourceKey: bucket.sourceKey, + tooth, + teeth: [tooth], + treatmentType: bucket.treatmentType, + prosthesisTypeCode: groupCode, + selectionGroupId: bucket.selectionGroupId, + workflowStepCode, + stepOrder: index + 1, + stepLabel: stepLabels.get(workflowStepCode) ?? workflowStepCode, status: LabTaskStatus.IN_PROGRESS, }); - } + }); } if (taskRows.length === 0) { @@ -141,15 +148,6 @@ export async function generateLabCaseTasks( return taskRows.length; } -function sortTeeth(teeth: string[]): string[] { - return [...new Set(teeth)].sort((a, b) => { - const na = Number(a); - const nb = Number(b); - if (!Number.isNaN(na) && !Number.isNaN(nb)) return na - nb; - return a.localeCompare(b); - }); -} - async function resolveStepLabels( tx: TransactionClient, stepCodes: string[], diff --git a/backend/src/modules/cases/lab-case-task.util.ts b/backend/src/modules/cases/lab-case-task.util.ts index b445412..f1eb853 100644 --- a/backend/src/modules/cases/lab-case-task.util.ts +++ b/backend/src/modules/cases/lab-case-task.util.ts @@ -9,3 +9,87 @@ export function normalizeTaskTeeth(value: Prisma.JsonValue | null | undefined): .filter((v): v is string | number => typeof v === 'string' || typeof v === 'number') .map((v) => String(v)); } + +export function sortProsthesisTeeth(teeth: readonly string[]): string[] { + return [...teeth].sort((a, b) => { + const rank = (tooth: string) => (tooth === 'UA' ? 0 : tooth === 'LA' ? 1 : 2); + const diff = rank(a) - rank(b); + if (diff !== 0) return diff; + return a.localeCompare(b, undefined, { numeric: true }); + }); +} + +/** Group ids that span more than one tooth (bridges). */ +export function connectedSelectionGroupIds( + rows: Array<{ + selectionGroupId?: string | null; + tooth?: string; + teeth?: Prisma.JsonValue; + }>, +): Set { + const byGroup = new Map>(); + for (const row of rows) { + const id = row.selectionGroupId?.trim(); + if (!id) continue; + const teeth = + row.tooth != null && row.tooth !== '' + ? [row.tooth] + : normalizeTaskTeeth(row.teeth); + const set = byGroup.get(id) ?? new Set(); + for (const tooth of teeth) set.add(tooth); + byGroup.set(id, set); + } + const connected = new Set(); + for (const [id, teeth] of byGroup) { + if (teeth.size > 1) connected.add(id); + } + return connected; +} + +export function aggregateProsthesisGroupsFromTasks( + tasks: Array<{ + prosthesisTypeCode: string; + teeth: Prisma.JsonValue; + selectionGroupId?: string | null; + }>, +): Array<{ + prosthesisTypeCode: string; + teeth: string[]; + connected?: boolean; + selectionGroupId?: string; +}> { + const connectedIds = connectedSelectionGroupIds(tasks); + const byKey = new Map< + string, + { prosthesisTypeCode: string; teeth: string[]; selectionGroupId: string } + >(); + + for (const task of tasks) { + if (!task.prosthesisTypeCode) continue; + const selectionGroupId = task.selectionGroupId?.trim() ?? ''; + const teeth = normalizeTaskTeeth(task.teeth); + const key = `${teeth.join(',')}::${task.prosthesisTypeCode}`; + const entry = byKey.get(key) ?? { + prosthesisTypeCode: task.prosthesisTypeCode, + teeth: [], + selectionGroupId, + }; + entry.teeth.push(...teeth); + if (!entry.selectionGroupId && selectionGroupId) { + entry.selectionGroupId = selectionGroupId; + } + byKey.set(key, entry); + } + + return [...byKey.values()] + .map((group) => { + const teeth = sortProsthesisTeeth([...new Set(group.teeth)]); + return { + prosthesisTypeCode: group.prosthesisTypeCode, + teeth, + selectionGroupId: group.selectionGroupId || undefined, + connected: Boolean(group.selectionGroupId) && connectedIds.has(group.selectionGroupId), + }; + }) + .sort((a, b) => a.prosthesisTypeCode.localeCompare(b.prosthesisTypeCode)); +} diff --git a/backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts b/backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts index f042165..a0cc311 100644 --- a/backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts +++ b/backend/src/modules/prosthesis-catalog/prosthesis-catalog.service.ts @@ -12,12 +12,26 @@ export type ProsthesisTypeCatalogEntry = { code: string; sortOrder: number; label: string; + category: string; + subcategory: string; + chartRegion: string; + stackGroup: string; + addonKind: string; +}; + +type CatalogRow = { + sortOrder: number; + category: string; + subcategory: string; + chartRegion: string; + stackGroup: string; + addonKind: string; }; @Injectable() export class ProsthesisCatalogService implements OnModuleInit { private loaded = false; - private byCode = new Map(); + private byCode = new Map(); constructor( private readonly prisma: PrismaService, @@ -32,10 +46,30 @@ export class ProsthesisCatalogService implements OnModuleInit { const rows = await this.prisma.prosthesisType.findMany({ where: { isActive: true }, orderBy: [{ sortOrder: 'asc' }, { code: 'asc' }], - select: { code: true, sortOrder: true }, + select: { + code: true, + sortOrder: true, + category: true, + subcategory: true, + chartRegion: true, + stackGroup: true, + addonKind: true, + }, }); - this.byCode = new Map(rows.map((row) => [row.code, { sortOrder: row.sortOrder }])); + this.byCode = new Map( + rows.map((row) => [ + row.code, + { + sortOrder: row.sortOrder, + category: row.category, + subcategory: row.subcategory, + chartRegion: row.chartRegion, + stackGroup: row.stackGroup, + addonKind: row.addonKind, + }, + ]), + ); this.loaded = true; } @@ -50,11 +84,19 @@ export class ProsthesisCatalogService implements OnModuleInit { ); return codes - .map((code) => ({ - code, - sortOrder: this.byCode.get(code)!.sortOrder, - label: labels.get(code) ?? code, - })) + .map((code) => { + const row = this.byCode.get(code)!; + return { + code, + sortOrder: row.sortOrder, + label: labels.get(code) ?? code, + category: row.category, + subcategory: row.subcategory, + chartRegion: row.chartRegion, + stackGroup: row.stackGroup, + addonKind: row.addonKind, + }; + }) .sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code)); } diff --git a/backend/src/modules/tasks/tasks.service.ts b/backend/src/modules/tasks/tasks.service.ts index efd45f1..233eacc 100644 --- a/backend/src/modules/tasks/tasks.service.ts +++ b/backend/src/modules/tasks/tasks.service.ts @@ -13,6 +13,11 @@ import { import { normalizeTaskTeeth } from '../cases/lab-case-task.util'; import { isLabCaseOverdue, startOfUtcDay } from '../../common/lab-case-due-date'; import { ListLabTasksDto, LocateTaskPageDto, UpdateLabTaskDto } from './dto/tasks.dto'; +import { + atomicProsthesisCodes, + prosthesisGroupLabel, + prosthesisTypeTaskWhere, +} from '../../common/prosthesis-group'; import { hasEffectivePermission } from '../../common/membership-permissions'; import { LabCaseActivityService } from '../notifications/lab-case-activity.service'; import { UserNotificationService } from '../notifications/user-notification.service'; @@ -79,7 +84,7 @@ export class TasksService { ]); const locale = normalizeCatalogLocale(localeInput); - const prosthesisCodes = [...new Set(items.map((t) => t.prosthesisTypeCode).filter(Boolean))]; + const prosthesisCodes = atomicProsthesisCodes(items.map((t) => t.prosthesisTypeCode)); const prosthesisLabels = await this.catalogLabels.resolveLabels( CatalogEntityKind.PROSTHESIS_TYPE, prosthesisCodes, @@ -137,7 +142,7 @@ export class TasksService { }); const locale = normalizeCatalogLocale(localeInput); - const prosthesisCodes = [...new Set(items.map((t) => t.prosthesisTypeCode).filter(Boolean))]; + const prosthesisCodes = atomicProsthesisCodes(items.map((t) => t.prosthesisTypeCode)); const prosthesisLabels = await this.catalogLabels.resolveLabels( CatalogEntityKind.PROSTHESIS_TYPE, prosthesisCodes, @@ -196,6 +201,7 @@ export class TasksService { sentAt: effectiveAt, labCaseId: target.labCaseId, sourceKey: target.sourceKey, + tooth: target.tooth, prosthesisTypeCode: target.prosthesisTypeCode, stepOrder: target.stepOrder, id: target.id, @@ -347,7 +353,7 @@ export class TasksService { const locale = normalizeCatalogLocale(localeInput); const prosthesisLabels = await this.catalogLabels.resolveLabels( CatalogEntityKind.PROSTHESIS_TYPE, - [updated.prosthesisTypeCode], + atomicProsthesisCodes([updated.prosthesisTypeCode]), locale, ); @@ -486,7 +492,7 @@ export class TasksService { ...(query.assignedToMe ? { assigneeUserId: actorUserId } : {}), ...(query.unassignedOnly ? { assigneeUserId: null } : {}), ...(query.prosthesisTypeCode?.trim() - ? { prosthesisTypeCode: query.prosthesisTypeCode.trim() } + ? prosthesisTypeTaskWhere(query.prosthesisTypeCode.trim()) : {}), }; @@ -496,7 +502,7 @@ export class TasksService { } const completedGroups = await this.prisma.labCaseTask.groupBy({ - by: ['labCaseId', 'sourceKey', 'prosthesisTypeCode'], + by: ['labCaseId', 'sourceKey', 'tooth', 'prosthesisTypeCode'], where: { workflowStepCode: stepCompleted, status: LabTaskStatus.COMPLETED, @@ -515,6 +521,7 @@ export class TasksService { OR: completedGroups.map((group) => ({ labCaseId: group.labCaseId, sourceKey: group.sourceKey, + tooth: group.tooth, prosthesisTypeCode: group.prosthesisTypeCode, })), }, @@ -552,6 +559,7 @@ export class TasksService { sentAt: Date; labCaseId: string; sourceKey: string; + tooth: string; prosthesisTypeCode: string; stepOrder: number; id: string; @@ -586,6 +594,15 @@ export class TasksService { sameSentAt, { labCaseId: target.labCaseId }, { sourceKey: target.sourceKey }, + { tooth: { lt: target.tooth } }, + ], + }, + { + AND: [ + sameSentAt, + { labCaseId: target.labCaseId }, + { sourceKey: target.sourceKey }, + { tooth: target.tooth }, { prosthesisTypeCode: { lt: target.prosthesisTypeCode } }, ], }, @@ -594,6 +611,7 @@ export class TasksService { sameSentAt, { labCaseId: target.labCaseId }, { sourceKey: target.sourceKey }, + { tooth: target.tooth }, { prosthesisTypeCode: target.prosthesisTypeCode }, { stepOrder: { lt: target.stepOrder } }, ], @@ -603,6 +621,7 @@ export class TasksService { sameSentAt, { labCaseId: target.labCaseId }, { sourceKey: target.sourceKey }, + { tooth: target.tooth }, { prosthesisTypeCode: target.prosthesisTypeCode }, { stepOrder: target.stepOrder }, { id: { lt: target.id } }, @@ -729,6 +748,7 @@ export class TasksService { { labCase: { sentAt: 'desc' } }, { labCaseId: 'asc' }, { sourceKey: 'asc' }, + { tooth: 'asc' }, { prosthesisTypeCode: 'asc' }, { stepOrder: 'asc' }, { id: 'asc' }, @@ -741,6 +761,7 @@ export class TasksService { { labCase: { sentAt: dir } }, { labCaseId: 'asc' }, { sourceKey: 'asc' }, + { tooth: 'asc' }, { prosthesisTypeCode: 'asc' }, { stepOrder: 'asc' }, { id: 'asc' }, @@ -781,7 +802,7 @@ export class TasksService { treatmentType: task.treatmentType, prosthesisTypeCode: task.prosthesisTypeCode, prosthesisTypeLabel: - prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode, + prosthesisGroupLabel(task.prosthesisTypeCode, prosthesisLabels), workflowStepCode: task.workflowStepCode, stepOrder: task.stepOrder, stepLabel: task.stepLabel, diff --git a/backend/src/modules/treatments/lab-case-send.validation.spec.ts b/backend/src/modules/treatments/lab-case-send.validation.spec.ts index 3916269..5860cb4 100644 --- a/backend/src/modules/treatments/lab-case-send.validation.spec.ts +++ b/backend/src/modules/treatments/lab-case-send.validation.spec.ts @@ -35,6 +35,36 @@ describe('assertCompleteToothProsthesisMap', () => { ).not.toThrow(); }); + it('passes when there are no FDI teeth but an arch sentinel is mapped', () => { + expect(() => + assertCompleteToothProsthesisMap({ + details: [ + { + treatmentDetailId: prosthesisDetailId, + detail: { id: prosthesisDetailId, treatmentType: 'prosthesis', teeth: [] }, + }, + ], + toothProsthesis: [ + { treatmentDetailId: prosthesisDetailId, tooth: 'UA', prosthesisTypeCode: 'full_denture' }, + ], + }), + ).not.toThrow(); + }); + + it('throws when a prosthesis detail has neither teeth nor an arch job', () => { + expect(() => + assertCompleteToothProsthesisMap({ + details: [ + { + treatmentDetailId: prosthesisDetailId, + detail: { id: prosthesisDetailId, treatmentType: 'prosthesis', teeth: [] }, + }, + ], + toothProsthesis: [], + }), + ).toThrow(AppException); + }); + it('throws when a prosthesis tooth is missing from the map', () => { expect(() => assertCompleteToothProsthesisMap({ diff --git a/backend/src/modules/treatments/lab-case-send.validation.ts b/backend/src/modules/treatments/lab-case-send.validation.ts index c923713..8462648 100644 --- a/backend/src/modules/treatments/lab-case-send.validation.ts +++ b/backend/src/modules/treatments/lab-case-send.validation.ts @@ -32,5 +32,16 @@ export function assertCompleteToothProsthesisMap(labCase: { throw new AppException(ErrorCode.TREATMENT_TOOTH_PROSTHESIS_INCOMPLETE, HttpStatus.BAD_REQUEST); } } + if (teeth.length === 0) { + const hasArchJob = labCase.toothProsthesis.some( + (tp) => + tp.treatmentDetailId === link.treatmentDetailId && + (tp.tooth === 'UA' || tp.tooth === 'LA') && + Boolean(tp.prosthesisTypeCode), + ); + if (!hasArchJob) { + throw new AppException(ErrorCode.TREATMENT_TOOTH_PROSTHESIS_INCOMPLETE, HttpStatus.BAD_REQUEST); + } + } } } diff --git a/docs/prosthesis-catalog.xlsx b/docs/prosthesis-catalog.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..e71778e884c1931e2e36ee6d8d41fd8edadce456 GIT binary patch literal 15648 zcmZ|01z42bw+AX+1B_Bb&qxReG6O>+Ez;7hFmy@@(vkzh(A^;29TE~lr*tWbl$4aT zz#YGHpZh=OyXVY2zc-%Qd+oJ;d+qh^ch6qx$~d@`_wL;z#Ngh&a{*y@Vyt`jp5olQ zM}fIFl689h#`5(W6KyYNOShL#JRKdz;z!hQV1Q@u*oK?zrl^S^GqyD1$z30O1BN_B zH7LX)rV&@?Hbqn<+ENPO+T))2^UTncT?zbI1oedW_Qy2a(DjzHhRWY3oBrN~NtC&C z*c7@~0UzeeH#TF{wV81g3zs#g8XJEYqQFQBO=elNh|GPompw%bqr>DAt9s?#RASAv z7+R$W`w+pgb(K9>D1OC?1^(oLfRBF7g*zXvix0LB)CBNP zySTH5x~goYAtKr0Jor^>8*BHJA_|A_+2MyWfm+}E5H)@LLpup2bU5z=8x!Bm7=DSE zR6-M#_CwvGn-tJqlS8dzPf8@0J*rP$;H;<$sNf$%_obq$L+)z5vOCcHZ9Z~b?~MN# z2GBXassWWqONuxNZB~}T0)C0zP%pg0`Ja}hsM7-GFcx`ZEF;I9Jso)8I=R}LIXT(? zb%?0gZaXv-fHdj*`AJw2fdV$C@cs1qisM;3mAVjL`-R8vy4E8!@ukOu@Pj+nTmR0DHCeGOMphG5= z`iN}bBi0!l<04KJm4|JzRKnTtzs{Z3N6%4w0ld782_BdvZ`oR9h#OXohb{a_vp7~X zT{htzJkYjap%Z@ffVgCL=Q{wN>$;T{=<7`S^Z7-ksUdow`1M_nN2-oQ7{Q$t)#4wy z-#3r_cwb+W{!a=TY-*5w7}I62@7<&OFADyvfB!oX|MYdo@2 z0B^=c9ME$`6~`g{*D0TFOlxXoXvI!hKd;UcEd83N97~J9N07Xn4uYKV;b-%-lUI|! zFl`WJdP&JnLZi8G(Bwq5LB%vj#d5|@nO)?Z&tHafrFJK!m4)Uc{h0rm zF7T{tIrVlt^_F*Zb3AoS2*29D^BVVm8Xua0)>b0BcP}vW-aXKN`rpmQ((;WP?>{Hl zUq@)nQq@{kpd09n7!IpkL?b~9UPh)70eggzj9eC*v61C*^hUw2jN>z#r$=>roWE<0 zhK77)j8H7wOh*4oiTy5^V^L13AM4k;{LXy~pY!;HQTxw5lW?~@nP^Kq^04MciH z)6)5`W`=CsZLkWmyz+qQ?d`GU$Ph^~KtGY)xA8P+tI}ysH@k3ATH;5$bM}Bne07uj zGuW*+0zxh?Zgtu5W;9{Tr6I=(_W0PxR?v8GfYv&A`&~qSXy5g@q)E6zYQvl^K4O$Y zBOu}EW_Hf|+WwtaX=6 zkFTSp;l%u9DxPnnz_{mleQ|pubxjcY^j_bvOl?yg_|_THg8$S8cjvi7%yZn=I6WH) zPoLRls2WV|81I=*V?6>h67umBPEcUaINn>b#{G%FQJ2YLCKr=rNv4XqfU1<{FZX=v z2*M-y%AJKG(k7Rw?FOrQBly4y67s|mhuhD2YGMl~tVExTzcCI`GftMsS6jphuY+)i zY=A@Zdin8MfTr!t=a$68N&bCmMCOu*5tGUmp%_(ZM>B-dKF zK%oR{;Vw1P6}2Y$U_;4&bLK~wtKr~Pv`|OY2aYg?5z2B}5j#VqGTCh@sq>*fI|21z zRhb>lN*q|TwaV7}%*D{u4X2cWjmy5}of6S@@h2}bcN#BhMay2jWJ)Pc8K80Eq|cbm zB4E+k$WB5o2XWBxt7i0v-J|2b6Xidt)*TJuaOH;pEQ>h1Lhqr&H@qaj(?+7DBmgp_ zRi>i!dMcLzgfMFyv&$-R-WO%_Ju6d;n`h}FlIF*dENGB;1X;^EjRlmH`$QIrB*iuEM*N~X1KeS4Y z!xSpX;JgJHImSs#(oJ{7Xj}nz1Tou#8k0=MEB;JHBmtkjW(&!yfd6#>hJkfQR@}wK zl~=`?^;ljuI6n+*<9Nb9xD5TaKZ6AH7gk$6@w4n>3xD{Qs7L)t8n!VZbn}mtM1^z- zgdYv@dpIxqb(@RH~F=a zGc4%BR&ZNJ;#rup81?``J#7l!!1AK|MMvt>M>!R;hhir@vth^WzQvaTVWy|zZP{+C z1ax)Q#V1WrRKMoh<`FDteA);G`Wfh-3g|%*;FWU89akxiSMop4AVK6Dhn%2()BNz| zU29R@*?v)M8wBT^BEs8;bBNh@{b*#rOkWL#1QTQs77B)M*Jl-wAtx^#GH1I9MRRr= z)aaEsIQ)jTn#T{{K` z;-0D^^{j5>5cDjvNl%BvrIO-YlmnbTFXe`>S3bLr? zPl*0$A*cpK;AV}wBTGm(m=pPA3ob2@HKp2CgeR5SR_$~*v8WWm`B>Bj%HLKH{4Rd! zDd6$j1*<8-;5f47QM$(68V!14H4a9MwqgLp2AM4ABid777>l;rZUu{$gzd%*${-|$ z{D?FclV$_tYb^?wh1`F`HIFD1Ol(pw!|16;+f($yi#>rB+wU>n$0)8T@?q8C414{4 z8P!K->yCWTcOR~v0=Ul?t@??A<0O_hNfwdj?6|6hs_Iuf$rqzP3nO|s9bTp4e} zkqgX=D6%Ql81pK`lG+k6`e%dSxDm$4IrkrBQI@Ew_MZf{@;nTO_hk1AyjK6b?_28> zdGH+{pH)XN+7uBIu<^{qJzf;#6uc4YwyWa!dFF|Hr9|SvuvW3CYp;*%#z(De6Q-2T zHgaOJ_%>3 zL!Y|aLIf74Q54Y=`33TG&!Rah91mP)CKK4+-KF6TtNh$CsbBY(0;{NUkmhDt$FKcG z*-MbpmR;6Y!`?chBVjWluMSjT`(t2-YlN+Y)pY82vk;g5^Tk)x5sfkUeoaE0W@_1m zWc^=1j=9fme0kE(^I}+N<9$mTRda*dWn{}3&qP^{q6U3pMcFJmKvR`R;PnLc;DD6q zGbbMVkit632(vuyqz{|r^l7|KA)!`1w@f1+4b{Rggx@gLWR^Gf&WW{_Tb{1&P|w~c zkx48zyWKu+!Nl8yWWkcBqqEs&5YDvXbT*szUaR>8{~RiY!&m+Q|DO z^*e_@YQ*0bc@@piKAw6`Xi%LeI6h_8>sF>hZeGU?ij!Z;{f(kW4-kq{?Oseh1Z}C8 z66Vct(N)h|s5rxVQZ1hiJ}i8{kgljFRT!=h-c-HZ#(&Q+l#ZO{#*GAv)sLxrc9u z-Hsyo=4BTH?Wm)%f#i$xA%h(D4R<_^UemX>HZ;MyVbhe8rEV^Qj2siVt)ko$+grJ& zecT`_A78%pxGstNotiyCl^plyhzr9sI1sQPwFOb-h7iZS0!nHb+mKLwQ3NDaZJ{XX z^f|nCx}ZI+hWxMC_@3kgIoA|Q>hg=Kn)ryPH58VgFw8-Tx68*&Cp8(l4TFM!b|#R- ze!cL*6RMzXUq?fOcTmL4t9M!K8)Lp-GiwIl>oc%dfUA)(QJ!B0?rLakGNm zKiCjO2$wMS%COrJL}@+yI}&m4TN~s&TZKYsUhbK;kOsQV6}CJZs#V`I(D6@5j(k3iPXFAdrN4oTiO-;{yA(-M56Yv5(-$_Zbi9Qng zg{f-d4#!kwC`rETQE=lu2}Nws>zFcJ%2Q!xA)Sd%bl+?>Q|)N3p5SNhzc@M~VJ^r> zwm*gD!cC$P@Jm;K{4nwTGlSnQE!)wRBkr-*w8Qiz4t)x4%Mpl;CNqYQl^8K$Ywf(Z zaiTqH3ByH31^Wu&FHyoLI?!s5xA`iKuZqI%7nYfUqRK!hO)3@}ok|w*k+~*Uw8BZb zo$X^GRK9xSknU@l*?>5#Y<725(gpo*tOAZrJ{g%8pIt+l`V;8w+#&@5@^r-a8<2B( zS(f%b4TyqE($Ja+s@Jesvrr=d+=tQdl_3Gkim{UDFsu()zp?DEQ?E~v7STU@z5c>BQq?qBN>F|9j8`J;yFCJBb&dvt;epu=Nx?Xsf2p{`mw#0ERWL< z^D9qF$bNklS-wHH%}P7Dotu6#9!(irQ}uB^829x%7%#`Uaqtrnv;W0p+%(o|D``vi zE30xmLo6)A=PlXWyUEo>`awTjgVAhw@Wo9>))6_oih*5}9V>*kbC@_~RGn1AI zxpbW#*WGt;X_d(6Bm2$NbMpg#-iKiz3;gA*eE+TFpWCa*m9va}y=BQ)?^c%m-EK^l ztE)Q0H&_n}RM$)1C8XRgxBrgZz>6Dg2Wv8pkUY#3+P$o|Q3GyD6T1aLmpdIgzxp@c zDjWU2WC6jUFT2gtbPd4hOX~x*8@q4{5(r2&d zId*YttM1IN_s?E*>@zog>wg%i-;D$?(YY@k>Q+4OXYR28oId5}k+wBB%=a*Z~TY4PWyN@Rg&^@fcv(wou_jNiv)Y%hONiQK@C8~p;FVojz$)2^?N zx%mf(w7xz)Yujgj^?ZGIm$&I#FH2Brr|r_n;sA3-r+QibxH%(LB%W;A zN7EA98FNVQ)#aaKzWb!|-kI!K&?$@uG`Hn2%;!9Jq&B-LvNaJ@-m-p1^;4B~N9nwoDpt?ORW(gBPNdm#FFjqjI0jl`m=<&EcVJgc7dL5bt_>|nP^0`7u87t%EC)izV0cfxng$1FLB$b z#HBvFkA|Z{6I<5Byj%N=b|NvPTr1X}!<_Ouz7^#S0O)cEx%bp)U!)SXKYN?*r8}QB z*A3#Fa<)qbYL89bN+dz1B$8bI!Xal^|I>>~u^dDGeGaRT?dLF-Ycbu6nkMs5a$7N7 zd^g6&==af$zZmSttoZt7YF1TX0@przNCh9genkvwJM72P_F~_xG|C^VkrD-!K^DLD z@yThn!Bj9nm3#%w%)fTGHn+F42$-cQkX#_Q%%gHUYjsyCRJ0Gq=Mjm<6K&DP)~FSYr`ZQm`kQ zW5mK-f{!mf+qHM#N!!!l8E#=HZsp||`Fx|w>F6r!>psi#3kR4{^^1vg4-c3m|EG-i zxd#Wi9c%OIYxe1*7yTc4fS>xz#lQW4G1YsfS)X<;FVn6{JP7@Vtp`Ev1=L-S1Hga|J*O zsjdnLDtj;exun}XydV>iiUCMt99qI_eU-I`QLRKCPkOclY%{aA8WuG|`^VEo1Px8j zTqUU6Q0Y8r_glxC{YwlvZq%?C(s>;aEaHmnc3lTZK1txeQ6oGrWl7?DhF6%#i5o>M ziG;2LaA;kTfpQ|~+=$HWx5$wuU6~7TkM7lSjyzj%g^K-aNP`C?EFfb@tT3mVriZ$AxvSQ%JGrjm-;z77( z1W-fi{=@!IfDA|m-vt0cGt}@{0G0K7=ZL;z9S13if;1EfSo-zqAtB8fq}}Zs4uqSI z*0NB=K7eSqnA9uvVd95M2GYGHAqc`^5m@SUCWyOOV$eijcC z_^yG^d}z6-;y?Q9_kwvS^frU}X^I#3oRQ*g}Olu=Vymdy|`~ z!SvMk;x#t_^YDgye#9jnFeoD5u^F`ZkVGeUR%h?ExN%#dld_TpA4D2OC z=PHHK;`5T5pH!79)C^5(vz{@3vm`9fgT~LQgN9ynpb7;N$A`!kbM2;mF*HVxm3=y2 z;f-n@g6Otaf>l|pGCL{+Lr_Cw)fSHJy458(6ePyqtA9*dGJ?fsHcf$AVzR|>yZ8Q zM*blEBY8B_m@Ry7F;hO+)F29Cb>DhsapC@S%Ioyh z`ysY6hc+x`;cr;XGBIFiyAMnL7A~LV#|V45T@<3IocaJarit~crP^cSQ+8B15>MjS z<1f2u;Wk!4%p7LFS;*eQr){d?I>p8pc(zsVakJT7N$B3>(d$r24=Hj)gaE}2KkzXL zxeu(qNN-*15&*P`slhq-w1$?}6=Bew)%*gvGchHi~ zKh`tvm+w641eVQ+dnPpU)*=z_Wmi!;3{H3lmW$CC-EezASX&4no)t;X?lN>?AFFl zqtk$=57Ew0J^<6#&!fQP@8+z^hp>2_3y|^y-?i~S(Qy)g3Je5QeXe#Mf3_~)usWSK ziFSI3leJE^biHfhUHyqLYm@l10GMnVyhIEA@;w46$_c0)j@-Fs zp1oV9lP#cPUi*uMh?rX6KA~T7hoD7S0rtb|J6DyncdK-=!ryEyp<7~=fKA`xr0#-) zhG;Qfartp|Qc?!GB-#4A>p9RvP6rS>2J)1GfE|(ym*RkdLHaV=K=VRZ!?Slsbh4~b zr0BoU??*BZ3JR{N-HVBPqRoQc+%|lF@h;RJu*yM?@0Lg!yum^5Z(QWipT)(*GEmY_ zTosgL-gxJ;1nO}X1Xu{u<3GqEy`cwYsy>Pt8008R3pCkD;jh2*SpfAU3Wi%K(YJ=r z#|ys62?9Hca*E4e9jY2q*H5H3iNxUJ?F?q*{u!~i;B_RAp%txi0LrKfhCkw7NQB4}G7P--Coq^&nmT!U2KS|5TIqCfzeW|9xew1)UiiDS_jk*3du$UsP>Io=lh9;8_=3i)kL@+uT_d?9WU3 zd)-`OUN#P@I%BLt$M8)g3|@BB#7}X4SB%DPijTK_ms9|LGcymB)4a#8f&q#nfOdwa zD?#nv3s>Blq$HK|atpk?{0B%#{8b9#FSu_;$SroiDHgwbByu%RtWb2RgN;#Ptp^zW z#}fMdmQO$W&&Gh|@!P4PZ;H`>8fB8U{gTunZ{D0mKmeyBQphVyu*S_)C$7njn&_$6 zkVdDj8DC{}Jm^fYDc{B-BF#j;Zj_g0aWm!ih-CBaRN6P9H+hkZ*1raDQJ0a7T9=6d zi2ThIiArn#?F8@5l%v$kG4XPH|KhC6te&w3rBsj>83kH?j90c`jCbuX7B*9AeNV*p zM-M2;MwK9e!6kMIXF{ETRlp7`mA_=bbUc?SSTWdpdi+Xll^Tze&si*xF72k0SC*9r z<9rx|CnKf(l==6+98i9gfjtf{wYGeBJ_1x!MnnH1xezng;2CSs`lNM1$HxgiJbh&+ zb*A?Tu&Pgo@5YVsO?^6lV=LRDEc)z+m6nVfy8qAe!wKVjD z^GoDoHvC^1Iu!Mt+*X9jQ_y%eThLH49L1)XZo3swtU&W=JOc6?^vh8NH)mi_2)#8w1 zTBU^ug=|vYhe3lbPlJKurcA*i!Crt(vQ1c~aH<}o`>(g>6Kv)xUiwvZYz~zxf)7}kZ42DptA$CKtvQYGw5&S}4n)<_}K2MF`_>#+T3jj2d z>5&{pWU{FiVdeQQK-zUuy(w!V;8oxi0()Ja5oU1_55o3S1_Y{5Y-P`#_RdbS5>oM@ z>`HQ*5@j1Zd61}vC&=6eUL@EEv?l320n&Ru4dzzhEkj7F1Cwt$U>3q|ezBW?GwF2x zMFQI?8xtM8l@^&I4HQ}M&LrYXi-;Af;VU8&1BTgO&@$7vWnIjAg92q~2yCqh6|CS< zkZmU#^MZ#KF8a3m-1UzdRrngQh`a!Yb4(>eBKA%yOdO52$zDY?aHG-W0MZv!!kfft ziUP)mPi&pdvRXj0nTR>q7^qA6aD2>51#)raxY13srzHMN-NP z;E|hyg2Dm;-Ds)k?|JX8s=k+E_ZeGqSn+dtc^E_x%M6^7nG3rGVkp3uNf)Cf^xCm8 zr7BtHl4Z+S9jYJH^aP4RJQw66e%pX?X!8RB>nQxq%;6y=)A({gf`b*?i-*bv(mz8M zqycX5*u2q6DWvOjoYilEVs!chS1se82XtS7$Tvds*8Ia@Q^%<;f1&L)S`s8)h2v#x zJ!sAk^IFkY0>}&$lA0IH!q)yGbtjd1NMpb~H+mP~?$FOVL8lCp{@Id;0K8z}Pk$6m z%cIP2f+OoPHpWnad|NxG>tRiOSMvd>rEM z*L)2Wh0zUyiv+AVrs?<-&7){l>igYIqwKAsByA11>+z8{-vxB98Uo|iex<^2-;M?Z ziCIbgjOKr_+qNd^JRsmU;98T~%i7Il8cU0Y4gxGd7Wl3JzCY-5QddUMdOEFz2vmhN zp^Nfx5Vaab#Fs9_K4`O&3eLAR4|4w=Yrh%0aRE(I`JvUt%8x|&q}Hh}Gi}D{EC-iF z>=XIAj``MQ(ig!MUlZ$F_Cqtte}KjbTpYH7W7dUp{J{y=D(vM5g#H6+`RK=%{fDFE z`Cgl0lXauyl2EwhP1n=&G@oRgeo8ozJ{HaxDESV-I&ue%-+2epIhMSUQan$KPo74P z!WCa&!Nx#ExMmz$*sw&FK#)p;)J>Prd76`d0{sXBpBa$0kCD&Q7z>8X4FIXDO92R} z)HiMY@Mg&0yvftc$UR4g7}X)Y-wXN>sOYX0)*MIqM?-hm{}12T53cZ#<50&rC{WB zl=W0p@*x_C!UK%8{Q^PxOaT-V}Jdkx10Az zLnpNfUB^AWOFu&K>`I}I=jEqG=WpvHN8ar)4%7|+>F0-05F1N;$_73hnaWHrLzBnk z*p=c^4>X4;uy?$;z9-P44~GC27Z`u@szXJiB3d z9=p$%F8ExSkoEW;=W{jz!jj^mfTvb~W=}}EQ9F>Q+H)0W`UlnECRTjBQWd7Q4%Wyh z$ZAc7wcbT~fLrj1+$^1*X$FCU5?7wI)KfSfoZoywH8Rbr-WImF2j{`1$k5GABT!J0 z$ij>nUfyueIu^S?=`V8gK?jiEZPdOLdEXir(g6#N$7;K8D-ovoVS}n%@6F_%YM|(y zj8KRM9(=wfY8(;A$nKCP0YL9D@Z)n%&Tzu`pq8UgeV|w0%;42du=M$6km{}xT8fK2 z&M(n5I2)FUn$ET^m7B^8n5X9yx)uOvQ`-h8=dP@x7s!1D0YXX&b1-~t(oeCmK(>W7ZW`E z1()Gl#r2Q$G9s;Lub=rd2Qi0!Uum;Fgy9Y>)0h+PM`DGZ^##=US!V0nm;^>L@kx@z z_l-VM&<$jxLm?V#G?n3?L3>1DiVXl~MBHMNiWT`@KQxBmr`hhTWTi6QadNO# zXANiC$UV0pY!DG20)Z;(2R@@eEUjD?@KM?I7@KMEZYzp5YMV_MRL&;ev9Z zxf^uGL!Y;2Bse?0$wS}WV06X-on&F<6*`%IRPS#790`%kJpVN@7sW`8M18BEm2ENL z&vmZ1lJcw{7q5I*&xF_H9a2%0%pNi`6%u>23^xZg+v4-Ro(8OcDWX+rQH{YCdMeYZ zsZ1ne$0&fL_5vq{js#(#lAcnBc*+UR6OUPg8I zR#9JU!ycsn*D`93$VWt^n16c4w@;UnitG5@6sn}K#3E#Xl9u@tonT}8G_fjA?4r4a zP@ie-7$a1lF#D%%IN}tJTjx!;yt4Wm-o$M9(>5@Vi6w>;AL+Os5GZ{pr)+Mx;uV9H z?{xq(h2K5JM-_h>FlA`jfj!7D3jrp`6LwQPjUw9podEz1dM04uh&b^LiX>&FD(03w zQZTf8k5EYk;m6EB=|4=z@G_P?S(pA)66$c4Af^8zc8L#hMY(}Q{2?=akcdvS9@fo!Z$x`}|AeoS{(A0>3Dyjak$jb#-y=4LJJ zYO?8vdW+%Ec4P%+u8fg|A21-$zewo8Ie=mysGDV3{ zCm|hPF9u0`=Cfi`u<1Y0qWATm3CZyrz8YxVC!I`?5_l1ttouT8Z~ zI;P-S%GF8l{UCgkXgP(uH`Gy;`iG|JW4NzU{j;QH;xYACk*%SXpA)1Wy@>S|H+pIO zBwf|bdxcv_?|qa9!0QkGqEDhrn*0HG1X~m}*GjR1WEkUZvg7=U@ zILxA56^5KF0u9<|0oEHh@hO&O;D>!$g5S9dL$=L0+3*CV`H}M_pfP)G)TRKJ{50Jx zyrx%6aFyEuSZYz)wR(6TJc-{b5WWv=V-4x0 zTu8#onYP#2qwUb`L&nw{Qb|A^bpo}r>jcJ?iWY9I*tGEGY(8(nMX7ux^0i=L6&_rz zs_1>~Q;p)Nw|B;Z&{z&!{0I3_#C#R}x^Ikq$2>MubZ2 zDx6)xjgF#V173u}sqfcjCJp9>1pc%ln*72IF>K(JMouPzhisHE?#-q!n?A)sNL&~8 zT0J%LcTkYcdt@(u@ffmKzGw_7C!~`~O4Qwpgp)lCLan;q?p<#FUYxCXD*3WHTfS3` zAg*%>;&X5i+tGPjcrL8=6geaJQU*}?1d^+nvTy;dQ1&XO+L>Y{q;w54%mr$aQGQ@= zr7+X~`ra`J+DbwH$fK1ai1Vv09WI`M3=an(Cjp)Y#WUhh<%hFV$iJ)F6kdVV9KLl#GqH4g1&L4 z#`2S$9Z=v^CkS0(0Y1}Pmczvoh#M85{TEq`+~Ck}pvG{lx7u9~jN^lC9ZN%J9{8 z>xLt(J0ob9Zb+F;gRq3v?(QXREF!r=vyBg|_{3o2t>teV(y!0^HN#18h+Pm}o~34( zxDQJ-C<6_1>Hvz8%KS}pt{p~>KcqDl*Z2KdR#vroBUpjt@n2Fd4{f%Da2v05b{6%OqiDZ2ATGD}Hj zhrqg=7;Gbc5V~3mwKdP#KfO!27U5pT%&{i1T(lP!R;hW0)-sL5H}|xRm~7|kvoWxY zUgO;Oi-vp(g;8Plgl9iqxrFA-f|X>Pq^NHI@d_A>2lRXRY4cIon$ zf;cb3JSDv2mWfTDP}W*b6Nn<7t_`NGBBjdDrr>Ioq*r;9?STIS4b$LSbfE z@+dB1 zx!SYy&n6_smhi{f>&UssBx~AEhlv+an0O%%AS~Cw#0xt(q1<7B>LjtES$%^{JaI*{ zDIlK?7ON2j8Z4Cuuxn!)^&n8d=OU7pVuRqF8A6eFikScPa^SG+Tg*j74xAKwQOxwA zb~yQTo{TRscY`V5pq^d+M8e*zp<*KaB5>7OHMom}Je7v}L0@}KYzcM-HwewG(48cuLjbvzYA@l)JI_DOmXXM=;}ig^~zo&Li@nZT{xQoyql3ehq0HMyw3nzMd7PIEF)kjzCJ1W zG&69q3M^H<2pI@udKltVd$>kWO1wwKZ2xmjfnx5d`O)H+M%)`RKc8ue3IQU^mDr`D|V$PIF>2~ zo4*Z(m@`B2*~4T-8dJOX41HVtSKt}{wUr4?Q+~Flcjtd__pcN?-F!ngOH2k|Kw?aOaE&E-PN?d7`9BW`Jf3How0EKzQYCY=jI-qU8- z-gQg`YbRrwadiWglQrzjiS-{-QM`DuNmbe&X^}DKDU;e#rVVU%)|@nA#6M~W1;?vp zp7quUEFq1xM^Cv`gDVD2L2c=Kb3a2*@I_ErCPjH?A(n3kfBxP%K7JxlQo&KDNn2Nm zb=WZ2H*IkyPGuYC?(^=Ud7M`>R!-!54!*@F+3eWDG{bh5+ff}Hd{hw?_536PO2g0g zIb%%{I93|gxHlduFRPY{hl@Xd-!7Q`DWYJ-(c50Hqp=*M^CB?ox$Zcb2x{uek;v}2 zYurTuXZveEdkwqtI)%FK+7qm|7ZpPXG)zIi8)&`Ew3COAhz56=jz#-Z`5NWL7J@jk zsDWFg^mPFJWESf?9Q0xv-55+AyN+m21-r2rZ=3=eWhG!&@dNjkgEvdZ(?8;*XsiAG zkzQL@&XgD3!zj%i!)4F05&tF7EuGBI#i&eMGqch7BqotW&sXb4#R zjh948U^gSQitw}lvS%6JTR+QRX1T4amw$xBWd$PQeKZUSzDIoeEo?Io(DY?GEPLmN zkHU8qmyB>H>_=$-Z^7RNgaul3ieR$68lud3(xe29bJqfgRYlI?i||*`*F#@T9GtPj zyuwKIOGGZKs2?d7Ki!7+L!7=?pHDz>>Df*{x%gaTu^R5S_!)25eSYKp`?l~V*ys=8.0.0" } @@ -1324,6 +1325,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.10.0.tgz", "integrity": "sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -1339,6 +1341,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.220.0.tgz", "integrity": "sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.220.0", "import-in-the-middle": "^3.0.0", @@ -1389,6 +1392,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.10.0.tgz", "integrity": "sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.10.0", "@opentelemetry/resources": "2.10.0", @@ -1792,6 +1796,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3316,6 +3321,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3388,6 +3394,7 @@ "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/types": "8.57.0", @@ -3912,7 +3919,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" @@ -3922,29 +3928,25 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-api-error": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-buffer": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-numbers": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", @@ -3955,15 +3957,13 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-wasm-section": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -3976,7 +3976,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", "license": "MIT", - "peer": true, "dependencies": { "@xtuc/ieee754": "^1.2.0" } @@ -3986,7 +3985,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@xtuc/long": "4.2.2" } @@ -3995,15 +3993,13 @@ "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/wasm-edit": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -4020,7 +4016,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", @@ -4034,7 +4029,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -4047,7 +4041,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", @@ -4062,7 +4055,6 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" @@ -4072,21 +4064,20 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/@xtuc/long": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4138,7 +4129,6 @@ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "license": "MIT", - "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -4156,7 +4146,6 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -4172,8 +4161,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/ansi-styles": { "version": "4.3.0", @@ -4444,6 +4432,7 @@ "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/types": "^7.26.0" } @@ -4520,6 +4509,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4538,8 +4528,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/call-bind": { "version": "1.0.8", @@ -4662,7 +4651,6 @@ "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=6.0" } @@ -4724,8 +4712,7 @@ "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/commondir": { "version": "1.0.1", @@ -5371,6 +5358,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5556,6 +5544,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -5812,7 +5801,6 @@ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.8.x" } @@ -5892,8 +5880,7 @@ "url": "https://opencollective.com/fastify" } ], - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/fastq": { "version": "1.20.1", @@ -6989,7 +6976,6 @@ "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -7004,7 +6990,6 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "license": "MIT", - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -7510,8 +7495,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/merge2": { "version": "1.4.1", @@ -7586,7 +7570,6 @@ "resolved": "https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.8.0.tgz", "integrity": "sha512-2cT9+goJfBhtMz+gJqejSf09ClgmYhPhccRb/fb0ztVbixt0BkO8mRuI26FoPPuUNkRk6iEDryWAIouc5W8eJA==", "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "jest-worker": "^27.4.5", @@ -7717,14 +7700,14 @@ "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/next": { "version": "16.1.6", "resolved": "https://registry.npmmirror.com/next/-/next-16.1.6.tgz", "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==", "license": "MIT", + "peer": true, "dependencies": { "@next/env": "16.1.6", "@swc/helpers": "0.5.15", @@ -7850,17 +7833,6 @@ } } }, - "node_modules/next-intl/node_modules/@swc/helpers": { - "version": "0.5.23", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", - "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.8.0" - } - }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.4.31.tgz", @@ -8367,6 +8339,7 @@ "resolved": "https://registry.npmmirror.com/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -8376,6 +8349,7 @@ "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -8388,6 +8362,7 @@ "resolved": "https://registry.npmmirror.com/react-hook-form/-/react-hook-form-7.71.2.tgz", "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -8403,7 +8378,8 @@ "version": "16.13.1", "resolved": "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-qr-code": { "version": "2.2.0", @@ -8423,6 +8399,7 @@ "resolved": "https://registry.npmmirror.com/react-redux/-/react-redux-9.3.0.tgz", "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -8475,7 +8452,8 @@ "version": "5.0.1", "resolved": "https://registry.npmmirror.com/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -8542,7 +8520,6 @@ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -8633,6 +8610,7 @@ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.9" }, @@ -8763,7 +8741,6 @@ "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "license": "MIT", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -8800,7 +8777,6 @@ "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -8812,8 +8788,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/semver": { "version": "6.3.1", @@ -9063,7 +9038,6 @@ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -9082,7 +9056,6 @@ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "license": "MIT", - "peer": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -9351,7 +9324,6 @@ "resolved": "https://registry.npmjs.org/terser/-/terser-5.51.2.tgz", "integrity": "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==", "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -9422,6 +9394,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9599,6 +9572,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9798,7 +9772,6 @@ "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2" }, @@ -9817,7 +9790,6 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.110.2.tgz", "integrity": "sha512-TciLrfM7zgEjqGdY851HkirDsSPQgTFsWQpl9oHqMAMYsHhEC0bKjscvjpnz+pzx10hLC8qISApGrsnrCP4UtQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "^1.0.8", "@types/json-schema": "^7.0.15", @@ -9860,7 +9832,6 @@ "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.1.tgz", "integrity": "sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.13.0" } @@ -9870,7 +9841,6 @@ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -10051,6 +10021,7 @@ "resolved": "https://registry.npmmirror.com/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/src/components/lab/taskListGrouping.ts b/frontend/src/components/lab/taskListGrouping.ts index 4308451..2a8e375 100644 --- a/frontend/src/components/lab/taskListGrouping.ts +++ b/frontend/src/components/lab/taskListGrouping.ts @@ -26,7 +26,7 @@ export type TaskDisplayModel = | { mode: 'flat'; tasks: LabTaskListItem[] }; function prosthesisGroupKey(task: LabTaskListItem): string { - return `${task.treatmentDetailId}:${task.prosthesisTypeCode}`; + return `${task.treatmentDetailId}:${task.teeth.join(',')}:${task.prosthesisTypeCode}`; } export function groupTasksForDisplay( diff --git a/frontend/src/components/shared/catalog-type-colors.ts b/frontend/src/components/shared/catalog-type-colors.ts index 9f8be02..d3f4120 100644 --- a/frontend/src/components/shared/catalog-type-colors.ts +++ b/frontend/src/components/shared/catalog-type-colors.ts @@ -44,6 +44,37 @@ export const PROSTHESIS_TYPE_COLORS: Record = { ips_overlay: '#fef3c7', smile_design: '#f9a8d4', mockup: '#fbcfe8', + press_ceramic: '#fde68a', + veneer_full_contour: '#fdba74', + veneer_layered: '#86efac', + inlay_full_contour: '#fcd34d', + inlay_layered: '#bbf7d0', + onlay_full_contour: '#fed7aa', + onlay_layered: '#a7f3d0', + overlay_full_contour: '#fef3c7', + overlay_layered: '#2dd4bf', + cast_post_core: '#d6d3d1', + fiber_post_core: '#e7e5e4', + complete_denture: '#c4b5fd', + partial_denture: '#ddd6fe', + overdenture: '#a78bfa', + night_guard_soft: '#e9d5ff', + night_guard_hard: '#d8b4fe', + night_guard_dual: '#c084fc', + bleaching_tray: '#a5f3fc', + clear_aligner: '#67e8f9', + surgical_guide: '#94a3b8', +}; + +/** Category tiles in the assign overlay (not per-leaf). */ +export const PROSTHESIS_CATEGORY_COLORS: Record = { + crown: '#bae6fd', + indirect: '#fdba74', + implant: '#a5b4fc', + post_core: '#d6d3d1', + removable: '#c4b5fd', + appliance: '#e9d5ff', + digital: '#f9a8d4', }; export const CATALOG_FALLBACK_COLORS = [ diff --git a/frontend/src/components/treatment/labDispatchAttention.ts b/frontend/src/components/treatment/labDispatchAttention.ts index 45afe3d..0e7e012 100644 --- a/frontend/src/components/treatment/labDispatchAttention.ts +++ b/frontend/src/components/treatment/labDispatchAttention.ts @@ -1,5 +1,6 @@ import type { PastTreatment, PastTreatmentDetail } from '@/types/treatment'; import { isDetailEligibleForLabAttention } from '@/components/treatment/treatmentDetailRules'; +import { isArchSentinel } from '@/components/treatment/prosthesisTree'; export type LabDispatchAttentionItem = { key: string; @@ -12,6 +13,20 @@ export type LabDispatchAttentionItem = { isCurrentDraft: boolean; }; +function detailHasArchJobs(treatment: PastTreatment, detail: PastTreatmentDetail): boolean { + const clientId = detail.clientId ?? detail.id; + return treatment.labCases.some((lc) => { + const linked = + lc.detail?.clientId === clientId || + lc.detail?.id === detail.id || + lc.treatmentDetailId === detail.id; + if (!linked) return false; + return (lc.toothProsthesis ?? []).some( + (tp) => isArchSentinel(tp.tooth) && Boolean(tp.prosthesisTypeCode), + ); + }); +} + function pushPendingDetails( items: LabDispatchAttentionItem[], treatment: PastTreatment, @@ -24,7 +39,12 @@ function pushPendingDetails( treatment.details.forEach((detail, index) => { if ( - !isDetailEligibleForLabAttention(detail, labDependentCodes, isCurrentDraft) + !isDetailEligibleForLabAttention( + detail, + labDependentCodes, + isCurrentDraft, + detailHasArchJobs(treatment, detail), + ) ) { return; } diff --git a/frontend/src/components/treatment/prosthesisTree.ts b/frontend/src/components/treatment/prosthesisTree.ts new file mode 100644 index 0000000..fa73eda --- /dev/null +++ b/frontend/src/components/treatment/prosthesisTree.ts @@ -0,0 +1,340 @@ +import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog'; +import type { FdiToothId, LabCaseToothProsthesisDraft, ToothSelectionGroup } from '@/types/treatment'; +import { PROSTHESIS_CATEGORY_COLORS } from '@/components/shared/catalog-type-colors'; +import { prosthesisTypeColorFromCatalog } from '@/components/treatment/prosthesisTypeDisplay'; +import { groupsFromFlatTeeth, newGroupId } from '@/components/treatment/toothSelectionGroups'; + +const PROSTHESIS_CATEGORY_ORDER = [ + 'implant', + 'crown', + 'indirect', + 'post_core', + 'removable', + 'appliance', + 'digital', +] as const; + +export const TOOTH_CATEGORIES = ['implant', 'crown', 'indirect', 'post_core'] as const; +export const ARCH_CATEGORIES = ['removable', 'appliance', 'digital'] as const; + +/** Not FDI — jaw-level prosthesis rows on LabCaseToothProsthesis.tooth */ +export const ARCH_TOOTH_UPPER = 'UA'; +export const ARCH_TOOTH_LOWER = 'LA'; + +export type ArchTarget = 'upper' | 'lower' | 'both'; +export type PickerScope = 'tooth' | 'arch'; + +export function isArchSentinel(tooth: string): boolean { + return tooth === ARCH_TOOTH_UPPER || tooth === ARCH_TOOTH_LOWER; +} + +export function archSentinels(arch: ArchTarget): string[] { + if (arch === 'upper') return [ARCH_TOOTH_UPPER]; + if (arch === 'lower') return [ARCH_TOOTH_LOWER]; + return [ARCH_TOOTH_UPPER, ARCH_TOOTH_LOWER]; +} + +export function catalogByCode( + catalog: readonly ProsthesisCatalogEntry[], +): Map { + return new Map(catalog.map((entry) => [entry.code, entry])); +} + +export function categoryCodesInCatalog( + catalog: readonly ProsthesisCatalogEntry[], + scope?: PickerScope, +): string[] { + const present = new Set(catalog.map((e) => e.category)); + const order = + scope === 'tooth' + ? TOOTH_CATEGORIES + : scope === 'arch' + ? ARCH_CATEGORIES + : PROSTHESIS_CATEGORY_ORDER; + return order.filter((code) => present.has(code)); +} + +export function subcategoriesFor( + catalog: readonly ProsthesisCatalogEntry[], + category: string, +): string[] { + const set = new Set( + catalog + .filter((e) => e.category === category && e.subcategory) + .map((e) => e.subcategory), + ); + return [...set].sort(); +} + +export function leavesFor( + catalog: readonly ProsthesisCatalogEntry[], + category: string, + subcategory?: string, +): ProsthesisCatalogEntry[] { + return catalog + .filter((e) => { + if (e.category !== category) return false; + if (subcategory) return e.subcategory === subcategory; + const subs = subcategoriesFor(catalog, category); + if (subs.length === 0) return true; + return !e.subcategory; + }) + .sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code)); +} + +export function addonLeaves( + catalog: readonly ProsthesisCatalogEntry[], + kind: 'implant' | 'post_core', +): ProsthesisCatalogEntry[] { + return catalog + .filter((e) => e.addonKind === kind) + .sort((a, b) => a.sortOrder - b.sortOrder); +} + +export function applyLeafToJobs( + existingCodes: readonly string[], + nextCode: string, + byCode: Map, +): string[] { + const next = byCode.get(nextCode); + if (!next) return [...existingCodes]; + + 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; + }); + + jobs.push(nextCode); + return jobs; +} + +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 (hasArchBlocking) return false; + return true; +} + +export function jobsAllowPostCoreAddon( + jobCodes: readonly string[], + byCode: Map, +): boolean { + if (jobCodes.includes('screw_retained')) return false; + return jobCodes.some((code) => byCode.get(code)?.stackGroup === 'restoration'); +} + +export function codesOnTooth( + rows: readonly Pick[], + detailClientId: string, + tooth: string, +): string[] { + return rows + .filter((row) => row.detailClientId === detailClientId && row.tooth === tooth && row.prosthesisTypeCode) + .map((row) => row.prosthesisTypeCode); +} + +export function writeJobsForTeeth( + rows: LabCaseToothProsthesisDraft[], + detailClientId: string, + teeth: readonly string[], + codes: readonly string[], + groupIdForTooth: (tooth: string) => string, +): LabCaseToothProsthesisDraft[] { + const toothSet = new Set(teeth); + const rest = rows.filter((row) => !(row.detailClientId === detailClientId && toothSet.has(row.tooth))); + const next = teeth.flatMap((tooth) => + codes.map((prosthesisTypeCode) => ({ + detailClientId, + tooth, + prosthesisTypeCode, + selectionGroupId: groupIdForTooth(tooth), + })), + ); + return [...rest, ...next]; +} + +export function clearJobsForTeeth( + rows: LabCaseToothProsthesisDraft[], + detailClientId: string, + teeth: readonly string[], +): LabCaseToothProsthesisDraft[] { + const toothSet = new Set(teeth); + return rows.filter((row) => !(row.detailClientId === detailClientId && toothSet.has(row.tooth))); +} + +export function ensureTeethInGroups( + groups: ToothSelectionGroup[], + teeth: readonly FdiToothId[], +): ToothSelectionGroup[] { + const present = new Set(groups.flatMap((g) => g.teeth)); + const missing = teeth.filter((t) => !present.has(t)); + if (missing.length === 0) return groups; + return [ + ...groups, + ...missing.map((tooth) => ({ + groupId: newGroupId(), + kind: 'single' as const, + teeth: [tooth], + })), + ]; +} + +export function removeTeethFromGroups( + groups: ToothSelectionGroup[], + teeth: readonly FdiToothId[], +): ToothSelectionGroup[] { + const drop = new Set(teeth); + const next: ToothSelectionGroup[] = []; + for (const group of groups) { + const kept = group.teeth.filter((t) => !drop.has(t)); + if (kept.length === 0) continue; + if (kept.length === 1) { + next.push({ groupId: group.groupId, kind: 'single', teeth: kept }); + } else { + next.push({ ...group, teeth: kept, kind: group.kind === 'connected' ? 'connected' : 'single' }); + } + } + return next.length > 0 ? next : groupsFromFlatTeeth([]); +} + +export function toothRegionColors( + rows: readonly Pick[], + catalog: readonly ProsthesisCatalogEntry[], +): { + crown: Partial>; + root: Partial>; +} { + const byCode = catalogByCode(catalog); + const crown: Partial> = {}; + const root: Partial> = {}; + for (const row of rows) { + if (isArchSentinel(row.tooth)) continue; + 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') { + crown[fdi] = crown[fdi] ?? color; + root[fdi] = root[fdi] ?? color; + } else { + crown[fdi] = color; + } + } + return { crown, root }; +} + +export function archTargetFromJobs( + rows: readonly Pick[], + detailClientId: string, +): ArchTarget | null { + const hasUpper = rows.some( + (r) => + r.detailClientId === detailClientId && + r.tooth === ARCH_TOOTH_UPPER && + r.prosthesisTypeCode, + ); + const hasLower = rows.some( + (r) => + r.detailClientId === detailClientId && + r.tooth === ARCH_TOOTH_LOWER && + r.prosthesisTypeCode, + ); + if (hasUpper && hasLower) return 'both'; + if (hasUpper) return 'upper'; + if (hasLower) return 'lower'; + return null; +} + +export function hasArchJobs( + rows: readonly Pick[], + detailClientId: string, +): boolean { + return archTargetFromJobs(rows, detailClientId) !== null; +} + +export function writeArchJobs( + rows: LabCaseToothProsthesisDraft[], + detailClientId: string, + arch: ArchTarget, + code: string, +): LabCaseToothProsthesisDraft[] { + const replace = new Set(archSentinels(arch)); + const rest = rows.filter( + (row) => !(row.detailClientId === detailClientId && replace.has(row.tooth)), + ); + const next = archSentinels(arch).map((tooth) => ({ + detailClientId, + tooth, + prosthesisTypeCode: code, + selectionGroupId: '', + })); + return [...rest, ...next]; +} + +export function uniqueCodesOnGroup( + rows: readonly LabCaseToothProsthesisDraft[], + detailClientId: string, + teeth: readonly string[], +): string[] { + const codes = new Set(); + const toothSet = new Set(teeth); + for (const row of rows) { + if (row.detailClientId === detailClientId && toothSet.has(row.tooth) && row.prosthesisTypeCode) { + codes.add(row.prosthesisTypeCode); + } + } + return [...codes]; +} + +function sortProsthesisTeeth(teeth: readonly string[]): string[] { + return [...teeth].sort((a, b) => { + const rank = (tooth: string) => + tooth === ARCH_TOOTH_UPPER ? 0 : tooth === ARCH_TOOTH_LOWER ? 1 : 2; + const diff = rank(a) - rank(b); + if (diff !== 0) return diff; + return a.localeCompare(b, undefined, { numeric: true }); + }); +} + +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(); + for (const row of rows) { + if (row.detailClientId !== detailClientId || !row.prosthesisTypeCode) continue; + const entry = byTooth.get(row.tooth) ?? { codes: [], connected: false }; + if (!entry.codes.includes(row.prosthesisTypeCode)) { + entry.codes.push(row.prosthesisTypeCode); + } + if (row.selectionGroupId && connectedGroupIds.has(row.selectionGroupId)) { + entry.connected = true; + } + byTooth.set(row.tooth, entry); + } + return sortProsthesisTeeth([...byTooth.keys()]).map((tooth) => { + const entry = byTooth.get(tooth)!; + return { tooth, codes: entry.codes, connected: entry.connected }; + }); +} + +export function categoryTileColor(category: string): string { + return PROSTHESIS_CATEGORY_COLORS[category] ?? '#e2e8f0'; +} diff --git a/frontend/src/components/treatment/prosthesisTypeDisplay.ts b/frontend/src/components/treatment/prosthesisTypeDisplay.ts index 0592030..ab2fe0d 100644 --- a/frontend/src/components/treatment/prosthesisTypeDisplay.ts +++ b/frontend/src/components/treatment/prosthesisTypeDisplay.ts @@ -7,28 +7,39 @@ import { } from '@/components/shared/catalog-type-colors'; /** - * Prosthesis-type colors for lab-facing surfaces (Tasks list, Cases detail group - * headers / badges). Uses a dedicated pastel map (unique per prosthesis code). - * - * Clinic-facing dispatch flows intentionally do NOT use these colors. + * Prosthesis-type colors for Cases, Tasks, Treatment chart/dispatch, and Today. + * Uses a dedicated pastel map (unique per prosthesis code). */ /** Dark ink that stays readable on every pastel in the palette. */ const BADGE_INK = '#14253d'; +export function primaryProsthesisCode(code: string): string { + return splitProsthesisGroupCode(code)[0] || code; +} + +export function splitProsthesisGroupCode(code: string): string[] { + return code.split('+').map((p) => p.trim()).filter(Boolean); +} + /** Stable catalog index for fallback colors — use sortOrder, not list row position. */ export function prosthesisCatalogColorIndex( code: string, catalog: readonly Pick[], ): number { - const entry = catalog.find((e) => e.code === code); + const entry = catalog.find((e) => e.code === primaryProsthesisCode(code)); if (entry) return Math.max(0, entry.sortOrder - 1); const idx = catalog.findIndex((e) => e.code === code); return idx >= 0 ? idx : 0; } export function prosthesisTypeColor(code: string, index = 0): string { - return resolveCatalogTypeColor(code, PROSTHESIS_TYPE_COLORS, index, PROSTHESIS_FALLBACK_COLORS); + return resolveCatalogTypeColor( + primaryProsthesisCode(code), + PROSTHESIS_TYPE_COLORS, + index, + PROSTHESIS_FALLBACK_COLORS, + ); } export function prosthesisTypeColorFromCatalog( @@ -66,6 +77,24 @@ export function prosthesisTypeBadgeStyleFromCatalog( return prosthesisTypeBadgeStyle(code, prosthesisCatalogColorIndex(code, catalog)); } -export function formatToothList(teeth: string[]): string { - return teeth.join(', '); +export function formatToothList( + teeth: string[], + archLabels?: { UA?: string; LA?: string }, +): string { + return teeth + .map((tooth) => { + if (tooth === 'UA') return archLabels?.UA ?? tooth; + if (tooth === 'LA') return archLabels?.LA ?? tooth; + return tooth; + }) + .join(', '); +} + +export function prosthesisGroupLabelFromCatalog( + code: string, + catalog: readonly Pick[], +): string { + const parts = splitProsthesisGroupCode(code); + if (parts.length === 0) return code; + return parts.map((part) => catalog.find((entry) => entry.code === part)?.label ?? part).join(' + '); } diff --git a/frontend/src/components/treatment/toothSelectionGroups.ts b/frontend/src/components/treatment/toothSelectionGroups.ts index 1fbcccd..3b6fed4 100644 --- a/frontend/src/components/treatment/toothSelectionGroups.ts +++ b/frontend/src/components/treatment/toothSelectionGroups.ts @@ -9,7 +9,7 @@ export type ToothSelectionGroup = { teeth: FdiToothId[]; }; -function newGroupId(): string { +export function newGroupId(): string { return typeof crypto !== 'undefined' && 'randomUUID' in crypto ? crypto.randomUUID() : `tg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; @@ -235,17 +235,11 @@ export function unlinkAdjacentTeeth( return next; } -/** - * Shift-click selects an inclusive same-arch span as singles (empty circles between - * neighbors — not connected). Overlapping existing bridges are absorbed into that - * selection and become singles too (one selected range, not connected). - * Midline pairs (11–21, 41–31) are allowed — they are neighbors in arch order. - */ -export function applyShiftRange( +export function shiftRangeTeeth( groups: ToothSelectionGroup[], anchor: FdiToothId, target: FdiToothId, -): ToothSelectionGroup[] | null { +): FdiToothId[] | null { if (anchor === target) return null; const span = teethBetweenInclusive(anchor, target); if (!span || span.length < 2) return null; @@ -267,7 +261,23 @@ export function applyShiftRange( } const unionTeeth = sortInArchOrder([...union] as FdiToothId[]); - if (unionTeeth.length < 2) return null; + return unionTeeth.length < 2 ? null : unionTeeth; +} + +/** + * Shift-click selects an inclusive same-arch span as singles (empty circles between + * neighbors — not connected). Overlapping existing bridges are absorbed into that + * selection and become singles too (one selected range, not connected). + * Midline pairs (11–21, 41–31) are allowed — they are neighbors in arch order. + */ +export function applyShiftRange( + groups: ToothSelectionGroup[], + anchor: FdiToothId, + target: FdiToothId, +): ToothSelectionGroup[] | null { + const unionTeeth = shiftRangeTeeth(groups, anchor, target); + if (!unionTeeth) return null; + const union = new Set(unionTeeth); const next = groups .map((g) => ({ @@ -298,7 +308,13 @@ export function pruneToothProsthesisForGroups< for (const tooth of group.teeth) toothToGroup.set(tooth, group); } return toothProsthesis - .filter((tp) => tp.detailClientId !== detailClientId || toothToGroup.has(tp.tooth)) + .filter( + (tp) => + tp.detailClientId !== detailClientId || + toothToGroup.has(tp.tooth) || + tp.tooth === 'UA' || + tp.tooth === 'LA', + ) .map((tp) => { if (tp.detailClientId !== detailClientId) return tp; const group = toothToGroup.get(tp.tooth); diff --git a/frontend/src/components/treatment/treatmentDetailRules.ts b/frontend/src/components/treatment/treatmentDetailRules.ts index 594c574..a611c38 100644 --- a/frontend/src/components/treatment/treatmentDetailRules.ts +++ b/frontend/src/components/treatment/treatmentDetailRules.ts @@ -79,16 +79,17 @@ export function areDetailsPersistable(details: TreatmentDetailDraft[]): boolean return details.every((d) => isDetailTypeSelected(d)); } -/** Lab dispatch UI applies only to persisted prosthesis (or lab-dependent) lines with teeth. */ +/** Lab dispatch UI applies only to persisted prosthesis lines with teeth and/or arch jobs. */ export function isDetailReadyForLabDispatch( detail: DetailLike, labDependentCodes: Set, + hasArchJobs = false, ): boolean { return ( Boolean(detail.id) && isDetailTypeSelected(detail) && labDependentCodes.has(detail.treatmentType) && - detail.teeth.length > 0 + (detail.teeth.length > 0 || hasArchJobs) ); } @@ -96,24 +97,27 @@ export function isDetailEligibleForLabAttention( detail: DetailLike, labDependentCodes: Set, isCurrentDraft: boolean, + hasArchJobs = false, ): boolean { if (detail.sentAt || !isDetailTypeSelected(detail)) return false; if (!labDependentCodes.has(detail.treatmentType)) return false; - if (detail.teeth.length === 0) return false; + if (detail.teeth.length === 0 && !hasArchJobs) return false; if (isCurrentDraft && !detail.id) return false; return true; } -/** Saved lab-dependent line with prosthesis type but no teeth — can save, cannot ship. */ +/** Saved lab-dependent line with no teeth and no arch jobs — can save, cannot ship. */ export function isLabDependentDetailMissingTeeth( detail: DetailLike, labDependentCodes: Set, + hasArchJobs = false, ): boolean { return ( Boolean(detail.id) && isDetailTypeSelected(detail) && labDependentCodes.has(detail.treatmentType) && detail.teeth.length === 0 && + !hasArchJobs && !detail.sentAt ); } diff --git a/frontend/src/components/ui/lab/CaseCreatePanel.tsx b/frontend/src/components/ui/lab/CaseCreatePanel.tsx index 3d60293..c1a4d63 100644 --- a/frontend/src/components/ui/lab/CaseCreatePanel.tsx +++ b/frontend/src/components/ui/lab/CaseCreatePanel.tsx @@ -1,28 +1,26 @@ 'use client'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslations } from 'next-intl'; import { Trash2 } from 'lucide-react'; import { getUserFacingError } from '@/components/shared/formatApiError'; import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles'; -import { prosthesisTypeColorFromCatalog } from '@/components/treatment/prosthesisTypeDisplay'; +import { toothJobRowsForDetail } from '@/components/treatment/prosthesisTree'; +import { LabCaseToothJobsList } from '@/components/ui/lab/LabCaseProsthesisGroupsList'; import { - applyShiftRange, deriveTeethFromGroups, groupsFromFlatTeeth, linkedEdgesFromGroups, linkAdjacentTeeth, normalizeToothSelectionGroups, pruneToothProsthesisForGroups, - toggleToothInGroups, toothEdgeKey, unlinkAdjacentTeeth, type ToothSelectionGroup, } from '@/components/treatment/toothSelectionGroups'; import { AppDateInput } from '@/components/ui/shared/AppDateInput'; import { Button } from '@/components/ui/shared/Button'; -import { ConnectedSelectionBadge } from '@/components/ui/treatment/ConnectedSelectionBadge'; -import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart'; +import { ProsthesisAssignChart } from '@/components/ui/treatment/ProsthesisAssignChart'; import { toDateInputValue } from '@/components/lab/labCaseDueDateDisplay'; import { casesApi } from '@/lib/api/cases'; import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog'; @@ -135,12 +133,10 @@ export function CaseCreatePanel({ ); const [partners, setPartners] = useState([]); const [prosthesisOptions, setProsthesisOptions] = useState([]); - const [applyAllProsthesis, setApplyAllProsthesis] = useState(''); const [saving, setSaving] = useState(false); const [starting, setStarting] = useState(false); const [deleting, setDeleting] = useState(false); const [uploadBusy, setUploadBusy] = useState(false); - const rangeAnchorRef = useRef(null); const hydratedIdRef = useRef(labCase.id); const skipSaveRef = useRef(true); const startingRef = useRef(false); @@ -173,23 +169,13 @@ export function CaseCreatePanel({ activeLine?.toothSelectionGroups.length ? activeLine.toothSelectionGroups : groupsFromFlatTeeth(activeLine?.teeth ?? []); - const selected = new Set(activeLine?.teeth ?? []); - const linkedEdges = linkedEdgesFromGroups(groups); - const prosthesisRows = groups.map((g) => ({ - groupId: g.groupId, - kind: g.kind, - teeth: g.teeth, - })); - - const toothColors = useMemo(() => { - const colors: Partial> = {}; - for (const tp of activeLine?.toothProsthesis ?? []) { - const color = prosthesisTypeColorFromCatalog(tp.prosthesisTypeCode, prosthesisOptions); - if (color) colors[tp.tooth as FdiToothId] = color; - } - return colors; - }, [activeLine?.toothProsthesis, prosthesisOptions]); + const connectedGroupIds = new Set( + groups.filter((group) => group.kind === 'connected').map((group) => group.groupId), + ); + const toothJobRows = activeLine + ? toothJobRowsForDetail(activeLine.toothProsthesis, activeLine.clientId, connectedGroupIds) + : []; const buildPayload = useCallback( () => ({ @@ -489,42 +475,12 @@ export function CaseCreatePanel({ {activeLine ? ( <> - { - if (disabled) return; - const currentGroups = - activeLine.toothSelectionGroups.length > 0 - ? activeLine.toothSelectionGroups - : groupsFromFlatTeeth(activeLine.teeth); - let nextGroups: ToothSelectionGroup[] | null = null; - if (event.shiftKey) { - const anchor = rangeAnchorRef.current; - if (!anchor || anchor === fdi) { - rangeAnchorRef.current = fdi; - return; - } - nextGroups = applyShiftRange(currentGroups, anchor, fdi); - rangeAnchorRef.current = fdi; - } else { - nextGroups = toggleToothInGroups(currentGroups, fdi); - rangeAnchorRef.current = fdi; - } - if (!nextGroups) return; - updateActiveLine((line) => ({ - ...line, - toothSelectionGroups: nextGroups!, - teeth: deriveTeethFromGroups(nextGroups!), - toothProsthesis: pruneToothProsthesisForGroups( - line.toothProsthesis, - line.clientId, - nextGroups!, - ), - })); - }} onToggleLink={(a, b) => { if (disabled) return; const currentGroups = @@ -547,111 +503,30 @@ export function CaseCreatePanel({ ), })); }} + onChange={({ groups: nextGroups, toothProsthesis }) => { + updateActiveLine((line) => ({ + ...line, + toothSelectionGroups: nextGroups, + teeth: deriveTeethFromGroups(nextGroups), + toothProsthesis, + })); + }} /> - {prosthesisRows.length > 0 ? ( -
-

- {tTreatment('prosthesisTypesTitle')} -

- {prosthesisRows.every((r) => r.kind === 'single') && - prosthesisRows.reduce((sum, r) => sum + r.teeth.length, 0) > 1 ? ( - - ) : null} -
- {prosthesisRows.map((row) => { - const current = - activeLine.toothProsthesis.find( - (tp) => - tp.selectionGroupId === row.groupId && - (row.teeth as string[]).includes(tp.tooth), - )?.prosthesisTypeCode ?? - activeLine.toothProsthesis.find((tp) => - (row.teeth as string[]).includes(tp.tooth), - )?.prosthesisTypeCode ?? - ''; - return ( - - ); - })} -
-
- ) : null} +
+

+ {tTreatment('prosthesisTypesTitle')} +

+

{tTreatment('prosthesisEditOnChart')}

+ {toothJobRows.length === 0 ? ( +

{tTreatment('prosthesisMissingOnChart')}

+ ) : ( + + )} +