improvement: icons added to prosthesis types catalog. #73

Merged
admin merged 1 commits from improvements/prosthesis-catalog-icons into master 2026-09-05 13:45:30 +03:30
83 changed files with 1146 additions and 187 deletions

View File

@@ -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 — `<img src>` 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 `<img>` — 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 — `<img src>` cannot inherit `currentColor`.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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 details 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 todays 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.

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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 }`.

View File

@@ -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 cards 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 details 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 1121 / 4131 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 tooths 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`.

View File

@@ -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,
);
}

View File

@@ -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;

View File

@@ -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",

View File

@@ -1136,7 +1136,6 @@
"noneYet": "هنوز هیچ",
"clearHint": "«پاک کردن این دندان» آن را از کیس حذف می‌کند. با × روی تراشه یک نوع را بردارید.",
"removeJob": "حذف این نوع",
"regionCrown": "تاج",
"pickerTooth": "دندان {tooth}",
"slotCrown": "روکش",
"suggestionsLabel": "افزودنی‌های پیشنهادی",

View File

@@ -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",

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 28 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 23 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 306 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 33 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 41 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -0,0 +1,87 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 330">
<!-- Complete tooth silhouette: one continuous, non-crossing contour -->
<path d="
M29 62
C38 41 57 31 77 34
C89 36 98 42 110 42
C122 42 131 36 143 34
C163 31 182 41 191 62
C201 86 198 116 187 142
C181 156 174 168 168 177
C163 185 160 194 159 205
C157 232 153 260 146 285
C140 304 132 316 124 319
C119 321 116 317 117 310
C120 289 119 270 116 249
C114 237 111 228 110 223
C109 228 106 237 104 249
C101 270 100 289 103 310
C104 317 101 321 96 319
C88 316 80 304 74 285
C67 260 63 232 61 205
C60 194 57 185 52 177
C46 168 39 156 33 142
C22 116 19 86 29 62 Z"
fill="#F3F3F3"
stroke="#243549"
stroke-width="4"
stroke-linecap="round"
stroke-linejoin="round"/>
<!-- Crown overlay; same clean upper contour, no duplicated/crossing segment -->
<path d="
M29 62
C38 41 57 31 77 34
C89 36 98 42 110 42
C122 42 131 36 143 34
C163 31 182 41 191 62
C201 86 198 116 187 142
C181 156 174 168 168 177
C164 183 160 187 154 187
H145
L138 137
C137 131 133 128 127 128
H93
C87 128 83 131 82 137
L75 187
H66
C60 187 56 183 52 177
C46 168 39 156 33 142
C22 116 19 86 29 62 Z"
fill="#FFFFFF"
stroke="#243549"
stroke-width="4"
stroke-linecap="round"
stroke-linejoin="round"/>
<!-- Core -->
<path d="
M82 137
C83 131 87 128 93 128
H127
C133 128 137 131 138 137
L145 187
H75 Z"
fill="#9A9A9A"
stroke="#243549"
stroke-width="4"
stroke-linecap="round"
stroke-linejoin="round"/>
<!-- Post -->
<path d="
M94 187
H126
L121 257
C120 273 116 282 110 282
C104 282 100 273 99 257
Z"
fill="#8A8A8A"
stroke="#243549"
stroke-width="4"
stroke-linecap="round"
stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 306 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 31 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 23 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 306 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.7 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 33 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 41 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -0,0 +1,87 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 330">
<!-- Complete tooth silhouette: one continuous, non-crossing contour -->
<path d="
M29 62
C38 41 57 31 77 34
C89 36 98 42 110 42
C122 42 131 36 143 34
C163 31 182 41 191 62
C201 86 198 116 187 142
C181 156 174 168 168 177
C163 185 160 194 159 205
C157 232 153 260 146 285
C140 304 132 316 124 319
C119 321 116 317 117 310
C120 289 119 270 116 249
C114 237 111 228 110 223
C109 228 106 237 104 249
C101 270 100 289 103 310
C104 317 101 321 96 319
C88 316 80 304 74 285
C67 260 63 232 61 205
C60 194 57 185 52 177
C46 168 39 156 33 142
C22 116 19 86 29 62 Z"
fill="#F3F3F3"
stroke="#243549"
stroke-width="4"
stroke-linecap="round"
stroke-linejoin="round"/>
<!-- Crown overlay; same clean upper contour, no duplicated/crossing segment -->
<path d="
M29 62
C38 41 57 31 77 34
C89 36 98 42 110 42
C122 42 131 36 143 34
C163 31 182 41 191 62
C201 86 198 116 187 142
C181 156 174 168 168 177
C164 183 160 187 154 187
H145
L138 137
C137 131 133 128 127 128
H93
C87 128 83 131 82 137
L75 187
H66
C60 187 56 183 52 177
C46 168 39 156 33 142
C22 116 19 86 29 62 Z"
fill="#FFFFFF"
stroke="#243549"
stroke-width="4"
stroke-linecap="round"
stroke-linejoin="round"/>
<!-- Core -->
<path d="
M82 137
C83 131 87 128 93 128
H127
C133 128 137 131 138 137
L145 187
H75 Z"
fill="#9A9A9A"
stroke="#243549"
stroke-width="4"
stroke-linecap="round"
stroke-linejoin="round"/>
<!-- Post -->
<path d="
M94 187
H126
L121 257
C120 273 116 282 110 282
C104 282 100 273 99 257
Z"
fill="#8A8A8A"
stroke="#243549"
stroke-width="4"
stroke-linecap="round"
stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 306 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 31 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

File diff suppressed because one or more lines are too long

View File

@@ -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<string, string> = {
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<string, string> = {
veneer: icon.veneer,
inlay: icon.inlay,
onlay: icon.onlay,
overlay: icon.overlay,
night_guard: icon.night_guard,
};
const BY_CATEGORY: Record<string, string> = {
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;
}

View File

@@ -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(' + ');
}

View File

@@ -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<string, readonly string[]> = {
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(

View File

@@ -187,12 +187,3 @@ export function formatToothList(
})
.join(', ');
}
export function prosthesisGroupLabelFromCatalog(
code: string,
catalog: readonly Pick<ProsthesisCatalogEntry, 'code' | 'label'>[],
): string {
const parts = splitProsthesisGroupCode(code);
if (parts.length === 0) return code;
return parts.map((part) => catalog.find((entry) => entry.code === part)?.label ?? part).join(' + ');
}

View File

@@ -312,10 +312,9 @@ export function CaseDetailPanel({
>
<div className="flex flex-wrap items-center gap-2">
<ProsthesisStackedTypeLabel
className="max-w-[16rem] text-sm font-medium"
className="min-w-0 whitespace-normal break-words text-sm font-medium"
code={group.prosthesisTypeCode}
catalog={prosthesisCatalog}
title={group.prosthesisTypeLabel}
/>
<span className="text-sm text-text-secondary">
{formatToothList(group.teeth, {

View File

@@ -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<FdiToothId>();
@@ -156,16 +159,11 @@ export function CaseSheetPrintLayout({
<li style={{ color: '#64748b' }}></li>
) : (
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 (
<li
key={`${row.selectionGroupId ?? ''}-${row.prosthesisTypeCode}-${row.teeth.join(',')}`}

View File

@@ -24,6 +24,7 @@ import { casesApi } from '@/lib/api/cases';
import { tasksApi } from '@/lib/api/tasks';
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
import { treatmentCatalogApi } from '@/lib/api/treatment-catalog';
import { prosthesisJobPathLabel } from '@/components/treatment/prosthesisJobPath';
import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay';
import { Badge } from '@/components/ui/shared/Badge';
import { Button } from '@/components/ui/shared/Button';
@@ -45,6 +46,7 @@ const PAGE_SIZE = 10;
export function CasesPage() {
const t = useTranslations('cases');
const tProsthesis = useTranslations('prosthesis');
const tErrors = useTranslations('errors');
const tCommon = useTranslations('common');
const { currentOrganization, user } = useAuth();
@@ -90,8 +92,8 @@ export function CasesPage() {
const prosthesisLabel = useCallback(
(code: string) =>
prosthesisCatalog.find((entry) => entry.code === code)?.label ?? code,
[prosthesisCatalog],
prosthesisJobPathLabel(code, prosthesisCatalog, (key) => tProsthesis(key as never)),
[prosthesisCatalog, tProsthesis],
);
const treatmentDetailLabel = useCallback(

View File

@@ -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({
<span className="text-text-muted" aria-hidden>
·
</span>
<span
<ProsthesisStackedTypeLabel
className="font-medium"
style={prosthesisTypeLabelStyleFromCatalog(code, prosthesisCatalog)}
>
{prosthesisLabel(code, prosthesisCatalog)}
</span>
code={code}
catalog={prosthesisCatalog}
/>
</span>
))}
{row.connected ? (

View File

@@ -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 (
<span
className={className}
title={fullTitle}
style={prosthesisTypeLabelStyleFromCatalog(part, catalog)}
>
{catalog.find((entry) => entry.code === part)?.label ?? part}
</span>
);
}
const tooltip = title?.trim() || prosthesisJobPathLabel(code, catalog, tr);
return (
<span className={className} title={fullTitle}>
{parts.map((part, index) => (
<span key={`${part}-${index}`}>
{index > 0 ? <span className="text-text-muted"> + </span> : null}
<span style={prosthesisTypeLabelStyleFromCatalog(part, catalog)}>
{catalog.find((entry) => entry.code === part)?.label ?? part}
<span className={className} title={tooltip}>
{parts.map((part, index) => {
const path = prosthesisJobPathParts(part, catalog, tr);
return (
<span key={`${part}-${index}`}>
{index > 0 ? <span className="text-text-muted"> + </span> : null}
{path.ancestors.length > 0 ? (
<span className="font-normal text-text-muted">
{path.ancestors.join(PROSTHESIS_JOB_PATH_SEP)}
{PROSTHESIS_JOB_PATH_SEP}
</span>
) : null}
<span style={prosthesisTypeLabelStyleFromCatalog(part, catalog)}>{path.leaf}</span>
</span>
</span>
))}
);
})}
</span>
);
}

View File

@@ -25,10 +25,9 @@ export function TaskProsthesisGroupHeader({
return (
<div className="flex flex-wrap items-center gap-2 px-3 py-1.5 bg-background-secondary/30 border-b border-border/40">
<ProsthesisStackedTypeLabel
className="max-w-[16rem] text-xs font-medium"
className="min-w-0 whitespace-normal break-words text-xs font-medium"
code={group.prosthesisTypeCode}
catalog={prosthesisCatalog}
title={group.prosthesisTypeLabel}
/>
<span className="text-xs text-text-secondary">
{t('teethLabel', { teeth: formatToothList(group.teeth, archLabels) })}

View File

@@ -206,10 +206,9 @@ export function TaskRow({
</Button>
) : null}
<ProsthesisStackedTypeLabel
className="w-full max-w-[10rem] text-xs font-medium sm:w-[8rem]"
className="min-w-0 max-w-full whitespace-normal break-words text-xs font-medium sm:max-w-[16rem] sm:text-end"
code={task.prosthesisTypeCode}
catalog={prosthesisCatalog}
title={task.prosthesisTypeLabel}
/>
</div>
) : null}

View File

@@ -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<ProsthesisCatalogEntry[]>([]);
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<LabCaseDraft>) {
@@ -258,6 +264,7 @@ export function LabCasesDispatchPanel({
? toothJobRowsForDetail(activeLabCase.toothProsthesis, activeDetailId)
: [];
const activeDetailAttachments = activeDetail.attachmentMetas ?? [];
const shareUrl = activeLabCase?.shareUrl?.trim() ?? '';
return (
<div className="surface-card p-3 sm:p-4 space-y-4 min-w-0 overflow-x-hidden">
@@ -272,32 +279,44 @@ export function LabCasesDispatchPanel({
{activeLabCase ? (
<div className="space-y-4 border border-border/60 rounded-[var(--radius-md)] p-3 sm:p-4 bg-background-secondary/30 min-w-0">
{sent ? (
<>
{activeLabOrgName ? (
<div>
<p className="text-xs font-medium text-text-secondary">{t('selectLab')}</p>
<p className="text-sm text-text-primary mt-1">{activeLabOrgName}</p>
<div className="space-y-4">
<div className="flex flex-col-reverse items-stretch gap-3 sm:flex-row sm:items-start">
<div className="min-w-0 flex-1 space-y-4">
{activeLabOrgName ? (
<div>
<p className="text-xs font-medium text-text-secondary">{t('selectLab')}</p>
<p className="text-sm text-text-primary mt-1">{activeLabOrgName}</p>
</div>
) : null}
<CaseSentLabel
treatmentCase={{
destinationOrganizationId: activeLabCase.destinationOrganizationId,
sendToOrganizationIds: activeLabCase.destinationOrganizationId
? [activeLabCase.destinationOrganizationId]
: [],
sentAt: activeLabCase.sentAt ?? null,
sends: activeLabCase.sends,
}}
orgs={orgs}
/>
{toothJobRows.length > 0 ? (
<LabCaseToothJobsList
rows={toothJobRows}
prosthesisCatalog={catalog}
/>
) : null}
</div>
) : null}
<CaseSentLabel
treatmentCase={{
destinationOrganizationId: activeLabCase.destinationOrganizationId,
sendToOrganizationIds: activeLabCase.destinationOrganizationId
? [activeLabCase.destinationOrganizationId]
: [],
sentAt: activeLabCase.sentAt ?? null,
sends: activeLabCase.sends,
}}
orgs={orgs}
/>
{toothJobRows.length > 0 ? (
<LabCaseToothJobsList
rows={toothJobRows}
prosthesisCatalog={catalog}
/>
) : null}
{shareUrl ? (
<div className="shrink-0 self-start">
<LabCaseShareQrThumb
shareUrl={shareUrl}
onClick={() => setShareQrOpen(true)}
/>
</div>
) : null}
</div>
{hasTrackerSummary && labCaseSummary ? (
<LabCaseTrackerCard
@@ -317,7 +336,7 @@ export function LabCasesDispatchPanel({
onActivityChange={onLabCaseActivityChange}
/>
) : null}
</>
</div>
) : (
<>
<div className="space-y-2">
@@ -430,6 +449,13 @@ export function LabCasesDispatchPanel({
)}
</div>
) : null}
{shareUrl ? (
<LabCaseShareQrDialog
open={shareQrOpen}
onClose={() => setShareQrOpen(false)}
shareUrl={shareUrl}
/>
) : null}
</div>
);
}

View File

@@ -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 (
<span className={`inline-flex shrink-0 items-center justify-center ${box}`} aria-hidden>
<img
src={src}
alt=""
draggable={false}
className="pointer-events-none max-h-full max-w-full object-contain"
/>
</span>
);
}

View File

@@ -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 (
<button
type="button"
title={hint ?? label}
onClick={onClick}
disabled={disabled}
className={`${className} ${tileSurfaceClass(active)}`}
className={`${base} ${compact ? 'gap-0.5' : 'gap-2'} ${tileSurfaceClass(active)}`}
style={color ? { backgroundColor: color, color: CHIP_INK } : undefined}
>
<span className={TILE_TEXT_CLASS}>{label}</span>
{iconSrc ? <ProsthesisCatalogIcon src={iconSrc} /> : null}
<span className={compact ? TILE_TEXT_CHILD_CLASS : TILE_TEXT_CLASS}>{label}</span>
</button>
);
}
@@ -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<HTMLDivElement>(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 (
<Tile
key={leaf.code}
label={leaf.label}
label={underIndirectSub ? path.leaf : leaf.label}
compact
iconSrc={prosthesisCatalogIconSrc(leaf)}
color={prosthesisTypeColorFromCatalog(leaf.code, catalog)}
active={jobCodes.includes(leaf.code)}
onClick={() => onPickLeaf(leaf.code, archForLeaves)}
@@ -180,7 +244,11 @@ export function ProsthesisJobPopover({
<Tile
key="slot-crown"
className={`${TILE_CLASS} h-full`}
compact
label={byCode.get(currentCrown)?.label ?? currentCrown}
iconSrc={prosthesisCatalogIconSrc(
byCode.get(currentCrown) ?? { code: currentCrown, category: 'crown' },
)}
color={prosthesisTypeColorFromCatalog(currentCrown, catalog)}
active={addonActive}
onClick={openAddon}
@@ -207,7 +275,7 @@ export function ProsthesisJobPopover({
}
}
let selectedNode: { label: string; color?: string } | null = null;
let selectedNode: { label: string; color?: string; iconSrc?: string } | null = null;
let childTiles: ReactNode[] = [];
let childrenKey = 'root';
@@ -219,6 +287,7 @@ export function ProsthesisJobPopover({
<Tile
key={code}
label={categoryLabel(code)}
iconSrc={prosthesisCatalogIconSrc({ category: code })}
color={categoryTileColor(code)}
disabled={disabled}
hint={disabled ? t('categoryDisabledHint') : undefined}
@@ -228,11 +297,16 @@ export function ProsthesisJobPopover({
});
break;
case 'addon':
selectedNode = { label: t('slotCrown'), color: categoryTileColor('crown') };
selectedNode = {
label: t('slotCrown'),
color: categoryTileColor('crown'),
iconSrc: prosthesisCatalogIconSrc({ category: 'crown' }),
};
childrenKey = 'addon-crown';
childTiles = [
<Tile
key="none"
compact
label={t('addonNone')}
active={!currentCrown}
onClick={() => onPickAddon(null, 'crown')}
@@ -240,7 +314,9 @@ export function ProsthesisJobPopover({
...crownLeaves.map((opt) => (
<Tile
key={opt.code}
compact
label={opt.label}
iconSrc={prosthesisCatalogIconSrc(opt)}
color={prosthesisTypeColorFromCatalog(opt.code, catalog)}
active={currentCrown === opt.code}
onClick={() => 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) => (
<Tile
key={sub}
compact
label={subLabel(sub)}
iconSrc={prosthesisCatalogIconSrc({ category: step.category, subcategory: sub })}
color={prosthesisFamilyTone(parentFill, i + 1)}
onClick={() =>
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 = (
<div
key={childrenKey}
className={`picker-expand min-w-0 flex-1 ${TILE_GRID_CLASS}${
selectedNode ? '' : ' h-full min-h-0 [grid-auto-rows:minmax(3.25rem,1fr)]'
}`}
ref={childGridRef}
className={`picker-expand min-w-0 flex-1 ${
selectedNode ? TILE_GRID_CHILD_CLASS : TILE_GRID_CLASS
}${selectedNode ? '' : ' h-full min-h-0 [grid-auto-rows:minmax(4.5rem,1fr)]'}`}
>
{childTiles}
</div>
@@ -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')}
>
<ArrowLeft
className="lucide-inherit h-4 w-4 shrink-0 rtl:rotate-180"
style={
selectedNode.color
? { color: prosthesisTypeFillAccent(selectedNode.color) }
: undefined
}
className="h-4 w-4 shrink-0 rtl:rotate-180"
style={{ color: PARENT_BACK_INK }}
aria-hidden
/>
<span className={`${TILE_TEXT_CLASS} text-start`}>{selectedNode.label}</span>
{selectedNode.iconSrc ? (
<ProsthesisCatalogIcon src={selectedNode.iconSrc} size="sm" />
) : null}
<span className="min-w-0 flex-1 whitespace-normal break-words text-start text-xs font-medium leading-tight sm:text-[13px]">
{selectedNode.label}
</span>
</button>
<div className="flex items-start">
<div className="relative h-[3.25rem] w-3 shrink-0" aria-hidden>
<div className="relative w-3 shrink-0" style={{ height: branchH || undefined }} aria-hidden>
<span
className="absolute w-0.5"
style={{
@@ -375,7 +464,7 @@ export function ProsthesisJobPopover({
) : (
<div className="flex flex-wrap gap-1.5">
{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)',
}}
>
<span className="inline-flex items-center gap-1 px-2 py-0.5">
<span className="opacity-70">
{entry?.category
? t(`category_${entry.category}` as never)
: t('regionCrown')}
</span>
{entry?.label ?? code}
<span className="inline-flex items-center px-2 py-0.5">
{path.ancestors.length > 0 ? (
<span className="opacity-70">
{path.ancestors.join(PROSTHESIS_JOB_PATH_SEP)}
{PROSTHESIS_JOB_PATH_SEP}
</span>
) : null}
{path.leaf}
</span>
<button
type="button"
@@ -418,8 +508,8 @@ export function ProsthesisJobPopover({
);
const body = (
<div className="grid h-full min-h-0 flex-1 grid-cols-[4fr_1fr] gap-x-3">
<section className="flex min-h-0 min-w-0 flex-col gap-2.5">
<div className="grid h-full min-h-0 flex-1 grid-cols-[4fr_1fr]">
<section className="flex min-h-0 min-w-0 flex-col gap-2.5 border-e border-border pe-3">
<p className="shrink-0 text-xs font-semibold leading-none text-text-primary">{title}</p>
{scope === 'arch' && onLockedArchChange ? (
<div
@@ -451,7 +541,7 @@ export function ProsthesisJobPopover({
{tileRow}
</div>
</section>
<section className="flex min-h-0 min-w-0 flex-col gap-1">
<section className="flex min-h-0 min-w-0 flex-col gap-1 ps-3">
<div className="flex shrink-0 flex-col gap-1">
<Button
type="button"

View File

@@ -18,6 +18,9 @@ import {
isLabDependentDetailMissingTeeth,
} from '@/components/treatment/treatmentDetailRules';
const WORKSPACE_ACTION_BTN_HEIGHT = 'h-[2.875rem] sm:h-9';
const WORKSPACE_ACTION_BTN_CLASS = `${WORKSPACE_ACTION_BTN_HEIGHT} shrink-0 px-3 text-sm`;
interface TreatmentDetailsEditorProps {
details: TreatmentDetailDraft[];
activeDetailId: string;
@@ -116,6 +119,8 @@ export function TreatmentDetailsEditor({
);
}
const actionWidthLabels = [t('showLabDispatch'), t('showChart'), t('addDetail')];
return (
<div className="surface-card p-3 sm:p-4 space-y-4">
{showChrome ? (
@@ -127,6 +132,7 @@ export function TreatmentDetailsEditor({
{voice ? (
<AddDetailWithVoice
addLabel={t('addDetail')}
widthLabels={actionWidthLabels}
startLabel={t('voiceStart')}
stopLabel={t('voiceStop')}
disabled={!canEdit || disabled}
@@ -137,12 +143,12 @@ export function TreatmentDetailsEditor({
<Button
type="button"
variant="primary"
size="sm"
disabled={!canEdit || disabled}
onClick={onAddDetail}
fullWidth
className="sm:w-auto shrink-0"
className={WORKSPACE_ACTION_BTN_CLASS}
>
{t('addDetail')}
<WorkspaceActionLabel label={t('addDetail')} widthLabels={actionWidthLabels} />
</Button>
)}
</div>
@@ -263,7 +269,8 @@ export function TreatmentDetailsEditor({
<Button
type="button"
variant={labWorkspaceView === 'dispatch' ? 'outline' : 'primary'}
className={`h-[2.875rem] sm:h-9 shrink-0 px-3 text-sm ${
size="sm"
className={`${WORKSPACE_ACTION_BTN_CLASS} ${
labWorkspaceView === 'dispatch' ? 'bg-background-secondary' : ''
}`}
onClick={() =>
@@ -272,17 +279,10 @@ export function TreatmentDetailsEditor({
)
}
>
<span className="inline-grid justify-items-center">
<span className="invisible col-start-1 row-start-1 whitespace-nowrap" aria-hidden>
{t('showLabDispatch')}
</span>
<span className="invisible col-start-1 row-start-1 whitespace-nowrap" aria-hidden>
{t('showChart')}
</span>
<span className="col-start-1 row-start-1 whitespace-nowrap">
{labWorkspaceView === 'dispatch' ? t('showChart') : t('showLabDispatch')}
</span>
</span>
<WorkspaceActionLabel
label={labWorkspaceView === 'dispatch' ? t('showChart') : t('showLabDispatch')}
widthLabels={actionWidthLabels}
/>
</Button>
) : null}
</div>
@@ -327,6 +327,29 @@ export function TreatmentDetailsEditor({
);
}
function WorkspaceActionLabel({
label,
widthLabels,
}: {
label: string;
widthLabels: string[];
}) {
return (
<span className="inline-grid justify-items-center">
{widthLabels.map((text, index) => (
<span
key={`${index}-${text}`}
className="invisible col-start-1 row-start-1 whitespace-nowrap"
aria-hidden
>
{text}
</span>
))}
<span className="col-start-1 row-start-1 whitespace-nowrap">{label}</span>
</span>
);
}
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 (
<div
className={`
inline-flex w-full items-stretch overflow-hidden rounded-[var(--radius-md)]
bg-primary text-white shrink-0 sm:w-auto
inline-flex items-stretch overflow-hidden rounded-[var(--radius-md)]
bg-primary text-white shrink-0 ${WORKSPACE_ACTION_BTN_HEIGHT}
${disabled ? 'opacity-60' : ''}
`}
>
@@ -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}
<WorkspaceActionLabel label={addLabel} widthLabels={widthLabels} />
</button>
<button
type="button"

View File

@@ -295,6 +295,7 @@ function mapLabCaseDraftFromApi(lc: PastLabCase): LabCaseDraft {
sends: lc.sends ?? [],
dueDate: lc.dueDate ?? null,
taskProgress: lc.taskProgress ?? null,
shareUrl: lc.shareUrl ?? null,
};
}
@@ -396,7 +397,7 @@ function mergeServerIdsIntoLabCases(
return local.map((lc) => {
const s = byClientId.get(lc.clientId);
if (!s) return lc;
return { ...lc, id: s.id ?? lc.id };
return { ...lc, id: s.id ?? lc.id, shareUrl: s.shareUrl ?? lc.shareUrl };
});
}
@@ -2541,6 +2542,7 @@ export function TreatmentWorkspace({
destinationOrganizationId: response.data.destinationOrganizationId,
sends: response.data.sends,
taskProgress: response.data.taskProgress ?? lc.taskProgress,
shareUrl: response.data.shareUrl ?? lc.shareUrl,
}
: lc,
),

View File

@@ -126,6 +126,8 @@ export interface PastLabCase {
dueDate?: string | null;
taskProgress?: LabCaseTaskProgress | null;
attachments?: TreatmentAttachmentMeta[];
/** Present on sent cases — same share URL the lab Cases QR encodes. */
shareUrl?: string | null;
}
export interface PastTreatment {
@@ -183,6 +185,7 @@ export interface LabCaseDraft {
sends?: LabCaseSendInfo[];
dueDate?: string | null;
taskProgress?: LabCaseTaskProgress | null;
shareUrl?: string | null;
}
export type SavedTreatmentDetailPayload = {
@@ -237,4 +240,5 @@ export interface LabCaseResponse {
taskProgress?: LabCaseTaskProgress | null;
toothProsthesis?: LabCaseToothProsthesisDraft[];
attachments?: TreatmentAttachmentMeta[];
shareUrl?: string | null;
}