diff --git a/.cursor/rules/frontend-assets.mdc b/.cursor/rules/frontend-assets.mdc
index ac5ebbf..2596c52 100644
--- a/.cursor/rules/frontend-assets.mdc
+++ b/.cursor/rules/frontend-assets.mdc
@@ -9,4 +9,5 @@ alwaysApply: false
- **Brand** (wordmark + logo): `frontend/src/assets/brand/*.svg`. Inline in `BrandWordmark` / `BrandLogo` / `BrandLockup` so fills can use `currentColor`. After editing an SVG, run `frontend/scripts/build-brand-components.mjs`.
- **Favicon:** `frontend/src/app/icon.svg` (hex fills — `currentColor` does not work in tab icons).
- **FDI tooth sources:** `frontend/src/assets/fdi/`. The chart uses inlined paths in `realisticToothAssets.ts`; regenerate with `frontend/scripts/extract-tooth-svgs.mjs`.
-- **`public/`** is only for files that must be fetched by URL (e.g. og images). Do not put themeable brand/FDI SVGs there — `` cannot inherit `currentColor`.
+- **Prosthesis catalog illustrations:** source `frontend/src/assets/prosthesis-catalog/*.svg` (painted navy, not `currentColor`). Serve the same filenames from `frontend/public/prosthesis-catalog/` as `` — Next cannot import these as URLs under Turbopack 16.1, and denture/veneer are too large to inline. Map codes in `prosthesisCatalogIcons.ts`. Copy into `public/` when adding a file.
+- **`public/`** is only for files that must be fetched by URL (e.g. og images, prosthesis catalog icons). Do not put themeable brand/FDI SVGs there — `` cannot inherit `currentColor`.
diff --git a/.cursor/rules/lab-case-share-link.mdc b/.cursor/rules/lab-case-share-link.mdc
index 95a5247..18a3a8d 100644
--- a/.cursor/rules/lab-case-share-link.mdc
+++ b/.cursor/rules/lab-case-share-link.mdc
@@ -1,14 +1,15 @@
---
description: Lab case QR share link — token, access API, focus page, auth redirect
-globs: frontend/src/app/**/lab-case/**,frontend/src/components/ui/lab/CaseTasksFocusView.tsx,frontend/src/components/ui/lab/LabCaseShareQr*.tsx,frontend/src/lib/api/lab-case-access.ts,frontend/src/lib/auth/postAuthRedirect.ts,frontend/src/app/**/login/page.tsx,backend/src/modules/cases/lab-case-access.*,backend/src/common/lab-case-access-token.ts
+globs: frontend/src/app/**/lab-case/**,frontend/src/components/ui/lab/CaseTasksFocusView.tsx,frontend/src/components/ui/lab/LabCaseShareQr*.tsx,frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx,frontend/src/lib/api/lab-case-access.ts,frontend/src/lib/auth/postAuthRedirect.ts,frontend/src/app/**/login/page.tsx,backend/src/modules/cases/lab-case-access.*,backend/src/common/lab-case-access-token.ts,backend/src/modules/treatments/treatments.service.ts
alwaysApply: false
---
# Lab case share link
-- **Token:** `LabCase.accessToken` on first ship; `shareUrl` on case detail only when sent.
+- **Token:** `LabCase.accessToken` on first ship; `shareUrl` on lab case detail **and** clinic treatment lab-case drafts when sent.
- **QR:** client-side `react-qr-code` in frontend — ❌ no backend QR endpoint.
- **Cases panel:** attachment preview left, QR thumb right, one row; dialog for QR + copy link.
+- **Treatment dispatch:** same thumb + dialog on sent cases (`LabCasesDispatchPanel`). Dest/jobs share a row with the QR; tracker + comments are full width below.
- **Route:** `/lab-case/[token]` → `CaseTasksFocusView` (dashboard layout, auth required).
- **Access:** lab (`TAB_TASKS_*`) or clinic treatment **provider** (`TAB_TREATMENT_EDIT` + `isActorTreatmentProvider`); else `LAB_CASE_ACCESS_DENIED`.
- **Task status on link page:** same assignee rule as Tasks — `canEditLabTaskStatus`; backend `PATCH /tasks/:id` enforces assignee.
diff --git a/.cursor/rules/lab-cases.mdc b/.cursor/rules/lab-cases.mdc
index 8b10c2e..4233dec 100644
--- a/.cursor/rules/lab-cases.mdc
+++ b/.cursor/rules/lab-cases.mdc
@@ -1,6 +1,6 @@
---
description: Lab Cases tab — prosthesis filter, auto-select, list cards, Case Sheet PDF
-globs: frontend/src/components/ui/lab/CasesPage.tsx,frontend/src/components/ui/lab/CaseDetailPanel.tsx,frontend/src/components/ui/lab/LabCaseProsthesisGroupsList.tsx,frontend/src/components/ui/lab/LabCaseShareQr*.tsx,frontend/src/components/ui/lab/CaseSheet*.tsx,frontend/src/components/lab/caseSheetPdf.ts,frontend/src/components/lab/caseDetailUtils.ts,backend/src/modules/cases/**
+globs: frontend/src/components/ui/lab/CasesPage.tsx,frontend/src/components/ui/lab/CaseDetailPanel.tsx,frontend/src/components/ui/lab/LabCaseProsthesisGroupsList.tsx,frontend/src/components/ui/lab/ProsthesisStackedTypeLabel.tsx,frontend/src/components/ui/lab/LabCaseShareQr*.tsx,frontend/src/components/ui/lab/CaseSheet*.tsx,frontend/src/components/lab/caseSheetPdf.ts,frontend/src/components/lab/caseDetailUtils.ts,frontend/src/components/treatment/prosthesisJobPath.ts,backend/src/modules/cases/**
alwaysApply: false
---
@@ -8,7 +8,7 @@ alwaysApply: false
- **Filter by prosthesis**, not treatment type — `prosthesisTypeCode`; matches cases with **any task** of that type.
- **Auto-select** newest case on tab open; right panel never empty when list has items; `?caseId=` deep link overrides.
-- **List cards:** `LabCaseProsthesisGroupsList` (catalog color + teeth); same component as Treatment shipments rail; no patient mobile on card. Connected same-type teeth share a group; stacked types on one tooth split by `+` in the type key.
+- **List cards:** `LabCaseProsthesisGroupsList` (catalog color + teeth); same component as Treatment shipments rail; no patient mobile on card. Connected same-type teeth share a group; stacked types on one tooth split by `+` in the type key. Job titles are the picker path to the **leaf** (`prosthesisJobPath.ts` / `ProsthesisStackedTypeLabel`) — e.g. Crowns · PFM Crown, never the category alone. Indirect omits Veneer/Inlay/Onlay/Overlay (Inlay · Layered ceramic). Join ancestors/leaf with `PROSTHESIS_JOB_PATH_SEP` (`\u00a0·\u00a0`) so the gap does not collapse between inline spans.
- **Assignment** in detail only (`TAB_CASES_EDIT`); task status changes on Tasks tab.
- **Share link:** QR + URL when case is sent; token API + focus page — see `.cursor/skills/lab-case-share-link/SKILL.md`.
- **Case Sheet PDF:** client A4 via `jspdf` + `html2canvas`; print layout must stay **hex-only** (Tailwind `lab()`/`oklch` breaks capture). Optional `externalCode` replaces PDF order number when set.
diff --git a/.cursor/rules/lab-tasks.mdc b/.cursor/rules/lab-tasks.mdc
index 2a8b43d..a28eeb5 100644
--- a/.cursor/rules/lab-tasks.mdc
+++ b/.cursor/rules/lab-tasks.mdc
@@ -1,6 +1,6 @@
---
description: Lab Tasks tab — sort, grouping, filters, prosthesis colors
-globs: frontend/src/components/ui/lab/TasksPage.tsx,frontend/src/components/ui/lab/Task*.tsx,frontend/src/components/ui/lab/Case*.tsx,frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx,frontend/src/components/lab/taskListGrouping.ts,frontend/src/components/lab/labCaseDueDateDisplay.ts,frontend/src/components/shared/formSelectStyles.ts,frontend/src/components/treatment/prosthesisTypeDisplay.ts,frontend/src/components/shared/catalog-type-colors.ts,backend/src/modules/tasks/**,backend/src/common/lab-case-due-date.ts,backend/src/modules/treatments/**
+globs: frontend/src/components/ui/lab/TasksPage.tsx,frontend/src/components/ui/lab/Task*.tsx,frontend/src/components/ui/lab/Case*.tsx,frontend/src/components/ui/lab/ProsthesisStackedTypeLabel.tsx,frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx,frontend/src/components/lab/taskListGrouping.ts,frontend/src/components/lab/labCaseDueDateDisplay.ts,frontend/src/components/shared/formSelectStyles.ts,frontend/src/components/treatment/prosthesisTypeDisplay.ts,frontend/src/components/treatment/prosthesisJobPath.ts,frontend/src/components/shared/catalog-type-colors.ts,backend/src/modules/tasks/**,backend/src/common/lab-case-due-date.ts,backend/src/modules/treatments/**
alwaysApply: false
---
@@ -8,7 +8,7 @@ alwaysApply: false
- **Default sort:** newest `labCase.sentAt` first; `stepOrder` asc within prosthesis group (backend `buildOrderBy`).
- **Grouping:** only when `sortBy=date`; flat list + hint for other sorts. Unconnected teeth stay one pipeline; **connected** teeth that share a type share a pipeline; `partial_denture` is one pipeline for all selected teeth.
-- **Prosthesis colors:** `PROSTHESIS_TYPE_COLORS` + `prosthesisTypeColorFromCatalog` as text (no type pills) — never row index. Stacked `code+code` labels use `ProsthesisStackedTypeLabel` (one color per type).
+- **Prosthesis colors:** `PROSTHESIS_TYPE_COLORS` + `prosthesisTypeColorFromCatalog` as text (no type pills) — never row index. Stacked `code+code` labels use `ProsthesisStackedTypeLabel` (one color per type). Titles are the picker path to the leaf (`prosthesisJobPath.ts`), not the category name alone. Indirect omits the Veneer/Inlay/Onlay/Overlay parent. Path joins use `PROSTHESIS_JOB_PATH_SEP` (`\u00a0·\u00a0`).
- **Important first:** `pinImportant=true` (sort pin, not filter).
- **Task assignment:** assign in Cases (`TAB_CASES_EDIT`); status edit on Tasks only for assignee or unassigned tasks; others see “Assigned to {name}”.
- **Grouped comments:** when `sortBy=date`, one comments icon on **case header** (not per row); flat sort keeps per-row comments.
diff --git a/.cursor/rules/treatment-workspace.mdc b/.cursor/rules/treatment-workspace.mdc
index b2622b9..212e8b3 100644
--- a/.cursor/rules/treatment-workspace.mdc
+++ b/.cursor/rules/treatment-workspace.mdc
@@ -8,10 +8,10 @@ alwaysApply: false
- **Browse mode** (`selectedPreviewId` set): preview only; banner + **Load into workspace**; form unchanged until load.
- **Current draft** preview: omitted in live editing (form is the source). History browse still uses preview + **Load into workspace**.
-- **Entry:** Type dropdown + `TreatmentDetailAttachmentsStrip` on one row (plus **Lab dispatch** toggle when the type is lab-dependent), bordered chart (Cases chrome), then full-width auto-growing Notes. Prosthesis types live on the chart (teeth, or Upper/Lower arch for jaw products). Picker: `4fr` wrapping category grid / `1fr` add-ons; empty crown slot is plus + dashed chip in a full-height cell; after a crown is picked the slot stays as the filled type chip (so it can be changed or cleared). Children stay a wrapping grid with an L-rail. Category parent is the heaviest family tone, children lighter. Lab dispatch is primary, Chart is outline, same width. Chart header: Full View + Reset as shared `Button` `sm`. Arch labels: dashed outline + plus. Picker Upper/Lower/Both remaps `UA`/`LA` (`retargetArchJobs`: Both→Upper drops lower). Implant or post & core suggests a **crown** slot; a veneer/inlay/onlay/overlay hides that slot; Post & core is disabled after a crown/implant; Implant is disabled after post & core; screw-retained does not suggest another crown and disables Crown / Indirect. Partial denture is selected on FDI teeth. Lab dispatch **replaces the chart** in the same slot (toggle beside attachments) — not stacked underneath. Rail / Go to dispatch opens that view (`pendingScrollToLabRef`). Prosthesis teeth **always have jobs**; hydrate/save prune selections without `toothProsthesis`. Lab-dependent chips use colored sent/unsent text; sent date is on Lab dispatch. Lab dispatch keeps comments.
+- **Entry:** Type dropdown + `TreatmentDetailAttachmentsStrip` on one row (plus **Lab dispatch** toggle when the type is lab-dependent), bordered chart (Cases chrome), then full-width auto-growing Notes. Prosthesis types live on the chart (teeth, or Upper/Lower arch for jaw products). Picker: `4fr` wrapping category grid / `1fr` add-ons with a vertical `border-e` divider; category/subcategory (and matching leaf) tiles use `prosthesis-catalog` SVGs; empty crown slot is plus + dashed chip in a full-height cell; after a crown is picked the slot stays as the filled type chip (so it can be changed or cleared). Children stay a wrapping grid with an L-rail sized to the **first child card**; parent-bar back arrow is black (not family tint). Category parent is the heaviest family tone, children lighter. **Add detail**, Lab dispatch, and Chart share the same control width (`WorkspaceActionLabel`). Lab dispatch is primary, Chart is outline. Chart header: Full View + Reset as shared `Button` `sm`. Arch labels: dashed outline + plus. Picker Upper/Lower/Both remaps `UA`/`LA` (`retargetArchJobs`: Both→Upper drops lower). Implant or post & core suggests a **crown** slot; a veneer/inlay/onlay/overlay hides that slot; Post & core is disabled after a crown/implant; Implant is disabled after post & core; screw-retained does not suggest another crown and disables Crown / Indirect. Partial denture is selected on FDI teeth. Lab dispatch **replaces the chart** in the same slot (toggle beside attachments) — not stacked underneath. Rail / Go to dispatch opens that view (`pendingScrollToLabRef`). Prosthesis teeth **always have jobs**; hydrate/save prune selections without `toothProsthesis`. Lab-dependent chips use colored sent/unsent text; sent date is on Lab dispatch. Lab dispatch keeps comments.
- **Tooth hits:** unrotated full cell, `pointerdown` only (not `click` too — double-toggles). Glyph `pointer-events-none`; nest scale/hover inside the rotate wrapper. Groups: `toothSelectionGroups.ts` (never 1-tooth connected); prune lab `toothProsthesis` on change.
- **Day strip:** `ScheduleDayPicker` `compact` centered in the header. Timed cards use purpose banners. Unscheduled cards use the same banner from the **first detail’s type only** (live draft for the open card); empty first line keeps chip theming. Trash inherits banner ink. Strip-delete only when `areUnscheduledDetailsStripDeletable` (blank lines or `[]`); persist `[]` then `DELETE /treatments/:id`.
-- **Lab dispatch UI:** due date end-aligned beside title (`sm:flex-row` + `justify-between`; stacks on mobile). Job list is `LabCaseToothJobsList`: connected same-type teeth share a row; unconnected teeth stay one row (stacked types as colored catalog text). Content clinical field = **Notes** (not case comments).
+- **Lab dispatch UI:** due date end-aligned beside title (`sm:flex-row` + `justify-between`; stacks on mobile). Job list is `LabCaseToothJobsList`: connected same-type teeth share a row; unconnected teeth stay one row (stacked types as colored catalog text). Content clinical field = **Notes** (not case comments). Sent cases: dest/jobs share a row with the Cases QR thumb; tracker + comments are **full width below** the QR.
- **Lab comments:** shared `LabCaseCommentsPanel` (newest-first; sent=`justify-start`, received=`justify-end`; `viewerSide`) across Treatment / Cases / Tasks / share. Use logical `text-start`/`text-end`, not left/right.
- **Detail chrome:** chips (type + teeth) + Add at top; **Remove** = trash on chip (unsent, including last line; disabled when day-locked / no edit / uploading). Last-line confirm: plan will be empty until Add. Empty `[]` shows `noDetails` (not the type-first overlay). New treatment seeds one blank detail. No delete in type/notes fields.
- **Patient search:** `PatientSearchCombobox` in the **page header** (workspace-wide). Opens today’s strip visit if any, else latest history. No visit/history → inline editor empty state pointing to **New treatment** in the rail (never a dialog, never auto-create). **New treatment** stays at the top of the left rail; selected-patient card sits under it. Picker: Walk-in first, then a matching full-width card for the current named patient (name + mobile/email, else hint), then search. Never auto-create from the appointment card.
diff --git a/.cursor/skills/lab-case-share-link/SKILL.md b/.cursor/skills/lab-case-share-link/SKILL.md
index 844dfc5..268f8f8 100644
--- a/.cursor/skills/lab-case-share-link/SKILL.md
+++ b/.cursor/skills/lab-case-share-link/SKILL.md
@@ -11,8 +11,8 @@ Shipped cases get a stable **access token** and share URL. QR + link open a focu
- **Schema:** `LabCase.accessToken` (`String?`, `@unique`).
- **On first ship:** `treatments.service` sets `accessToken` + `sentAt` in the same update.
-- **Backfill:** `LabCaseAccessService.ensureAccessToken()` for older sent cases when building case detail `shareUrl`.
-- **URL:** `buildLabCaseShareUrl(token, locale)` → `{FRONTEND_URL}/{locale}/lab-case/{token}` (`backend/src/common/lab-case-access-token.ts`).
+- **Backfill:** `LabCaseAccessService.ensureAccessToken()` for older sent cases when building case detail `shareUrl`; clinic drafts use `TreatmentsService.ensureSentLabCaseAccessTokens()` on getDraft.
+- **URL:** `buildLabCaseShareUrl(token, locale)` → `{FRONTEND_URL}/{locale}/lab-case/{token}` (`backend/src/common/lab-case-access-token.ts`). Clinic lab-case payloads include the same `shareUrl`.
## Backend API (`LabCaseAccessController`)
@@ -41,11 +41,13 @@ Task status updates use **`PATCH /tasks/:id`** (not token routes) — same assig
|-------|------|
| Focus page | `app/[locale]/(dashboard)/lab-case/[token]/page.tsx` → `CaseTasksFocusView` |
| API client | `lib/api/lab-case-access.ts` |
-| QR UI | `LabCaseShareQrCode`, `LabCaseShareQrDialog`, thumb in `CaseDetailPanel` |
+| QR UI | `LabCaseShareQrCode`, `LabCaseShareQrDialog`, thumb in `CaseDetailPanel` **and** Treatment `LabCasesDispatchPanel` |
| QR package | `react-qr-code` (frontend only — no backend QR generation) |
**Cases detail header:** attachment preview **left**, QR thumb **right**, same row (`w-24 sm:w-32`). QR opens dialog (large QR + URL + copy); no inline copy on panel. Only when `shareUrl` present (sent case).
+**Treatment lab dispatch:** after send, the same thumb + dialog (same `shareUrl` / token) so the dentist can scan to the focus page. Dest/jobs share a row with the QR; tracker + comments span the card **below** it (not beside it).
+
**Share focus page:** grouped tasks (reuse `TaskRow`, `TaskCaseGroupHeader`); comments via `LabCaseCommentsPanel` + token API (`viewerSide` from `session.accessMode`). Newest-first; sent/received use logical start/end. Access denied → inline message (`asApiError` for `LAB_CASE_ACCESS_DENIED`).
## Auth redirect (logged out → login → back)
diff --git a/.cursor/skills/lab-cases/SKILL.md b/.cursor/skills/lab-cases/SKILL.md
index 7baf45e..ff44283 100644
--- a/.cursor/skills/lab-cases/SKILL.md
+++ b/.cursor/skills/lab-cases/SKILL.md
@@ -12,7 +12,7 @@ description: Lab Cases tab — list, filters, detail panel, assignment, share QR
## List behavior
- **Default sort:** `startedAt` / `sentAt` desc (newest first). Lab-origin drafts (`origin === LAB_INTERNAL && !startedAt`) show a **Draft** badge. All list/detail cards also show **Received** (`CLINIC_DISPATCH`) or **Generated** (`LAB_INTERNAL`) via `LabCaseOriginBadge`.
-- **Task group headers:** prosthesis type is **colored catalog text** (`prosthesisTypeColorFromCatalog`), not a Badge pill. Same on Tasks rows/headers.
+- **Task group headers:** prosthesis type is **colored catalog text** (`prosthesisTypeColorFromCatalog`), not a Badge pill. Same on Tasks rows/headers. Titles use the picker path to the **leaf** (`prosthesisJobPath.ts` via `ProsthesisStackedTypeLabel`) — e.g. Crowns · PFM Crown, not “Crowns” alone. Indirect jobs skip the long parent title: Inlay · Layered ceramic, not Veneer/Inlay/Onlay/Overlay · Inlay · …. Join ancestors/leaf with `PROSTHESIS_JOB_PATH_SEP` (`\u00a0·\u00a0`).
- **Page size:** `PAGE_SIZE = 10` in `CasesPage.tsx`.
- **Auto-select:** On tab open / after filter reload, select first list item if none selected; keep selection when still in list; `?caseId=` URL wins.
- **Add case:** `TAB_CASES_EDIT` — `POST /cases` then replace the **right pane** with `CaseCreatePanel` (inline wizard, not a modal). Prosthesis-only lines, `ProsthesisAssignChart` (same picker/stacking as Treatment). Summary rows: `LabCaseToothJobsList` (connected same-type teeth share a row; otherwise tooth · types). Header: referring clinic/dentist, patient name/mobile, optional ACTIVE partner clinic, due date (`AppDateInput`). **Start** (`POST /cases/:id/start`) generates tasks; no `LabCaseSend`, no clinic inbox/`CASE_SENT`. **Delete** (`DELETE /cases/:id`) only for unstarted lab-origin drafts (`LAB_INTERNAL` && `!startedAt`); confirm in the create-panel header next to Start. Received clinic cases and started generated cases cannot be deleted.
@@ -21,7 +21,7 @@ description: Lab Cases tab — list, filters, detail panel, assignment, share QR
List `where`: received clinic cases (`sends` + `sentAt`) **OR** `origin = LAB_INTERNAL` and `destinationOrganizationId = lab`. Map clinic/patient from `treatment` **or** snapshots / partner org. Search includes snapshot names.
-Share QR remains clinic-sent only (`sentAt` / `shareUrl`). Lab-origin has no share link in this pass.
+Share QR remains clinic-sent only (`sentAt` / `shareUrl`). Lab-origin has no share link in this pass. Clinic Treatment **Lab dispatch** shows the same QR after send (see lab-case-share-link skill).
## Filters (`GET /cases`)
@@ -35,7 +35,7 @@ Filter options: `GET /cases/filter-options` → `clinics`, `prosthesisTypes` (di
## List card UI
-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.
+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. Type text is the picker path to the leaf (`prosthesisJobPath.ts`).
List item shape: `prosthesisGroups: { prosthesisTypeCode, teeth[] }[]` from task teeth aggregation. Unconnected teeth stay one group each (stacked types on that tooth share one `prosthesisTypeCode` key like `crown+abutment`). **Connected** teeth that share a type are one group whose `teeth` lists the bridge. **Partial denture** is one group whose `teeth` is every selected unit.
@@ -63,7 +63,7 @@ Stock `html2canvas` **cannot** parse `lab()` / `oklch()` from app CSS. Do **not*
| Hex/rgb **inline styles** only in print layout | Tailwind color utilities / CSS variables in capture |
| [`CaseSheetFdiChart`](frontend/src/components/ui/lab/CaseSheetFdiChart.tsx) (SVG, catalog hex colors) | Reuse interactive `FdiToothChart` for PDF |
| `onclone`: strip `style` + `link[rel=stylesheet]` | Rely on global stylesheets during capture |
-| Prosthesis labels from **catalog** (then task label) | Fallback `code.replace(/_/g,' ')` alone (breaks casing like `ti base…`) |
+| Prosthesis labels from **`prosthesisJobPathLabel`** (category · … · leaf) | Fallback `code.replace(/_/g,' ')` alone (breaks casing like `ti base…`) |
### Layout conventions
diff --git a/.cursor/skills/lab-tasks/SKILL.md b/.cursor/skills/lab-tasks/SKILL.md
index db58b3a..1298848 100644
--- a/.cursor/skills/lab-tasks/SKILL.md
+++ b/.cursor/skills/lab-tasks/SKILL.md
@@ -32,6 +32,7 @@ Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`.
- Map: [`catalog-type-colors.ts`](frontend/src/components/shared/catalog-type-colors.ts) → `PROSTHESIS_TYPE_COLORS` (one hex per catalog code).
- Resolve with [`prosthesisTypeDisplay.ts`](frontend/src/components/treatment/prosthesisTypeDisplay.ts) — use `prosthesisTypeColorFromCatalog(code, catalog)` as **text color** (no type pills), **not** list row index.
+- Job titles: picker path to the last node ([`prosthesisJobPath.ts`](frontend/src/components/treatment/prosthesisJobPath.ts) / `ProsthesisStackedTypeLabel`) so technicians see e.g. Implants · Screw Retained, not “Implants”. Indirect skips the long parent: Inlay · Layered ceramic. Join with `PROSTHESIS_JOB_PATH_SEP` (`\u00a0·\u00a0`) so the gap around · does not collapse.
- Load catalog via `prosthesisCatalogApi.list()` on Tasks/Cases/Today dashboard.
## Filters
diff --git a/.cursor/skills/treatment-workspace/SKILL.md b/.cursor/skills/treatment-workspace/SKILL.md
index f62b0dd..d02602b 100644
--- a/.cursor/skills/treatment-workspace/SKILL.md
+++ b/.cursor/skills/treatment-workspace/SKILL.md
@@ -41,9 +41,9 @@ Right-column entry is **not** a three-step wizard. Type dropdown + `TreatmentDet
| Stage | UI | When |
|-------|-----|------|
| **Treatment** | Type dropdown + `TreatmentDetailAttachmentsStrip`, `FdiToothChart` / `ProsthesisAssignChart`, full-width Notes | Default |
-| **Lab** | `LabCasesDispatchPanel` in the chart slot | Lab-dependent type + user clicks **Lab dispatch** (or rail / Go to dispatch). Auto-ensures a shipment draft (teeth/arch not required to create the draft). **No default lab or prosthesis type** on a new detail (including siblings in the same plan). Last **3 sent** labs appear as chips under search — pick is explicit. Comments stay on the dispatch panel. |
+| **Lab** | `LabCasesDispatchPanel` in the chart slot | Lab-dependent type + user clicks **Lab dispatch** (or rail / Go to dispatch). Auto-ensures a shipment draft (teeth/arch not required to create the draft). **No default lab or prosthesis type** on a new detail (including siblings in the same plan). Last **3 sent** labs appear as chips under search — pick is explicit. Comments stay on the dispatch panel. After send, the same case QR as lab Cases is shown (`shareUrl`): dest/jobs share a row with the thumb; tracker + comments are full width below. |
-- Prosthesis types are assigned on the chart (`ProsthesisAssignChart` + `ProsthesisJobPopover` in `prosthesisTree.ts`). The picker is two columns (`4fr` wrapping category grid / `1fr` add-ons). Category tiles keep the wrapping `minmax(8rem, 1fr)` grid and stretch to fill the overlay; expanded children use that same wrapping grid with a parent-colored **L** rail (not a per-card tree). Empty crown suggestion: plus + dashed chip (`addonCrownCanBeAdded`) inside a full-height slot; after a crown is picked the slot stays as the filled type chip. Arch Upper/Lower/Both is a compact `h-8` segmented control at half the tree column width — it **is** the assignment (`retargetArchJobs`): Both→Upper/Lower drops the other jaw; Upper/Lower→Both copies the type onto the empty jaw; Upper↔Lower moves the job. Chart Upper/Lower arch labels are dashed outline buttons with plus (open the picker; control shows current jobs, or the clicked jaw if none). Category parents use the heaviest family pastel; children only lighten. One **restoration** per tooth (crown / veneer-inlay-onlay-overlay). **Screw-retained** is implant (`stackGroup: implant`, paints the crown) and is itself the restoration — no crown suggestion slot, and Crown / Indirect are disabled. Implant or post & core (without a non-crown restoration) shows a **crown** suggestion slot. A veneer/inlay/onlay/overlay hides the suggestion slot. **Post & core** category is visible but disabled when a restoration or implant is on the tooth. **Implant** category is disabled when post & core is on the tooth. Complete denture / overdenture / appliances / digital use **Upper arch / Lower arch** (`UA`/`LA`). **Partial denture** is tooth-level (select FDI teeth, Removable in the tooth picker); after send it is **one lab job** for all those teeth. Picker leaves are filtered by `chartRegion` so Removable appears in both tooth and arch pickers. Prosthesis FDI teeth **must** have jobs — never persist selected teeth without `toothProsthesis` (`pruneDetailTeethToJobs`). Catalog has no `addonKind` — stacking uses `stackGroup` plus the crown suggestion slot.
+- Prosthesis types are assigned on the chart (`ProsthesisAssignChart` + `ProsthesisJobPopover` in `prosthesisTree.ts`). The picker is two columns (`4fr` wrapping category grid / `1fr` add-ons) with a vertical `border-e` divider. Category and subcategory tiles (and matching leaves) show SVGs from `src/assets/prosthesis-catalog` (served from `public/prosthesis-catalog`) via `prosthesisCatalogIcons.ts`. Category tiles keep the wrapping `minmax(8rem, 1fr)` grid and stretch to fill the overlay; expanded children use `minmax(10.2rem, 1fr)` with a parent-colored **L** rail sized to the first child card (not a per-card tree). Parent-bar back arrow is black. Child labels stay one line and ellipsize (`…`) when they overflow. **Add detail**, Lab dispatch, and Chart share one control width (`WorkspaceActionLabel` in `TreatmentDetailsEditor`). Indirect children are **Veneer → Inlay → Onlay → Overlay** (same order as the category title). Crown leaves without a dedicated SVG use the monolithic zirconia drawing, not the Crown parent icon. Empty crown suggestion: plus + dashed chip (`addonCrownCanBeAdded`) inside a full-height slot; after a crown is picked the slot stays as the filled type chip. Arch Upper/Lower/Both is a compact `h-8` segmented control at half the tree column width — it **is** the assignment (`retargetArchJobs`): Both→Upper/Lower drops the other jaw; Upper/Lower→Both copies the type onto the empty jaw; Upper↔Lower moves the job. Chart Upper/Lower arch labels are dashed outline buttons with plus (open the picker; control shows current jobs, or the clicked jaw if none). Category parents use the heaviest family pastel; children only lighten. One **restoration** per tooth (crown / veneer-inlay-onlay-overlay). **Screw-retained** is implant (`stackGroup: implant`, paints the crown) and is itself the restoration — no crown suggestion slot, and Crown / Indirect are disabled. Implant or post & core (without a non-crown restoration) shows a **crown** suggestion slot. A veneer/inlay/onlay/overlay hides the suggestion slot. **Post & core** category is visible but disabled when a restoration or implant is on the tooth. **Implant** category is disabled when post & core is on the tooth. Complete denture / overdenture / appliances / digital use **Upper arch / Lower arch** (`UA`/`LA`). **Partial denture** is tooth-level (select FDI teeth, Removable in the tooth picker); after send it is **one lab job** for all those teeth. Picker leaves are filtered by `chartRegion` so Removable appears in both tooth and arch pickers. Prosthesis FDI teeth **must** have jobs — never persist selected teeth without `toothProsthesis` (`pruneDetailTeethToJobs`). Catalog has no `addonKind` — stacking uses `stackGroup` plus the crown suggestion slot.
- Detail chips show **type + teeth**, not “Detail N”. Lab-dependent chips use colored sent/unsent text (same size as the label); sent date stays on Lab dispatch.
- Detail type may differ from appointment purpose. Purpose seeds the first line of an empty **appointment** draft (first open, and **Add detail** when the plan is `[]`). Later **Add detail** starts with an empty type. Unscheduled / New treatment still seeds a blank first line.
- Lab shipments rail / “Go to dispatch” / load-with-focus **opens the dispatch view** in the chart slot (`pendingScrollToLabRef` + `labPanelRef`).
@@ -132,7 +132,7 @@ Saved lab-dependent detail with **no FDI teeth and no arch jobs** (`UA`/`LA`) ca
Comments for a shipment live in the Lab dispatch panel (and Cases/Tasks/share), not on Content notes.
- Unsent: deferred composer in `DetailLabCaseCommentsSection` (posts with Send to lab).
-- Sent / in progress: live composer until tasks complete (`canPostComments`).
+- Sent / in progress: live composer until tasks complete (`canPostComments`). Same-card share QR sits beside dest/jobs only; tracker + comments stay full width below (`LabCasesDispatchPanel`).
- UI: `DetailLabCaseCommentsSection` → `LabCaseCommentsPanel` + `treatmentsApi` comment endpoints (`viewerSide="CLINIC"`).
- Backend includes `tasks: { select: { id, status } }` on lab cases; `mapDetail` exposes `taskProgress: { completed, total }`.
diff --git a/AGENTS.md b/AGENTS.md
index 4267220..b6e107a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -47,9 +47,9 @@ 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 (plus **Lab dispatch** / **Chart** toggle when the type is lab-dependent; dispatch is primary, Chart is outline, same width), then bordered FDI chart (Cases chrome/scale) **or** the lab dispatch panel in that same slot, then full-width auto-growing **Notes**. Prosthesis types are assigned on the chart (teeth, or Upper/Lower arch labels for jaw products — dashed outline + plus). Arch Upper/Lower/Both remaps existing `UA`/`LA` jobs (Both→Upper clears lower). Picker: `4fr` wrapping category grid / `1fr` add-ons; empty crown slot is plus + dashed “Crown can be added”; filled crown slot stays visible so it can be changed. Implant/post & core show a crown suggestion slot; a veneer/inlay/onlay/overlay hides it; Post & core is disabled after crown or implant; Implant is disabled after post & core; screw-retained does not suggest another crown. Prosthesis teeth always have jobs (hydrate/save prune). Lab-rail / Go to dispatch **opens the dispatch view** (`pendingScrollToLabRef`). Detail chips show type + teeth; lab-dependent chips use colored sent/unsent text (sent date on dispatch). Lab dispatch keeps comments.
+- **Entry:** Type dropdown + compact attachments strip (`TreatmentDetailAttachmentsStrip`) on one row (plus **Lab dispatch** / **Chart** toggle when the type is lab-dependent; dispatch is primary, Chart is outline; **Add detail** uses the same width), then bordered FDI chart (Cases chrome/scale) **or** the lab dispatch panel in that same slot, then full-width auto-growing **Notes**. Prosthesis types are assigned on the chart (teeth, or Upper/Lower arch labels for jaw products — dashed outline + plus). Arch Upper/Lower/Both remaps existing `UA`/`LA` jobs (Both→Upper clears lower). Picker: `4fr` wrapping category grid / `1fr` add-ons with a vertical divider; category/subcategory (and matching leaf) tiles use `prosthesis-catalog` SVGs; expanded children use a parent-colored **L** rail sized to the first child card; parent-bar back arrow is black; empty crown slot is plus + dashed “Crown can be added”; filled crown slot stays visible so it can be changed. Implant/post & core show a crown suggestion slot; a veneer/inlay/onlay/overlay hides it; Post & core is disabled after crown or implant; Implant is disabled after post & core; screw-retained does not suggest another crown. Prosthesis teeth always have jobs (hydrate/save prune). Lab-rail / Go to dispatch **opens the dispatch view** (`pendingScrollToLabRef`). Detail chips show type + teeth; lab-dependent chips use colored sent/unsent text (sent date on dispatch). Lab dispatch keeps comments.
- **Tooth selection:** Hit target is the unrotated cell (`pointerdown` only — do not also bind `click`). Glyph is `pointer-events-none`; nest hover/selected scale inside the rotate wrapper. Neighbor empty/filled circles between selected adjacent teeth connect/disconnect bridges; midline 11–21 / 41–31 allowed. Never a 1-tooth connected. On the non-prosthesis chart, Shift+range selects as singles. **Prosthesis assign chart (Exocad-style):** plain click opens the picker (tooth is not selected until a type is picked); **Done** stores that tooth’s jobs as the copy brush; **Ctrl/Cmd+click** or drag copies those jobs onto one tooth; **Shift+click** copies onto the inclusive same-arch range as **singles** (connect with neighbor circles if it is a bridge). Helpers: `toothSelectionGroups.ts`. Connected label: `ConnectedSelectionBadge`. Unconnected teeth stay **one item per tooth** (stacked types on that tooth union their lab steps). **Connected** teeth that share a prosthesis type are **one job** (teeth listed together); an extra type on one unit of the bridge is its own row. `UA`/`LA` → Upper/Lower arch. **Partial denture** is tooth-selected and becomes **one lab job** for all those teeth.
-- **Lab dispatch layout:** due date beside title (`justify-between`, logical start/end for RTL); job rows from `LabCaseToothJobsList` (connected same-type teeth share a row; stacked types on a single tooth stay on that tooth).
+- **Lab dispatch layout:** due date beside title (`justify-between`, logical start/end for RTL); job rows from `LabCaseToothJobsList` (connected same-type teeth share a row; stacked types on a single tooth stay on that tooth). Sent cases: dest/jobs share a row with the Cases **QR** thumb; tracker + comments are full width below.
- **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):**
@@ -64,14 +64,14 @@ frontend/src/
**Appointments (quick ref):** Do not delete (or change patient) when `hasTreatment`; codes `APPOINTMENT_HAS_TREATMENT` / `APPOINTMENT_PATIENT_LOCKED`. Past days: no new bookings; edit/delete OK without treatment; with treatment → toast. Appointment delete does not cascade-delete treatments. Working hours: client IANA `timeZone` on create/update — never `Date#getHours()`/`getDay()` on the UTC server. Logical API errors: `AppException` + `errors.*` (never Nest English throws). See `.cursor/rules/appointments.mdc`, `.cursor/skills/api-errors/SKILL.md`.
-**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; filter by case source (`origin`: received vs generated); prosthesis colors from catalog; task assignment in **Cases** (compact row: status + assignee + last update); on **Tasks**, all staff see every task but only assignee (or unassigned pool) can change status — others see “Assigned to {name}” instead of the status dropdown; **case due dates** set/edited in clinic Treatment lab dispatch, shown on lab Cases/Tasks with overdue filter + sort; completing **`intraoral_scan`** completes every scan task in that case (case-scoped; catalog first step for all prosthesis types); **mobile:** larger task status controls, sticky case header when grouped; **tab badges:** `LabCaseActivity` + `GET /notifications/tab-counts` (lab Cases/Tasks split, clinic Treatment) — live via inbox Socket.IO → `notifyTabBadgesChanged()` + soft list refresh — see `.cursor/skills/lab-tasks/SKILL.md`, `.cursor/skills/tab-badges/SKILL.md`, `.cursor/skills/notifications-inbox/SKILL.md`.
+**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; filter by case source (`origin`: received vs generated); prosthesis colors from catalog; job titles show the picker path to the leaf (`prosthesisJobPath.ts`); task assignment in **Cases** (compact row: status + assignee + last update); on **Tasks**, all staff see every task but only assignee (or unassigned pool) can change status — others see “Assigned to {name}” instead of the status dropdown; **case due dates** set/edited in clinic Treatment lab dispatch, shown on lab Cases/Tasks with overdue filter + sort; completing **`intraoral_scan`** completes every scan task in that case (case-scoped; catalog first step for all prosthesis types); **mobile:** larger task status controls, sticky case header when grouped; **tab badges:** `LabCaseActivity` + `GET /notifications/tab-counts` (lab Cases/Tasks split, clinic Treatment) — live via inbox Socket.IO → `notifyTabBadgesChanged()` + soft list refresh — see `.cursor/skills/lab-tasks/SKILL.md`, `.cursor/skills/tab-badges/SKILL.md`, `.cursor/skills/notifications-inbox/SKILL.md`.
-**Lab Cases tab:** Filter by **prosthesis type** (not treatment type); auto-select newest case on open; list **10 per page**; left rail list fills column height (`flex-1 overflow-y-auto`); list cards use `LabCaseProsthesisGroupsList` (colored type + teeth, shared with Treatment rail) plus **Received** / **Generated** origin badges. Deep link: `?caseId=`, `?clinicOrganizationId=`. **Share link:** QR + URL on sent cases (attachment left, QR right); opens `/lab-case/[token]` focus page. **Case Sheet PDF:** client A4 (`jspdf`/`html2canvas`); hex-only print layout; optional `externalCode` replaces order number. Lab-origin Start has no clinic send; comments and mark-read still work (no clinic-visibility toggle). Unstarted generated drafts can be deleted (`DELETE /cases/:id`). **Live:** inbox Socket.IO → `notifyTabBadgesChanged()` soft-refreshes list + selected detail (no remount). See `.cursor/skills/lab-cases/SKILL.md` and `.cursor/skills/lab-case-share-link/SKILL.md`.
+**Lab Cases tab:** Filter by **prosthesis type** (not treatment type); auto-select newest case on open; list **10 per page**; left rail list fills column height (`flex-1 overflow-y-auto`); list cards use `LabCaseProsthesisGroupsList` (colored type + teeth, shared with Treatment rail) plus **Received** / **Generated** origin badges. Job titles show the picker path to the **leaf** (`prosthesisJobPath.ts` — e.g. Crowns · PFM Crown, not “Crowns”; indirect is Inlay · Layered ceramic, not the long Veneer/Inlay/Onlay/Overlay parent). Deep link: `?caseId=`, `?clinicOrganizationId=`. **Share link:** QR + URL on sent cases (attachment left, QR right); opens `/lab-case/[token]` focus page. **Case Sheet PDF:** client A4 (`jspdf`/`html2canvas`); hex-only print layout; optional `externalCode` replaces order number. Lab-origin Start has no clinic send; comments and mark-read still work (no clinic-visibility toggle). Unstarted generated drafts can be deleted (`DELETE /cases/:id`). **Live:** inbox Socket.IO → `notifyTabBadgesChanged()` soft-refreshes list + selected detail (no remount). See `.cursor/skills/lab-cases/SKILL.md` and `.cursor/skills/lab-case-share-link/SKILL.md`.
**Lab case share link (quick ref):**
- Token on first ship → `/{locale}/lab-case/{token}` after login.
- **Lab:** view/edit tasks (assignee rules), comments + visibility toggle.
-- **Clinic:** treatment **provider** only — read-only tasks, can comment.
+- **Clinic:** treatment **provider** with `TAB_TREATMENT_EDIT` — read-only tasks, can comment. Same QR as lab Cases appears on Treatment **Lab dispatch** after send (dest/jobs beside the thumb; tracker + comments full width below).
- Logged out → login with `?from=` → `storeAuthRedirectFromPath` then `useEnterAppWhenAuthenticated` (`consumeAuthRedirect` once after org ready — not inside `useAuth.login()` / `registerTrial`). Trial register uses the same hook; staff/org invite accept then `login()` + `navigateIntoAppIfOrgSelected`. See `.cursor/rules/post-auth-navigation.mdc`.
**Today dashboard:** KPIs + charts per org type/permissions; deep links via `today-deep-links.ts` (Tasks KPIs/charts, Staff highlight, case partners). See `.cursor/skills/today-dashboard/SKILL.md`.
diff --git a/backend/src/modules/treatments/treatments.controller.ts b/backend/src/modules/treatments/treatments.controller.ts
index abd6f8c..9f183ca 100644
--- a/backend/src/modules/treatments/treatments.controller.ts
+++ b/backend/src/modules/treatments/treatments.controller.ts
@@ -115,13 +115,14 @@ export class TreatmentsController {
@ApiOperation({ summary: 'Get draft treatment for an appointment (TAB_TREATMENT_READ)' })
getDraft(
@Param('appointmentId') appointmentId: string,
- @Req() req: { user: { id: string; organizationId?: string } },
+ @Req() req: { user: { id: string; organizationId?: string; language?: string | null } },
) {
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
return this.treatmentsService.getDraftForAppointment(
appointmentId,
organizationId,
req.user.id,
+ req.user.language,
);
}
@@ -130,7 +131,7 @@ export class TreatmentsController {
saveDraft(
@Param('appointmentId') appointmentId: string,
@Body() dto: SaveTreatmentDraftDto,
- @Req() req: { user: { id: string; organizationId?: string } },
+ @Req() req: { user: { id: string; organizationId?: string; language?: string | null } },
) {
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
return this.treatmentsService.saveDraftForAppointment(
@@ -138,6 +139,7 @@ export class TreatmentsController {
dto,
organizationId,
req.user.id,
+ req.user.language,
);
}
@@ -146,7 +148,7 @@ export class TreatmentsController {
saveLabCases(
@Param('appointmentId') appointmentId: string,
@Body() dto: SaveTreatmentLabCasesDto,
- @Req() req: { user: { id: string; organizationId?: string } },
+ @Req() req: { user: { id: string; organizationId?: string; language?: string | null } },
) {
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
return this.treatmentsService.saveLabCasesForAppointment(
@@ -154,6 +156,7 @@ export class TreatmentsController {
dto,
organizationId,
req.user.id,
+ req.user.language,
);
}
@@ -301,13 +304,14 @@ export class TreatmentsController {
@ApiOperation({ summary: 'Get a treatment draft by treatment id (TAB_TREATMENT_READ)' })
getDraftByTreatment(
@Param('treatmentId') treatmentId: string,
- @Req() req: { user: { id: string; organizationId?: string } },
+ @Req() req: { user: { id: string; organizationId?: string; language?: string | null } },
) {
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
return this.treatmentsService.getDraftForTreatment(
treatmentId,
organizationId,
req.user.id,
+ req.user.language,
);
}
@@ -316,7 +320,7 @@ export class TreatmentsController {
saveDraftByTreatment(
@Param('treatmentId') treatmentId: string,
@Body() dto: SaveTreatmentDraftDto,
- @Req() req: { user: { id: string; organizationId?: string } },
+ @Req() req: { user: { id: string; organizationId?: string; language?: string | null } },
) {
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
return this.treatmentsService.saveDraftForTreatment(
@@ -324,6 +328,7 @@ export class TreatmentsController {
dto,
organizationId,
req.user.id,
+ req.user.language,
);
}
@@ -332,7 +337,7 @@ export class TreatmentsController {
saveLabCasesByTreatment(
@Param('treatmentId') treatmentId: string,
@Body() dto: SaveTreatmentLabCasesDto,
- @Req() req: { user: { id: string; organizationId?: string } },
+ @Req() req: { user: { id: string; organizationId?: string; language?: string | null } },
) {
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
return this.treatmentsService.saveLabCasesForTreatment(
@@ -340,6 +345,7 @@ export class TreatmentsController {
dto,
organizationId,
req.user.id,
+ req.user.language,
);
}
diff --git a/backend/src/modules/treatments/treatments.service.ts b/backend/src/modules/treatments/treatments.service.ts
index ff0ef68..72792c7 100644
--- a/backend/src/modules/treatments/treatments.service.ts
+++ b/backend/src/modules/treatments/treatments.service.ts
@@ -7,7 +7,10 @@ import { LabCaseActivityType, LabTaskStatus, LinkStatus, Prisma, UserNotificatio
import { createReadStream, existsSync, mkdirSync } from 'fs';
import { join } from 'path';
import { randomUUID } from 'crypto';
-import { generateLabCaseAccessToken } from '../../common/lab-case-access-token';
+import {
+ buildLabCaseShareUrl,
+ generateLabCaseAccessToken,
+} from '../../common/lab-case-access-token';
import { PrismaService } from '../../../prisma/prisma.service';
import { generateLabCaseTasks } from '../cases/lab-case-task.generator';
import { ProsthesisCatalogService } from '../prosthesis-catalog/prosthesis-catalog.service';
@@ -477,6 +480,7 @@ export class TreatmentsService {
appointmentId: string,
organizationId: string,
actorUserId: string,
+ actorLanguage?: string | null,
) {
await this.assertCanReadTreatment(actorUserId, organizationId);
const appointment = await this.ensureAppointmentProvider(
@@ -493,16 +497,25 @@ export class TreatmentsService {
include: treatmentInclude,
});
- return { success: true, data: treatment ? this.mapTreatment(treatment) : null };
+ if (treatment) {
+ await this.ensureSentLabCaseAccessTokens(treatment.labCases);
+ }
+
+ return {
+ success: true,
+ data: treatment ? this.mapTreatment(treatment, actorLanguage) : null,
+ };
}
async getDraftForTreatment(
treatmentId: string,
organizationId: string,
actorUserId: string,
+ actorLanguage?: string | null,
) {
const treatment = await this.ensureTreatmentProvider(treatmentId, organizationId, actorUserId);
- return { success: true, data: this.mapTreatment(treatment) };
+ await this.ensureSentLabCaseAccessTokens(treatment.labCases);
+ return { success: true, data: this.mapTreatment(treatment, actorLanguage) };
}
async saveDraftForAppointment(
@@ -510,6 +523,7 @@ export class TreatmentsService {
dto: SaveTreatmentDraftDto,
organizationId: string,
actorUserId: string,
+ actorLanguage?: string | null,
) {
await this.assertCanEditTreatment(actorUserId, organizationId);
const appointment = await this.ensureAppointmentProvider(
@@ -549,7 +563,7 @@ export class TreatmentsService {
patientId: appointment.patientId,
});
- return { success: true, data: this.mapTreatment(saved) };
+ return { success: true, data: this.mapTreatment(saved, actorLanguage) };
}
async saveDraftForTreatment(
@@ -557,6 +571,7 @@ export class TreatmentsService {
dto: SaveTreatmentDraftDto,
organizationId: string,
actorUserId: string,
+ actorLanguage?: string | null,
) {
await this.assertCanEditTreatment(actorUserId, organizationId);
const existing = await this.ensureTreatmentProvider(treatmentId, organizationId, actorUserId);
@@ -572,7 +587,7 @@ export class TreatmentsService {
patientId: existing.patientId,
});
- return { success: true, data: this.mapTreatment(saved) };
+ return { success: true, data: this.mapTreatment(saved, actorLanguage) };
}
private async persistDraftDetails(args: {
@@ -719,6 +734,7 @@ export class TreatmentsService {
dto: SaveTreatmentLabCasesDto,
organizationId: string,
actorUserId: string,
+ actorLanguage?: string | null,
) {
await this.assertCanEditTreatment(actorUserId, organizationId);
const appointment = await this.ensureAppointmentProvider(
@@ -736,7 +752,13 @@ export class TreatmentsService {
throw new AppException(ErrorCode.TREATMENT_SAVE_DETAILS_BEFORE_LAB, HttpStatus.NOT_FOUND);
}
- return this.saveLabCasesForTreatment(treatment.id, dto, organizationId, actorUserId);
+ return this.saveLabCasesForTreatment(
+ treatment.id,
+ dto,
+ organizationId,
+ actorUserId,
+ actorLanguage,
+ );
}
async saveLabCasesForTreatment(
@@ -744,6 +766,7 @@ export class TreatmentsService {
dto: SaveTreatmentLabCasesDto,
organizationId: string,
actorUserId: string,
+ actorLanguage?: string | null,
) {
await this.assertCanEditTreatment(actorUserId, organizationId);
await this.ensureTreatmentProvider(treatmentId, organizationId, actorUserId);
@@ -907,7 +930,7 @@ export class TreatmentsService {
});
});
- return { success: true, data: this.mapTreatment(saved) };
+ return { success: true, data: this.mapTreatment(saved, actorLanguage) };
}
async sendLabCase(
@@ -1049,7 +1072,7 @@ export class TreatmentsService {
},
});
- return { success: true, data: this.mapLabCase(refreshed) };
+ return { success: true, data: this.mapLabCase(refreshed, actorLanguage) };
}
async updateLabCaseDueDate(
@@ -1411,7 +1434,7 @@ export class TreatmentsService {
organization: { id: string; name: string };
}>;
}>;
- }) {
+ }, locale?: string | null) {
const documents = treatment.details.flatMap((d) =>
d.attachments.map((a) => this.mapAttachment(a)),
);
@@ -1434,7 +1457,7 @@ export class TreatmentsService {
}
: null,
details: treatment.details.map((d) => this.mapDetail(d)),
- labCases: treatment.labCases.map((lc) => this.mapLabCase(lc)),
+ labCases: treatment.labCases.map((lc) => this.mapLabCase(lc, locale)),
documents,
};
}
@@ -1497,6 +1520,7 @@ export class TreatmentsService {
destinationOrganizationId?: string | null;
sentAt?: Date | null;
dueDate?: Date | null;
+ accessToken?: string | null;
details?: Array<{
treatmentDetailId: string;
detail?: {
@@ -1528,7 +1552,7 @@ export class TreatmentsService {
};
}>;
tasks?: Array<{ id: string; status: LabTaskStatus }>;
- }) {
+ }, locale?: string | null) {
const taskProgress = this.mapTaskProgress(lc.tasks ?? []);
const detailTeeth = lc.details?.[0]?.detail
? normalizeTeeth(lc.details[0].detail.teeth)
@@ -1572,9 +1596,32 @@ export class TreatmentsService {
organizationName: s.organization?.name ?? 'Unknown organization',
sentAt: s.sentAt.toISOString(),
})) ?? [],
+ shareUrl: this.labCaseShareUrl(lc, locale),
};
}
+ private labCaseShareUrl(
+ lc: { sentAt?: Date | null; accessToken?: string | null },
+ locale?: string | null,
+ ): string | null {
+ if (!lc.sentAt || !lc.accessToken) return null;
+ return buildLabCaseShareUrl(lc.accessToken, locale ?? 'en');
+ }
+
+ private async ensureSentLabCaseAccessTokens(
+ labCases: Array<{ id: string; sentAt: Date | null; accessToken: string | null }>,
+ ) {
+ for (const lc of labCases) {
+ if (!lc.sentAt || lc.accessToken) continue;
+ const accessToken = generateLabCaseAccessToken();
+ await this.prisma.labCase.update({
+ where: { id: lc.id },
+ data: { accessToken },
+ });
+ lc.accessToken = accessToken;
+ }
+ }
+
private mapTaskProgress(tasks: Array<{ status: LabTaskStatus }>) {
const total = tasks.length;
const completed = tasks.filter((task) => task.status === LabTaskStatus.COMPLETED).length;
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 238b9c2..0e05950 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -1135,7 +1135,6 @@
"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}",
"slotCrown": "Crown",
"suggestionsLabel": "Suggested add-ons",
diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json
index 7e6fcb0..23565fe 100644
--- a/frontend/messages/fa.json
+++ b/frontend/messages/fa.json
@@ -1136,7 +1136,6 @@
"noneYet": "هنوز هیچ",
"clearHint": "«پاک کردن این دندان» آن را از کیس حذف میکند. با × روی تراشه یک نوع را بردارید.",
"removeJob": "حذف این نوع",
- "regionCrown": "تاج",
"pickerTooth": "دندان {tooth}",
"slotCrown": "روکش",
"suggestionsLabel": "افزودنیهای پیشنهادی",
diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json
index 80d78c0..16f4b43 100644
--- a/frontend/messages/nl.json
+++ b/frontend/messages/nl.json
@@ -1135,7 +1135,6 @@
"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}",
"slotCrown": "Kroon",
"suggestionsLabel": "Voorgestelde toevoegingen",
diff --git a/frontend/public/prosthesis-catalog/crown.svg b/frontend/public/prosthesis-catalog/crown.svg
new file mode 100644
index 0000000..b8cc4c6
--- /dev/null
+++ b/frontend/public/prosthesis-catalog/crown.svg
@@ -0,0 +1,5 @@
+
+
\ No newline at end of file
diff --git a/frontend/public/prosthesis-catalog/crown_anatomical.svg b/frontend/public/prosthesis-catalog/crown_anatomical.svg
new file mode 100644
index 0000000..a103c25
--- /dev/null
+++ b/frontend/public/prosthesis-catalog/crown_anatomical.svg
@@ -0,0 +1,9 @@
+
+
diff --git a/frontend/public/prosthesis-catalog/crown_full_metal.svg b/frontend/public/prosthesis-catalog/crown_full_metal.svg
new file mode 100644
index 0000000..86da5b7
--- /dev/null
+++ b/frontend/public/prosthesis-catalog/crown_full_metal.svg
@@ -0,0 +1,25 @@
+
+
diff --git a/frontend/public/prosthesis-catalog/crown_pfm.svg b/frontend/public/prosthesis-catalog/crown_pfm.svg
new file mode 100644
index 0000000..66247fc
--- /dev/null
+++ b/frontend/public/prosthesis-catalog/crown_pfm.svg
@@ -0,0 +1,19 @@
+
+
diff --git a/frontend/public/prosthesis-catalog/custom_abutment_molar_navy.svg b/frontend/public/prosthesis-catalog/custom_abutment_molar_navy.svg
new file mode 100644
index 0000000..358b073
--- /dev/null
+++ b/frontend/public/prosthesis-catalog/custom_abutment_molar_navy.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/frontend/public/prosthesis-catalog/custom_abutment_titanium.svg b/frontend/public/prosthesis-catalog/custom_abutment_titanium.svg
new file mode 100644
index 0000000..d45b75a
--- /dev/null
+++ b/frontend/public/prosthesis-catalog/custom_abutment_titanium.svg
@@ -0,0 +1,5 @@
+
+
\ No newline at end of file
diff --git a/frontend/public/prosthesis-catalog/custom_abutment_zirconia.svg b/frontend/public/prosthesis-catalog/custom_abutment_zirconia.svg
new file mode 100644
index 0000000..87161a4
--- /dev/null
+++ b/frontend/public/prosthesis-catalog/custom_abutment_zirconia.svg
@@ -0,0 +1,5 @@
+
+
\ No newline at end of file
diff --git a/frontend/public/prosthesis-catalog/denture.svg b/frontend/public/prosthesis-catalog/denture.svg
new file mode 100644
index 0000000..20bd709
--- /dev/null
+++ b/frontend/public/prosthesis-catalog/denture.svg
@@ -0,0 +1,8 @@
+
+
diff --git a/frontend/public/prosthesis-catalog/implant.svg b/frontend/public/prosthesis-catalog/implant.svg
new file mode 100644
index 0000000..9d7eeba
--- /dev/null
+++ b/frontend/public/prosthesis-catalog/implant.svg
@@ -0,0 +1,6 @@
+
+
\ No newline at end of file
diff --git a/frontend/public/prosthesis-catalog/inlay.svg b/frontend/public/prosthesis-catalog/inlay.svg
new file mode 100644
index 0000000..3e5563d
--- /dev/null
+++ b/frontend/public/prosthesis-catalog/inlay.svg
@@ -0,0 +1,6 @@
+
+
\ No newline at end of file
diff --git a/frontend/public/prosthesis-catalog/mock_up.svg b/frontend/public/prosthesis-catalog/mock_up.svg
new file mode 100644
index 0000000..9a19e8f
--- /dev/null
+++ b/frontend/public/prosthesis-catalog/mock_up.svg
@@ -0,0 +1,9 @@
+
+
diff --git a/frontend/public/prosthesis-catalog/multi_unit_abutment.svg b/frontend/public/prosthesis-catalog/multi_unit_abutment.svg
new file mode 100644
index 0000000..6f2bfac
--- /dev/null
+++ b/frontend/public/prosthesis-catalog/multi_unit_abutment.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/frontend/public/prosthesis-catalog/night_guard.svg b/frontend/public/prosthesis-catalog/night_guard.svg
new file mode 100644
index 0000000..9ed6c7a
--- /dev/null
+++ b/frontend/public/prosthesis-catalog/night_guard.svg
@@ -0,0 +1,8 @@
+
+
diff --git a/frontend/public/prosthesis-catalog/onlay.svg b/frontend/public/prosthesis-catalog/onlay.svg
new file mode 100644
index 0000000..591d541
--- /dev/null
+++ b/frontend/public/prosthesis-catalog/onlay.svg
@@ -0,0 +1,6 @@
+
+
\ No newline at end of file
diff --git a/frontend/public/prosthesis-catalog/overdenture.svg b/frontend/public/prosthesis-catalog/overdenture.svg
new file mode 100644
index 0000000..ae2c7cb
--- /dev/null
+++ b/frontend/public/prosthesis-catalog/overdenture.svg
@@ -0,0 +1,9 @@
+
+
diff --git a/frontend/public/prosthesis-catalog/overlay.svg b/frontend/public/prosthesis-catalog/overlay.svg
new file mode 100644
index 0000000..0ba9519
--- /dev/null
+++ b/frontend/public/prosthesis-catalog/overlay.svg
@@ -0,0 +1,6 @@
+
+
\ No newline at end of file
diff --git a/frontend/public/prosthesis-catalog/partial_denture.svg b/frontend/public/prosthesis-catalog/partial_denture.svg
new file mode 100644
index 0000000..fe1d94e
--- /dev/null
+++ b/frontend/public/prosthesis-catalog/partial_denture.svg
@@ -0,0 +1,9 @@
+
+
diff --git a/frontend/public/prosthesis-catalog/pfz_crown_layered.svg b/frontend/public/prosthesis-catalog/pfz_crown_layered.svg
new file mode 100644
index 0000000..46b89a5
--- /dev/null
+++ b/frontend/public/prosthesis-catalog/pfz_crown_layered.svg
@@ -0,0 +1,7 @@
+
+
diff --git a/frontend/public/prosthesis-catalog/post_and_core.svg b/frontend/public/prosthesis-catalog/post_and_core.svg
new file mode 100644
index 0000000..2335acc
--- /dev/null
+++ b/frontend/public/prosthesis-catalog/post_and_core.svg
@@ -0,0 +1,87 @@
+
+
diff --git a/frontend/public/prosthesis-catalog/prefabricated_abutment.svg b/frontend/public/prosthesis-catalog/prefabricated_abutment.svg
new file mode 100644
index 0000000..0fd30f4
--- /dev/null
+++ b/frontend/public/prosthesis-catalog/prefabricated_abutment.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/frontend/public/prosthesis-catalog/removable.svg b/frontend/public/prosthesis-catalog/removable.svg
new file mode 100644
index 0000000..8b54f3e
--- /dev/null
+++ b/frontend/public/prosthesis-catalog/removable.svg
@@ -0,0 +1,8 @@
+
+
diff --git a/frontend/public/prosthesis-catalog/screw_retained.svg b/frontend/public/prosthesis-catalog/screw_retained.svg
new file mode 100644
index 0000000..0c67f2e
--- /dev/null
+++ b/frontend/public/prosthesis-catalog/screw_retained.svg
@@ -0,0 +1,5 @@
+
+
\ No newline at end of file
diff --git a/frontend/public/prosthesis-catalog/smile_design.svg b/frontend/public/prosthesis-catalog/smile_design.svg
new file mode 100644
index 0000000..2defd80
--- /dev/null
+++ b/frontend/public/prosthesis-catalog/smile_design.svg
@@ -0,0 +1,9 @@
+
+
diff --git a/frontend/public/prosthesis-catalog/surgical_guide.svg b/frontend/public/prosthesis-catalog/surgical_guide.svg
new file mode 100644
index 0000000..3f0eb75
--- /dev/null
+++ b/frontend/public/prosthesis-catalog/surgical_guide.svg
@@ -0,0 +1,9 @@
+
+
diff --git a/frontend/public/prosthesis-catalog/ti_base_abutment.svg b/frontend/public/prosthesis-catalog/ti_base_abutment.svg
new file mode 100644
index 0000000..18cee63
--- /dev/null
+++ b/frontend/public/prosthesis-catalog/ti_base_abutment.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/frontend/public/prosthesis-catalog/veneer.svg b/frontend/public/prosthesis-catalog/veneer.svg
new file mode 100644
index 0000000..2e1bd9a
--- /dev/null
+++ b/frontend/public/prosthesis-catalog/veneer.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/assets/prosthesis-catalog/crown.svg b/frontend/src/assets/prosthesis-catalog/crown.svg
new file mode 100644
index 0000000..b8cc4c6
--- /dev/null
+++ b/frontend/src/assets/prosthesis-catalog/crown.svg
@@ -0,0 +1,5 @@
+
+
\ No newline at end of file
diff --git a/frontend/src/assets/prosthesis-catalog/crown_anatomical.svg b/frontend/src/assets/prosthesis-catalog/crown_anatomical.svg
new file mode 100644
index 0000000..a103c25
--- /dev/null
+++ b/frontend/src/assets/prosthesis-catalog/crown_anatomical.svg
@@ -0,0 +1,9 @@
+
+
diff --git a/frontend/src/assets/prosthesis-catalog/crown_full_metal.svg b/frontend/src/assets/prosthesis-catalog/crown_full_metal.svg
new file mode 100644
index 0000000..86da5b7
--- /dev/null
+++ b/frontend/src/assets/prosthesis-catalog/crown_full_metal.svg
@@ -0,0 +1,25 @@
+
+
diff --git a/frontend/src/assets/prosthesis-catalog/crown_pfm.svg b/frontend/src/assets/prosthesis-catalog/crown_pfm.svg
new file mode 100644
index 0000000..66247fc
--- /dev/null
+++ b/frontend/src/assets/prosthesis-catalog/crown_pfm.svg
@@ -0,0 +1,19 @@
+
+
diff --git a/frontend/src/assets/prosthesis-catalog/custom_abutment_titanium.svg b/frontend/src/assets/prosthesis-catalog/custom_abutment_titanium.svg
new file mode 100644
index 0000000..d45b75a
--- /dev/null
+++ b/frontend/src/assets/prosthesis-catalog/custom_abutment_titanium.svg
@@ -0,0 +1,5 @@
+
+
\ No newline at end of file
diff --git a/frontend/src/assets/prosthesis-catalog/custom_abutment_zirconia.svg b/frontend/src/assets/prosthesis-catalog/custom_abutment_zirconia.svg
new file mode 100644
index 0000000..87161a4
--- /dev/null
+++ b/frontend/src/assets/prosthesis-catalog/custom_abutment_zirconia.svg
@@ -0,0 +1,5 @@
+
+
\ No newline at end of file
diff --git a/frontend/src/assets/prosthesis-catalog/denture.svg b/frontend/src/assets/prosthesis-catalog/denture.svg
new file mode 100644
index 0000000..20bd709
--- /dev/null
+++ b/frontend/src/assets/prosthesis-catalog/denture.svg
@@ -0,0 +1,8 @@
+
+
diff --git a/frontend/src/assets/prosthesis-catalog/implant.svg b/frontend/src/assets/prosthesis-catalog/implant.svg
new file mode 100644
index 0000000..9d7eeba
--- /dev/null
+++ b/frontend/src/assets/prosthesis-catalog/implant.svg
@@ -0,0 +1,6 @@
+
+
\ No newline at end of file
diff --git a/frontend/src/assets/prosthesis-catalog/inlay.svg b/frontend/src/assets/prosthesis-catalog/inlay.svg
new file mode 100644
index 0000000..3e5563d
--- /dev/null
+++ b/frontend/src/assets/prosthesis-catalog/inlay.svg
@@ -0,0 +1,6 @@
+
+
\ No newline at end of file
diff --git a/frontend/src/assets/prosthesis-catalog/mock_up.svg b/frontend/src/assets/prosthesis-catalog/mock_up.svg
new file mode 100644
index 0000000..9a19e8f
--- /dev/null
+++ b/frontend/src/assets/prosthesis-catalog/mock_up.svg
@@ -0,0 +1,9 @@
+
+
diff --git a/frontend/src/assets/prosthesis-catalog/multi_unit_abutment.svg b/frontend/src/assets/prosthesis-catalog/multi_unit_abutment.svg
new file mode 100644
index 0000000..6f2bfac
--- /dev/null
+++ b/frontend/src/assets/prosthesis-catalog/multi_unit_abutment.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/frontend/src/assets/prosthesis-catalog/night_guard.svg b/frontend/src/assets/prosthesis-catalog/night_guard.svg
new file mode 100644
index 0000000..9ed6c7a
--- /dev/null
+++ b/frontend/src/assets/prosthesis-catalog/night_guard.svg
@@ -0,0 +1,8 @@
+
+
diff --git a/frontend/src/assets/prosthesis-catalog/onlay.svg b/frontend/src/assets/prosthesis-catalog/onlay.svg
new file mode 100644
index 0000000..591d541
--- /dev/null
+++ b/frontend/src/assets/prosthesis-catalog/onlay.svg
@@ -0,0 +1,6 @@
+
+
\ No newline at end of file
diff --git a/frontend/src/assets/prosthesis-catalog/overdenture.svg b/frontend/src/assets/prosthesis-catalog/overdenture.svg
new file mode 100644
index 0000000..ae2c7cb
--- /dev/null
+++ b/frontend/src/assets/prosthesis-catalog/overdenture.svg
@@ -0,0 +1,9 @@
+
+
diff --git a/frontend/src/assets/prosthesis-catalog/overlay.svg b/frontend/src/assets/prosthesis-catalog/overlay.svg
new file mode 100644
index 0000000..0ba9519
--- /dev/null
+++ b/frontend/src/assets/prosthesis-catalog/overlay.svg
@@ -0,0 +1,6 @@
+
+
\ No newline at end of file
diff --git a/frontend/src/assets/prosthesis-catalog/partial_denture.svg b/frontend/src/assets/prosthesis-catalog/partial_denture.svg
new file mode 100644
index 0000000..fe1d94e
--- /dev/null
+++ b/frontend/src/assets/prosthesis-catalog/partial_denture.svg
@@ -0,0 +1,9 @@
+
+
diff --git a/frontend/src/assets/prosthesis-catalog/pfz_crown_layered.svg b/frontend/src/assets/prosthesis-catalog/pfz_crown_layered.svg
new file mode 100644
index 0000000..46b89a5
--- /dev/null
+++ b/frontend/src/assets/prosthesis-catalog/pfz_crown_layered.svg
@@ -0,0 +1,7 @@
+
+
diff --git a/frontend/src/assets/prosthesis-catalog/post_and_core.svg b/frontend/src/assets/prosthesis-catalog/post_and_core.svg
new file mode 100644
index 0000000..2335acc
--- /dev/null
+++ b/frontend/src/assets/prosthesis-catalog/post_and_core.svg
@@ -0,0 +1,87 @@
+
+
diff --git a/frontend/src/assets/prosthesis-catalog/prefabricated_abutment.svg b/frontend/src/assets/prosthesis-catalog/prefabricated_abutment.svg
new file mode 100644
index 0000000..0fd30f4
--- /dev/null
+++ b/frontend/src/assets/prosthesis-catalog/prefabricated_abutment.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/frontend/src/assets/prosthesis-catalog/removable.svg b/frontend/src/assets/prosthesis-catalog/removable.svg
new file mode 100644
index 0000000..8b54f3e
--- /dev/null
+++ b/frontend/src/assets/prosthesis-catalog/removable.svg
@@ -0,0 +1,8 @@
+
+
diff --git a/frontend/src/assets/prosthesis-catalog/screw_retained.svg b/frontend/src/assets/prosthesis-catalog/screw_retained.svg
new file mode 100644
index 0000000..0c67f2e
--- /dev/null
+++ b/frontend/src/assets/prosthesis-catalog/screw_retained.svg
@@ -0,0 +1,5 @@
+
+
\ No newline at end of file
diff --git a/frontend/src/assets/prosthesis-catalog/smile_design.svg b/frontend/src/assets/prosthesis-catalog/smile_design.svg
new file mode 100644
index 0000000..2defd80
--- /dev/null
+++ b/frontend/src/assets/prosthesis-catalog/smile_design.svg
@@ -0,0 +1,9 @@
+
+
diff --git a/frontend/src/assets/prosthesis-catalog/surgical_guide.svg b/frontend/src/assets/prosthesis-catalog/surgical_guide.svg
new file mode 100644
index 0000000..3f0eb75
--- /dev/null
+++ b/frontend/src/assets/prosthesis-catalog/surgical_guide.svg
@@ -0,0 +1,9 @@
+
+
diff --git a/frontend/src/assets/prosthesis-catalog/ti_base_abutment.svg b/frontend/src/assets/prosthesis-catalog/ti_base_abutment.svg
new file mode 100644
index 0000000..18cee63
--- /dev/null
+++ b/frontend/src/assets/prosthesis-catalog/ti_base_abutment.svg
@@ -0,0 +1,5 @@
+
+
diff --git a/frontend/src/assets/prosthesis-catalog/veneer.svg b/frontend/src/assets/prosthesis-catalog/veneer.svg
new file mode 100644
index 0000000..2e1bd9a
--- /dev/null
+++ b/frontend/src/assets/prosthesis-catalog/veneer.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/components/treatment/prosthesisCatalogIcons.ts b/frontend/src/components/treatment/prosthesisCatalogIcons.ts
new file mode 100644
index 0000000..c5ff6b5
--- /dev/null
+++ b/frontend/src/components/treatment/prosthesisCatalogIcons.ts
@@ -0,0 +1,99 @@
+const BASE = '/prosthesis-catalog';
+
+const icon = {
+ crown: `${BASE}/crown.svg`,
+ crown_anatomical: `${BASE}/crown_anatomical.svg`,
+ crown_full_metal: `${BASE}/crown_full_metal.svg`,
+ crown_pfm: `${BASE}/crown_pfm.svg`,
+ custom_abutment_titanium: `${BASE}/custom_abutment_titanium.svg`,
+ custom_abutment_zirconia: `${BASE}/custom_abutment_zirconia.svg`,
+ denture: `${BASE}/denture.svg`,
+ implant: `${BASE}/implant.svg`,
+ inlay: `${BASE}/inlay.svg`,
+ mock_up: `${BASE}/mock_up.svg`,
+ multi_unit_abutment: `${BASE}/multi_unit_abutment.svg`,
+ night_guard: `${BASE}/night_guard.svg`,
+ onlay: `${BASE}/onlay.svg`,
+ overdenture: `${BASE}/overdenture.svg`,
+ overlay: `${BASE}/overlay.svg`,
+ partial_denture: `${BASE}/partial_denture.svg`,
+ pfz_crown_layered: `${BASE}/pfz_crown_layered.svg`,
+ post_and_core: `${BASE}/post_and_core.svg`,
+ prefabricated_abutment: `${BASE}/prefabricated_abutment.svg`,
+ removable: `${BASE}/removable.svg`,
+ screw_retained: `${BASE}/screw_retained.svg`,
+ smile_design: `${BASE}/smile_design.svg`,
+ surgical_guide: `${BASE}/surgical_guide.svg`,
+ ti_base_abutment: `${BASE}/ti_base_abutment.svg`,
+ veneer: `${BASE}/veneer.svg`,
+} as const;
+
+const BY_CODE: Record = {
+ pfm_crown: icon.crown_pfm,
+ pfz_crown: icon.pfz_crown_layered,
+ full_metal_crown: icon.crown_full_metal,
+ monolithic_zirconia: icon.crown_anatomical,
+ glass_ceramic_crown: icon.crown_anatomical,
+ temporary_resin_crown: icon.crown_anatomical,
+ pmma: icon.crown_anatomical,
+ peek_crown: icon.crown_anatomical,
+ press_ceramic: icon.crown_anatomical,
+ veneer_full_contour: icon.veneer,
+ veneer_layered: icon.veneer,
+ inlay_full_contour: icon.inlay,
+ inlay_layered: icon.inlay,
+ onlay_full_contour: icon.onlay,
+ onlay_layered: icon.onlay,
+ overlay_full_contour: icon.overlay,
+ overlay_layered: icon.overlay,
+ cast_post_core: icon.post_and_core,
+ fiber_post_core: icon.post_and_core,
+ prefabricated_abutment: icon.prefabricated_abutment,
+ ti_base_abutment: icon.ti_base_abutment,
+ multi_unit_abutment: icon.multi_unit_abutment,
+ customized_abutment: icon.custom_abutment_titanium,
+ zirconia_abutment: icon.custom_abutment_zirconia,
+ screw_retained: icon.screw_retained,
+ complete_denture: icon.denture,
+ partial_denture: icon.partial_denture,
+ overdenture: icon.overdenture,
+ night_guard_soft: icon.night_guard,
+ night_guard_hard: icon.night_guard,
+ night_guard_dual: icon.night_guard,
+ surgical_guide: icon.surgical_guide,
+ smile_design: icon.smile_design,
+ mockup: icon.mock_up,
+};
+
+const BY_SUBCATEGORY: Record = {
+ veneer: icon.veneer,
+ inlay: icon.inlay,
+ onlay: icon.onlay,
+ overlay: icon.overlay,
+ night_guard: icon.night_guard,
+};
+
+const BY_CATEGORY: Record = {
+ crown: icon.crown,
+ implant: icon.implant,
+ post_core: icon.post_and_core,
+ indirect: icon.inlay,
+ removable: icon.removable,
+ appliance: icon.night_guard,
+ digital: icon.smile_design,
+};
+
+export function prosthesisCatalogIconSrc(opts: {
+ code?: string;
+ category?: string;
+ subcategory?: string;
+}): string | undefined {
+ const code = opts.code?.trim();
+ if (code && BY_CODE[code]) return BY_CODE[code];
+ const category = opts.category?.trim();
+ if (code && category === 'crown') return icon.crown_anatomical;
+ const sub = opts.subcategory?.trim();
+ if (sub && BY_SUBCATEGORY[sub]) return BY_SUBCATEGORY[sub];
+ if (category && BY_CATEGORY[category]) return BY_CATEGORY[category];
+ return undefined;
+}
diff --git a/frontend/src/components/treatment/prosthesisJobPath.ts b/frontend/src/components/treatment/prosthesisJobPath.ts
new file mode 100644
index 0000000..97891a7
--- /dev/null
+++ b/frontend/src/components/treatment/prosthesisJobPath.ts
@@ -0,0 +1,83 @@
+import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
+import { splitProsthesisGroupCode } from '@/components/treatment/prosthesisTypeDisplay';
+
+/** Non-breaking spaces so the gap around · does not collapse between inline spans. */
+export const PROSTHESIS_JOB_PATH_SEP = '\u00a0·\u00a0';
+
+type CatalogLite = readonly Pick<
+ ProsthesisCatalogEntry,
+ 'code' | 'label' | 'category' | 'subcategory'
+>[];
+
+export type ProsthesisMessageFn = (key: string) => string;
+
+function sameLabel(a: string, b: string): boolean {
+ return a.localeCompare(b, undefined, { sensitivity: 'accent' }) === 0;
+}
+
+function leafFromCatalogLabel(label: string): string {
+ const parts = label
+ .split(/\s*·\s*/)
+ .map((part) => part.trim())
+ .filter(Boolean);
+ return parts.length > 1 ? parts[parts.length - 1] : label;
+}
+
+function humanizeCode(code: string): string {
+ return code
+ .split('_')
+ .filter(Boolean)
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join(' ');
+}
+
+/** Picker root whose title is a slash-joined list of its children (redundant in job titles). */
+const SKIP_CATEGORY_IN_JOB_PATH = new Set(['indirect']);
+
+/** Category → subcategory → last picker node for one catalog code. */
+export function prosthesisJobPathParts(
+ code: string,
+ catalog: CatalogLite,
+ t: ProsthesisMessageFn,
+): { ancestors: string[]; leaf: string } {
+ const entry = catalog.find((item) => item.code === code);
+ if (!entry) {
+ return { ancestors: [], leaf: humanizeCode(code) };
+ }
+
+ const ancestors: string[] = [];
+ const subcategory = entry.subcategory?.trim() ?? '';
+ const skipCategory = SKIP_CATEGORY_IN_JOB_PATH.has(entry.category);
+ if (entry.category && !skipCategory) {
+ ancestors.push(t(`category_${entry.category}`));
+ }
+ if (subcategory) {
+ ancestors.push(t(`sub_${subcategory}`));
+ }
+
+ const leaf = leafFromCatalogLabel(entry.label) || entry.label || code;
+ const uniqueAncestors: string[] = [];
+ for (const ancestor of ancestors) {
+ if (!ancestor || sameLabel(ancestor, leaf)) continue;
+ if (uniqueAncestors.some((existing) => sameLabel(existing, ancestor))) continue;
+ uniqueAncestors.push(ancestor);
+ }
+ return { ancestors: uniqueAncestors, leaf };
+}
+
+function formatProsthesisJobPath(
+ parts: { ancestors: string[]; leaf: string },
+): string {
+ return [...parts.ancestors, parts.leaf].filter(Boolean).join(PROSTHESIS_JOB_PATH_SEP);
+}
+
+/** Full picker path for a type or stacked `code+code` group. */
+export function prosthesisJobPathLabel(
+ code: string,
+ catalog: CatalogLite,
+ t: ProsthesisMessageFn,
+): string {
+ return splitProsthesisGroupCode(code)
+ .map((part) => formatProsthesisJobPath(prosthesisJobPathParts(part, catalog, t)))
+ .join(' + ');
+}
diff --git a/frontend/src/components/treatment/prosthesisTree.ts b/frontend/src/components/treatment/prosthesisTree.ts
index 320450d..8494352 100644
--- a/frontend/src/components/treatment/prosthesisTree.ts
+++ b/frontend/src/components/treatment/prosthesisTree.ts
@@ -79,6 +79,11 @@ export function categoryCodesInCatalog(
return order.filter((code) => present.has(code));
}
+/** Matches category titles (e.g. Veneer/Inlay/Onlay/Overlay), not alphabetical codes. */
+const SUBCATEGORY_ORDER: Record = {
+ indirect: ['veneer', 'inlay', 'onlay', 'overlay'],
+};
+
export function subcategoriesFor(
catalog: readonly ProsthesisCatalogEntry[],
category: string,
@@ -94,7 +99,10 @@ export function subcategoriesFor(
)
.map((e) => e.subcategory),
);
- return [...set].sort();
+ const preferred = SUBCATEGORY_ORDER[category];
+ if (!preferred) return [...set].sort();
+ const rest = [...set].filter((code) => !preferred.includes(code)).sort();
+ return [...preferred.filter((code) => set.has(code)), ...rest];
}
export function leavesFor(
diff --git a/frontend/src/components/treatment/prosthesisTypeDisplay.ts b/frontend/src/components/treatment/prosthesisTypeDisplay.ts
index 52f56bf..06274c4 100644
--- a/frontend/src/components/treatment/prosthesisTypeDisplay.ts
+++ b/frontend/src/components/treatment/prosthesisTypeDisplay.ts
@@ -187,12 +187,3 @@ export function formatToothList(
})
.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/ui/lab/CaseDetailPanel.tsx b/frontend/src/components/ui/lab/CaseDetailPanel.tsx
index b10a686..cf9b410 100644
--- a/frontend/src/components/ui/lab/CaseDetailPanel.tsx
+++ b/frontend/src/components/ui/lab/CaseDetailPanel.tsx
@@ -312,10 +312,9 @@ export function CaseDetailPanel({
>
{formatToothList(group.teeth, {
diff --git a/frontend/src/components/ui/lab/CaseSheetPrintLayout.tsx b/frontend/src/components/ui/lab/CaseSheetPrintLayout.tsx
index c9a2f88..0a0f39a 100644
--- a/frontend/src/components/ui/lab/CaseSheetPrintLayout.tsx
+++ b/frontend/src/components/ui/lab/CaseSheetPrintLayout.tsx
@@ -1,6 +1,7 @@
'use client';
import QRCode from 'react-qr-code';
+import { useTranslations } from 'next-intl';
import { CaseSheetFdiChart } from '@/components/ui/lab/CaseSheetFdiChart';
import {
buildCaseConnectedTeeth,
@@ -9,6 +10,7 @@ import {
formatPatientName,
} from '@/components/lab/caseDetailUtils';
import { formatToothList } from '@/components/treatment/prosthesisTypeDisplay';
+import { prosthesisJobPathLabel } from '@/components/treatment/prosthesisJobPath';
import type { LabCaseDetail } from '@/types/cases';
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
import type { FdiToothId } from '@/types/treatment';
@@ -54,6 +56,7 @@ export function CaseSheetPrintLayout({
labels,
prosthesisCatalog,
}: CaseSheetPrintLayoutProps) {
+ const tProsthesis = useTranslations('prosthesis');
const prosthesisRows = buildCaseProsthesisRows(labCase);
const connectedTeeth = buildCaseConnectedTeeth(labCase);
const selected = new Set();
@@ -156,16 +159,11 @@ export function CaseSheetPrintLayout({