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 0000000..e71778e Binary files /dev/null and b/docs/prosthesis-catalog.xlsx differ diff --git a/frontend/messages/en.json b/frontend/messages/en.json index c325c48..4b1f0f1 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -725,6 +725,8 @@ "addDetail": "Add detail", "confirmRemoveDetail": "Remove this treatment detail?", "confirmRemoveLastDetail": "This is the last detail. Removing it leaves the plan empty until you add another. Continue?", + "confirmLeaveProsthesis": "Leaving prosthesis removes the tooth chart and assigned lab types on this detail. To keep that work and add another type in the same plan, use Add detail first, then change type on the new line. Continue?", + "confirmResetChart": "Clear all selected teeth and assigned types on this chart?", "removeDetailAria": "Remove detail {n}", "detailLabel": "Detail {n}", "detailSentBadge": "sent", @@ -755,7 +757,7 @@ "noOrgInvitePermission": "You do not have permission to invite labs. Contact your organization owner.", "sendThisCase": "Send this case", "labDispatchTitle": "Lab dispatch", - "labDispatchSubtitle": "Choose a lab, set prosthesis types, and send.", + "labDispatchSubtitle": "Choose a lab and send.", "labDispatchSendHint": "Shipment grouping is saved when you send to the lab.", "addLabShipment": "Add lab shipment", "labShipmentLabel": "Shipment {n}", @@ -767,12 +769,12 @@ "noLabDetails": "No prosthesis treatment details yet. Add prosthesis in treatment details above.", "labDispatchEmpty": "Add a lab shipment to group details and send them to a lab.", "prosthesisTypesTitle": "Prosthesis types", - "prosthesisApplyAll": "Apply to all teeth", - "prosthesisSelectPlaceholder": "Select prosthesis type…", "prosthesisColTooth": "Tooth", "prosthesisColDetail": "Detail", "prosthesisColType": "Prosthesis type", "prosthesisUnassigned": "Unassigned", + "prosthesisEditOnChart": "Choose types on the chart — click a tooth or an arch label to add or change work.", + "prosthesisMissingOnChart": "Click each tooth on the chart to assign a type before sending.", "selectLab": "Destination lab", "selectLabPlaceholder": "Choose a linked lab…", "sendToLab": "Send to lab", @@ -799,7 +801,7 @@ "historyClearFilters": "Remove filters", "historyFilterEmpty": "No treatments match these filters.", "labShipmentBlockedTitle": "Lab shipment not available yet", - "labShipmentBlockedBody": "Select at least one tooth on this prosthesis detail before you can create a lab shipment.", + "labShipmentBlockedBody": "Assign at least one tooth or an arch product on this prosthesis detail before you can send it to the lab.", "labCaseCommentsTitle": "Lab case comments", "labCaseCommentsHint": "Message the lab while this case is still in progress. Comments close when all lab tasks are completed.", "labShipmentsTitle": "Lab shipments", @@ -867,22 +869,15 @@ "noActiveOrgs": "No active linked organizations.", "confirmSend": "Confirm send", "toothChartTitle": "FDI tooth chart", - "entryWizardLabel": "Treatment entry steps", "entryStepTeeth": "Teeth", - "entryStepContent": "Content", "entryStepLab": "Lab", - "entryStepTreatment": "Treatment", - "entryStepBack": "Back", - "entryStepNext": "Next", - "continueToLab": "Continue to lab", - "backToTreatment": "Back to treatment", "selectTypeBeforeTeeth": "Choose a treatment type before selecting teeth.", "labDispatchNotSent": "Not sent", "labDispatchLastLab": "Last lab: {name}", "labCommentsMovedHint": "Progress and comments are in Lab shipments.", - "entryStepLabUnavailable": "Lab dispatch is available after you save a lab-dependent detail with teeth selected.", "toothChartTitleCompact": "Tooth chart", - "toothChartHint": "Tap teeth to select or deselect. When two neighbors are selected, an empty circle appears between them — tap it to connect (or tap a filled circle to disconnect). Shift-click a range to select all in between (not connected until you fill the circles).", + "toothChartHint": "Click a tooth to select or deselect it. Hold Shift and click another tooth to select a range. Click the circle between neighbors to connect or disconnect a bridge.", + "toothChartHintAssign": "Click a tooth to assign work. Shift-click a second tooth to select a range, then click any tooth in that range to assign to all of them. Hold Ctrl or drag to copy the last type set. Click Upper arch or Lower arch to assign a jaw-level product. Click the circle between neighbors to connect or disconnect a bridge.", "toothChartHelpAria": "How to select teeth", "toothConnectedHint": "Connected selection", "toothLinkHint": "Connect these teeth", @@ -893,10 +888,14 @@ "connectedBadge": "Connected", "prosthesisConnectedLabel": "Connected teeth prosthesis type", "toothChartWholePlan": "Full View", + "resetChart": "Reset", + "resetChartAria": "Clear all teeth on this chart", "labShipmentAttachments": "Files for the lab", "labShipmentAttachmentsHint": "All files are included. Uncheck any you do not want to send.", "selectedLabel": "Selected:", "selectedEmpty": "—", + "selectedArchUpper": "Upper arch", + "selectedArchLower": "Lower arch", "upperArch": "Upper arch", "lowerArch": "Lower arch", "toothAria": "FDI tooth {fdi}", @@ -1099,6 +1098,44 @@ "destStaff": "Open Staff", "destOpen": "Open" }, + "prosthesis": { + "pickerTitle": "Assign work", + "arch_upper": "Upper", + "arch_lower": "Lower", + "arch_both": "Both", + "category_crown": "Crowns", + "category_indirect": "Indirect restorations", + "category_implant": "Implants", + "category_post_core": "Post & core", + "category_removable": "Removable", + "category_appliance": "Appliances", + "category_digital": "Digital", + "sub_full_contour": "Full contour", + "sub_layered": "Layered", + "sub_veneer": "Veneer", + "sub_inlay": "Inlay", + "sub_onlay": "Onlay", + "sub_overlay": "Overlay", + "sub_complete_denture": "Complete denture", + "sub_partial_denture": "Partial denture", + "sub_night_guard": "Night guard", + "addonNone": "None", + "clear": "Clear this tooth", + "done": "Done", + "pickerTeeth": "Teeth {teeth}", + "pickerArchUpper": "Upper arch", + "pickerArchLower": "Lower arch", + "pickerArchBoth": "Both arches", + "clearArch": "Clear this arch", + "clearHintArch": "Clear removes the jaw-level product. Teeth on this detail stay as they are.", + "noneYet": "None yet", + "clearHint": "Clear this tooth removes it from the case. Use × on a chip to drop one type.", + "removeJob": "Remove this type", + "regionCrown": "Crown", + "pickerTooth": "Tooth {tooth}", + "slotImplant": "Implant add-on", + "slotPostCore": "Post & core" + }, "errors": { "GENERIC": "Something went wrong. Please try again.", "NETWORK_ERROR": "Could not reach the server. Check your connection and try again.", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index c5977f4..7a4bb06 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -726,6 +726,8 @@ "addDetail": "افزودن جزئیات", "confirmRemoveDetail": "این جزئیات درمان حذف شود؟", "confirmRemoveLastDetail": "این آخرین جزئیات است. با حذف آن برنامه خالی می‌ماند تا جزئیات جدیدی اضافه کنید. ادامه می‌دهید؟", + "confirmLeaveProsthesis": "خروج از پروتز، انتخاب دندان‌ها و انواع اختصاص‌داده‌شده این جزئیات را پاک می‌کند. برای نگه داشتن این کار و افزودن نوع دیگر در همین برنامه، ابتدا «افزودن جزئیات» را بزنید و نوع را روی خط جدید عوض کنید. ادامه می‌دهید؟", + "confirmResetChart": "همه دندان‌های انتخاب‌شده و انواع اختصاص‌داده‌شده این نمودار پاک شود؟", "removeDetailAria": "حذف جزئیات {n}", "detailLabel": "جزئیات {n}", "detailSentBadge": "ارسال‌شده", @@ -756,7 +758,7 @@ "noOrgInvitePermission": "شما مجوز دعوت از آزمایشگاه را ندارید. با مالک سازمان تماس بگیرید.", "sendThisCase": "ارسال این پرونده", "labDispatchTitle": "ارسال به لابراتوار", - "labDispatchSubtitle": "لابراتوار را انتخاب کنید، نوع پروتز را مشخص کنید و ارسال کنید.", + "labDispatchSubtitle": "لابراتوار را انتخاب کنید و ارسال کنید.", "labDispatchSendHint": "گروه‌بندی محموله هنگام ارسال به لاب ذخیره می‌شود.", "addLabShipment": "افزودن محموله لاب", "labShipmentLabel": "محموله {n}", @@ -768,12 +770,12 @@ "noLabDetails": "هنوز جزئیات پروتز وجود ندارد. پروتز را در جزئیات درمان بالا اضافه کنید.", "labDispatchEmpty": "یک محموله لاب اضافه کنید تا جزئیات را گروه‌بندی و ارسال کنید.", "prosthesisTypesTitle": "انواع پروتز", - "prosthesisApplyAll": "اعمال برای همه دندان‌ها", - "prosthesisSelectPlaceholder": "نوع پروتز را انتخاب کنید…", "prosthesisColTooth": "دندان", "prosthesisColDetail": "جزئیات", "prosthesisColType": "نوع پروتز", "prosthesisUnassigned": "تخصیص‌داده‌نشده", + "prosthesisEditOnChart": "انواع را روی چارت انتخاب کنید — روی دندان یا برچسب قوس کلیک کنید تا کار اضافه یا تغییر شود.", + "prosthesisMissingOnChart": "قبل از ارسال، روی هر دندان در چارت کلیک کنید و نوع را اختصاص دهید.", "selectLab": "لابراتوار مقصد", "selectLabPlaceholder": "یک لابراتوار متصل انتخاب کنید…", "sendToLab": "ارسال به لابراتوار", @@ -800,7 +802,7 @@ "historyClearFilters": "حذف فیلترها", "historyFilterEmpty": "هیچ درمانی با این فیلترها یافت نشد.", "labShipmentBlockedTitle": "ارسال به لابراتوار هنوز ممکن نیست", - "labShipmentBlockedBody": "قبل از ایجاد ارسال لابراتوار، حداقل یک دندان برای این جزئیات پروتز انتخاب کنید.", + "labShipmentBlockedBody": "قبل از ارسال به لابراتوار، حداقل یک دندان یا یک محصول فکی برای این جزئیات پروتز اختصاص دهید.", "labCaseCommentsTitle": "نظرات پرونده لابراتوار", "labCaseCommentsHint": "تا زمانی که پرونده در لابراتوار در حال انجام است با لابراتوار پیام بگذارید. پس از تکمیل همه کارها، نظردهی بسته می‌شود.", "labShipmentsTitle": "ارسال‌های لابراتوار", @@ -869,7 +871,8 @@ "confirmSend": "تأیید ارسال", "toothChartTitle": "نمودار دندان‌ها FDI", "toothChartTitleCompact": "نمودار دندان", - "toothChartHint": "برای انتخاب یا لغو انتخاب روی دندان ضربه بزنید. وقتی دو دندان همسایه انتخاب شوند، دایرهٔ خالی بینشان ظاهر می‌شود — برای اتصال روی آن بزنید (یا روی دایرهٔ پر برای قطع اتصال). Shift+کلیک روی یک بازه، همه را انتخاب می‌کند (تا وقتی دایره‌ها را پر نکنید متصل نیستند).", + "toothChartHint": "برای انتخاب یا لغو انتخاب روی دندان کلیک کنید. Shift را نگه دارید و روی دندان دیگر کلیک کنید تا یک بازه انتخاب شود. روی دایره بین همسایه‌ها کلیک کنید تا بریج وصل یا قطع شود.", + "toothChartHintAssign": "برای اختصاص کار روی دندان کلیک کنید. Shift را نگه دارید و روی دندان دوم کلیک کنید تا یک بازه انتخاب شود، سپس روی هر دندان آن بازه کلیک کنید تا به همه اختصاص داده شود. Ctrl را نگه دارید یا بکشید تا آخرین مجموعه نوع کپی شود. روی «قوس بالا» یا «قوس پایین» کلیک کنید تا محصول فکی اختصاص داده شود. روی دایره بین همسایه‌ها کلیک کنید تا بریج وصل یا قطع شود.", "toothChartHelpAria": "راهنمای انتخاب دندان", "toothConnectedHint": "انتخاب متصل", "toothLinkHint": "اتصال این دندان‌ها", @@ -879,25 +882,21 @@ "toothConnectedSuffix": " (متصل)", "connectedBadge": "متصل", "prosthesisConnectedLabel": "نوع پروتز دندان‌های متصل", - "entryWizardLabel": "مراحل ثبت درمان", "entryStepTeeth": "دندان‌ها", - "entryStepContent": "محتوا", "entryStepLab": "لابراتوار", - "entryStepTreatment": "درمان", - "entryStepBack": "قبلی", - "entryStepNext": "بعدی", - "continueToLab": "ادامه به لابراتوار", - "backToTreatment": "بازگشت به درمان", "selectTypeBeforeTeeth": "قبل از انتخاب دندان، نوع درمان را انتخاب کنید.", "labDispatchNotSent": "ارسال نشده", "labDispatchLastLab": "آخرین لاب: {name}", "labCommentsMovedHint": "پیشرفت و پیام‌ها در بخش محموله‌های لاب هستند.", - "entryStepLabUnavailable": "ارسال به لابراتوار پس از ذخیره جزئیات وابسته به لاب با انتخاب دندان در دسترس است.", "toothChartWholePlan": "نمایش کامل", + "resetChart": "بازنشانی", + "resetChartAria": "پاک کردن همه دندان‌های این نمودار", "labShipmentAttachments": "فایل‌ها برای لابراتوار", "labShipmentAttachmentsHint": "همه فایل‌ها پیوست می‌شوند. برای حذف از ارسال، تیک را بردارید.", "selectedLabel": "انتخاب شده:", "selectedEmpty": "—", + "selectedArchUpper": "قوس بالا", + "selectedArchLower": "قوس پایین", "upperArch": "قوس بالا", "lowerArch": "قوس پایین", "toothAria": "دندان FDI {fdi}", @@ -1100,6 +1099,44 @@ "destStaff": "باز کردن کارکنان", "destOpen": "باز کردن" }, + "prosthesis": { + "pickerTitle": "اختصاص کار", + "arch_upper": "فک بالا", + "arch_lower": "فک پایین", + "arch_both": "هر دو", + "category_crown": "روکش‌ها", + "category_indirect": "ترمیم‌های غیرمستقیم", + "category_implant": "ایمپلنت", + "category_post_core": "پست و کور", + "category_removable": "متحرک", + "category_appliance": "اپلاینس‌ها", + "category_digital": "دیجیتال", + "sub_full_contour": "کانتور کامل", + "sub_layered": "لایه‌ای", + "sub_veneer": "ونیر", + "sub_inlay": "اینلی", + "sub_onlay": "آنلی", + "sub_overlay": "اورلی", + "sub_complete_denture": "دست دندان کامل", + "sub_partial_denture": "پارسیل", + "sub_night_guard": "نایت گارد", + "addonNone": "هیچ‌کدام", + "clear": "پاک کردن این دندان", + "done": "تمام", + "pickerTeeth": "دندان‌های {teeth}", + "pickerArchUpper": "قوس بالا", + "pickerArchLower": "قوس پایین", + "pickerArchBoth": "هر دو قوس", + "clearArch": "پاک کردن این قوس", + "clearHintArch": "پاک کردن، محصول فکی را برمی‌دارد. دندان‌های این جزئیات باقی می‌مانند.", + "noneYet": "هنوز هیچ", + "clearHint": "«پاک کردن این دندان» آن را از کیس حذف می‌کند. با × روی تراشه یک نوع را بردارید.", + "removeJob": "حذف این نوع", + "regionCrown": "تاج", + "pickerTooth": "دندان {tooth}", + "slotImplant": "افزونه ایمپلنت", + "slotPostCore": "پست و کور" + }, "errors": { "GENERIC": "مشکلی پیش آمد. لطفاً دوباره تلاش کنید.", "NETWORK_ERROR": "اتصال به سرور برقرار نشد. اتصال اینترنت را بررسی کنید.", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 291c209..948416a 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -725,6 +725,8 @@ "addDetail": "Detail toevoegen", "confirmRemoveDetail": "Dit behandelingsdetail verwijderen?", "confirmRemoveLastDetail": "Dit is het laatste detail. Als u het verwijdert, blijft het plan leeg tot u een nieuw detail toevoegt. Doorgaan?", + "confirmLeaveProsthesis": "Als u prothese verlaat, worden het tanddiagram en de toegewezen labtypen van dit detail gewist. Om dat werk te behouden en een ander type in hetzelfde plan toe te voegen, gebruikt u eerst Detail toevoegen en wijzigt u het type op de nieuwe regel. Doorgaan?", + "confirmResetChart": "Alle geselecteerde tanden en toegewezen typen op dit diagram wissen?", "removeDetailAria": "Detail {n} verwijderen", "detailLabel": "Detail {n}", "detailSentBadge": "verzonden", @@ -755,7 +757,7 @@ "noOrgInvitePermission": "U heeft geen toestemming om labs uit te nodigen. Neem contact op met de organisatie-eigenaar.", "sendThisCase": "Verzend deze case", "labDispatchTitle": "Lab-dispatch", - "labDispatchSubtitle": "Kies een lab, stel prothesetypes in en verstuur.", + "labDispatchSubtitle": "Kies een lab en verstuur.", "labDispatchSendHint": "Groepering wordt opgeslagen wanneer u naar het lab verzendt.", "addLabShipment": "Labzending toevoegen", "labShipmentLabel": "Zending {n}", @@ -767,12 +769,12 @@ "noLabDetails": "Nog geen prothese-details. Voeg prothese toe in de behandeldetails hierboven.", "labDispatchEmpty": "Voeg een labzending toe om details te groeperen en naar een lab te sturen.", "prosthesisTypesTitle": "Prothesetypes", - "prosthesisApplyAll": "Toepassen op alle tanden", - "prosthesisSelectPlaceholder": "Selecteer prothesetype…", "prosthesisColTooth": "Tand", "prosthesisColDetail": "Detail", "prosthesisColType": "Prothesetype", "prosthesisUnassigned": "Niet toegewezen", + "prosthesisEditOnChart": "Kies types op het schema — klik op een tand of een booglabel om werk toe te voegen of te wijzigen.", + "prosthesisMissingOnChart": "Klik op elke tand op het schema om een type toe te wijzen voordat u verstuurt.", "selectLab": "Bestemmingslab", "selectLabPlaceholder": "Kies een gekoppeld lab…", "sendToLab": "Versturen naar lab", @@ -799,7 +801,7 @@ "historyClearFilters": "Filters verwijderen", "historyFilterEmpty": "Geen behandelingen komen overeen met deze filters.", "labShipmentBlockedTitle": "Labverzending nog niet beschikbaar", - "labShipmentBlockedBody": "Selecteer minstens één tand voor dit prothesedetail voordat u een labverzending kunt aanmaken.", + "labShipmentBlockedBody": "Wijs minstens één tand of een boogproduct toe aan dit prothesedetail voordat u het naar het lab kunt sturen.", "labCaseCommentsTitle": "Opmerkingen labcase", "labCaseCommentsHint": "Stuur berichten naar het lab terwijl deze case nog in behandeling is. Opmerkingen sluiten wanneer alle labtaken zijn afgerond.", "labShipmentsTitle": "Labzendingen", @@ -868,7 +870,8 @@ "confirmSend": "Bevestig verzending", "toothChartTitle": "FDI-tanddiagram", "toothChartTitleCompact": "Tanddiagram", - "toothChartHint": "Tik op tanden om te selecteren of deselecteren. Bij twee geselecteerde buren verschijnt een lege cirkel ertussen — tik om te verbinden (of tik op een gevulde cirkel om te ontkoppelen). Shift-klik een bereik om alles ertussen te selecteren (nog niet verbonden tot je de cirkels vult).", + "toothChartHint": "Klik op een tand om deze te selecteren of te deselecteren. Houd Shift ingedrukt en klik op een andere tand om een bereik te selecteren. Klik op de cirkel tussen buren om een brug te verbinden of te ontkoppelen.", + "toothChartHintAssign": "Klik op een tand om werk toe te wijzen. Shift-klik op een tweede tand om een bereik te selecteren; klik daarna op een tand in dat bereik om aan allemaal toe te wijzen. Houd Ctrl ingedrukt of sleep om de laatste typeset te kopiëren. Klik op Bovenboog of Onderboog voor een boogproduct. Klik op de cirkel tussen buren om een brug te verbinden of te ontkoppelen.", "toothChartHelpAria": "Tanden selecteren", "toothConnectedHint": "Verbonden selectie", "toothLinkHint": "Deze tanden verbinden", @@ -878,25 +881,21 @@ "toothConnectedSuffix": " (verbonden)", "connectedBadge": "Verbonden", "prosthesisConnectedLabel": "Prothesetype voor verbonden tanden", - "entryWizardLabel": "Behandeling invoerstappen", "entryStepTeeth": "Tanden", - "entryStepContent": "Inhoud", "entryStepLab": "Lab", - "entryStepTreatment": "Behandeling", - "entryStepBack": "Terug", - "entryStepNext": "Volgende", - "continueToLab": "Verder naar lab", - "backToTreatment": "Terug naar behandeling", "selectTypeBeforeTeeth": "Kies een behandeltype voordat u tanden selecteert.", "labDispatchNotSent": "Niet verzonden", "labDispatchLastLab": "Laatste lab: {name}", "labCommentsMovedHint": "Voortgang en berichten staan bij Labzendingen.", - "entryStepLabUnavailable": "Labverzending is beschikbaar nadat u een lab-afhankelijk detail met geselecteerde tanden hebt opgeslagen.", "toothChartWholePlan": "Volledig overzicht", + "resetChart": "Resetten", + "resetChartAria": "Alle tanden op dit diagram wissen", "labShipmentAttachments": "Bestanden voor het lab", "labShipmentAttachmentsHint": "Alle bestanden gaan mee. Vink uit wat u niet wilt meesturen.", "selectedLabel": "Geselecteerd:", "selectedEmpty": "—", + "selectedArchUpper": "Bovenboog", + "selectedArchLower": "Onderboog", "upperArch": "Bovenboog", "lowerArch": "Onderboog", "toothAria": "FDI-tand {fdi}", @@ -1099,6 +1098,44 @@ "destStaff": "Personeel openen", "destOpen": "Openen" }, + "prosthesis": { + "pickerTitle": "Werk toewijzen", + "arch_upper": "Boven", + "arch_lower": "Onder", + "arch_both": "Beide", + "category_crown": "Kronen", + "category_indirect": "Indirecte restauraties", + "category_implant": "Implantaten", + "category_post_core": "Stiftopbouw", + "category_removable": "Uitneembaar", + "category_appliance": "Apparatuur", + "category_digital": "Digitaal", + "sub_full_contour": "Volledige contour", + "sub_layered": "Gelaagd", + "sub_veneer": "Veneer", + "sub_inlay": "Inlay", + "sub_onlay": "Onlay", + "sub_overlay": "Overlay", + "sub_complete_denture": "Volledige prothese", + "sub_partial_denture": "Partiële prothese", + "sub_night_guard": "Nachtbeugel", + "addonNone": "Geen", + "clear": "Deze tand wissen", + "done": "Klaar", + "pickerTeeth": "Tanden {teeth}", + "pickerArchUpper": "Bovenboog", + "pickerArchLower": "Onderboog", + "pickerArchBoth": "Beide bogen", + "clearArch": "Deze boog wissen", + "clearHintArch": "Wissen verwijdert het boogproduct. Tanden op dit detail blijven staan.", + "noneYet": "Nog geen", + "clearHint": "Deze tand wissen verwijdert hem uit de case. Gebruik × op een chip om één type te verwijderen.", + "removeJob": "Dit type verwijderen", + "regionCrown": "Kroon", + "pickerTooth": "Tand {tooth}", + "slotImplant": "Implantaat-add-on", + "slotPostCore": "Stiftopbouw" + }, "errors": { "GENERIC": "Er is iets misgegaan. Probeer het opnieuw.", "NETWORK_ERROR": "Kan de server niet bereiken. Controleer uw verbinding.", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7b8ab34..bdc41b9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -79,6 +79,7 @@ "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1055,7 +1056,6 @@ "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" @@ -1303,6 +1303,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=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')}

+ ) : ( + + )} +