From 5d3597f97337b3d1ae326dbec03bcfcae797498f Mon Sep 17 00:00:00 2001 From: Admin Date: Sat, 5 Sep 2026 01:29:53 +0330 Subject: [PATCH] improvement: icons added to prosthesis types catalog. --- .cursor/rules/frontend-assets.mdc | 3 +- .cursor/rules/lab-case-share-link.mdc | 5 +- .cursor/rules/lab-cases.mdc | 4 +- .cursor/rules/lab-tasks.mdc | 4 +- .cursor/rules/treatment-workspace.mdc | 4 +- .cursor/skills/lab-case-share-link/SKILL.md | 8 +- .cursor/skills/lab-cases/SKILL.md | 8 +- .cursor/skills/lab-tasks/SKILL.md | 1 + .cursor/skills/treatment-workspace/SKILL.md | 6 +- AGENTS.md | 10 +- .../treatments/treatments.controller.ts | 18 +- .../modules/treatments/treatments.service.ts | 69 ++++++-- frontend/messages/en.json | 1 - frontend/messages/fa.json | 1 - frontend/messages/nl.json | 1 - frontend/public/prosthesis-catalog/crown.svg | 5 + .../prosthesis-catalog/crown_anatomical.svg | 9 + .../prosthesis-catalog/crown_full_metal.svg | 25 +++ .../public/prosthesis-catalog/crown_pfm.svg | 19 +++ .../custom_abutment_molar_navy.svg | 5 + .../custom_abutment_titanium.svg | 5 + .../custom_abutment_zirconia.svg | 5 + .../public/prosthesis-catalog/denture.svg | 8 + .../public/prosthesis-catalog/implant.svg | 6 + frontend/public/prosthesis-catalog/inlay.svg | 6 + .../public/prosthesis-catalog/mock_up.svg | 9 + .../multi_unit_abutment.svg | 5 + .../public/prosthesis-catalog/night_guard.svg | 8 + frontend/public/prosthesis-catalog/onlay.svg | 6 + .../public/prosthesis-catalog/overdenture.svg | 9 + .../public/prosthesis-catalog/overlay.svg | 6 + .../prosthesis-catalog/partial_denture.svg | 9 + .../prosthesis-catalog/pfz_crown_layered.svg | 7 + .../prosthesis-catalog/post_and_core.svg | 87 ++++++++++ .../prefabricated_abutment.svg | 5 + .../public/prosthesis-catalog/removable.svg | 8 + .../prosthesis-catalog/screw_retained.svg | 5 + .../prosthesis-catalog/smile_design.svg | 9 + .../prosthesis-catalog/surgical_guide.svg | 9 + .../prosthesis-catalog/ti_base_abutment.svg | 5 + frontend/public/prosthesis-catalog/veneer.svg | 6 + .../src/assets/prosthesis-catalog/crown.svg | 5 + .../prosthesis-catalog/crown_anatomical.svg | 9 + .../prosthesis-catalog/crown_full_metal.svg | 25 +++ .../assets/prosthesis-catalog/crown_pfm.svg | 19 +++ .../custom_abutment_titanium.svg | 5 + .../custom_abutment_zirconia.svg | 5 + .../src/assets/prosthesis-catalog/denture.svg | 8 + .../src/assets/prosthesis-catalog/implant.svg | 6 + .../src/assets/prosthesis-catalog/inlay.svg | 6 + .../src/assets/prosthesis-catalog/mock_up.svg | 9 + .../multi_unit_abutment.svg | 5 + .../assets/prosthesis-catalog/night_guard.svg | 8 + .../src/assets/prosthesis-catalog/onlay.svg | 6 + .../assets/prosthesis-catalog/overdenture.svg | 9 + .../src/assets/prosthesis-catalog/overlay.svg | 6 + .../prosthesis-catalog/partial_denture.svg | 9 + .../prosthesis-catalog/pfz_crown_layered.svg | 7 + .../prosthesis-catalog/post_and_core.svg | 87 ++++++++++ .../prefabricated_abutment.svg | 5 + .../assets/prosthesis-catalog/removable.svg | 8 + .../prosthesis-catalog/screw_retained.svg | 5 + .../prosthesis-catalog/smile_design.svg | 9 + .../prosthesis-catalog/surgical_guide.svg | 9 + .../prosthesis-catalog/ti_base_abutment.svg | 5 + .../src/assets/prosthesis-catalog/veneer.svg | 6 + .../treatment/prosthesisCatalogIcons.ts | 99 +++++++++++ .../components/treatment/prosthesisJobPath.ts | 83 ++++++++++ .../components/treatment/prosthesisTree.ts | 10 +- .../treatment/prosthesisTypeDisplay.ts | 9 - .../src/components/ui/lab/CaseDetailPanel.tsx | 3 +- .../ui/lab/CaseSheetPrintLayout.tsx | 18 +- frontend/src/components/ui/lab/CasesPage.tsx | 6 +- .../ui/lab/LabCaseProsthesisGroupsList.tsx | 19 +-- .../ui/lab/ProsthesisStackedTypeLabel.tsx | 46 +++--- .../ui/lab/TaskProsthesisGroupHeader.tsx | 3 +- frontend/src/components/ui/lab/TaskRow.tsx | 3 +- .../ui/treatment/LabCasesDispatchPanel.tsx | 78 ++++++--- .../ui/treatment/ProsthesisCatalogIcon.tsx | 19 +++ .../ui/treatment/ProsthesisJobPopover.tsx | 156 ++++++++++++++---- .../ui/treatment/TreatmentDetailsEditor.tsx | 63 ++++--- .../ui/treatment/TreatmentWorkspace.tsx | 4 +- frontend/src/types/treatment.ts | 4 + 83 files changed, 1146 insertions(+), 187 deletions(-) create mode 100644 frontend/public/prosthesis-catalog/crown.svg create mode 100644 frontend/public/prosthesis-catalog/crown_anatomical.svg create mode 100644 frontend/public/prosthesis-catalog/crown_full_metal.svg create mode 100644 frontend/public/prosthesis-catalog/crown_pfm.svg create mode 100644 frontend/public/prosthesis-catalog/custom_abutment_molar_navy.svg create mode 100644 frontend/public/prosthesis-catalog/custom_abutment_titanium.svg create mode 100644 frontend/public/prosthesis-catalog/custom_abutment_zirconia.svg create mode 100644 frontend/public/prosthesis-catalog/denture.svg create mode 100644 frontend/public/prosthesis-catalog/implant.svg create mode 100644 frontend/public/prosthesis-catalog/inlay.svg create mode 100644 frontend/public/prosthesis-catalog/mock_up.svg create mode 100644 frontend/public/prosthesis-catalog/multi_unit_abutment.svg create mode 100644 frontend/public/prosthesis-catalog/night_guard.svg create mode 100644 frontend/public/prosthesis-catalog/onlay.svg create mode 100644 frontend/public/prosthesis-catalog/overdenture.svg create mode 100644 frontend/public/prosthesis-catalog/overlay.svg create mode 100644 frontend/public/prosthesis-catalog/partial_denture.svg create mode 100644 frontend/public/prosthesis-catalog/pfz_crown_layered.svg create mode 100644 frontend/public/prosthesis-catalog/post_and_core.svg create mode 100644 frontend/public/prosthesis-catalog/prefabricated_abutment.svg create mode 100644 frontend/public/prosthesis-catalog/removable.svg create mode 100644 frontend/public/prosthesis-catalog/screw_retained.svg create mode 100644 frontend/public/prosthesis-catalog/smile_design.svg create mode 100644 frontend/public/prosthesis-catalog/surgical_guide.svg create mode 100644 frontend/public/prosthesis-catalog/ti_base_abutment.svg create mode 100644 frontend/public/prosthesis-catalog/veneer.svg create mode 100644 frontend/src/assets/prosthesis-catalog/crown.svg create mode 100644 frontend/src/assets/prosthesis-catalog/crown_anatomical.svg create mode 100644 frontend/src/assets/prosthesis-catalog/crown_full_metal.svg create mode 100644 frontend/src/assets/prosthesis-catalog/crown_pfm.svg create mode 100644 frontend/src/assets/prosthesis-catalog/custom_abutment_titanium.svg create mode 100644 frontend/src/assets/prosthesis-catalog/custom_abutment_zirconia.svg create mode 100644 frontend/src/assets/prosthesis-catalog/denture.svg create mode 100644 frontend/src/assets/prosthesis-catalog/implant.svg create mode 100644 frontend/src/assets/prosthesis-catalog/inlay.svg create mode 100644 frontend/src/assets/prosthesis-catalog/mock_up.svg create mode 100644 frontend/src/assets/prosthesis-catalog/multi_unit_abutment.svg create mode 100644 frontend/src/assets/prosthesis-catalog/night_guard.svg create mode 100644 frontend/src/assets/prosthesis-catalog/onlay.svg create mode 100644 frontend/src/assets/prosthesis-catalog/overdenture.svg create mode 100644 frontend/src/assets/prosthesis-catalog/overlay.svg create mode 100644 frontend/src/assets/prosthesis-catalog/partial_denture.svg create mode 100644 frontend/src/assets/prosthesis-catalog/pfz_crown_layered.svg create mode 100644 frontend/src/assets/prosthesis-catalog/post_and_core.svg create mode 100644 frontend/src/assets/prosthesis-catalog/prefabricated_abutment.svg create mode 100644 frontend/src/assets/prosthesis-catalog/removable.svg create mode 100644 frontend/src/assets/prosthesis-catalog/screw_retained.svg create mode 100644 frontend/src/assets/prosthesis-catalog/smile_design.svg create mode 100644 frontend/src/assets/prosthesis-catalog/surgical_guide.svg create mode 100644 frontend/src/assets/prosthesis-catalog/ti_base_abutment.svg create mode 100644 frontend/src/assets/prosthesis-catalog/veneer.svg create mode 100644 frontend/src/components/treatment/prosthesisCatalogIcons.ts create mode 100644 frontend/src/components/treatment/prosthesisJobPath.ts create mode 100644 frontend/src/components/ui/treatment/ProsthesisCatalogIcon.tsx 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({
  • ) : ( prosthesisRows.map((row) => { - const fromCatalog = prosthesisCatalog.find( - (e) => e.code === row.prosthesisTypeCode, - )?.label; - const fromTasks = labCase.tasksByTooth.find( - (g) => g.prosthesisTypeCode === row.prosthesisTypeCode, - )?.prosthesisTypeLabel; - const label = - fromCatalog?.trim() || - fromTasks?.trim() || - row.prosthesisTypeCode.replace(/_/g, ' '); + const label = prosthesisJobPathLabel( + row.prosthesisTypeCode, + prosthesisCatalog, + (key) => tProsthesis(key as never), + ); return (
  • - prosthesisCatalog.find((entry) => entry.code === code)?.label ?? code, - [prosthesisCatalog], + prosthesisJobPathLabel(code, prosthesisCatalog, (key) => tProsthesis(key as never)), + [prosthesisCatalog, tProsthesis], ); const treatmentDetailLabel = useCallback( diff --git a/frontend/src/components/ui/lab/LabCaseProsthesisGroupsList.tsx b/frontend/src/components/ui/lab/LabCaseProsthesisGroupsList.tsx index e0f9eb0..a6f46d9 100644 --- a/frontend/src/components/ui/lab/LabCaseProsthesisGroupsList.tsx +++ b/frontend/src/components/ui/lab/LabCaseProsthesisGroupsList.tsx @@ -2,11 +2,7 @@ import { useTranslations } from 'next-intl'; import { ConnectedSelectionBadge } from '@/components/ui/treatment/ConnectedSelectionBadge'; -import { - formatToothList, - prosthesisGroupLabelFromCatalog, - prosthesisTypeLabelStyleFromCatalog, -} from '@/components/treatment/prosthesisTypeDisplay'; +import { formatToothList } from '@/components/treatment/prosthesisTypeDisplay'; import { ProsthesisStackedTypeLabel } from '@/components/ui/lab/ProsthesisStackedTypeLabel'; import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog'; @@ -23,10 +19,6 @@ interface LabCaseProsthesisGroupsListProps { fallbackTeeth?: string[]; } -function prosthesisLabel(code: string, catalog: readonly ProsthesisCatalogEntry[]): string { - return catalog.find((entry) => entry.code === code)?.label ?? prosthesisGroupLabelFromCatalog(code, catalog); -} - export function LabCaseProsthesisGroupsList({ groups, prosthesisCatalog, @@ -109,12 +101,11 @@ export function LabCaseToothJobsList({ · - - {prosthesisLabel(code, prosthesisCatalog)} - + code={code} + catalog={prosthesisCatalog} + /> ))} {row.connected ? ( diff --git a/frontend/src/components/ui/lab/ProsthesisStackedTypeLabel.tsx b/frontend/src/components/ui/lab/ProsthesisStackedTypeLabel.tsx index ce1daa4..cc8755b 100644 --- a/frontend/src/components/ui/lab/ProsthesisStackedTypeLabel.tsx +++ b/frontend/src/components/ui/lab/ProsthesisStackedTypeLabel.tsx @@ -1,10 +1,15 @@ 'use client'; +import { useTranslations } from 'next-intl'; import { - prosthesisGroupLabelFromCatalog, prosthesisTypeLabelStyleFromCatalog, splitProsthesisGroupCode, } from '@/components/treatment/prosthesisTypeDisplay'; +import { + PROSTHESIS_JOB_PATH_SEP, + prosthesisJobPathLabel, + prosthesisJobPathParts, +} from '@/components/treatment/prosthesisJobPath'; import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog'; interface ProsthesisStackedTypeLabelProps { @@ -20,31 +25,28 @@ export function ProsthesisStackedTypeLabel({ className, title, }: ProsthesisStackedTypeLabelProps) { + const t = useTranslations('prosthesis'); + const tr = (key: string) => t(key as never); const parts = splitProsthesisGroupCode(code); - const fullTitle = title ?? prosthesisGroupLabelFromCatalog(code, catalog); - if (parts.length <= 1) { - const part = parts[0] ?? code; - return ( - - {catalog.find((entry) => entry.code === part)?.label ?? part} - - ); - } + const tooltip = title?.trim() || prosthesisJobPathLabel(code, catalog, tr); return ( - - {parts.map((part, index) => ( - - {index > 0 ? + : null} - - {catalog.find((entry) => entry.code === part)?.label ?? part} + + {parts.map((part, index) => { + const path = prosthesisJobPathParts(part, catalog, tr); + return ( + + {index > 0 ? + : null} + {path.ancestors.length > 0 ? ( + + {path.ancestors.join(PROSTHESIS_JOB_PATH_SEP)} + {PROSTHESIS_JOB_PATH_SEP} + + ) : null} + {path.leaf} - - ))} + ); + })} ); } diff --git a/frontend/src/components/ui/lab/TaskProsthesisGroupHeader.tsx b/frontend/src/components/ui/lab/TaskProsthesisGroupHeader.tsx index ce97464..5c75af1 100644 --- a/frontend/src/components/ui/lab/TaskProsthesisGroupHeader.tsx +++ b/frontend/src/components/ui/lab/TaskProsthesisGroupHeader.tsx @@ -25,10 +25,9 @@ export function TaskProsthesisGroupHeader({ return (
    {t('teethLabel', { teeth: formatToothList(group.teeth, archLabels) })} diff --git a/frontend/src/components/ui/lab/TaskRow.tsx b/frontend/src/components/ui/lab/TaskRow.tsx index 8cdfe79..173df2c 100644 --- a/frontend/src/components/ui/lab/TaskRow.tsx +++ b/frontend/src/components/ui/lab/TaskRow.tsx @@ -206,10 +206,9 @@ export function TaskRow({ ) : null}
    ) : null} diff --git a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx index 8bf2eb7..f95d815 100644 --- a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx +++ b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx @@ -24,6 +24,10 @@ import { toothJobRowsForDetail, } from '@/components/treatment/prosthesisTree'; import { LabCaseToothJobsList } from '@/components/ui/lab/LabCaseProsthesisGroupsList'; +import { + LabCaseShareQrDialog, + LabCaseShareQrThumb, +} from '@/components/ui/lab/LabCaseShareQrDialog'; interface LabCasesDispatchPanelProps { details: TreatmentDetailDraft[]; @@ -105,6 +109,7 @@ export function LabCasesDispatchPanel({ const tErrors = useTranslations('errors'); const [fetchedCatalog, setFetchedCatalog] = useState([]); const [pendingComment, setPendingComment] = useState(''); + const [shareQrOpen, setShareQrOpen] = useState(false); const hasTrackerSummary = Boolean(labCaseSummary && labCaseSummary.labCaseId); const activeLinkedOrganizations = orgs.filter((o) => o.active); @@ -161,6 +166,7 @@ export function LabCasesDispatchPanel({ useEffect(() => { setPendingComment(''); + setShareQrOpen(false); }, [activeLabCase?.clientId]); function updateActiveLabCase(patch: Partial) { @@ -258,6 +264,7 @@ export function LabCasesDispatchPanel({ ? toothJobRowsForDetail(activeLabCase.toothProsthesis, activeDetailId) : []; const activeDetailAttachments = activeDetail.attachmentMetas ?? []; + const shareUrl = activeLabCase?.shareUrl?.trim() ?? ''; return (
    @@ -272,32 +279,44 @@ export function LabCasesDispatchPanel({ {activeLabCase ? (
    {sent ? ( - <> - {activeLabOrgName ? ( -
    -

    {t('selectLab')}

    -

    {activeLabOrgName}

    +
    +
    +
    + {activeLabOrgName ? ( +
    +

    {t('selectLab')}

    +

    {activeLabOrgName}

    +
    + ) : null} + + + + {toothJobRows.length > 0 ? ( + + ) : null}
    - ) : null} - - - - {toothJobRows.length > 0 ? ( - - ) : null} + {shareUrl ? ( +
    + setShareQrOpen(true)} + /> +
    + ) : null} +
    {hasTrackerSummary && labCaseSummary ? ( ) : null} - +
    ) : ( <>
    @@ -430,6 +449,13 @@ export function LabCasesDispatchPanel({ )}
    ) : null} + {shareUrl ? ( + setShareQrOpen(false)} + shareUrl={shareUrl} + /> + ) : null}
    ); } diff --git a/frontend/src/components/ui/treatment/ProsthesisCatalogIcon.tsx b/frontend/src/components/ui/treatment/ProsthesisCatalogIcon.tsx new file mode 100644 index 0000000..35c8a12 --- /dev/null +++ b/frontend/src/components/ui/treatment/ProsthesisCatalogIcon.tsx @@ -0,0 +1,19 @@ +export function ProsthesisCatalogIcon({ + src, + size = 'md', +}: { + src: string; + size?: 'sm' | 'md'; +}) { + const box = size === 'sm' ? 'h-8 w-8 sm:h-9 sm:w-9' : 'h-9 w-9 sm:h-10 sm:w-10'; + return ( + + + + ); +} diff --git a/frontend/src/components/ui/treatment/ProsthesisJobPopover.tsx b/frontend/src/components/ui/treatment/ProsthesisJobPopover.tsx index 2641abf..0e49275 100644 --- a/frontend/src/components/ui/treatment/ProsthesisJobPopover.tsx +++ b/frontend/src/components/ui/treatment/ProsthesisJobPopover.tsx @@ -1,6 +1,6 @@ 'use client'; -import type { ReactNode } from 'react'; +import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'; import { useTranslations } from 'next-intl'; import { ArrowLeft, Plus, Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/shared/Button'; @@ -11,6 +11,11 @@ import { import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog'; import { prosthesisFamilyTone } from '@/components/shared/catalog-type-colors'; import { prosthesisTypeColorFromCatalog, prosthesisTypeFillAccent } from '@/components/treatment/prosthesisTypeDisplay'; +import { prosthesisCatalogIconSrc } from '@/components/treatment/prosthesisCatalogIcons'; +import { + PROSTHESIS_JOB_PATH_SEP, + prosthesisJobPathParts, +} from '@/components/treatment/prosthesisJobPath'; import { catalogByCode, categoryCodesInCatalog, @@ -23,14 +28,24 @@ import { type ArchTarget, type PickerScope, } from '@/components/treatment/prosthesisTree'; +import { ProsthesisCatalogIcon } from '@/components/ui/treatment/ProsthesisCatalogIcon'; const CHIP_INK = '#14253d'; +const PARENT_BACK_INK = '#000000'; const TILE_CLASS = - 'flex min-h-[3.25rem] w-full flex-col items-center justify-center gap-0.5 rounded-[var(--radius-md)] border px-2.5 py-2 text-center transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:cursor-not-allowed disabled:opacity-45'; + 'flex min-h-[4.5rem] min-w-0 w-full flex-col items-center justify-center rounded-[var(--radius-md)] border px-2.5 py-2 text-center transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:cursor-not-allowed disabled:opacity-45'; +const CHILD_TILE_CLASS = + 'flex min-h-[3.75rem] min-w-0 w-full flex-col items-center justify-center rounded-[var(--radius-md)] border px-2 py-1 text-center transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:cursor-not-allowed disabled:opacity-45'; +const PARENT_BAR_CLASS = + 'flex min-h-[3.75rem] min-w-0 w-full flex-row items-center justify-start gap-2 rounded-[var(--radius-md)] border px-3 py-1.5 text-start transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:cursor-not-allowed disabled:opacity-45'; const TILE_TEXT_CLASS = - 'w-full whitespace-normal break-words text-center text-xs font-medium leading-snug sm:text-[13px]'; + 'w-full whitespace-normal break-words text-center text-xs font-medium leading-tight sm:text-[13px]'; +const TILE_TEXT_CHILD_CLASS = + 'w-full min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-center text-[11px] font-medium leading-tight sm:text-xs'; const TILE_GRID_CLASS = 'grid gap-2 [grid-template-columns:repeat(auto-fill,minmax(8rem,1fr))]'; +const TILE_GRID_CHILD_CLASS = + 'grid gap-2 [grid-template-columns:repeat(auto-fill,minmax(10.2rem,1fr))]'; const HEADER_BTN_CLASS = 'h-auto min-h-8 w-full px-2 py-1 text-xs leading-tight whitespace-normal'; function tileSurfaceClass(active?: boolean) { @@ -70,7 +85,9 @@ function Tile({ active, disabled, hint, - className = TILE_CLASS, + iconSrc, + compact, + className, onClick, }: { label: string; @@ -78,19 +95,23 @@ function Tile({ active?: boolean; disabled?: boolean; hint?: string; + iconSrc?: string; + compact?: boolean; className?: string; onClick: () => void; }) { + const base = className ?? (compact ? CHILD_TILE_CLASS : TILE_CLASS); return ( ); } @@ -109,6 +130,19 @@ function parentStep(step: PickerStep): PickerStep | null { } } +function pickerStepKey(step: PickerStep): string { + switch (step.kind) { + case 'category': + return 'category'; + case 'addon': + return `addon:${step.slot}`; + case 'subcategory': + return `sub:${step.category}:${step.arch ?? ''}`; + case 'leaves': + return `leaves:${step.category}:${step.subcategory ?? ''}:${step.arch ?? ''}`; + } +} + export function ProsthesisJobPopover({ open, mobile, @@ -128,6 +162,31 @@ export function ProsthesisJobPopover({ onClose, }: ProsthesisJobPopoverProps) { const t = useTranslations('prosthesis'); + const childGridRef = useRef(null); + const [branchH, setBranchH] = useState(0); + const stepKey = pickerStepKey(step); + + useLayoutEffect(() => { + if (!open || stepKey === 'category') return; + const grid = childGridRef.current; + if (!grid) return; + + const sync = () => { + const first = grid.firstElementChild; + if (!(first instanceof HTMLElement)) return; + setBranchH(first.offsetHeight); + }; + + sync(); + const ro = new ResizeObserver(sync); + ro.observe(grid); + grid.addEventListener('animationend', sync); + return () => { + ro.disconnect(); + grid.removeEventListener('animationend', sync); + }; + }, [open, stepKey]); + if (!open) return null; const categories = categoryCodesInCatalog(catalog, scope); @@ -159,10 +218,15 @@ export function ProsthesisJobPopover({ } function leafTile(leaf: ProsthesisCatalogEntry) { + const path = prosthesisJobPathParts(leaf.code, catalog, (key) => t(key as never)); + const underIndirectSub = + leaf.category === 'indirect' && step.kind === 'leaves' && Boolean(step.subcategory); return ( onPickLeaf(leaf.code, archForLeaves)} @@ -180,7 +244,11 @@ export function ProsthesisJobPopover({ onPickAddon(null, 'crown')} @@ -240,7 +314,9 @@ export function ProsthesisJobPopover({ ...crownLeaves.map((opt) => ( onPickAddon(opt.code, 'crown')} @@ -250,13 +326,19 @@ export function ProsthesisJobPopover({ break; case 'subcategory': { const parentFill = categoryTileColor(step.category); - selectedNode = { label: categoryLabel(step.category), color: parentFill }; + selectedNode = { + label: categoryLabel(step.category), + color: parentFill, + iconSrc: prosthesisCatalogIconSrc({ category: step.category }), + }; childrenKey = `sub-${step.category}`; childTiles = [ ...subcategoriesFor(catalog, step.category, scope).map((sub, i) => ( onStep({ @@ -277,6 +359,10 @@ export function ProsthesisJobPopover({ selectedNode = { label: subLabel(step.subcategory), color: prosthesisFamilyTone(categoryTileColor(step.category), 1), + iconSrc: prosthesisCatalogIconSrc({ + category: step.category, + subcategory: step.subcategory, + }), }; childrenKey = `leaves-${step.category}-${step.subcategory}`; childTiles = leavesFor(catalog, step.category, step.subcategory, scope).map(leafTile); @@ -284,6 +370,7 @@ export function ProsthesisJobPopover({ selectedNode = { label: categoryLabel(step.category), color: categoryTileColor(step.category), + iconSrc: prosthesisCatalogIconSrc({ category: step.category }), }; childrenKey = `leaves-${step.category}`; childTiles = leavesFor(catalog, step.category, undefined, scope).map(leafTile); @@ -294,9 +381,10 @@ export function ProsthesisJobPopover({ const tileGrid = (
    {childTiles}
    @@ -311,7 +399,7 @@ export function ProsthesisJobPopover({ if (back) onStep(back); }} disabled={!back} - className={`${TILE_CLASS} flex-row justify-start gap-2 px-3 text-start`} + className={PARENT_BAR_CLASS} style={ selectedNode.color ? { backgroundColor: selectedNode.color, color: CHIP_INK } @@ -320,18 +408,19 @@ export function ProsthesisJobPopover({ aria-label={t('pickerBack')} > - {selectedNode.label} + {selectedNode.iconSrc ? ( + + ) : null} + + {selectedNode.label} +
    -
    +
    {jobCodes.map((code) => { - const entry = byCode.get(code); + const path = prosthesisJobPathParts(code, catalog, (key) => t(key as never)); const color = prosthesisTypeColorFromCatalog(code, catalog); const trashColor = prosthesisTypeFillAccent(color); return ( @@ -388,13 +477,14 @@ export function ProsthesisJobPopover({ borderColor: 'rgba(0,0,0,0.16)', }} > - - - {entry?.category - ? t(`category_${entry.category}` as never) - : t('regionCrown')} - - {entry?.label ?? code} + + {path.ancestors.length > 0 ? ( + + {path.ancestors.join(PROSTHESIS_JOB_PATH_SEP)} + {PROSTHESIS_JOB_PATH_SEP} + + ) : null} + {path.leaf} )}
    @@ -263,7 +269,8 @@ export function TreatmentDetailsEditor({ ) : null}
    @@ -327,6 +327,29 @@ export function TreatmentDetailsEditor({ ); } +function WorkspaceActionLabel({ + label, + widthLabels, +}: { + label: string; + widthLabels: string[]; +}) { + return ( + + {widthLabels.map((text, index) => ( + + {text} + + ))} + {label} + + ); +} + function NotesField({ textareaRef, value, @@ -377,6 +400,7 @@ function NotesField({ */ function AddDetailWithVoice({ addLabel, + widthLabels, startLabel, stopLabel, disabled, @@ -384,6 +408,7 @@ function AddDetailWithVoice({ voice, }: { addLabel: string; + widthLabels: string[]; startLabel: string; stopLabel: string; disabled: boolean; @@ -397,8 +422,8 @@ function AddDetailWithVoice({ return (
    @@ -407,13 +432,13 @@ function AddDetailWithVoice({ onClick={onAddDetail} disabled={disabled || isBusy} className=" - flex-1 px-4 py-2 text-sm font-medium transition-all duration-200 + px-3 text-sm font-medium transition-all duration-200 hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60 disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:opacity-60 " > - {addLabel} +