improvement: all confirmed suggestions implemented
This commit is contained in:
@@ -8,7 +8,7 @@ alwaysApply: false
|
|||||||
|
|
||||||
- **Browse mode** (`selectedPreviewId` set): preview only; banner + **Load into workspace**; form unchanged until load.
|
- **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**.
|
- **Current draft** preview: omitted in live editing (form is the source). History browse still uses preview + **Load into workspace**.
|
||||||
- **Entry:** Type dropdown + `TreatmentDetailAttachmentsStrip` on one row, bordered chart (Cases chrome), then full-width auto-growing Notes. Prosthesis types live on the chart (teeth, or Upper/Lower arch for jaw products). Screw-retained stacks with a crown; partial denture is selected on FDI teeth. Lab dispatch is always under chart+notes when the active type is lab-dependent — no Treatment→Lab stepper. Lab-rail / Go to dispatch scrolls to the panel (`pendingScrollToLabRef`). Lab-dependent chips use colored sent/unsent text; sent date is on Lab dispatch. Lab dispatch keeps comments.
|
- **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.
|
||||||
- **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.
|
- **Tooth hits:** unrotated full cell, `pointerdown` only (not `click` too — double-toggles). Glyph `pointer-events-none`; nest scale/hover inside the rotate wrapper. Groups: `toothSelectionGroups.ts` (never 1-tooth connected); prune lab `toothProsthesis` on change.
|
||||||
- **Day strip:** `ScheduleDayPicker` `compact` centered in the header. Timed cards use purpose banners. Unscheduled cards use the same banner from the **first detail’s type only** (live draft for the open card); empty first line keeps chip theming. Trash inherits banner ink. Strip-delete only when `areUnscheduledDetailsStripDeletable` (blank lines or `[]`); persist `[]` then `DELETE /treatments/:id`.
|
- **Day strip:** `ScheduleDayPicker` `compact` centered in the header. Timed cards use purpose banners. Unscheduled cards use the same banner from the **first detail’s type only** (live draft for the open card); empty first line keeps chip theming. Trash inherits banner ink. Strip-delete only when `areUnscheduledDetailsStripDeletable` (blank lines or `[]`); persist `[]` then `DELETE /treatments/:id`.
|
||||||
- **Lab dispatch UI:** due date end-aligned beside title (`sm:flex-row` + `justify-between`; stacks on mobile). Job list is `LabCaseToothJobsList`: connected same-type teeth share a row; unconnected teeth stay one row (stacked types as colored catalog text). Content clinical field = **Notes** (not case comments).
|
- **Lab dispatch UI:** due date end-aligned beside title (`sm:flex-row` + `justify-between`; stacks on mobile). Job list is `LabCaseToothJobsList`: connected same-type teeth share a row; unconnected teeth stay one row (stacked types as colored catalog text). Content clinical field = **Notes** (not case comments).
|
||||||
@@ -16,7 +16,7 @@ alwaysApply: false
|
|||||||
- **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.
|
- **Detail chrome:** chips (type + teeth) + Add at top; **Remove** = trash on chip (unsent, including last line; disabled when day-locked / no edit / uploading). Last-line confirm: plan will be empty until Add. Empty `[]` shows `noDetails` (not the type-first overlay). New treatment seeds one blank detail. No delete in type/notes fields.
|
||||||
- **Patient search:** `PatientSearchCombobox` in the **page header** (workspace-wide). Opens today’s strip visit if any, else latest history. No visit/history → inline editor empty state pointing to **New treatment** in the rail (never a dialog, never auto-create). **New treatment** stays at the top of the left rail; selected-patient card sits under it. Picker: Walk-in first, then a matching full-width card for the current named patient (name + mobile/email, else hint), then search. Never auto-create from the appointment card.
|
- **Patient search:** `PatientSearchCombobox` in the **page header** (workspace-wide). Opens today’s strip visit if any, else latest history. No visit/history → inline editor empty state pointing to **New treatment** in the rail (never a dialog, never auto-create). **New treatment** stays at the top of the left rail; selected-patient card sits under it. Picker: Walk-in first, then a matching full-width card for the current named patient (name + mobile/email, else hint), then search. Never auto-create from the appointment card.
|
||||||
- **Lab dispatch attention:** `LabDispatchAttentionPanel` — unsent lab-dependent details; quick jump to dispatch.
|
- **Lab dispatch attention:** `LabDispatchAttentionPanel` — unsent lab-dependent details; quick jump to dispatch.
|
||||||
- **History API:** patient-scoped; non-owners filtered by provider on treatment or appointment; org owners see all.
|
- **History API:** patient-scoped; filtered by provider on treatment or appointment — owners are not exempt.
|
||||||
- **History filters (client-side):** `PastTreatmentsPanel` — “Not shipped to lab” + single date; helpers in `treatmentHistoryFilters.ts`.
|
- **History filters (client-side):** `PastTreatmentsPanel` — “Not shipped to lab” + single date; helpers in `treatmentHistoryFilters.ts`.
|
||||||
- **Lab shipment block:** lab-dependent detail with no teeth and no arch job (`UA`/`LA`) saves but cannot ship — inline amber banner (`labBlockedBannerClass`).
|
- **Lab shipment block:** lab-dependent detail with no teeth and no arch job (`UA`/`LA`) saves but cannot ship — inline amber banner (`labBlockedBannerClass`).
|
||||||
- **Edit gating:** `canEditTreatmentForDay` = permission + appointment + not past day + `live` mode. Sent detail locks that line; Add still OK same day. Upload rejects sent (`TREATMENT_DETAIL_SENT`).
|
- **Edit gating:** `canEditTreatmentForDay` = permission + appointment + not past day + `live` mode. Sent detail locks that line; Add still OK same day. Upload rejects sent (`TREATMENT_DETAIL_SENT`).
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ Thin route: `app/[locale]/(dashboard)/treatment/page.tsx` (supports `?appointmen
|
|||||||
|
|
||||||
3. **Treatment history** — `PastTreatmentsPanel.tsx` (past saved plans for patient; **client-side** filters in `treatmentHistoryFilters.ts`)
|
3. **Treatment history** — `PastTreatmentsPanel.tsx` (past saved plans for patient; **client-side** filters in `treatmentHistoryFilters.ts`)
|
||||||
|
|
||||||
4. **Editor** — detail chips + Add (`TreatmentDetailsEditor`); type-first form with chart + notes; lab send sheet under the form when the active type is prosthesis (`LabCasesDispatchPanel`)
|
4. **Editor** — detail chips + Add (`TreatmentDetailsEditor`); type-first form with chart **or** lab dispatch in the same slot (`LabCasesDispatchPanel` replaces the chart; not stacked underneath)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -36,17 +36,17 @@ Thin route: `app/[locale]/(dashboard)/treatment/page.tsx` (supports `?appointmen
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
Right-column entry is **not** a three-step wizard. Type dropdown + `TreatmentDetailAttachmentsStrip` on one row (same height as `Dropdown`; paperclip | divider | thumbs grouped image → pdf → other; upload progress in one square; click thumb → preview/remove dialog). Compact FDI chart (same scale as Cases), then full-width auto-growing **Notes** (`rows={1}`). Chart is dimmed until a type is chosen. Lab-dependent chips show colored sent/unsent **text** (not badge pills); sent date stays on the Lab dispatch panel.
|
Right-column entry is **not** a three-step wizard. Type dropdown + `TreatmentDetailAttachmentsStrip` on one row (same height as `Dropdown`; paperclip | divider | thumbs grouped image → pdf → other; upload progress in one square; click thumb → preview/remove dialog). When the active type is lab-dependent, a **Lab dispatch** / **Chart** button sits beside attachments. Compact FDI chart (same scale as Cases) **or** `LabCasesDispatchPanel` in that same slot, then full-width auto-growing **Notes** (`rows={1}`). Chart is dimmed until a type is chosen. Lab-dependent chips show colored sent/unsent **text** (not badge pills); sent date stays on the Lab dispatch panel.
|
||||||
|
|
||||||
| Stage | UI | When |
|
| Stage | UI | When |
|
||||||
|-------|-----|------|
|
|-------|-----|------|
|
||||||
| **Treatment** | Type dropdown + `TreatmentDetailAttachmentsStrip`, `FdiToothChart` / `ProsthesisAssignChart`, full-width Notes | Always |
|
| **Treatment** | Type dropdown + `TreatmentDetailAttachmentsStrip`, `FdiToothChart` / `ProsthesisAssignChart`, full-width Notes | Default |
|
||||||
| **Lab** | `LabCasesDispatchPanel` under the form | When active detail type is lab-dependent. Auto-ensures a shipment draft (teeth/arch not required to create the draft). **No default lab or prosthesis type** on a new detail (including siblings in the same plan). Last **3 sent** labs appear as chips under search — pick is explicit. Comments stay on the dispatch panel. |
|
| **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. |
|
||||||
|
|
||||||
- Prosthesis types are assigned on the chart (`ProsthesisAssignChart` + `ProsthesisJobPopover` in `prosthesisTree.ts`). One **restoration** slot per tooth (crown / veneer-inlay-onlay-overlay); **screw-retained** is an implant add-on (`stackGroup: implant`, paints the crown) and stacks with a restoration. Implant abutments and post & core also stack (post & core stays off when the implant is screw-retained). 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. Lab dispatch is on the same screen (no Treatment → Lab stepper).
|
- 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.
|
||||||
- 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 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.
|
- 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 **scrolls** to the dispatch panel (`pendingScrollToLabRef` + `labPanelRef`).
|
- Lab shipments rail / “Go to dispatch” / load-with-focus **opens the dispatch view** in the chart slot (`pendingScrollToLabRef` + `labPanelRef`).
|
||||||
- Live draft is **not** duplicated in the left rail preview; preview is for history browse only.
|
- Live draft is **not** duplicated in the left rail preview; preview is for history browse only.
|
||||||
|
|
||||||
|
|
||||||
@@ -59,10 +59,10 @@ Helpers: `frontend/src/components/treatment/toothSelectionGroups.ts`. Persisted
|
|||||||
|
|
||||||
Chart hit-testing (`FdiToothChart.tsx`): the hit target is the **unrotated full cell** (`pointerdown` + keyboard). Do **not** bind both `pointerdown` and `click` (double-toggle looks like a miss). Visual glyph is `pointer-events-none`; tilt lives on the outer wrapper; **scale/hover is nested inside** so inline `transform` does not kill scale. Horizontal inset (~22%) leaves a dead zone between teeth; FDI numbers are also clickable (`ToothNumber`).
|
Chart hit-testing (`FdiToothChart.tsx`): the hit target is the **unrotated full cell** (`pointerdown` + keyboard). Do **not** bind both `pointerdown` and `click` (double-toggle looks like a miss). Visual glyph is `pointer-events-none`; tilt lives on the outer wrapper; **scale/hover is nested inside** so inline `transform` does not kill scale. Horizontal inset (~22%) leaves a dead zone between teeth; FDI numbers are also clickable (`ToothNumber`).
|
||||||
|
|
||||||
- **Plain click:** add/remove single; deselecting a tooth in a bridge removes it and splits/shrinks remaining sides (never a 1-tooth connected group).
|
- **Plain click (non-prosthesis chart):** add/remove single; deselecting a tooth in a bridge removes it and splits/shrinks remaining sides (never a 1-tooth connected group).
|
||||||
- **Neighbor circles:** when two arch-adjacent teeth are both selected, an empty circle appears **between** them (not per-tooth). Click empty → connect; click filled → disconnect (teeth stay selected). Transitive A–B + B–C = one bridge.
|
- **Neighbor circles:** when two arch-adjacent teeth are both selected, an empty circle appears **between** them (not per-tooth). Click empty → connect; click filled → disconnect (teeth stay selected). Transitive A–B + B–C = one bridge.
|
||||||
- **Shift+click:** inclusive same-arch range → all selected as **singles** (empty circles); overlapping existing bridges are absorbed as singles too. Midline neighbors (11–21, 41–31) allowed.
|
- **Shift+click (non-prosthesis chart):** inclusive same-arch range → all selected as **singles** (empty circles); overlapping existing bridges are absorbed as singles too. Midline neighbors (11–21, 41–31) allowed.
|
||||||
- **Prosthesis assign chart:** Shift = range then assign to the span; Ctrl/Cmd or drag copies the last type-set. Click a selected tooth **opens the picker**; click drops a tooth only from an in-progress Shift range that still has no jobs. Arch products open from the Upper/Lower arch labels and store `UA`/`LA` (not 16 FDI teeth). Partial denture is assigned from selected teeth (not the arch labels). `pruneToothProsthesisForGroups` must keep those sentinels.
|
- **Prosthesis assign chart (Exocad-style):** plain click **opens the picker** and does not select the tooth until a type is picked. **Done** stores that tooth’s job set as the copy brush. **Ctrl/Cmd+click** or drag copies the brush onto one tooth. **Shift+click** copies the brush onto the inclusive same-arch range as **singles** (no auto-connect; neighbor circles still make bridges). Arch products open from the Upper/Lower arch labels and store `UA`/`LA` (not 16 FDI teeth). Partial denture is assigned from selected teeth (not the arch labels). `pruneToothProsthesisForGroups` must keep those sentinels. Hydrate/save prune FDI teeth that have no job (`pruneDetailTeethToJobs`).
|
||||||
- Prevent browser selection artifacts (`select-none`, Shift `preventDefault` on mousedown).
|
- Prevent browser selection artifacts (`select-none`, Shift `preventDefault` on mousedown).
|
||||||
- On group change, prune/remap `labCase.toothProsthesis` via `pruneToothProsthesisForGroups`.
|
- On group change, prune/remap `labCase.toothProsthesis` via `pruneToothProsthesisForGroups`.
|
||||||
- Connected UI label: `ConnectedSelectionBadge` (shared `Badge` + primary tint) in dispatch + lab case lists.
|
- Connected UI label: `ConnectedSelectionBadge` (shared `Badge` + primary tint) in dispatch + lab case lists.
|
||||||
@@ -115,15 +115,15 @@ Helpers: `frontend/src/components/treatment/treatmentHistoryFilters.ts`.
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Lab shipment without teeth
|
## Lab shipment without teeth or arch jobs
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Saved lab-dependent detail with **no teeth** can autosave but **cannot** create a lab shipment.
|
Saved lab-dependent detail with **no FDI teeth and no arch jobs** (`UA`/`LA`) can autosave but **cannot** create a lab shipment.
|
||||||
|
|
||||||
- Inline amber banner in `TreatmentDetailsEditor` on **both** Treatment and Lab steps when the active detail qualifies (`isLabDependentDetailMissingTeeth`). Do not use a separate notice card.
|
- Inline amber banner in `TreatmentDetailsEditor` on **both** Treatment and Lab steps when the active detail qualifies (`isLabDependentDetailMissingTeeth`). Do not use a separate notice card.
|
||||||
- `handleAddLabCase` shows toast with `labShipmentBlockedBody`.
|
- `handleAddLabCase` shows toast with `labShipmentBlockedBody`.
|
||||||
- Dispatch panel only appears when a detail passes `isDetailReadyForLabDispatch` (persisted + lab-dependent + teeth).
|
- Dispatch panel only appears when a detail passes `isDetailReadyForLabDispatch` (persisted + lab-dependent + teeth **or** arch jobs).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ frontend/src/
|
|||||||
- Day/mode gate: editable only for live draft on today/future (`canEditTreatmentForDay`). Past day / historical load → read-only form. Patient search sits in the **page header** (workspace-wide). If the patient has a visit on the day strip, that visit opens; else the latest history plan loads. If neither exists, the editor shows an inline empty state (no dialog) pointing to **New treatment** in the rail — do not auto-create. **New treatment** is at the top of the left rail (selected-patient card below it) and opens a picker: Walk-in first, then a matching card for the current named patient (name + mobile/email), then search. It does not auto-copy the appointment card’s patient. New treatment seeds one blank detail; a persisted empty plan loads as `[]` until Add.
|
- Day/mode gate: editable only for live draft on today/future (`canEditTreatmentForDay`). Past day / historical load → read-only form. Patient search sits in the **page header** (workspace-wide). If the patient has a visit on the day strip, that visit opens; else the latest history plan loads. If neither exists, the editor shows an inline empty state (no dialog) pointing to **New treatment** in the rail — do not auto-create. **New treatment** is at the top of the left rail (selected-patient card below it) and opens a picker: Walk-in first, then a matching card for the current named patient (name + mobile/email), then search. It does not auto-copy the appointment card’s patient. New treatment seeds one blank detail; a persisted empty plan loads as `[]` until Add.
|
||||||
- **Unscheduled strip cards** use the same treatment-type banner as appointments from the **first detail’s type only** (`unscheduledStripColorCode`; live draft for the open card). Empty first line → chip theming even if later lines are typed. Strip trash only when every line is blank (no type/teeth/notes/attachments, including `[]`); typed cards need chip-delete first. Backend `DELETE /treatments/:id` is empty-only (`TREATMENT_HAS_DETAILS`).
|
- **Unscheduled strip cards** use the same treatment-type banner as appointments from the **first detail’s type only** (`unscheduledStripColorCode`; live draft for the open card). Empty first line → chip theming even if later lines are typed. Strip trash only when every line is blank (no type/teeth/notes/attachments, including `[]`); typed cards need chip-delete first. Backend `DELETE /treatments/:id` is empty-only (`TREATMENT_HAS_DETAILS`).
|
||||||
- Sent-to-lab detail locks that line; **Add detail** still OK same day; **Remove detail** = trash on chip (unsent, including last line; day/edit gates apply). Empty details persist as `[]`. Attachment upload blocked when sent (`TREATMENT_DETAIL_SENT`).
|
- Sent-to-lab detail locks that line; **Add detail** still OK same day; **Remove detail** = trash on chip (unsent, including last line; day/edit gates apply). Empty details persist as `[]`. Attachment upload blocked when sent (`TREATMENT_DETAIL_SENT`).
|
||||||
- **Entry:** Type dropdown + compact attachments strip (`TreatmentDetailAttachmentsStrip`) on one row, then bordered FDI chart (Cases chrome/scale), then full-width auto-growing **Notes**. Prosthesis types are assigned on the chart (teeth, or Upper/Lower arch labels for jaw products). Lab dispatch sits under chart+notes when the active type is lab-dependent — no Treatment→Lab stepper. Detail chips show type + teeth; lab-dependent chips use colored sent/unsent text (sent date on dispatch). Lab-rail / Go to dispatch **scrolls** to the panel (`pendingScrollToLabRef`). Lab dispatch keeps comments.
|
- **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.
|
||||||
- **Tooth selection:** Hit target is the unrotated cell (`pointerdown` only — do not also bind `click`). Glyph is `pointer-events-none`; nest hover/selected scale inside the rotate wrapper. Neighbor empty/filled circles between selected adjacent teeth connect/disconnect bridges; Shift+range selects only (empty circles; overlap absorbs as singles); midline 11–21 / 41–31 allowed. Plain click selects/deselects (deselect splits bridges). Never a 1-tooth connected. Prosthesis assign chart: Shift = range then assign; Ctrl/Cmd or drag = copy last type-set. Helpers: `toothSelectionGroups.ts`. Connected label: `ConnectedSelectionBadge`. 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.
|
- **Tooth selection:** Hit target is the unrotated cell (`pointerdown` only — do not also bind `click`). Glyph is `pointer-events-none`; nest hover/selected scale inside the rotate wrapper. Neighbor empty/filled circles between selected adjacent teeth connect/disconnect bridges; midline 11–21 / 41–31 allowed. Never a 1-tooth connected. On the non-prosthesis chart, Shift+range selects as singles. **Prosthesis assign chart (Exocad-style):** plain click opens the picker (tooth is not selected until a type is picked); **Done** stores that tooth’s jobs as the copy brush; **Ctrl/Cmd+click** or drag copies those jobs onto one tooth; **Shift+click** copies onto the inclusive same-arch range as **singles** (connect with neighbor circles if it is a bridge). Helpers: `toothSelectionGroups.ts`. Connected label: `ConnectedSelectionBadge`. Unconnected teeth stay **one item per tooth** (stacked types on that tooth union their lab steps). **Connected** teeth that share a prosthesis type are **one job** (teeth listed together); an extra type on one unit of the bridge is its own row. `UA`/`LA` → Upper/Lower arch. **Partial denture** is tooth-selected and becomes **one lab job** for all those teeth.
|
||||||
- **Lab dispatch layout:** due date beside title (`justify-between`, logical start/end for RTL); job rows from `LabCaseToothJobsList` (connected same-type teeth share a row; stacked types on a single tooth stay on that tooth).
|
- **Lab dispatch layout:** due date beside title (`justify-between`, logical start/end for RTL); job rows from `LabCaseToothJobsList` (connected same-type teeth share a row; stacked types on a single tooth stay on that tooth).
|
||||||
- **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).
|
- **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).
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ frontend/src/
|
|||||||
- New prosthesis dispatch lines start **empty** (no default lab). Types are assigned only on the chart (`ProsthesisAssignChart` / `ProsthesisJobPopover`). Recent-lab chips are the last **3 sent** destinations — pick is explicit, never auto-selected.
|
- New prosthesis dispatch lines start **empty** (no default lab). Types are assigned only on the chart (`ProsthesisAssignChart` / `ProsthesisJobPopover`). Recent-lab chips are the last **3 sent** destinations — pick is explicit, never auto-selected.
|
||||||
- Detail treatment type need **not** match appointment purpose — purpose only seeds the **first** line of an empty **appointment** draft (first open, and **Add detail** when the plan is `[]`). Further **Add detail** starts with an empty type. Unscheduled / New treatment still seeds a blank first line.
|
- Detail treatment type need **not** match appointment purpose — purpose only seeds the **first** line of an empty **appointment** draft (first open, and **Add detail** when the plan is `[]`). Further **Add detail** starts with an empty type. Unscheduled / New treatment still seeds a blank first line.
|
||||||
- **History filters** are client-side only (`treatmentHistoryFilters.ts`): “Not shipped to lab” + single date on already-fetched patient history; includes live current draft when filtering.
|
- **History filters** are client-side only (`treatmentHistoryFilters.ts`): “Not shipped to lab” + single date on already-fetched patient history; includes live current draft when filtering.
|
||||||
- **Lab shipments rail**: unified list with scope toggle **This patient** vs **All updates** (unread across org for **this clinician's cases only**, includes patient name). Opening a case from the rail **scrolls** to the lab dispatch panel.
|
- **Lab shipments rail**: unified list with scope toggle **This patient** vs **All updates** (unread across org for **this clinician's cases only**, includes patient name). Opening a case from the rail **opens the lab dispatch view** (same slot as the chart).
|
||||||
- **Unread semantics**: Treatment tab badge = count of unread cases **for the user's own treatment plans** (per-case read cursor) and clears when a case is opened/marked read (not on tab visit).
|
- **Unread semantics**: Treatment tab badge = count of unread cases **for the user's own treatment plans** (per-case read cursor) and clears when a case is opened/marked read (not on tab visit).
|
||||||
- **Live lab rail**: `notification.created` → `notifyTabBadgesChanged()` silently refreshes patient lab cases + unread rail (does **not** clear draft/form state).
|
- **Live lab rail**: `notification.created` → `notifyTabBadgesChanged()` silently refreshes patient lab cases + unread rail (does **not** clear draft/form state).
|
||||||
- **Lab shipment progress + comments**: shown in **Lab dispatch panel** for the active shipment; expanding activity / opening comments marks that case read. Shared UI: `LabCaseCommentsPanel` — newest first; sent = start / received = end (`text-start`/`justify-start`, RTL-safe); pass `viewerSide`.
|
- **Lab shipment progress + comments**: shown in **Lab dispatch panel** for the active shipment; expanding activity / opening comments marks that case read. Shared UI: `LabCaseCommentsPanel` — newest first; sent = start / received = end (`text-start`/`justify-start`, RTL-safe); pass `viewerSide`.
|
||||||
|
|||||||
@@ -72,7 +72,6 @@ export const LAB_WORKFLOW_STEPS = [
|
|||||||
|
|
||||||
export type ProsthesisChartRegion = 'crown' | 'root' | 'arch';
|
export type ProsthesisChartRegion = 'crown' | 'root' | 'arch';
|
||||||
export type ProsthesisStackGroup = 'restoration' | 'implant' | 'post_core' | 'arch';
|
export type ProsthesisStackGroup = 'restoration' | 'implant' | 'post_core' | 'arch';
|
||||||
export type ProsthesisAddonKind = '' | 'implant' | 'post_core';
|
|
||||||
|
|
||||||
export type ProsthesisTypeSeed = {
|
export type ProsthesisTypeSeed = {
|
||||||
code: string;
|
code: string;
|
||||||
@@ -83,7 +82,6 @@ export type ProsthesisTypeSeed = {
|
|||||||
subcategory?: string;
|
subcategory?: string;
|
||||||
chartRegion: ProsthesisChartRegion;
|
chartRegion: ProsthesisChartRegion;
|
||||||
stackGroup: ProsthesisStackGroup;
|
stackGroup: ProsthesisStackGroup;
|
||||||
addonKind?: ProsthesisAddonKind;
|
|
||||||
/** Manufacturing sequence. Packing and shipping are appended except for smile_design. */
|
/** Manufacturing sequence. Packing and shipping are appended except for smile_design. */
|
||||||
manufacturingSteps: readonly string[];
|
manufacturingSteps: readonly string[];
|
||||||
};
|
};
|
||||||
@@ -138,7 +136,7 @@ function crown(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function implantAddon(
|
function implant(
|
||||||
code: string,
|
code: string,
|
||||||
sortOrder: number,
|
sortOrder: number,
|
||||||
manufacturingSteps: readonly string[],
|
manufacturingSteps: readonly string[],
|
||||||
@@ -150,7 +148,6 @@ function implantAddon(
|
|||||||
category: 'implant',
|
category: 'implant',
|
||||||
chartRegion: 'root',
|
chartRegion: 'root',
|
||||||
stackGroup: 'implant',
|
stackGroup: 'implant',
|
||||||
addonKind: 'implant',
|
|
||||||
manufacturingSteps,
|
manufacturingSteps,
|
||||||
...extra,
|
...extra,
|
||||||
};
|
};
|
||||||
@@ -230,7 +227,6 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [
|
|||||||
category: 'post_core',
|
category: 'post_core',
|
||||||
chartRegion: 'root',
|
chartRegion: 'root',
|
||||||
stackGroup: 'post_core',
|
stackGroup: 'post_core',
|
||||||
addonKind: 'post_core',
|
|
||||||
manufacturingSteps: POST_CORE,
|
manufacturingSteps: POST_CORE,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -239,29 +235,28 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [
|
|||||||
category: 'post_core',
|
category: 'post_core',
|
||||||
chartRegion: 'root',
|
chartRegion: 'root',
|
||||||
stackGroup: 'post_core',
|
stackGroup: 'post_core',
|
||||||
addonKind: 'post_core',
|
|
||||||
manufacturingSteps: POST_CORE,
|
manufacturingSteps: POST_CORE,
|
||||||
},
|
},
|
||||||
implantAddon('prefabricated_abutment', 40, [
|
implant('prefabricated_abutment', 40, [
|
||||||
'intraoral_scan',
|
'intraoral_scan',
|
||||||
'choosing_abutment',
|
'choosing_abutment',
|
||||||
'model_create',
|
'model_create',
|
||||||
'polish_prep',
|
'polish_prep',
|
||||||
]),
|
]),
|
||||||
implantAddon('ti_base_abutment', 41, ['intraoral_scan', 'model_create', 'polish_prep']),
|
implant('ti_base_abutment', 41, ['intraoral_scan', 'model_create', 'polish_prep']),
|
||||||
implantAddon('multi_unit_abutment', 42, [
|
implant('multi_unit_abutment', 42, [
|
||||||
'intraoral_scan',
|
'intraoral_scan',
|
||||||
'choosing_abutment',
|
'choosing_abutment',
|
||||||
'model_create',
|
'model_create',
|
||||||
'polish_prep',
|
'polish_prep',
|
||||||
]),
|
]),
|
||||||
implantAddon('customized_abutment', 43, [
|
implant('customized_abutment', 43, [
|
||||||
...SCAN_DESIGN_MODEL,
|
...SCAN_DESIGN_MODEL,
|
||||||
'milling_wet',
|
'milling_wet',
|
||||||
'polish_prep',
|
'polish_prep',
|
||||||
]),
|
]),
|
||||||
implantAddon('zirconia_abutment', 44, [...SCAN_DESIGN_MODEL, 'milling_dry', 'sinter']),
|
implant('zirconia_abutment', 44, [...SCAN_DESIGN_MODEL, 'milling_dry', 'sinter']),
|
||||||
implantAddon(
|
implant(
|
||||||
'screw_retained',
|
'screw_retained',
|
||||||
45,
|
45,
|
||||||
[...SCAN_DESIGN_MODEL, 'milling_wet', 'polish_prep'],
|
[...SCAN_DESIGN_MODEL, 'milling_wet', 'polish_prep'],
|
||||||
|
|||||||
@@ -41,25 +41,31 @@ const STACK_RULES = [
|
|||||||
'restoration',
|
'restoration',
|
||||||
'One per tooth: crown, veneer, inlay, onlay, or overlay.',
|
'One per tooth: crown, veneer, inlay, onlay, or overlay.',
|
||||||
],
|
],
|
||||||
['implant', 'One abutment add-on per tooth (Ti-base, custom, prefab, multi-unit, zirconia, screw-retained).'],
|
['implant', 'One implant type per tooth. Disabled when post & core is on the tooth.'],
|
||||||
['post_core', 'One post & core, only if a restoration is already present. Blocked when screw-retained is on the tooth.'],
|
[
|
||||||
|
'post_core',
|
||||||
|
'One post & core. Disabled when a restoration or implant is already on the tooth.',
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'arch',
|
'arch',
|
||||||
'Complete denture, overdenture, appliances, and digital use Upper/Lower arch labels (UA/LA). Not 16 FDI teeth.',
|
'Complete denture, overdenture, appliances, and digital use Upper/Lower/Both (UA/LA). The picker control remaps jobs (Both→Upper drops lower).',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'partial_denture',
|
'partial_denture',
|
||||||
'Tooth-selected (FDI), not the arch labels. All selected teeth become one lab job. May stack with implant add-ons.',
|
'Tooth-selected (FDI), not the arch labels. All selected teeth become one lab job.',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'screw_retained',
|
'screw_retained',
|
||||||
'Fills the implant slot (chart paints the crown) and stacks with a restoration. Blocks post & core.',
|
'Implant that paints the crown. No crown suggestion slot; Crown and Indirect are disabled. Does not stack with another restoration.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'crown_suggestion',
|
||||||
|
'Implant or post & core (not screw-retained) shows a crown suggestion slot. A veneer/inlay/onlay/overlay hides it. After a crown is picked the slot stays filled so it can be changed.',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'connected',
|
'connected',
|
||||||
'Connected teeth that share a type are one lab job. An extra type on one unit of the bridge is its own job. Unconnected teeth stay separate even if they share a type.',
|
'Connected teeth that share a type are one lab job. An extra type on one unit of the bridge is its own job. Unconnected teeth stay separate even if they share a type.',
|
||||||
],
|
],
|
||||||
['maximum', 'Usual maximum is 3 types per tooth (e.g. zirconia crown + Ti-base + fiber post).'],
|
|
||||||
['two_crowns', 'Two crowns cannot stack.'],
|
['two_crowns', 'Two crowns cannot stack.'],
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -198,7 +204,6 @@ function buildWorkbook(): Buffer {
|
|||||||
'label_nl',
|
'label_nl',
|
||||||
'chartRegion',
|
'chartRegion',
|
||||||
'stackGroup',
|
'stackGroup',
|
||||||
'addonKind',
|
|
||||||
'active',
|
'active',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
@@ -215,7 +220,6 @@ function buildWorkbook(): Buffer {
|
|||||||
labels.nl ?? '',
|
labels.nl ?? '',
|
||||||
type.chartRegion,
|
type.chartRegion,
|
||||||
type.stackGroup,
|
type.stackGroup,
|
||||||
type.addonKind ?? '',
|
|
||||||
type.isActive === false ? 'false' : 'true',
|
type.isActive === false ? 'false' : 'true',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Crown suggestion slot replaced catalog addonKind (implant / post_core add-on lists).
|
||||||
|
|
||||||
|
ALTER TABLE "prosthesis_types" DROP COLUMN IF EXISTS "addonKind";
|
||||||
@@ -359,8 +359,6 @@ model ProsthesisType {
|
|||||||
chartRegion String @default("crown")
|
chartRegion String @default("crown")
|
||||||
/// Mutual exclusion group: restoration | implant | post_core | arch
|
/// Mutual exclusion group: restoration | implant | post_core | arch
|
||||||
stackGroup String @default("restoration")
|
stackGroup String @default("restoration")
|
||||||
/// Also listed as an add-on: implant | post_core | empty
|
|
||||||
addonKind String @default("")
|
|
||||||
|
|
||||||
steps ProsthesisTypeStep[]
|
steps ProsthesisTypeStep[]
|
||||||
|
|
||||||
|
|||||||
@@ -191,7 +191,6 @@ async function main() {
|
|||||||
subcategory: type.subcategory ?? '',
|
subcategory: type.subcategory ?? '',
|
||||||
chartRegion: type.chartRegion,
|
chartRegion: type.chartRegion,
|
||||||
stackGroup: type.stackGroup,
|
stackGroup: type.stackGroup,
|
||||||
addonKind: type.addonKind ?? '',
|
|
||||||
};
|
};
|
||||||
const prosthesisType = await prisma.prosthesisType.upsert({
|
const prosthesisType = await prisma.prosthesisType.upsert({
|
||||||
where: { code: type.code },
|
where: { code: type.code },
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ export type ProsthesisTypeCatalogEntry = {
|
|||||||
subcategory: string;
|
subcategory: string;
|
||||||
chartRegion: string;
|
chartRegion: string;
|
||||||
stackGroup: string;
|
stackGroup: string;
|
||||||
addonKind: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type CatalogRow = {
|
type CatalogRow = {
|
||||||
@@ -25,7 +24,6 @@ type CatalogRow = {
|
|||||||
subcategory: string;
|
subcategory: string;
|
||||||
chartRegion: string;
|
chartRegion: string;
|
||||||
stackGroup: string;
|
stackGroup: string;
|
||||||
addonKind: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -53,7 +51,6 @@ export class ProsthesisCatalogService implements OnModuleInit {
|
|||||||
subcategory: true,
|
subcategory: true,
|
||||||
chartRegion: true,
|
chartRegion: true,
|
||||||
stackGroup: true,
|
stackGroup: true,
|
||||||
addonKind: true,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,7 +63,6 @@ export class ProsthesisCatalogService implements OnModuleInit {
|
|||||||
subcategory: row.subcategory,
|
subcategory: row.subcategory,
|
||||||
chartRegion: row.chartRegion,
|
chartRegion: row.chartRegion,
|
||||||
stackGroup: row.stackGroup,
|
stackGroup: row.stackGroup,
|
||||||
addonKind: row.addonKind,
|
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
@@ -94,7 +90,6 @@ export class ProsthesisCatalogService implements OnModuleInit {
|
|||||||
subcategory: row.subcategory,
|
subcategory: row.subcategory,
|
||||||
chartRegion: row.chartRegion,
|
chartRegion: row.chartRegion,
|
||||||
stackGroup: row.stackGroup,
|
stackGroup: row.stackGroup,
|
||||||
addonKind: row.addonKind,
|
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code));
|
.sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code));
|
||||||
|
|||||||
Binary file not shown.
@@ -757,6 +757,8 @@
|
|||||||
"noOrgInvitePermission": "You do not have permission to invite labs. Contact your organization owner.",
|
"noOrgInvitePermission": "You do not have permission to invite labs. Contact your organization owner.",
|
||||||
"sendThisCase": "Send this case",
|
"sendThisCase": "Send this case",
|
||||||
"labDispatchTitle": "Lab dispatch",
|
"labDispatchTitle": "Lab dispatch",
|
||||||
|
"showLabDispatch": "Lab dispatch",
|
||||||
|
"showChart": "Chart",
|
||||||
"labDispatchSubtitle": "Choose a lab and send.",
|
"labDispatchSubtitle": "Choose a lab and send.",
|
||||||
"labDispatchSendHint": "Shipment grouping is saved when you send to the lab.",
|
"labDispatchSendHint": "Shipment grouping is saved when you send to the lab.",
|
||||||
"addLabShipment": "Add lab shipment",
|
"addLabShipment": "Add lab shipment",
|
||||||
@@ -898,6 +900,7 @@
|
|||||||
"selectedArchLower": "Lower arch",
|
"selectedArchLower": "Lower arch",
|
||||||
"upperArch": "Upper arch",
|
"upperArch": "Upper arch",
|
||||||
"lowerArch": "Lower arch",
|
"lowerArch": "Lower arch",
|
||||||
|
"archAssignHint": "Click to assign a jaw-level product",
|
||||||
"toothAria": "FDI tooth {fdi}",
|
"toothAria": "FDI tooth {fdi}",
|
||||||
"toothSelectedSuffix": ", selected",
|
"toothSelectedSuffix": ", selected",
|
||||||
"sentToAt": "Sent to {orgName} at {datetime}",
|
"sentToAt": "Sent to {orgName} at {datetime}",
|
||||||
@@ -1120,6 +1123,7 @@
|
|||||||
"sub_partial_denture": "Partial denture",
|
"sub_partial_denture": "Partial denture",
|
||||||
"sub_night_guard": "Night guard",
|
"sub_night_guard": "Night guard",
|
||||||
"addonNone": "None",
|
"addonNone": "None",
|
||||||
|
"addonCrownCanBeAdded": "Crown can be added",
|
||||||
"clear": "Clear this tooth",
|
"clear": "Clear this tooth",
|
||||||
"done": "Done",
|
"done": "Done",
|
||||||
"pickerTeeth": "Teeth {teeth}",
|
"pickerTeeth": "Teeth {teeth}",
|
||||||
@@ -1133,8 +1137,10 @@
|
|||||||
"removeJob": "Remove this type",
|
"removeJob": "Remove this type",
|
||||||
"regionCrown": "Crown",
|
"regionCrown": "Crown",
|
||||||
"pickerTooth": "Tooth {tooth}",
|
"pickerTooth": "Tooth {tooth}",
|
||||||
"slotImplant": "Implant add-on",
|
"slotCrown": "Crown",
|
||||||
"slotPostCore": "Post & core"
|
"suggestionsLabel": "Suggested add-ons",
|
||||||
|
"pickerBack": "Back",
|
||||||
|
"categoryDisabledHint": "This category cannot be combined with the types already on this tooth."
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"GENERIC": "Something went wrong. Please try again.",
|
"GENERIC": "Something went wrong. Please try again.",
|
||||||
|
|||||||
@@ -758,6 +758,8 @@
|
|||||||
"noOrgInvitePermission": "شما مجوز دعوت از آزمایشگاه را ندارید. با مالک سازمان تماس بگیرید.",
|
"noOrgInvitePermission": "شما مجوز دعوت از آزمایشگاه را ندارید. با مالک سازمان تماس بگیرید.",
|
||||||
"sendThisCase": "ارسال این پرونده",
|
"sendThisCase": "ارسال این پرونده",
|
||||||
"labDispatchTitle": "ارسال به لابراتوار",
|
"labDispatchTitle": "ارسال به لابراتوار",
|
||||||
|
"showLabDispatch": "ارسال به لابراتوار",
|
||||||
|
"showChart": "نمودار",
|
||||||
"labDispatchSubtitle": "لابراتوار را انتخاب کنید و ارسال کنید.",
|
"labDispatchSubtitle": "لابراتوار را انتخاب کنید و ارسال کنید.",
|
||||||
"labDispatchSendHint": "گروهبندی محموله هنگام ارسال به لاب ذخیره میشود.",
|
"labDispatchSendHint": "گروهبندی محموله هنگام ارسال به لاب ذخیره میشود.",
|
||||||
"addLabShipment": "افزودن محموله لاب",
|
"addLabShipment": "افزودن محموله لاب",
|
||||||
@@ -899,6 +901,7 @@
|
|||||||
"selectedArchLower": "قوس پایین",
|
"selectedArchLower": "قوس پایین",
|
||||||
"upperArch": "قوس بالا",
|
"upperArch": "قوس بالا",
|
||||||
"lowerArch": "قوس پایین",
|
"lowerArch": "قوس پایین",
|
||||||
|
"archAssignHint": "برای اختصاص محصول فکی کلیک کنید",
|
||||||
"toothAria": "دندان FDI {fdi}",
|
"toothAria": "دندان FDI {fdi}",
|
||||||
"toothSelectedSuffix": "، انتخاب شده",
|
"toothSelectedSuffix": "، انتخاب شده",
|
||||||
"sentToAt": "ارسال به {orgName} در {datetime}",
|
"sentToAt": "ارسال به {orgName} در {datetime}",
|
||||||
@@ -1121,6 +1124,7 @@
|
|||||||
"sub_partial_denture": "پارسیل",
|
"sub_partial_denture": "پارسیل",
|
||||||
"sub_night_guard": "نایت گارد",
|
"sub_night_guard": "نایت گارد",
|
||||||
"addonNone": "هیچکدام",
|
"addonNone": "هیچکدام",
|
||||||
|
"addonCrownCanBeAdded": "روکش میتواند افزوده شود",
|
||||||
"clear": "پاک کردن این دندان",
|
"clear": "پاک کردن این دندان",
|
||||||
"done": "تمام",
|
"done": "تمام",
|
||||||
"pickerTeeth": "دندانهای {teeth}",
|
"pickerTeeth": "دندانهای {teeth}",
|
||||||
@@ -1134,8 +1138,10 @@
|
|||||||
"removeJob": "حذف این نوع",
|
"removeJob": "حذف این نوع",
|
||||||
"regionCrown": "تاج",
|
"regionCrown": "تاج",
|
||||||
"pickerTooth": "دندان {tooth}",
|
"pickerTooth": "دندان {tooth}",
|
||||||
"slotImplant": "افزونه ایمپلنت",
|
"slotCrown": "روکش",
|
||||||
"slotPostCore": "پست و کور"
|
"suggestionsLabel": "افزودنیهای پیشنهادی",
|
||||||
|
"pickerBack": "بازگشت",
|
||||||
|
"categoryDisabledHint": "این دسته با انواعی که روی این دندان هستند قابل ترکیب نیست."
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"GENERIC": "مشکلی پیش آمد. لطفاً دوباره تلاش کنید.",
|
"GENERIC": "مشکلی پیش آمد. لطفاً دوباره تلاش کنید.",
|
||||||
|
|||||||
@@ -757,6 +757,8 @@
|
|||||||
"noOrgInvitePermission": "U heeft geen toestemming om labs uit te nodigen. Neem contact op met de organisatie-eigenaar.",
|
"noOrgInvitePermission": "U heeft geen toestemming om labs uit te nodigen. Neem contact op met de organisatie-eigenaar.",
|
||||||
"sendThisCase": "Verzend deze case",
|
"sendThisCase": "Verzend deze case",
|
||||||
"labDispatchTitle": "Lab-dispatch",
|
"labDispatchTitle": "Lab-dispatch",
|
||||||
|
"showLabDispatch": "Lab-dispatch",
|
||||||
|
"showChart": "Gebitskaart",
|
||||||
"labDispatchSubtitle": "Kies een lab en verstuur.",
|
"labDispatchSubtitle": "Kies een lab en verstuur.",
|
||||||
"labDispatchSendHint": "Groepering wordt opgeslagen wanneer u naar het lab verzendt.",
|
"labDispatchSendHint": "Groepering wordt opgeslagen wanneer u naar het lab verzendt.",
|
||||||
"addLabShipment": "Labzending toevoegen",
|
"addLabShipment": "Labzending toevoegen",
|
||||||
@@ -898,6 +900,7 @@
|
|||||||
"selectedArchLower": "Onderboog",
|
"selectedArchLower": "Onderboog",
|
||||||
"upperArch": "Bovenboog",
|
"upperArch": "Bovenboog",
|
||||||
"lowerArch": "Onderboog",
|
"lowerArch": "Onderboog",
|
||||||
|
"archAssignHint": "Klik om een boogproduct toe te wijzen",
|
||||||
"toothAria": "FDI-tand {fdi}",
|
"toothAria": "FDI-tand {fdi}",
|
||||||
"toothSelectedSuffix": ", geselecteerd",
|
"toothSelectedSuffix": ", geselecteerd",
|
||||||
"sentToAt": "Verzonden naar {orgName} op {datetime}",
|
"sentToAt": "Verzonden naar {orgName} op {datetime}",
|
||||||
@@ -1120,6 +1123,7 @@
|
|||||||
"sub_partial_denture": "Partiële prothese",
|
"sub_partial_denture": "Partiële prothese",
|
||||||
"sub_night_guard": "Nachtbeugel",
|
"sub_night_guard": "Nachtbeugel",
|
||||||
"addonNone": "Geen",
|
"addonNone": "Geen",
|
||||||
|
"addonCrownCanBeAdded": "Kroon kan worden toegevoegd",
|
||||||
"clear": "Deze tand wissen",
|
"clear": "Deze tand wissen",
|
||||||
"done": "Klaar",
|
"done": "Klaar",
|
||||||
"pickerTeeth": "Tanden {teeth}",
|
"pickerTeeth": "Tanden {teeth}",
|
||||||
@@ -1133,8 +1137,10 @@
|
|||||||
"removeJob": "Dit type verwijderen",
|
"removeJob": "Dit type verwijderen",
|
||||||
"regionCrown": "Kroon",
|
"regionCrown": "Kroon",
|
||||||
"pickerTooth": "Tand {tooth}",
|
"pickerTooth": "Tand {tooth}",
|
||||||
"slotImplant": "Implantaat-add-on",
|
"slotCrown": "Kroon",
|
||||||
"slotPostCore": "Stiftopbouw"
|
"suggestionsLabel": "Voorgestelde toevoegingen",
|
||||||
|
"pickerBack": "Terug",
|
||||||
|
"categoryDisabledHint": "Deze categorie kan niet worden gecombineerd met de types die al op deze tand staan."
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"GENERIC": "Er is iets misgegaan. Probeer het opnieuw.",
|
"GENERIC": "Er is iets misgegaan. Probeer het opnieuw.",
|
||||||
|
|||||||
@@ -20,61 +20,124 @@ export const TREATMENT_TYPE_COLORS: Record<string, string> = {
|
|||||||
continue_treatment: '#99f6e4',
|
continue_treatment: '#99f6e4',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Dedicated prosthesis palette — one distinct pastel per catalog code. */
|
/** Category tiles — heaviest (darkest) pastel of each family; leaves lighten from here. */
|
||||||
export const PROSTHESIS_TYPE_COLORS: Record<string, string> = {
|
export const PROSTHESIS_CATEGORY_COLORS: Record<string, string> = {
|
||||||
pfm_crown: '#e2e8f0',
|
crown: '#e6c84f',
|
||||||
pfz_crown: '#bbf7d0',
|
indirect: '#f59e4c',
|
||||||
monolithic_zirconia: '#bae6fd',
|
implant: '#38bdf8',
|
||||||
glass_ceramic_crown: '#fef08a',
|
post_core: '#a8a29e',
|
||||||
full_metal_crown: '#d4d4d8',
|
removable: '#a78bfa',
|
||||||
temporary_resin_crown: '#ddd6fe',
|
appliance: '#d8b4fe',
|
||||||
pmma: '#fcd34d',
|
digital: '#f472b6',
|
||||||
peek_crown: '#fda4af',
|
|
||||||
veneer_zirconia: '#86efac',
|
|
||||||
veneer_ips_press: '#fed7aa',
|
|
||||||
veneer_ips_cad: '#fdba74',
|
|
||||||
soft_structure: '#e9d5ff',
|
|
||||||
customized_abutment: '#a5b4fc',
|
|
||||||
prefabricated_abutment: '#c7d2fe',
|
|
||||||
ti_base_abutment: '#93c5fd',
|
|
||||||
multi_unit_abutment: '#818cf8',
|
|
||||||
zirconia_abutment: '#4ade80',
|
|
||||||
screw_retained: '#f0abfc',
|
|
||||||
zirconia_overlay: '#2dd4bf',
|
|
||||||
ips_overlay: '#fef3c7',
|
|
||||||
smile_design: '#f9a8d4',
|
|
||||||
mockup: '#fbcfe8',
|
|
||||||
press_ceramic: '#fde68a',
|
|
||||||
veneer_full_contour: '#fdba74',
|
|
||||||
veneer_layered: '#86efac',
|
|
||||||
inlay_full_contour: '#fcd34d',
|
|
||||||
inlay_layered: '#bbf7d0',
|
|
||||||
onlay_full_contour: '#fed7aa',
|
|
||||||
onlay_layered: '#a7f3d0',
|
|
||||||
overlay_full_contour: '#fef3c7',
|
|
||||||
overlay_layered: '#2dd4bf',
|
|
||||||
cast_post_core: '#d6d3d1',
|
|
||||||
fiber_post_core: '#e7e5e4',
|
|
||||||
complete_denture: '#c4b5fd',
|
|
||||||
partial_denture: '#ddd6fe',
|
|
||||||
overdenture: '#a78bfa',
|
|
||||||
night_guard_soft: '#e9d5ff',
|
|
||||||
night_guard_hard: '#d8b4fe',
|
|
||||||
night_guard_dual: '#c084fc',
|
|
||||||
bleaching_tray: '#a5f3fc',
|
|
||||||
clear_aligner: '#67e8f9',
|
|
||||||
surgical_guide: '#94a3b8',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Category tiles in the assign overlay (not per-leaf). */
|
function hexToRgbChannels(hex: string): [number, number, number] | null {
|
||||||
export const PROSTHESIS_CATEGORY_COLORS: Record<string, string> = {
|
const raw = hex.replace('#', '').trim();
|
||||||
crown: '#bae6fd',
|
if (raw.length !== 6) return null;
|
||||||
indirect: '#fdba74',
|
const n = Number.parseInt(raw, 16);
|
||||||
implant: '#a5b4fc',
|
if (Number.isNaN(n)) return null;
|
||||||
post_core: '#d6d3d1',
|
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||||
removable: '#c4b5fd',
|
}
|
||||||
appliance: '#e9d5ff',
|
|
||||||
digital: '#f9a8d4',
|
function rgbToHslChannels(r: number, g: number, b: number): [number, number, number] {
|
||||||
|
const rr = r / 255;
|
||||||
|
const gg = g / 255;
|
||||||
|
const bb = b / 255;
|
||||||
|
const max = Math.max(rr, gg, bb);
|
||||||
|
const min = Math.min(rr, gg, bb);
|
||||||
|
const l = (max + min) / 2;
|
||||||
|
if (max === min) return [0, 0, l];
|
||||||
|
const d = max - min;
|
||||||
|
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||||
|
let h = 0;
|
||||||
|
if (max === rr) h = ((gg - bb) / d + (gg < bb ? 6 : 0)) / 6;
|
||||||
|
else if (max === gg) h = ((bb - rr) / d + 2) / 6;
|
||||||
|
else h = ((rr - gg) / d + 4) / 6;
|
||||||
|
return [h, s, l];
|
||||||
|
}
|
||||||
|
|
||||||
|
function hslToHexChannels(h: number, s: number, l: number): string {
|
||||||
|
const hue2rgb = (p: number, q: number, t: number) => {
|
||||||
|
let tt = t;
|
||||||
|
if (tt < 0) tt += 1;
|
||||||
|
if (tt > 1) tt -= 1;
|
||||||
|
if (tt < 1 / 6) return p + (q - p) * 6 * tt;
|
||||||
|
if (tt < 1 / 2) return q;
|
||||||
|
if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||||
|
const p = 2 * l - q;
|
||||||
|
const r = Math.round(hue2rgb(p, q, h + 1 / 3) * 255);
|
||||||
|
const g = Math.round(hue2rgb(p, q, h) * 255);
|
||||||
|
const b = Math.round(hue2rgb(p, q, h - 1 / 3) * 255);
|
||||||
|
return `#${[r, g, b].map((c) => c.toString(16).padStart(2, '0')).join('')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp01(n: number): number {
|
||||||
|
return Math.min(1, Math.max(0, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same-hue sibling of a category pastel. Index 0 is the parent (heaviest); higher indices only lighten. */
|
||||||
|
export function prosthesisFamilyTone(parentHex: string, siblingIndex: number): string {
|
||||||
|
if (siblingIndex <= 0) return parentHex;
|
||||||
|
const rgb = hexToRgbChannels(parentHex);
|
||||||
|
if (!rgb) return parentHex;
|
||||||
|
const [h, s, l] = rgbToHslChannels(rgb[0], rgb[1], rgb[2]);
|
||||||
|
const nextL = clamp01(Math.min(0.9, l + siblingIndex * 0.038));
|
||||||
|
const nextS = clamp01(s - siblingIndex * 0.025);
|
||||||
|
return hslToHexChannels(h, nextS, nextL);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prosthesisCategoryFamilyColor(category: string, siblingIndex = 0): string {
|
||||||
|
const parent = PROSTHESIS_CATEGORY_COLORS[category] ?? '#e2e8f0';
|
||||||
|
return prosthesisFamilyTone(parent, siblingIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dedicated prosthesis palette — family tones (legacy map kept as fallback). */
|
||||||
|
export const PROSTHESIS_TYPE_COLORS: Record<string, string> = {
|
||||||
|
pfm_crown: prosthesisCategoryFamilyColor('crown', 1),
|
||||||
|
pfz_crown: prosthesisCategoryFamilyColor('crown', 2),
|
||||||
|
monolithic_zirconia: prosthesisCategoryFamilyColor('crown', 3),
|
||||||
|
glass_ceramic_crown: prosthesisCategoryFamilyColor('crown', 4),
|
||||||
|
full_metal_crown: prosthesisCategoryFamilyColor('crown', 5),
|
||||||
|
temporary_resin_crown: prosthesisCategoryFamilyColor('crown', 6),
|
||||||
|
pmma: prosthesisCategoryFamilyColor('crown', 7),
|
||||||
|
peek_crown: prosthesisCategoryFamilyColor('crown', 8),
|
||||||
|
press_ceramic: prosthesisCategoryFamilyColor('crown', 9),
|
||||||
|
veneer_zirconia: prosthesisCategoryFamilyColor('indirect', 1),
|
||||||
|
veneer_ips_press: prosthesisCategoryFamilyColor('indirect', 2),
|
||||||
|
veneer_ips_cad: prosthesisCategoryFamilyColor('indirect', 3),
|
||||||
|
zirconia_overlay: prosthesisCategoryFamilyColor('indirect', 4),
|
||||||
|
ips_overlay: prosthesisCategoryFamilyColor('indirect', 5),
|
||||||
|
veneer_full_contour: prosthesisCategoryFamilyColor('indirect', 1),
|
||||||
|
veneer_layered: prosthesisCategoryFamilyColor('indirect', 2),
|
||||||
|
inlay_full_contour: prosthesisCategoryFamilyColor('indirect', 3),
|
||||||
|
inlay_layered: prosthesisCategoryFamilyColor('indirect', 4),
|
||||||
|
onlay_full_contour: prosthesisCategoryFamilyColor('indirect', 5),
|
||||||
|
onlay_layered: prosthesisCategoryFamilyColor('indirect', 6),
|
||||||
|
overlay_full_contour: prosthesisCategoryFamilyColor('indirect', 7),
|
||||||
|
overlay_layered: prosthesisCategoryFamilyColor('indirect', 8),
|
||||||
|
customized_abutment: prosthesisCategoryFamilyColor('implant', 1),
|
||||||
|
prefabricated_abutment: prosthesisCategoryFamilyColor('implant', 2),
|
||||||
|
ti_base_abutment: prosthesisCategoryFamilyColor('implant', 3),
|
||||||
|
multi_unit_abutment: prosthesisCategoryFamilyColor('implant', 4),
|
||||||
|
zirconia_abutment: prosthesisCategoryFamilyColor('implant', 5),
|
||||||
|
screw_retained: prosthesisCategoryFamilyColor('implant', 6),
|
||||||
|
cast_post_core: prosthesisCategoryFamilyColor('post_core', 1),
|
||||||
|
fiber_post_core: prosthesisCategoryFamilyColor('post_core', 2),
|
||||||
|
complete_denture: prosthesisCategoryFamilyColor('removable', 1),
|
||||||
|
partial_denture: prosthesisCategoryFamilyColor('removable', 2),
|
||||||
|
overdenture: prosthesisCategoryFamilyColor('removable', 3),
|
||||||
|
night_guard_soft: prosthesisCategoryFamilyColor('appliance', 1),
|
||||||
|
night_guard_hard: prosthesisCategoryFamilyColor('appliance', 2),
|
||||||
|
night_guard_dual: prosthesisCategoryFamilyColor('appliance', 3),
|
||||||
|
bleaching_tray: prosthesisCategoryFamilyColor('appliance', 4),
|
||||||
|
clear_aligner: prosthesisCategoryFamilyColor('appliance', 5),
|
||||||
|
soft_structure: prosthesisCategoryFamilyColor('appliance', 6),
|
||||||
|
smile_design: prosthesisCategoryFamilyColor('digital', 1),
|
||||||
|
mockup: prosthesisCategoryFamilyColor('digital', 2),
|
||||||
|
surgical_guide: prosthesisCategoryFamilyColor('digital', 3),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CATALOG_FALLBACK_COLORS = [
|
export const CATALOG_FALLBACK_COLORS = [
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
|||||||
import type { FdiToothId, LabCaseToothProsthesisDraft, ToothSelectionGroup } from '@/types/treatment';
|
import type { FdiToothId, LabCaseToothProsthesisDraft, ToothSelectionGroup } from '@/types/treatment';
|
||||||
import { PROSTHESIS_CATEGORY_COLORS } from '@/components/shared/catalog-type-colors';
|
import { PROSTHESIS_CATEGORY_COLORS } from '@/components/shared/catalog-type-colors';
|
||||||
import { prosthesisTypeColorFromCatalog } from '@/components/treatment/prosthesisTypeDisplay';
|
import { prosthesisTypeColorFromCatalog } from '@/components/treatment/prosthesisTypeDisplay';
|
||||||
import { groupsFromFlatTeeth, newGroupId } from '@/components/treatment/toothSelectionGroups';
|
import {
|
||||||
|
deriveTeethFromGroups,
|
||||||
|
groupsFromFlatTeeth,
|
||||||
|
newGroupId,
|
||||||
|
} from '@/components/treatment/toothSelectionGroups';
|
||||||
|
|
||||||
const PROSTHESIS_CATEGORY_ORDER = [
|
const PROSTHESIS_CATEGORY_ORDER = [
|
||||||
'implant',
|
'implant',
|
||||||
@@ -111,13 +115,35 @@ export function leavesFor(
|
|||||||
.sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code));
|
.sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addonLeaves(
|
export const SCREW_RETAINED_CODE = 'screw_retained';
|
||||||
catalog: readonly ProsthesisCatalogEntry[],
|
|
||||||
kind: 'implant' | 'post_core',
|
export type AddonSlot = 'crown';
|
||||||
): ProsthesisCatalogEntry[] {
|
|
||||||
return catalog
|
export function jobsHaveStackGroup(
|
||||||
.filter((e) => e.addonKind === kind)
|
jobCodes: readonly string[],
|
||||||
.sort((a, b) => a.sortOrder - b.sortOrder);
|
byCode: Map<string, ProsthesisCatalogEntry>,
|
||||||
|
group: string,
|
||||||
|
): boolean {
|
||||||
|
return jobCodes.some((code) => byCode.get(code)?.stackGroup === group);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canStackLeaf(
|
||||||
|
existingCodes: readonly string[],
|
||||||
|
nextCode: string,
|
||||||
|
byCode: Map<string, ProsthesisCatalogEntry>,
|
||||||
|
): boolean {
|
||||||
|
const next = byCode.get(nextCode);
|
||||||
|
if (!next) return false;
|
||||||
|
const hasRestoration = jobsHaveStackGroup(existingCodes, byCode, 'restoration');
|
||||||
|
const hasImplant = jobsHaveStackGroup(existingCodes, byCode, 'implant');
|
||||||
|
const hasPost = jobsHaveStackGroup(existingCodes, byCode, 'post_core');
|
||||||
|
if (next.stackGroup === 'post_core') return !hasRestoration && !hasImplant;
|
||||||
|
if (next.stackGroup === 'implant') return !hasPost;
|
||||||
|
// Screw-retained already paints the crown — do not stack a second restoration.
|
||||||
|
if (next.stackGroup === 'restoration' && existingCodes.includes(SCREW_RETAINED_CODE)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyLeafToJobs(
|
export function applyLeafToJobs(
|
||||||
@@ -127,36 +153,115 @@ export function applyLeafToJobs(
|
|||||||
): string[] {
|
): string[] {
|
||||||
const next = byCode.get(nextCode);
|
const next = byCode.get(nextCode);
|
||||||
if (!next) return [...existingCodes];
|
if (!next) return [...existingCodes];
|
||||||
|
if (!canStackLeaf(existingCodes, nextCode, byCode)) return [...existingCodes];
|
||||||
|
|
||||||
let jobs = existingCodes.filter((code) => {
|
const jobs = existingCodes.filter((code) => {
|
||||||
const cur = byCode.get(code);
|
const cur = byCode.get(code);
|
||||||
if (!cur) return false;
|
if (!cur) return false;
|
||||||
return cur.stackGroup !== next.stackGroup;
|
if (cur.stackGroup === next.stackGroup) return false;
|
||||||
|
if (nextCode === SCREW_RETAINED_CODE && cur.stackGroup === 'restoration') return false;
|
||||||
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
jobs.push(nextCode);
|
jobs.push(nextCode);
|
||||||
return jobs;
|
return jobs;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function jobsAllowImplantAddon(
|
export function crownRestorationCode(
|
||||||
jobCodes: readonly string[],
|
jobCodes: readonly string[],
|
||||||
byCode: Map<string, ProsthesisCatalogEntry>,
|
byCode: Map<string, ProsthesisCatalogEntry>,
|
||||||
): boolean {
|
): string | undefined {
|
||||||
const hasArchBlocking = jobCodes.some((code) => {
|
return jobCodes.find((code) => {
|
||||||
const e = byCode.get(code);
|
const entry = byCode.get(code);
|
||||||
if (!e || e.stackGroup !== 'arch') return false;
|
return entry?.category === 'crown' && entry.stackGroup === 'restoration';
|
||||||
return e.code !== 'overdenture' && e.code !== PARTIAL_DENTURE_CODE;
|
|
||||||
});
|
});
|
||||||
if (hasArchBlocking) return false;
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function jobsAllowPostCoreAddon(
|
/**
|
||||||
|
* Crown suggestion slot after implant or post & core.
|
||||||
|
* Stays visible when the restoration is a crown (filled chip). Hidden for
|
||||||
|
* screw-retained and for non-crown restorations (veneer / inlay / onlay / overlay).
|
||||||
|
*/
|
||||||
|
export function jobsAllowCrownAddon(
|
||||||
jobCodes: readonly string[],
|
jobCodes: readonly string[],
|
||||||
byCode: Map<string, ProsthesisCatalogEntry>,
|
byCode: Map<string, ProsthesisCatalogEntry>,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (jobCodes.includes('screw_retained')) return false;
|
if (jobCodes.length === 0) return false;
|
||||||
return jobCodes.some((code) => byCode.get(code)?.stackGroup === 'restoration');
|
if (jobCodes.includes(SCREW_RETAINED_CODE)) return false;
|
||||||
|
if (
|
||||||
|
!jobsHaveStackGroup(jobCodes, byCode, 'implant') &&
|
||||||
|
!jobsHaveStackGroup(jobCodes, byCode, 'post_core')
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const restoration = jobCodes.find((code) => byCode.get(code)?.stackGroup === 'restoration');
|
||||||
|
if (!restoration) return true;
|
||||||
|
return byCode.get(restoration)?.category === 'crown';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function categoryDisabledForJobs(
|
||||||
|
category: string,
|
||||||
|
jobCodes: readonly string[],
|
||||||
|
byCode: Map<string, ProsthesisCatalogEntry>,
|
||||||
|
): boolean {
|
||||||
|
const hasRestoration = jobsHaveStackGroup(jobCodes, byCode, 'restoration');
|
||||||
|
const hasImplant = jobsHaveStackGroup(jobCodes, byCode, 'implant');
|
||||||
|
const hasPost = jobsHaveStackGroup(jobCodes, byCode, 'post_core');
|
||||||
|
if (category === 'post_core') return hasRestoration || hasImplant;
|
||||||
|
if (category === 'implant') return hasPost;
|
||||||
|
if (
|
||||||
|
jobCodes.includes(SCREW_RETAINED_CODE) &&
|
||||||
|
(category === 'crown' || category === 'indirect')
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fdiTeethWithJobs(
|
||||||
|
rows: readonly Pick<LabCaseToothProsthesisDraft, 'tooth' | 'prosthesisTypeCode' | 'detailClientId'>[],
|
||||||
|
detailClientId: string,
|
||||||
|
): Set<string> {
|
||||||
|
const teeth = new Set<string>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (
|
||||||
|
row.detailClientId === detailClientId &&
|
||||||
|
row.prosthesisTypeCode &&
|
||||||
|
!isArchSentinel(row.tooth)
|
||||||
|
) {
|
||||||
|
teeth.add(row.tooth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return teeth;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pruneGroupsToJobTeeth(
|
||||||
|
groups: ToothSelectionGroup[],
|
||||||
|
jobTeeth: Set<string>,
|
||||||
|
): ToothSelectionGroup[] {
|
||||||
|
const extra = groups.flatMap((g) => g.teeth).filter((tooth) => !jobTeeth.has(tooth));
|
||||||
|
if (extra.length === 0) return groups;
|
||||||
|
return removeTeethFromGroups(groups, extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pruneDetailTeethToJobs<
|
||||||
|
T extends {
|
||||||
|
clientId: string;
|
||||||
|
teeth: readonly string[];
|
||||||
|
toothSelectionGroups: ToothSelectionGroup[];
|
||||||
|
},
|
||||||
|
>(detail: T, jobs: readonly LabCaseToothProsthesisDraft[]): T {
|
||||||
|
const jobTeeth = fdiTeethWithJobs(jobs, detail.clientId);
|
||||||
|
const groups =
|
||||||
|
detail.toothSelectionGroups.length > 0
|
||||||
|
? detail.toothSelectionGroups
|
||||||
|
: groupsFromFlatTeeth(detail.teeth as FdiToothId[]);
|
||||||
|
const nextGroups = pruneGroupsToJobTeeth(groups, jobTeeth);
|
||||||
|
return {
|
||||||
|
...detail,
|
||||||
|
toothSelectionGroups: nextGroups,
|
||||||
|
teeth: deriveTeethFromGroups(nextGroups),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function codesOnTooth(
|
export function codesOnTooth(
|
||||||
@@ -295,9 +400,8 @@ export function writeArchJobs(
|
|||||||
arch: ArchTarget,
|
arch: ArchTarget,
|
||||||
code: string,
|
code: string,
|
||||||
): LabCaseToothProsthesisDraft[] {
|
): LabCaseToothProsthesisDraft[] {
|
||||||
const replace = new Set(archSentinels(arch));
|
|
||||||
const rest = rows.filter(
|
const rest = rows.filter(
|
||||||
(row) => !(row.detailClientId === detailClientId && replace.has(row.tooth)),
|
(row) => !(row.detailClientId === detailClientId && isArchSentinel(row.tooth)),
|
||||||
);
|
);
|
||||||
const next = archSentinels(arch).map((tooth) => ({
|
const next = archSentinels(arch).map((tooth) => ({
|
||||||
detailClientId,
|
detailClientId,
|
||||||
@@ -308,6 +412,23 @@ export function writeArchJobs(
|
|||||||
return [...rest, ...next];
|
return [...rest, ...next];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Move / copy / drop UA·LA rows so they match the segmented arch control. */
|
||||||
|
export function retargetArchJobs(
|
||||||
|
rows: LabCaseToothProsthesisDraft[],
|
||||||
|
detailClientId: string,
|
||||||
|
from: ArchTarget,
|
||||||
|
to: ArchTarget,
|
||||||
|
): LabCaseToothProsthesisDraft[] {
|
||||||
|
if (from === to) return rows;
|
||||||
|
const keep = archSentinels(to).filter((tooth) => archSentinels(from).includes(tooth));
|
||||||
|
const code =
|
||||||
|
uniqueCodesOnGroup(rows, detailClientId, keep)[0] ??
|
||||||
|
uniqueCodesOnGroup(rows, detailClientId, archSentinels(from))[0] ??
|
||||||
|
uniqueCodesOnGroup(rows, detailClientId, [ARCH_TOOTH_UPPER, ARCH_TOOTH_LOWER])[0];
|
||||||
|
if (!code) return rows;
|
||||||
|
return writeArchJobs(rows, detailClientId, to, code);
|
||||||
|
}
|
||||||
|
|
||||||
export function uniqueCodesOnGroup(
|
export function uniqueCodesOnGroup(
|
||||||
rows: readonly LabCaseToothProsthesisDraft[],
|
rows: readonly LabCaseToothProsthesisDraft[],
|
||||||
detailClientId: string,
|
detailClientId: string,
|
||||||
|
|||||||
@@ -3,15 +3,19 @@ import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
|||||||
import {
|
import {
|
||||||
PROSTHESIS_FALLBACK_COLORS,
|
PROSTHESIS_FALLBACK_COLORS,
|
||||||
PROSTHESIS_TYPE_COLORS,
|
PROSTHESIS_TYPE_COLORS,
|
||||||
|
prosthesisCategoryFamilyColor,
|
||||||
resolveCatalogTypeColor,
|
resolveCatalogTypeColor,
|
||||||
} from '@/components/shared/catalog-type-colors';
|
} from '@/components/shared/catalog-type-colors';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prosthesis-type colors for Cases, Tasks, Treatment chart/dispatch, and Today.
|
* Prosthesis-type colors for Cases, Tasks, Treatment chart/dispatch, and Today.
|
||||||
* Uses a dedicated pastel map (unique per prosthesis code).
|
* Uses a category family pastel; sibling types are stepped tones of that hue.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Dark ink that stays readable on every pastel in the palette. */
|
type ProsthesisColorCatalog = readonly Pick<
|
||||||
|
ProsthesisCatalogEntry,
|
||||||
|
'code' | 'sortOrder' | 'category'
|
||||||
|
>[];
|
||||||
const BADGE_INK = '#14253d';
|
const BADGE_INK = '#14253d';
|
||||||
|
|
||||||
export function primaryProsthesisCode(code: string): string {
|
export function primaryProsthesisCode(code: string): string {
|
||||||
@@ -25,7 +29,7 @@ export function splitProsthesisGroupCode(code: string): string[] {
|
|||||||
/** Stable catalog index for fallback colors — use sortOrder, not list row position. */
|
/** Stable catalog index for fallback colors — use sortOrder, not list row position. */
|
||||||
export function prosthesisCatalogColorIndex(
|
export function prosthesisCatalogColorIndex(
|
||||||
code: string,
|
code: string,
|
||||||
catalog: readonly Pick<ProsthesisCatalogEntry, 'code' | 'sortOrder'>[],
|
catalog: ProsthesisColorCatalog,
|
||||||
): number {
|
): number {
|
||||||
const entry = catalog.find((e) => e.code === primaryProsthesisCode(code));
|
const entry = catalog.find((e) => e.code === primaryProsthesisCode(code));
|
||||||
if (entry) return Math.max(0, entry.sortOrder - 1);
|
if (entry) return Math.max(0, entry.sortOrder - 1);
|
||||||
@@ -44,9 +48,18 @@ export function prosthesisTypeColor(code: string, index = 0): string {
|
|||||||
|
|
||||||
export function prosthesisTypeColorFromCatalog(
|
export function prosthesisTypeColorFromCatalog(
|
||||||
code: string,
|
code: string,
|
||||||
catalog: readonly Pick<ProsthesisCatalogEntry, 'code' | 'sortOrder'>[],
|
catalog: ProsthesisColorCatalog,
|
||||||
): string {
|
): string {
|
||||||
return prosthesisTypeColor(code, prosthesisCatalogColorIndex(code, catalog));
|
const primary = primaryProsthesisCode(code);
|
||||||
|
const entry = catalog.find((e) => e.code === primary);
|
||||||
|
if (!entry?.category) {
|
||||||
|
return prosthesisTypeColor(primary, prosthesisCatalogColorIndex(code, catalog));
|
||||||
|
}
|
||||||
|
const siblings = catalog
|
||||||
|
.filter((e) => e.category === entry.category)
|
||||||
|
.sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code));
|
||||||
|
const idx = siblings.findIndex((e) => e.code === primary);
|
||||||
|
return prosthesisCategoryFamilyColor(entry.category, Math.max(0, idx) + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hexToRgb(hex: string): [number, number, number] | null {
|
function hexToRgb(hex: string): [number, number, number] | null {
|
||||||
@@ -118,7 +131,7 @@ function prosthesisTypeLabelSwatch(hex: string): string {
|
|||||||
*/
|
*/
|
||||||
export function prosthesisTypeLabelStyleFromCatalog(
|
export function prosthesisTypeLabelStyleFromCatalog(
|
||||||
code: string,
|
code: string,
|
||||||
catalog: readonly Pick<ProsthesisCatalogEntry, 'code' | 'sortOrder'>[],
|
catalog: ProsthesisColorCatalog,
|
||||||
): CSSProperties {
|
): CSSProperties {
|
||||||
const swatch = prosthesisTypeLabelSwatch(prosthesisTypeColorFromCatalog(code, catalog));
|
const swatch = prosthesisTypeLabelSwatch(prosthesisTypeColorFromCatalog(code, catalog));
|
||||||
return {
|
return {
|
||||||
@@ -134,9 +147,12 @@ export function prosthesisTypeSwatchStyle(code: string, index = 0): CSSPropertie
|
|||||||
|
|
||||||
export function prosthesisTypeSwatchStyleFromCatalog(
|
export function prosthesisTypeSwatchStyleFromCatalog(
|
||||||
code: string,
|
code: string,
|
||||||
catalog: readonly Pick<ProsthesisCatalogEntry, 'code' | 'sortOrder'>[],
|
catalog: ProsthesisColorCatalog,
|
||||||
): CSSProperties {
|
): CSSProperties {
|
||||||
return prosthesisTypeSwatchStyle(code, prosthesisCatalogColorIndex(code, catalog));
|
return {
|
||||||
|
backgroundColor: prosthesisTypeColorFromCatalog(code, catalog),
|
||||||
|
borderColor: 'rgba(0, 0, 0, 0.18)',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pastel pill / banner fill with readable dark text (group headers, badges). */
|
/** Pastel pill / banner fill with readable dark text (group headers, badges). */
|
||||||
@@ -150,9 +166,13 @@ export function prosthesisTypeBadgeStyle(code: string, index = 0): CSSProperties
|
|||||||
|
|
||||||
export function prosthesisTypeBadgeStyleFromCatalog(
|
export function prosthesisTypeBadgeStyleFromCatalog(
|
||||||
code: string,
|
code: string,
|
||||||
catalog: readonly Pick<ProsthesisCatalogEntry, 'code' | 'sortOrder'>[],
|
catalog: ProsthesisColorCatalog,
|
||||||
): CSSProperties {
|
): CSSProperties {
|
||||||
return prosthesisTypeBadgeStyle(code, prosthesisCatalogColorIndex(code, catalog));
|
return {
|
||||||
|
backgroundColor: prosthesisTypeColorFromCatalog(code, catalog),
|
||||||
|
borderColor: 'rgba(0, 0, 0, 0.16)',
|
||||||
|
color: BADGE_INK,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatToothList(
|
export function formatToothList(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useId, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
import { useEffect, useId, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Info } from 'lucide-react';
|
import { Info, Plus } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
|
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
|
||||||
import {
|
import {
|
||||||
@@ -117,6 +117,76 @@ function ToothNumber({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CHART_TOOL_BTN_CLASS = 'h-8';
|
||||||
|
|
||||||
|
function ArchAssignControl({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
highlighted,
|
||||||
|
disabled,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
highlighted: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={highlighted ? 'secondary' : 'outline'}
|
||||||
|
disabled={disabled}
|
||||||
|
title={hint}
|
||||||
|
aria-pressed={highlighted}
|
||||||
|
onClick={onClick}
|
||||||
|
className={`${CHART_TOOL_BTN_CLASS} gap-1 ${highlighted ? '' : 'border-dashed'}`}
|
||||||
|
>
|
||||||
|
{highlighted ? null : <Plus className="h-3.5 w-3.5 shrink-0" aria-hidden />}
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ArchLabelRow({
|
||||||
|
placement,
|
||||||
|
interactive,
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
highlighted,
|
||||||
|
disabled,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
placement: 'upper' | 'lower';
|
||||||
|
interactive: boolean;
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
highlighted: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
const spacing = placement === 'upper' ? 'mb-1' : 'mt-1';
|
||||||
|
if (!interactive) {
|
||||||
|
return (
|
||||||
|
<p className={`text-[11px] uppercase tracking-wide text-text-muted ${spacing} text-center`}>
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={`${spacing} flex justify-center`}>
|
||||||
|
<ArchAssignControl
|
||||||
|
label={label}
|
||||||
|
hint={hint}
|
||||||
|
highlighted={highlighted}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface FdiToothChartProps {
|
interface FdiToothChartProps {
|
||||||
selected: ReadonlySet<FdiToothId>;
|
selected: ReadonlySet<FdiToothId>;
|
||||||
/**
|
/**
|
||||||
@@ -129,7 +199,7 @@ interface FdiToothChartProps {
|
|||||||
linkedEdges?: ReadonlySet<string>;
|
linkedEdges?: ReadonlySet<string>;
|
||||||
onToggle?: (fdi: FdiToothId, event: { shiftKey: boolean }) => void;
|
onToggle?: (fdi: FdiToothId, event: { shiftKey: boolean }) => void;
|
||||||
/**
|
/**
|
||||||
* Click-to-assign (Exocad): click opens the job picker; shift/drag paints.
|
* Click-to-assign (Exocad): click opens the job picker; Ctrl/drag or Shift copies the last jobs.
|
||||||
* When set, this replaces `onToggle` for tooth hits.
|
* When set, this replaces `onToggle` for tooth hits.
|
||||||
*/
|
*/
|
||||||
onAssignPointer?: (
|
onAssignPointer?: (
|
||||||
@@ -148,7 +218,7 @@ interface FdiToothChartProps {
|
|||||||
crownColors?: Partial<Record<FdiToothId, string>>;
|
crownColors?: Partial<Record<FdiToothId, string>>;
|
||||||
rootColors?: Partial<Record<FdiToothId, string>>;
|
rootColors?: Partial<Record<FdiToothId, string>>;
|
||||||
archHighlight?: 'upper' | 'lower' | 'both' | null;
|
archHighlight?: 'upper' | 'lower' | 'both' | null;
|
||||||
/** Extra control rendered in the chart header (e.g. whole-plan checkbox). */
|
/** Extra control rendered next to Reset in the chart header (e.g. Full View). */
|
||||||
headerControl?: ReactNode;
|
headerControl?: ReactNode;
|
||||||
/** Centered overlay on the odontogram (prosthesis picker). */
|
/** Centered overlay on the odontogram (prosthesis picker). */
|
||||||
overlay?: ReactNode;
|
overlay?: ReactNode;
|
||||||
@@ -540,22 +610,15 @@ export function FdiToothChart({
|
|||||||
|
|
||||||
const chartBody = (
|
const chartBody = (
|
||||||
<>
|
<>
|
||||||
{onArchClick && !readOnly ? (
|
<ArchLabelRow
|
||||||
<button
|
placement="upper"
|
||||||
type="button"
|
interactive={Boolean(onArchClick && !readOnly)}
|
||||||
disabled={isDisabled}
|
label={t('upperArch')}
|
||||||
onClick={() => onArchClick('upper')}
|
hint={t('archAssignHint')}
|
||||||
className={`mx-auto mb-1 block text-[11px] uppercase tracking-wide focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45 rounded-sm px-1 ${
|
highlighted={archHighlight === 'upper' || archHighlight === 'both'}
|
||||||
archHighlight === 'upper' || archHighlight === 'both'
|
disabled={isDisabled}
|
||||||
? 'text-primary font-semibold'
|
onClick={() => onArchClick?.('upper')}
|
||||||
: 'text-text-muted hover:text-text-primary'
|
/>
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t('upperArch')}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<p className="text-[11px] uppercase tracking-wide text-text-muted mb-1 text-center">{t('upperArch')}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="overflow-x-auto py-1 -mx-1 px-1 select-none" dir="ltr">
|
<div className="overflow-x-auto py-1 -mx-1 px-1 select-none" dir="ltr">
|
||||||
<div className="relative isolate min-w-max mx-auto w-fit select-none">
|
<div className="relative isolate min-w-max mx-auto w-fit select-none">
|
||||||
@@ -657,28 +720,21 @@ export function FdiToothChart({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{onArchClick && !readOnly ? (
|
<ArchLabelRow
|
||||||
<button
|
placement="lower"
|
||||||
type="button"
|
interactive={Boolean(onArchClick && !readOnly)}
|
||||||
disabled={isDisabled}
|
label={t('lowerArch')}
|
||||||
onClick={() => onArchClick('lower')}
|
hint={t('archAssignHint')}
|
||||||
className={`mx-auto mt-1 block text-[11px] uppercase tracking-wide focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45 rounded-sm px-1 ${
|
highlighted={archHighlight === 'lower' || archHighlight === 'both'}
|
||||||
archHighlight === 'lower' || archHighlight === 'both'
|
disabled={isDisabled}
|
||||||
? 'text-primary font-semibold'
|
onClick={() => onArchClick?.('lower')}
|
||||||
: 'text-text-muted hover:text-text-primary'
|
/>
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t('lowerArch')}
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<p className="text-[11px] uppercase tracking-wide text-text-muted mt-1 text-center">{t('lowerArch')}</p>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${cardClass} ${className}`}>
|
<div className={`${cardClass} ${className}`}>
|
||||||
<div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-x-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="flex min-w-0 items-center gap-1">
|
<div className="flex min-w-0 items-center gap-1">
|
||||||
<h3 className="text-sm font-semibold text-text-primary">
|
<h3 className="text-sm font-semibold text-text-primary">
|
||||||
{compact || embedded ? t('toothChartTitleCompact') : t('toothChartTitle')}
|
{compact || embedded ? t('toothChartTitleCompact') : t('toothChartTitle')}
|
||||||
@@ -694,18 +750,24 @@ export function FdiToothChart({
|
|||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-center">{headerControl}</div>
|
<div className="flex min-w-0 flex-col items-end gap-1.5">
|
||||||
<div className="flex min-w-0 flex-col items-end gap-1">
|
{headerControl || (onReset && !readOnly) ? (
|
||||||
{onReset && !readOnly ? (
|
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||||||
<button
|
{headerControl}
|
||||||
type="button"
|
{onReset && !readOnly ? (
|
||||||
disabled={resetDisabled || isDisabled || !(hasResetWork ?? selected.size > 0)}
|
<Button
|
||||||
onClick={onReset}
|
type="button"
|
||||||
aria-label={t('resetChartAria')}
|
variant="outline"
|
||||||
className="rounded-[var(--radius-md)] border border-border/70 px-2 py-0.5 text-[11px] text-text-secondary hover:border-border hover:text-text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45 disabled:cursor-not-allowed disabled:opacity-40"
|
size="sm"
|
||||||
>
|
className={CHART_TOOL_BTN_CLASS}
|
||||||
{t('resetChart')}
|
disabled={resetDisabled || isDisabled || !(hasResetWork ?? selected.size > 0)}
|
||||||
</button>
|
onClick={onReset}
|
||||||
|
aria-label={t('resetChartAria')}
|
||||||
|
>
|
||||||
|
{t('resetChart')}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<p className="min-w-0 text-[11px] text-text-secondary tabular-nums text-end">
|
<p className="min-w-0 text-[11px] text-text-secondary tabular-nums text-end">
|
||||||
{t('selectedLabel')}{' '}
|
{t('selectedLabel')}{' '}
|
||||||
|
|||||||
@@ -19,22 +19,22 @@ import {
|
|||||||
ensureTeethInGroups,
|
ensureTeethInGroups,
|
||||||
hasArchJobs,
|
hasArchJobs,
|
||||||
isArchSentinel,
|
isArchSentinel,
|
||||||
jobsAllowImplantAddon,
|
jobsAllowCrownAddon,
|
||||||
jobsAllowPostCoreAddon,
|
|
||||||
removeTeethFromGroups,
|
removeTeethFromGroups,
|
||||||
|
retargetArchJobs,
|
||||||
toothRegionColors,
|
toothRegionColors,
|
||||||
uniqueCodesOnGroup,
|
uniqueCodesOnGroup,
|
||||||
writeArchJobs,
|
writeArchJobs,
|
||||||
writeJobsForTeeth,
|
writeJobsForTeeth,
|
||||||
|
type AddonSlot,
|
||||||
type ArchTarget,
|
type ArchTarget,
|
||||||
type PickerScope,
|
type PickerScope,
|
||||||
} from '@/components/treatment/prosthesisTree';
|
} from '@/components/treatment/prosthesisTree';
|
||||||
import {
|
import {
|
||||||
applyShiftRange,
|
|
||||||
deriveTeethFromGroups,
|
deriveTeethFromGroups,
|
||||||
groupsFromFlatTeeth,
|
groupsFromFlatTeeth,
|
||||||
linkedEdgesFromGroups,
|
linkedEdgesFromGroups,
|
||||||
shiftRangeTeeth,
|
teethBetweenInclusive,
|
||||||
} from '@/components/treatment/toothSelectionGroups';
|
} from '@/components/treatment/toothSelectionGroups';
|
||||||
|
|
||||||
interface ProsthesisAssignChartProps {
|
interface ProsthesisAssignChartProps {
|
||||||
@@ -64,7 +64,6 @@ export function ProsthesisAssignChart({
|
|||||||
onChange,
|
onChange,
|
||||||
}: ProsthesisAssignChartProps) {
|
}: ProsthesisAssignChartProps) {
|
||||||
const tTreatment = useTranslations('treatment');
|
const tTreatment = useTranslations('treatment');
|
||||||
const tProsthesis = useTranslations('prosthesis');
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [mobile, setMobile] = useState(false);
|
const [mobile, setMobile] = useState(false);
|
||||||
const [anchor, setAnchor] = useState<FdiToothId | null>(null);
|
const [anchor, setAnchor] = useState<FdiToothId | null>(null);
|
||||||
@@ -73,8 +72,6 @@ export function ProsthesisAssignChart({
|
|||||||
const [lockedArch, setLockedArch] = useState<ArchTarget>('upper');
|
const [lockedArch, setLockedArch] = useState<ArchTarget>('upper');
|
||||||
const brushRef = useRef<string[]>([]);
|
const brushRef = useRef<string[]>([]);
|
||||||
const rangeAnchorRef = useRef<FdiToothId | null>(null);
|
const rangeAnchorRef = useRef<FdiToothId | null>(null);
|
||||||
const pendingRangeRef = useRef<FdiToothId[] | null>(null);
|
|
||||||
const [pendingRange, setPendingRange] = useState<FdiToothId[] | null>(null);
|
|
||||||
const byCode = useMemo(() => catalogByCode(catalog), [catalog]);
|
const byCode = useMemo(() => catalogByCode(catalog), [catalog]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -102,12 +99,11 @@ export function ProsthesisAssignChart({
|
|||||||
const archHighlight = open && scope === 'arch' ? lockedArch : persistedArch;
|
const archHighlight = open && scope === 'arch' ? lockedArch : persistedArch;
|
||||||
const archWork = hasArchJobs(toothProsthesis, detailClientId);
|
const archWork = hasArchJobs(toothProsthesis, detailClientId);
|
||||||
|
|
||||||
const focusTooth = anchor ?? [...selected][0] ?? null;
|
|
||||||
const jobCodes =
|
const jobCodes =
|
||||||
scope === 'arch'
|
scope === 'arch'
|
||||||
? uniqueCodesOnGroup(toothProsthesis, detailClientId, archSentinels(lockedArch))
|
? uniqueCodesOnGroup(toothProsthesis, detailClientId, archSentinels(lockedArch))
|
||||||
: focusTooth
|
: anchor
|
||||||
? codesOnTooth(toothProsthesis, detailClientId, focusTooth)
|
? codesOnTooth(toothProsthesis, detailClientId, anchor)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
function groupIdForTooth(tooth: string, nextGroups: ToothSelectionGroup[]): string {
|
function groupIdForTooth(tooth: string, nextGroups: ToothSelectionGroup[]): string {
|
||||||
@@ -119,6 +115,7 @@ export function ProsthesisAssignChart({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyCodes(teeth: FdiToothId[], codes: string[]) {
|
function applyCodes(teeth: FdiToothId[], codes: string[]) {
|
||||||
|
if (codes.length === 0 || teeth.length === 0) return;
|
||||||
const nextGroups = ensureTeethInGroups(groups, teeth);
|
const nextGroups = ensureTeethInGroups(groups, teeth);
|
||||||
const nextRows = writeJobsForTeeth(
|
const nextRows = writeJobsForTeeth(
|
||||||
toothProsthesis,
|
toothProsthesis,
|
||||||
@@ -127,24 +124,21 @@ export function ProsthesisAssignChart({
|
|||||||
codes,
|
codes,
|
||||||
(tooth) => groupIdForTooth(tooth, nextGroups),
|
(tooth) => groupIdForTooth(tooth, nextGroups),
|
||||||
);
|
);
|
||||||
brushRef.current = codes;
|
|
||||||
commit(nextGroups, nextRows);
|
commit(nextGroups, nextRows);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setRange(next: FdiToothId[] | null) {
|
function rememberBrushFromTooth(tooth: FdiToothId | null) {
|
||||||
pendingRangeRef.current = next;
|
if (!tooth) return;
|
||||||
setPendingRange(next);
|
const codes = codesOnTooth(toothProsthesis, detailClientId, tooth);
|
||||||
|
if (codes.length === 0) return;
|
||||||
|
brushRef.current = codes;
|
||||||
|
rangeAnchorRef.current = tooth;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openToothPicker(fdi: FdiToothId) {
|
function openToothPicker(fdi: FdiToothId) {
|
||||||
const pending = pendingRangeRef.current;
|
|
||||||
if (pending && !pending.includes(fdi)) {
|
|
||||||
setRange(null);
|
|
||||||
}
|
|
||||||
setScope('tooth');
|
setScope('tooth');
|
||||||
setAnchor(fdi);
|
setAnchor(fdi);
|
||||||
setStep({ kind: 'category' });
|
setStep({ kind: 'category' });
|
||||||
rangeAnchorRef.current = fdi;
|
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,44 +149,21 @@ export function ProsthesisAssignChart({
|
|||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
if (event.paint || event.ctrlKey) {
|
if (event.paint || event.ctrlKey) {
|
||||||
if (brushRef.current.length === 0) return;
|
if (brushRef.current.length === 0) return;
|
||||||
setRange(null);
|
|
||||||
applyCodes([fdi], brushRef.current);
|
applyCodes([fdi], brushRef.current);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.shiftKey) {
|
if (event.shiftKey) {
|
||||||
|
if (brushRef.current.length === 0) return;
|
||||||
const start = rangeAnchorRef.current;
|
const start = rangeAnchorRef.current;
|
||||||
if (!start || start === fdi) {
|
if (!start || start === fdi) {
|
||||||
rangeAnchorRef.current = fdi;
|
rangeAnchorRef.current = fdi;
|
||||||
|
applyCodes([fdi], brushRef.current);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nextGroups = applyShiftRange(groups, start, fdi);
|
const span = teethBetweenInclusive(start, fdi);
|
||||||
const union = shiftRangeTeeth(groups, start, fdi);
|
if (!span || span.length === 0) return;
|
||||||
|
applyCodes(span, brushRef.current);
|
||||||
rangeAnchorRef.current = fdi;
|
rangeAnchorRef.current = fdi;
|
||||||
if (!nextGroups || !union) return;
|
|
||||||
setRange(union);
|
|
||||||
commit(nextGroups, toothProsthesis);
|
|
||||||
setScope('tooth');
|
|
||||||
setAnchor(fdi);
|
|
||||||
setStep({ kind: 'category' });
|
|
||||||
setOpen(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSelected = selected.has(fdi);
|
|
||||||
const hasJobs = codesOnTooth(toothProsthesis, detailClientId, fdi).length > 0;
|
|
||||||
const inPendingRange = Boolean(pendingRangeRef.current?.includes(fdi));
|
|
||||||
// Drop a tooth from an in-progress Shift range. A selected tooth with no jobs
|
|
||||||
// after hydrate must open the picker — otherwise reload looks like a deselect.
|
|
||||||
if (isSelected && !hasJobs && inPendingRange) {
|
|
||||||
const nextGroups = removeTeethFromGroups(groups, [fdi]);
|
|
||||||
const nextRows = clearJobsForTeeth(toothProsthesis, detailClientId, [fdi]);
|
|
||||||
const remainingRange = (pendingRangeRef.current ?? []).filter((tooth) => tooth !== fdi);
|
|
||||||
setRange(remainingRange.length > 1 ? remainingRange : null);
|
|
||||||
commit(nextGroups, nextRows);
|
|
||||||
if (anchor === fdi) {
|
|
||||||
setOpen(false);
|
|
||||||
setAnchor(null);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,45 +172,55 @@ export function ProsthesisAssignChart({
|
|||||||
|
|
||||||
function handleArchClick(arch: 'upper' | 'lower') {
|
function handleArchClick(arch: 'upper' | 'lower') {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
setRange(null);
|
|
||||||
setScope('arch');
|
setScope('arch');
|
||||||
setLockedArch(arch);
|
setLockedArch(archTargetFromJobs(toothProsthesis, detailClientId) ?? arch);
|
||||||
setStep({ kind: 'category' });
|
setStep({ kind: 'category' });
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePickLeaf(code: string, arch?: ArchTarget) {
|
function handleLockedArchChange(next: ArchTarget) {
|
||||||
if (scope === 'arch' || arch) {
|
if (next === lockedArch) return;
|
||||||
const target = arch ?? lockedArch;
|
const previous = lockedArch;
|
||||||
commit(groups, writeArchJobs(toothProsthesis, detailClientId, target, code));
|
setLockedArch(next);
|
||||||
brushRef.current = [code];
|
setStep((current) =>
|
||||||
|
current.kind === 'subcategory' || current.kind === 'leaves'
|
||||||
|
? { ...current, arch: next }
|
||||||
|
: current,
|
||||||
|
);
|
||||||
|
const nextRows = retargetArchJobs(toothProsthesis, detailClientId, previous, next);
|
||||||
|
if (nextRows !== toothProsthesis) {
|
||||||
|
commit(groups, nextRows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePickLeaf(code: string) {
|
||||||
|
if (scope === 'arch') {
|
||||||
|
commit(groups, writeArchJobs(toothProsthesis, detailClientId, lockedArch, code));
|
||||||
setStep({ kind: 'category' });
|
setStep({ kind: 'category' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const pending = pendingRangeRef.current;
|
if (!anchor) return;
|
||||||
const targets =
|
const base = codesOnTooth(toothProsthesis, detailClientId, anchor);
|
||||||
pending && pending.length > 0 && anchor && pending.includes(anchor)
|
|
||||||
? pending
|
|
||||||
: anchor
|
|
||||||
? [anchor]
|
|
||||||
: [];
|
|
||||||
if (targets.length === 0) return;
|
|
||||||
const base = codesOnTooth(toothProsthesis, detailClientId, targets[0]);
|
|
||||||
const nextCodes = applyLeafToJobs(base, code, byCode);
|
const nextCodes = applyLeafToJobs(base, code, byCode);
|
||||||
applyCodes(targets, nextCodes);
|
applyCodes([anchor], nextCodes);
|
||||||
setRange(null);
|
|
||||||
setStep({ kind: 'category' });
|
setStep({ kind: 'category' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePickAddon(code: string | null, kind: 'implant' | 'post_core') {
|
function handlePickAddon(code: string | null, kind: AddonSlot) {
|
||||||
const tooth = focusTooth;
|
if (!anchor || scope === 'arch' || kind !== 'crown') return;
|
||||||
if (!tooth || scope === 'arch') return;
|
const current = codesOnTooth(toothProsthesis, detailClientId, anchor).filter((c) => {
|
||||||
const current = codesOnTooth(toothProsthesis, detailClientId, tooth).filter((c) => {
|
return byCode.get(c)?.stackGroup !== 'restoration';
|
||||||
const entry = byCode.get(c);
|
|
||||||
return entry?.addonKind !== kind;
|
|
||||||
});
|
});
|
||||||
const next = code ? applyLeafToJobs(current, code, byCode) : current;
|
const next = code ? applyLeafToJobs(current, code, byCode) : current;
|
||||||
applyCodes([tooth], next);
|
if (next.length === 0) {
|
||||||
|
commit(
|
||||||
|
removeTeethFromGroups(groups, [anchor]),
|
||||||
|
clearJobsForTeeth(toothProsthesis, detailClientId, [anchor]),
|
||||||
|
);
|
||||||
|
setStep({ kind: 'category' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyCodes([anchor], next);
|
||||||
setStep({ kind: 'category' });
|
setStep({ kind: 'category' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,14 +230,14 @@ export function ProsthesisAssignChart({
|
|||||||
if (rest.length === 0) {
|
if (rest.length === 0) {
|
||||||
commit(
|
commit(
|
||||||
groups,
|
groups,
|
||||||
clearJobsForTeeth(toothProsthesis, detailClientId, archSentinels(lockedArch)),
|
clearJobsForTeeth(toothProsthesis, detailClientId, archSentinels('both')),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
commit(groups, writeArchJobs(toothProsthesis, detailClientId, lockedArch, rest[0]));
|
commit(groups, writeArchJobs(toothProsthesis, detailClientId, lockedArch, rest[0]));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const tooth = focusTooth;
|
const tooth = anchor;
|
||||||
if (!tooth) return;
|
if (!tooth) return;
|
||||||
const next = codesOnTooth(toothProsthesis, detailClientId, tooth).filter((c) => c !== code);
|
const next = codesOnTooth(toothProsthesis, detailClientId, tooth).filter((c) => c !== code);
|
||||||
if (next.length === 0) {
|
if (next.length === 0) {
|
||||||
@@ -264,7 +245,10 @@ export function ProsthesisAssignChart({
|
|||||||
removeTeethFromGroups(groups, [tooth]),
|
removeTeethFromGroups(groups, [tooth]),
|
||||||
clearJobsForTeeth(toothProsthesis, detailClientId, [tooth]),
|
clearJobsForTeeth(toothProsthesis, detailClientId, [tooth]),
|
||||||
);
|
);
|
||||||
brushRef.current = [];
|
if (rangeAnchorRef.current === tooth) {
|
||||||
|
brushRef.current = [];
|
||||||
|
rangeAnchorRef.current = null;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
applyCodes([tooth], next);
|
applyCodes([tooth], next);
|
||||||
@@ -274,27 +258,28 @@ export function ProsthesisAssignChart({
|
|||||||
if (scope === 'arch') {
|
if (scope === 'arch') {
|
||||||
commit(
|
commit(
|
||||||
groups,
|
groups,
|
||||||
clearJobsForTeeth(toothProsthesis, detailClientId, archSentinels(lockedArch)),
|
clearJobsForTeeth(toothProsthesis, detailClientId, archSentinels('both')),
|
||||||
);
|
);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const pending = pendingRangeRef.current;
|
const teeth = anchor ? [anchor] : [];
|
||||||
const teeth =
|
|
||||||
pending && pending.length > 0
|
|
||||||
? pending
|
|
||||||
: focusTooth
|
|
||||||
? [focusTooth]
|
|
||||||
: [];
|
|
||||||
if (teeth.length === 0) return;
|
if (teeth.length === 0) return;
|
||||||
commit(
|
commit(
|
||||||
removeTeethFromGroups(groups, teeth),
|
removeTeethFromGroups(groups, teeth),
|
||||||
clearJobsForTeeth(toothProsthesis, detailClientId, teeth),
|
clearJobsForTeeth(toothProsthesis, detailClientId, teeth),
|
||||||
);
|
);
|
||||||
brushRef.current = [];
|
brushRef.current = [];
|
||||||
|
rangeAnchorRef.current = null;
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setAnchor(null);
|
setAnchor(null);
|
||||||
setRange(null);
|
}
|
||||||
|
|
||||||
|
function handleClosePicker() {
|
||||||
|
if (scope === 'tooth') {
|
||||||
|
rememberBrushFromTooth(anchor);
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleReset() {
|
function handleReset() {
|
||||||
@@ -307,9 +292,9 @@ export function ProsthesisAssignChart({
|
|||||||
);
|
);
|
||||||
commit(groupsFromFlatTeeth([]), nextRows);
|
commit(groupsFromFlatTeeth([]), nextRows);
|
||||||
brushRef.current = [];
|
brushRef.current = [];
|
||||||
|
rangeAnchorRef.current = null;
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setAnchor(null);
|
setAnchor(null);
|
||||||
setRange(null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedSummary = (() => {
|
const selectedSummary = (() => {
|
||||||
@@ -349,26 +334,15 @@ export function ProsthesisAssignChart({
|
|||||||
scope={scope}
|
scope={scope}
|
||||||
lockedArch={scope === 'arch' ? lockedArch : undefined}
|
lockedArch={scope === 'arch' ? lockedArch : undefined}
|
||||||
jobCodes={jobCodes}
|
jobCodes={jobCodes}
|
||||||
tooth={focusTooth}
|
tooth={anchor}
|
||||||
rangeLabel={
|
allowCrownAddon={scope === 'tooth' && jobsAllowCrownAddon(jobCodes, byCode)}
|
||||||
pendingRange && pendingRange.length > 1 ? pendingRange.join(', ') : null
|
|
||||||
}
|
|
||||||
allowImplantAddon={
|
|
||||||
scope === 'tooth' && jobsAllowImplantAddon(jobCodes, byCode)
|
|
||||||
}
|
|
||||||
allowPostCoreAddon={
|
|
||||||
scope === 'tooth' && jobsAllowPostCoreAddon(jobCodes, byCode)
|
|
||||||
}
|
|
||||||
onStep={setStep}
|
onStep={setStep}
|
||||||
onLockedArchChange={setLockedArch}
|
onLockedArchChange={handleLockedArchChange}
|
||||||
onPickLeaf={handlePickLeaf}
|
onPickLeaf={handlePickLeaf}
|
||||||
onPickAddon={handlePickAddon}
|
onPickAddon={handlePickAddon}
|
||||||
onRemoveJob={handleRemoveJob}
|
onRemoveJob={handleRemoveJob}
|
||||||
onClear={handleClear}
|
onClear={handleClear}
|
||||||
onClose={() => {
|
onClose={handleClosePicker}
|
||||||
setOpen(false);
|
|
||||||
setRange(null);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
onAssignPointer={handleAssignPointer}
|
onAssignPointer={handleAssignPointer}
|
||||||
|
|||||||
@@ -2,36 +2,48 @@
|
|||||||
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Trash2 } from 'lucide-react';
|
import { ArrowLeft, Plus, Trash2 } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import {
|
import {
|
||||||
ResponsiveDialogOverlay,
|
ResponsiveDialogOverlay,
|
||||||
ResponsiveDialogPanel,
|
ResponsiveDialogPanel,
|
||||||
} from '@/components/ui/shared/ResponsiveDialog';
|
} from '@/components/ui/shared/ResponsiveDialog';
|
||||||
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
||||||
|
import { prosthesisFamilyTone } from '@/components/shared/catalog-type-colors';
|
||||||
import { prosthesisTypeColorFromCatalog, prosthesisTypeFillAccent } from '@/components/treatment/prosthesisTypeDisplay';
|
import { prosthesisTypeColorFromCatalog, prosthesisTypeFillAccent } from '@/components/treatment/prosthesisTypeDisplay';
|
||||||
import {
|
import {
|
||||||
addonLeaves,
|
catalogByCode,
|
||||||
categoryCodesInCatalog,
|
categoryCodesInCatalog,
|
||||||
|
categoryDisabledForJobs,
|
||||||
categoryTileColor,
|
categoryTileColor,
|
||||||
|
crownRestorationCode,
|
||||||
leavesFor,
|
leavesFor,
|
||||||
subcategoriesFor,
|
subcategoriesFor,
|
||||||
|
type AddonSlot,
|
||||||
type ArchTarget,
|
type ArchTarget,
|
||||||
type PickerScope,
|
type PickerScope,
|
||||||
} from '@/components/treatment/prosthesisTree';
|
} from '@/components/treatment/prosthesisTree';
|
||||||
|
|
||||||
const CHIP_INK = '#14253d';
|
const CHIP_INK = '#14253d';
|
||||||
const TILE_CLASS =
|
const TILE_CLASS =
|
||||||
'flex h-full min-h-0 w-[3.6rem] shrink-0 flex-row items-center justify-center gap-0.5 rounded-[var(--radius-md)] border px-0.5 py-2 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40';
|
'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';
|
||||||
const TILE_TEXT_CLASS =
|
const TILE_TEXT_CLASS =
|
||||||
'max-h-full overflow-hidden [writing-mode:vertical-rl] [text-orientation:mixed] whitespace-normal break-words text-center text-xs font-medium leading-tight sm:text-[13px]';
|
'w-full whitespace-normal break-words text-center text-xs font-medium leading-snug sm:text-[13px]';
|
||||||
const HEADER_BTN_CLASS = 'h-auto min-h-8 px-2.5 py-1 text-xs leading-tight';
|
const TILE_GRID_CLASS =
|
||||||
|
'grid gap-2 [grid-template-columns:repeat(auto-fill,minmax(8rem,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) {
|
||||||
|
return active
|
||||||
|
? 'border-primary ring-1 ring-primary/30'
|
||||||
|
: 'border-border/70 hover:border-border hover:bg-background-card/70';
|
||||||
|
}
|
||||||
|
|
||||||
export type PickerStep =
|
export type PickerStep =
|
||||||
| { kind: 'category' }
|
| { kind: 'category' }
|
||||||
| { kind: 'subcategory'; category: string; arch?: ArchTarget }
|
| { kind: 'subcategory'; category: string; arch?: ArchTarget }
|
||||||
| { kind: 'leaves'; category: string; subcategory?: string; arch?: ArchTarget }
|
| { kind: 'leaves'; category: string; subcategory?: string; arch?: ArchTarget }
|
||||||
| { kind: 'addon'; slot: 'implant' | 'post_core' };
|
| { kind: 'addon'; slot: AddonSlot };
|
||||||
|
|
||||||
interface ProsthesisJobPopoverProps {
|
interface ProsthesisJobPopoverProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -42,13 +54,11 @@ interface ProsthesisJobPopoverProps {
|
|||||||
lockedArch?: ArchTarget;
|
lockedArch?: ArchTarget;
|
||||||
jobCodes: string[];
|
jobCodes: string[];
|
||||||
tooth: string | null;
|
tooth: string | null;
|
||||||
rangeLabel?: string | null;
|
allowCrownAddon: boolean;
|
||||||
allowImplantAddon: boolean;
|
|
||||||
allowPostCoreAddon: boolean;
|
|
||||||
onStep: (step: PickerStep) => void;
|
onStep: (step: PickerStep) => void;
|
||||||
onLockedArchChange?: (arch: ArchTarget) => void;
|
onLockedArchChange?: (arch: ArchTarget) => void;
|
||||||
onPickLeaf: (code: string, arch?: ArchTarget) => void;
|
onPickLeaf: (code: string, arch?: ArchTarget) => void;
|
||||||
onPickAddon: (code: string | null, kind: 'implant' | 'post_core') => void;
|
onPickAddon: (code: string | null, kind: AddonSlot) => void;
|
||||||
onRemoveJob: (code: string) => void;
|
onRemoveJob: (code: string) => void;
|
||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -56,54 +66,47 @@ interface ProsthesisJobPopoverProps {
|
|||||||
|
|
||||||
function Tile({
|
function Tile({
|
||||||
label,
|
label,
|
||||||
caption,
|
|
||||||
color,
|
color,
|
||||||
active,
|
active,
|
||||||
|
disabled,
|
||||||
|
hint,
|
||||||
|
className = TILE_CLASS,
|
||||||
onClick,
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
caption?: string;
|
|
||||||
color?: string;
|
color?: string;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
hint?: string;
|
||||||
|
className?: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title={caption ? `${caption}: ${label}` : label}
|
title={hint ?? label}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`${TILE_CLASS} ${
|
disabled={disabled}
|
||||||
active
|
className={`${className} ${tileSurfaceClass(active)}`}
|
||||||
? 'border-primary ring-1 ring-primary/30'
|
|
||||||
: 'border-border/70 hover:border-border hover:bg-background-card/70'
|
|
||||||
}`}
|
|
||||||
style={color ? { backgroundColor: color, color: CHIP_INK } : undefined}
|
style={color ? { backgroundColor: color, color: CHIP_INK } : undefined}
|
||||||
>
|
>
|
||||||
{caption ? (
|
|
||||||
<span className="max-h-full max-w-[0.7rem] shrink-0 overflow-hidden [writing-mode:vertical-rl] [text-orientation:mixed] whitespace-normal break-words text-center text-[10px] font-medium leading-tight opacity-70">
|
|
||||||
{caption}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
<span className={TILE_TEXT_CLASS}>{label}</span>
|
<span className={TILE_TEXT_CLASS}>{label}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function VRule() {
|
|
||||||
return <div className="w-px shrink-0 self-stretch bg-border-strong" aria-hidden />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parentStep(step: PickerStep): PickerStep | null {
|
function parentStep(step: PickerStep): PickerStep | null {
|
||||||
if (step.kind === 'category') return null;
|
switch (step.kind) {
|
||||||
if (step.kind === 'addon') return { kind: 'category' };
|
case 'category':
|
||||||
if (step.kind === 'leaves') {
|
return null;
|
||||||
if (step.subcategory) {
|
case 'addon':
|
||||||
return { kind: 'subcategory', category: step.category, arch: step.arch };
|
case 'subcategory':
|
||||||
}
|
return { kind: 'category' };
|
||||||
return { kind: 'category' };
|
case 'leaves':
|
||||||
|
return step.subcategory
|
||||||
|
? { kind: 'subcategory', category: step.category, arch: step.arch }
|
||||||
|
: { kind: 'category' };
|
||||||
}
|
}
|
||||||
if (step.kind === 'subcategory') return { kind: 'category' };
|
|
||||||
return { kind: 'category' };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProsthesisJobPopover({
|
export function ProsthesisJobPopover({
|
||||||
@@ -115,9 +118,7 @@ export function ProsthesisJobPopover({
|
|||||||
lockedArch,
|
lockedArch,
|
||||||
jobCodes,
|
jobCodes,
|
||||||
tooth,
|
tooth,
|
||||||
rangeLabel,
|
allowCrownAddon,
|
||||||
allowImplantAddon,
|
|
||||||
allowPostCoreAddon,
|
|
||||||
onStep,
|
onStep,
|
||||||
onLockedArchChange,
|
onLockedArchChange,
|
||||||
onPickLeaf,
|
onPickLeaf,
|
||||||
@@ -130,14 +131,10 @@ export function ProsthesisJobPopover({
|
|||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
const categories = categoryCodesInCatalog(catalog, scope);
|
const categories = categoryCodesInCatalog(catalog, scope);
|
||||||
const implantAddons = addonLeaves(catalog, 'implant');
|
const byCode = catalogByCode(catalog);
|
||||||
const postCoreAddons = addonLeaves(catalog, 'post_core');
|
const crownLeaves = leavesFor(catalog, 'crown', undefined, 'tooth');
|
||||||
const currentImplant =
|
const currentCrown = crownRestorationCode(jobCodes, byCode) ?? '';
|
||||||
jobCodes.find((c) => catalog.find((e) => e.code === c)?.addonKind === 'implant') ?? '';
|
|
||||||
const currentPost =
|
|
||||||
jobCodes.find((c) => catalog.find((e) => e.code === c)?.addonKind === 'post_core') ?? '';
|
|
||||||
const hasLeaf = jobCodes.length > 0;
|
const hasLeaf = jobCodes.length > 0;
|
||||||
const byCode = new Map(catalog.map((e) => [e.code, e]));
|
|
||||||
const archForLeaves =
|
const archForLeaves =
|
||||||
step.kind === 'leaves' || step.kind === 'subcategory' ? step.arch : lockedArch;
|
step.kind === 'leaves' || step.kind === 'subcategory' ? step.arch : lockedArch;
|
||||||
|
|
||||||
@@ -151,6 +148,7 @@ export function ProsthesisJobPopover({
|
|||||||
const back = parentStep(step);
|
const back = parentStep(step);
|
||||||
|
|
||||||
function openCategory(code: string) {
|
function openCategory(code: string) {
|
||||||
|
if (categoryDisabledForJobs(code, jobCodes, byCode)) return;
|
||||||
const subs = subcategoriesFor(catalog, code, scope);
|
const subs = subcategoriesFor(catalog, code, scope);
|
||||||
const arch = scope === 'arch' ? lockedArch : undefined;
|
const arch = scope === 'arch' ? lockedArch : undefined;
|
||||||
if (subs.length > 0) {
|
if (subs.length > 0) {
|
||||||
@@ -173,53 +171,38 @@ export function ProsthesisJobPopover({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const addonTiles: ReactNode[] = [];
|
const addonTiles: ReactNode[] = [];
|
||||||
if (scope === 'tooth' && hasLeaf) {
|
if (scope === 'tooth' && allowCrownAddon && crownLeaves.length > 0) {
|
||||||
if (allowImplantAddon && implantAddons.length > 0) {
|
const addonActive = step.kind === 'addon' && step.slot === 'crown';
|
||||||
|
const openAddon = () =>
|
||||||
|
onStep(addonActive ? { kind: 'category' } : { kind: 'addon', slot: 'crown' });
|
||||||
|
if (currentCrown) {
|
||||||
addonTiles.push(
|
addonTiles.push(
|
||||||
<Tile
|
<Tile
|
||||||
key="slot-implant"
|
key="slot-crown"
|
||||||
caption={t('slotImplant')}
|
className={`${TILE_CLASS} h-full`}
|
||||||
label={
|
label={byCode.get(currentCrown)?.label ?? currentCrown}
|
||||||
currentImplant
|
color={prosthesisTypeColorFromCatalog(currentCrown, catalog)}
|
||||||
? (byCode.get(currentImplant)?.label ?? currentImplant)
|
active={addonActive}
|
||||||
: t('addonNone')
|
onClick={openAddon}
|
||||||
}
|
|
||||||
color={
|
|
||||||
currentImplant
|
|
||||||
? prosthesisTypeColorFromCatalog(currentImplant, catalog)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
active={step.kind === 'addon' && step.slot === 'implant'}
|
|
||||||
onClick={() =>
|
|
||||||
onStep(
|
|
||||||
step.kind === 'addon' && step.slot === 'implant'
|
|
||||||
? { kind: 'category' }
|
|
||||||
: { kind: 'addon', slot: 'implant' },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
}
|
} else {
|
||||||
if (allowPostCoreAddon && postCoreAddons.length > 0) {
|
|
||||||
addonTiles.push(
|
addonTiles.push(
|
||||||
<Tile
|
<button
|
||||||
key="slot-post"
|
key="slot-crown"
|
||||||
caption={t('slotPostCore')}
|
type="button"
|
||||||
label={
|
title={t('addonCrownCanBeAdded')}
|
||||||
currentPost ? (byCode.get(currentPost)?.label ?? currentPost) : t('addonNone')
|
aria-pressed={addonActive}
|
||||||
}
|
onClick={openAddon}
|
||||||
color={
|
className={`${TILE_CLASS} h-full ${tileSurfaceClass(addonActive)}`}
|
||||||
currentPost ? prosthesisTypeColorFromCatalog(currentPost, catalog) : undefined
|
>
|
||||||
}
|
<span className="inline-flex max-w-full items-center justify-center gap-1 rounded-[var(--radius-md)] border border-dashed border-border px-3 py-1.5 text-sm font-medium text-text-primary">
|
||||||
active={step.kind === 'addon' && step.slot === 'post_core'}
|
<Plus className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||||
onClick={() =>
|
<span className="whitespace-normal break-words text-center leading-tight">
|
||||||
onStep(
|
{t('addonCrownCanBeAdded')}
|
||||||
step.kind === 'addon' && step.slot === 'post_core'
|
</span>
|
||||||
? { kind: 'category' }
|
</span>
|
||||||
: { kind: 'addon', slot: 'post_core' },
|
</button>,
|
||||||
)
|
|
||||||
}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -228,103 +211,155 @@ export function ProsthesisJobPopover({
|
|||||||
let childTiles: ReactNode[] = [];
|
let childTiles: ReactNode[] = [];
|
||||||
let childrenKey = 'root';
|
let childrenKey = 'root';
|
||||||
|
|
||||||
if (step.kind === 'category') {
|
switch (step.kind) {
|
||||||
childTiles = categories.map((code) => (
|
case 'category':
|
||||||
<Tile
|
childTiles = categories.map((code) => {
|
||||||
key={code}
|
const disabled = categoryDisabledForJobs(code, jobCodes, byCode);
|
||||||
label={categoryLabel(code)}
|
return (
|
||||||
color={categoryTileColor(code)}
|
<Tile
|
||||||
onClick={() => openCategory(code)}
|
key={code}
|
||||||
/>
|
label={categoryLabel(code)}
|
||||||
));
|
color={categoryTileColor(code)}
|
||||||
} else if (step.kind === 'addon') {
|
disabled={disabled}
|
||||||
selectedNode = {
|
hint={disabled ? t('categoryDisabledHint') : undefined}
|
||||||
label: step.slot === 'implant' ? t('slotImplant') : t('slotPostCore'),
|
onClick={() => openCategory(code)}
|
||||||
};
|
/>
|
||||||
childrenKey = `addon-${step.slot}`;
|
);
|
||||||
const options = step.slot === 'implant' ? implantAddons : postCoreAddons;
|
});
|
||||||
const current = step.slot === 'implant' ? currentImplant : currentPost;
|
break;
|
||||||
childTiles = [
|
case 'addon':
|
||||||
<Tile
|
selectedNode = { label: t('slotCrown'), color: categoryTileColor('crown') };
|
||||||
key="none"
|
childrenKey = 'addon-crown';
|
||||||
label={t('addonNone')}
|
childTiles = [
|
||||||
active={!current}
|
|
||||||
onClick={() => onPickAddon(null, step.slot)}
|
|
||||||
/>,
|
|
||||||
...options.map((opt) => (
|
|
||||||
<Tile
|
<Tile
|
||||||
key={opt.code}
|
key="none"
|
||||||
label={opt.label}
|
label={t('addonNone')}
|
||||||
color={prosthesisTypeColorFromCatalog(opt.code, catalog)}
|
active={!currentCrown}
|
||||||
active={current === opt.code}
|
onClick={() => onPickAddon(null, 'crown')}
|
||||||
onClick={() => onPickAddon(opt.code, step.slot)}
|
/>,
|
||||||
/>
|
...crownLeaves.map((opt) => (
|
||||||
)),
|
<Tile
|
||||||
];
|
key={opt.code}
|
||||||
} else if (step.kind === 'leaves' && step.subcategory) {
|
label={opt.label}
|
||||||
selectedNode = { label: subLabel(step.subcategory) };
|
color={prosthesisTypeColorFromCatalog(opt.code, catalog)}
|
||||||
childrenKey = `leaves-${step.category}-${step.subcategory}`;
|
active={currentCrown === opt.code}
|
||||||
childTiles = leavesFor(catalog, step.category, step.subcategory, scope).map(leafTile);
|
onClick={() => onPickAddon(opt.code, 'crown')}
|
||||||
} else {
|
/>
|
||||||
selectedNode = {
|
)),
|
||||||
label: categoryLabel(step.category),
|
];
|
||||||
color: categoryTileColor(step.category),
|
break;
|
||||||
};
|
case 'subcategory': {
|
||||||
childrenKey = `${step.kind}-${step.category}`;
|
const parentFill = categoryTileColor(step.category);
|
||||||
const arch = step.arch;
|
selectedNode = { label: categoryLabel(step.category), color: parentFill };
|
||||||
const subs =
|
childrenKey = `sub-${step.category}`;
|
||||||
step.kind === 'subcategory'
|
childTiles = [
|
||||||
? subcategoriesFor(catalog, step.category, scope).map((sub) => (
|
...subcategoriesFor(catalog, step.category, scope).map((sub, i) => (
|
||||||
<Tile
|
<Tile
|
||||||
key={sub}
|
key={sub}
|
||||||
label={subLabel(sub)}
|
label={subLabel(sub)}
|
||||||
onClick={() =>
|
color={prosthesisFamilyTone(parentFill, i + 1)}
|
||||||
onStep({
|
onClick={() =>
|
||||||
kind: 'leaves',
|
onStep({
|
||||||
category: step.category,
|
kind: 'leaves',
|
||||||
subcategory: sub,
|
category: step.category,
|
||||||
arch,
|
subcategory: sub,
|
||||||
})
|
arch: step.arch,
|
||||||
}
|
})
|
||||||
/>
|
}
|
||||||
))
|
/>
|
||||||
: [];
|
)),
|
||||||
const leaves = leavesFor(
|
...leavesFor(catalog, step.category, undefined, scope).map(leafTile),
|
||||||
catalog,
|
];
|
||||||
step.category,
|
break;
|
||||||
step.kind === 'leaves' ? step.subcategory : undefined,
|
}
|
||||||
scope,
|
case 'leaves':
|
||||||
).map(leafTile);
|
if (step.subcategory) {
|
||||||
childTiles = [...subs, ...leaves];
|
selectedNode = {
|
||||||
|
label: subLabel(step.subcategory),
|
||||||
|
color: prosthesisFamilyTone(categoryTileColor(step.category), 1),
|
||||||
|
};
|
||||||
|
childrenKey = `leaves-${step.category}-${step.subcategory}`;
|
||||||
|
childTiles = leavesFor(catalog, step.category, step.subcategory, scope).map(leafTile);
|
||||||
|
} else {
|
||||||
|
selectedNode = {
|
||||||
|
label: categoryLabel(step.category),
|
||||||
|
color: categoryTileColor(step.category),
|
||||||
|
};
|
||||||
|
childrenKey = `leaves-${step.category}`;
|
||||||
|
childTiles = leavesFor(catalog, step.category, undefined, scope).map(leafTile);
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tileRow = (
|
const tileGrid = (
|
||||||
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-row items-stretch gap-2 overflow-x-auto overflow-y-hidden pe-1">
|
<div
|
||||||
{selectedNode ? (
|
key={childrenKey}
|
||||||
<div className="flex h-full min-h-0 shrink-0 flex-row items-stretch gap-4">
|
className={`picker-expand min-w-0 flex-1 ${TILE_GRID_CLASS}${
|
||||||
<Tile
|
selectedNode ? '' : ' h-full min-h-0 [grid-auto-rows:minmax(3.25rem,1fr)]'
|
||||||
label={selectedNode.label}
|
}`}
|
||||||
color={selectedNode.color}
|
>
|
||||||
active
|
{childTiles}
|
||||||
onClick={() => back && onStep(back)}
|
|
||||||
/>
|
|
||||||
<VRule />
|
|
||||||
<div
|
|
||||||
key={childrenKey}
|
|
||||||
className="picker-expand flex h-full min-h-0 shrink-0 flex-row items-stretch gap-2"
|
|
||||||
>
|
|
||||||
{childTiles}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
childTiles
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const title = rangeLabel
|
const branchColor = selectedNode?.color ?? 'var(--color-border-strong)';
|
||||||
? t('pickerTeeth', { teeth: rangeLabel })
|
const tileRow = selectedNode ? (
|
||||||
: scope === 'arch'
|
<div className="flex min-h-0 flex-col gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (back) onStep(back);
|
||||||
|
}}
|
||||||
|
disabled={!back}
|
||||||
|
className={`${TILE_CLASS} flex-row justify-start gap-2 px-3 text-start`}
|
||||||
|
style={
|
||||||
|
selectedNode.color
|
||||||
|
? { backgroundColor: selectedNode.color, color: CHIP_INK }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className={`${TILE_TEXT_CLASS} text-start`}>{selectedNode.label}</span>
|
||||||
|
</button>
|
||||||
|
<div className="flex items-start">
|
||||||
|
<div className="relative h-[3.25rem] w-3 shrink-0" aria-hidden>
|
||||||
|
<span
|
||||||
|
className="absolute w-0.5"
|
||||||
|
style={{
|
||||||
|
backgroundColor: branchColor,
|
||||||
|
insetInlineStart: 'calc(50% - 1px)',
|
||||||
|
top: 0,
|
||||||
|
height: '50%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="absolute h-0.5"
|
||||||
|
style={{
|
||||||
|
backgroundColor: branchColor,
|
||||||
|
top: 'calc(50% - 1px)',
|
||||||
|
insetInlineStart: '50%',
|
||||||
|
width: '50%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{tileGrid}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
tileGrid
|
||||||
|
);
|
||||||
|
|
||||||
|
const title =
|
||||||
|
scope === 'arch'
|
||||||
? lockedArch === 'both'
|
? lockedArch === 'both'
|
||||||
? t('pickerArchBoth')
|
? t('pickerArchBoth')
|
||||||
: lockedArch === 'lower'
|
: lockedArch === 'lower'
|
||||||
@@ -334,11 +369,90 @@ export function ProsthesisJobPopover({
|
|||||||
? t('pickerTooth', { tooth })
|
? t('pickerTooth', { tooth })
|
||||||
: t('pickerTitle');
|
: t('pickerTitle');
|
||||||
|
|
||||||
const header = (
|
const jobChips =
|
||||||
<div className="space-y-1.5 shrink-0">
|
jobCodes.length === 0 ? (
|
||||||
<div className="flex items-start justify-between gap-2">
|
<p className="text-xs text-text-muted">{t('noneYet')}</p>
|
||||||
<p className="min-w-0 text-xs font-semibold text-text-primary">{title}</p>
|
) : (
|
||||||
<div className="flex shrink-0 items-center gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{jobCodes.map((code) => {
|
||||||
|
const entry = byCode.get(code);
|
||||||
|
const color = prosthesisTypeColorFromCatalog(code, catalog);
|
||||||
|
const trashColor = prosthesisTypeFillAccent(color);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={code}
|
||||||
|
className="inline-flex items-stretch overflow-hidden rounded-[var(--radius-md)] border text-[11px] font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: color,
|
||||||
|
color: CHIP_INK,
|
||||||
|
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>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center justify-center border-s px-1.5 transition-colors hover:brightness-90"
|
||||||
|
style={{
|
||||||
|
borderColor: trashColor,
|
||||||
|
color: trashColor,
|
||||||
|
}}
|
||||||
|
aria-label={t('removeJob')}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemoveJob(code);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="lucide-inherit h-3.5 w-3.5" aria-hidden />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
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">
|
||||||
|
<p className="shrink-0 text-xs font-semibold leading-none text-text-primary">{title}</p>
|
||||||
|
{scope === 'arch' && onLockedArchChange ? (
|
||||||
|
<div
|
||||||
|
className="flex h-8 w-1/2 shrink-0 rounded-[var(--radius-md)] border border-border p-0.5"
|
||||||
|
role="group"
|
||||||
|
aria-label={title}
|
||||||
|
>
|
||||||
|
{(['upper', 'lower', 'both'] as const).map((arch) => (
|
||||||
|
<button
|
||||||
|
key={arch}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onLockedArchChange(arch)}
|
||||||
|
className={`h-full min-w-0 flex-1 rounded-[var(--radius-sm)] px-2 text-xs font-medium leading-tight ${
|
||||||
|
lockedArch === arch
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'text-text-secondary hover:text-text-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`arch_${arch}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="shrink-0">{jobChips}</div>
|
||||||
|
<p className="shrink-0 text-[10px] leading-tight text-text-muted">
|
||||||
|
{scope === 'arch' ? t('clearHintArch') : t('clearHint')}
|
||||||
|
</p>
|
||||||
|
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden pe-0.5">
|
||||||
|
{tileRow}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="flex min-h-0 min-w-0 flex-col gap-1">
|
||||||
|
<div className="flex shrink-0 flex-col gap-1">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -359,91 +473,15 @@ export function ProsthesisJobPopover({
|
|||||||
{t('done')}
|
{t('done')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{addonTiles.length > 0 ? (
|
||||||
{scope === 'arch' && onLockedArchChange ? (
|
<div className="flex min-h-0 flex-1 flex-col gap-1">
|
||||||
<div className="flex gap-1">
|
<p className="shrink-0 text-[10px] font-medium leading-tight text-text-muted">
|
||||||
{(['upper', 'lower', 'both'] as const).map((arch) => (
|
{t('suggestionsLabel')}
|
||||||
<button
|
</p>
|
||||||
key={arch}
|
<div className="min-h-0 flex-1">{addonTiles}</div>
|
||||||
type="button"
|
</div>
|
||||||
onClick={() => onLockedArchChange(arch)}
|
) : null}
|
||||||
className={`flex-1 min-h-9 rounded-[var(--radius-sm)] border text-xs font-medium ${
|
</section>
|
||||||
lockedArch === arch
|
|
||||||
? 'border-primary bg-primary-soft text-text-primary'
|
|
||||||
: 'border-border/70 text-text-secondary hover:border-border'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t(`arch_${arch}`)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{jobCodes.length === 0 ? (
|
|
||||||
<p className="text-xs text-text-muted">{t('noneYet')}</p>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{jobCodes.map((code) => {
|
|
||||||
const entry = byCode.get(code);
|
|
||||||
const color = prosthesisTypeColorFromCatalog(code, catalog);
|
|
||||||
const trashColor = prosthesisTypeFillAccent(color);
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
key={code}
|
|
||||||
className="inline-flex items-stretch overflow-hidden rounded-[var(--radius-md)] border text-[11px] font-medium"
|
|
||||||
style={{
|
|
||||||
backgroundColor: color,
|
|
||||||
color: CHIP_INK,
|
|
||||||
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>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center justify-center border-s px-1.5 transition-colors hover:brightness-90"
|
|
||||||
style={{
|
|
||||||
borderColor: trashColor,
|
|
||||||
color: trashColor,
|
|
||||||
}}
|
|
||||||
aria-label={t('removeJob')}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onRemoveJob(code);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="lucide-inherit h-3.5 w-3.5" aria-hidden />
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className="text-[10px] text-text-muted">
|
|
||||||
{scope === 'arch' ? t('clearHintArch') : t('clearHint')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const addonRail =
|
|
||||||
addonTiles.length > 0 ? (
|
|
||||||
<div className="flex shrink-0 flex-row items-stretch gap-2 border-s border-border ps-3">
|
|
||||||
{addonTiles}
|
|
||||||
</div>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
const body = (
|
|
||||||
<div className="flex h-full min-h-0 flex-col gap-3">
|
|
||||||
{header}
|
|
||||||
<div className="flex min-h-0 flex-1 flex-row items-stretch gap-0">
|
|
||||||
{tileRow}
|
|
||||||
{addonRail}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -453,7 +491,7 @@ export function ProsthesisJobPopover({
|
|||||||
<ResponsiveDialogPanel
|
<ResponsiveDialogPanel
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
maxWidthClass="sm:max-w-lg"
|
maxWidthClass="sm:max-w-2xl"
|
||||||
className="max-h-[90dvh] flex flex-col"
|
className="max-h-[90dvh] flex flex-col"
|
||||||
>
|
>
|
||||||
<h4 className="sr-only">{t('pickerTitle')}</h4>
|
<h4 className="sr-only">{t('pickerTitle')}</h4>
|
||||||
@@ -472,7 +510,7 @@ export function ProsthesisJobPopover({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 z-10 pointer-events-auto flex flex-col overflow-hidden rounded-[var(--radius-md)] border border-border bg-background-secondary p-3 shadow-lg"
|
className="absolute inset-0 z-10 pointer-events-auto flex flex-col overflow-hidden rounded-[var(--radius-md)] border border-border bg-background-secondary px-3 pb-3 pt-2 shadow-lg"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ interface TreatmentDetailsEditorProps {
|
|||||||
onTreatmentTypeChange?: (nextType: string) => void;
|
onTreatmentTypeChange?: (nextType: string) => void;
|
||||||
/** Arch-level jobs count as work for the missing-teeth lab banner. */
|
/** Arch-level jobs count as work for the missing-teeth lab banner. */
|
||||||
hasArchJobs?: boolean;
|
hasArchJobs?: boolean;
|
||||||
|
showLabDispatchToggle?: boolean;
|
||||||
|
labWorkspaceView?: 'chart' | 'dispatch';
|
||||||
|
onLabWorkspaceViewChange?: (view: 'chart' | 'dispatch') => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TreatmentDetailsEditor({
|
export function TreatmentDetailsEditor({
|
||||||
@@ -79,6 +82,9 @@ export function TreatmentDetailsEditor({
|
|||||||
chartLockMessage,
|
chartLockMessage,
|
||||||
onTreatmentTypeChange,
|
onTreatmentTypeChange,
|
||||||
hasArchJobs = false,
|
hasArchJobs = false,
|
||||||
|
showLabDispatchToggle = false,
|
||||||
|
labWorkspaceView = 'chart',
|
||||||
|
onLabWorkspaceViewChange,
|
||||||
}: TreatmentDetailsEditorProps) {
|
}: TreatmentDetailsEditorProps) {
|
||||||
const t = useTranslations('treatment');
|
const t = useTranslations('treatment');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
@@ -229,7 +235,7 @@ export function TreatmentDetailsEditor({
|
|||||||
|
|
||||||
{showFields && activeDetail ? (
|
{showFields && activeDetail ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:items-end">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] sm:items-end">
|
||||||
<Dropdown
|
<Dropdown
|
||||||
label={t('treatmentType')}
|
label={t('treatmentType')}
|
||||||
value={activeDetail.treatmentType}
|
value={activeDetail.treatmentType}
|
||||||
@@ -253,6 +259,32 @@ export function TreatmentDetailsEditor({
|
|||||||
onUploadFiles={onUploadFiles}
|
onUploadFiles={onUploadFiles}
|
||||||
onRemoveAttachment={readOnly ? undefined : onRemoveAttachment}
|
onRemoveAttachment={readOnly ? undefined : onRemoveAttachment}
|
||||||
/>
|
/>
|
||||||
|
{showLabDispatchToggle ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={labWorkspaceView === 'dispatch' ? 'outline' : 'primary'}
|
||||||
|
className={`h-[2.875rem] sm:h-9 shrink-0 px-3 text-sm ${
|
||||||
|
labWorkspaceView === 'dispatch' ? 'bg-background-secondary' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() =>
|
||||||
|
onLabWorkspaceViewChange?.(
|
||||||
|
labWorkspaceView === 'dispatch' ? 'chart' : 'dispatch',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{chart ? (
|
{chart ? (
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ import {
|
|||||||
toggleToothInGroups,
|
toggleToothInGroups,
|
||||||
unlinkAdjacentTeeth,
|
unlinkAdjacentTeeth,
|
||||||
} from '@/components/treatment/toothSelectionGroups';
|
} from '@/components/treatment/toothSelectionGroups';
|
||||||
import { hasArchJobs } from '@/components/treatment/prosthesisTree';
|
import { hasArchJobs, pruneDetailTeethToJobs } from '@/components/treatment/prosthesisTree';
|
||||||
import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention';
|
import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention';
|
||||||
import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention';
|
import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention';
|
||||||
import {
|
import {
|
||||||
@@ -114,6 +114,19 @@ function labCaseDraftForDetail(
|
|||||||
return rows.find((lc) => !lc.sentAt) ?? rows[0];
|
return rows.find((lc) => !lc.sentAt) ?? rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pruneLabDependentDetailsToJobs(
|
||||||
|
details: TreatmentDetailDraft[],
|
||||||
|
drafts: LabCaseDraft[],
|
||||||
|
labDependentCodes: Set<string>,
|
||||||
|
): TreatmentDetailDraft[] {
|
||||||
|
if (labDependentCodes.size === 0) return details;
|
||||||
|
return details.map((detail) => {
|
||||||
|
if (!labDependentCodes.has(detail.treatmentType)) return detail;
|
||||||
|
const jobs = labCaseDraftForDetail(drafts, detail.clientId)?.toothProsthesis ?? [];
|
||||||
|
return pruneDetailTeethToJobs(detail, jobs);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function labCaseDraftsToPast(
|
function labCaseDraftsToPast(
|
||||||
labCaseDrafts: LabCaseDraft[],
|
labCaseDrafts: LabCaseDraft[],
|
||||||
details: TreatmentDetailDraft[],
|
details: TreatmentDetailDraft[],
|
||||||
@@ -422,6 +435,29 @@ interface TreatmentWorkspaceProps {
|
|||||||
initialLabCaseId?: string | null;
|
initialLabCaseId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function WholePlanToggle({
|
||||||
|
pressed,
|
||||||
|
label,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
pressed: boolean;
|
||||||
|
label: string;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={pressed ? 'secondary' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
aria-pressed={pressed}
|
||||||
|
onClick={onToggle}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function TreatmentWorkspace({
|
export function TreatmentWorkspace({
|
||||||
userId,
|
userId,
|
||||||
currentOrganization,
|
currentOrganization,
|
||||||
@@ -518,6 +554,7 @@ export function TreatmentWorkspace({
|
|||||||
const pendingAppointmentIdRef = useRef<string | null>(initialAppointmentId);
|
const pendingAppointmentIdRef = useRef<string | null>(initialAppointmentId);
|
||||||
const pendingLabCaseIdRef = useRef<string | null>(initialLabCaseId);
|
const pendingLabCaseIdRef = useRef<string | null>(initialLabCaseId);
|
||||||
const labPanelRef = useRef<HTMLDivElement>(null);
|
const labPanelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [labWorkspaceView, setLabWorkspaceView] = useState<'chart' | 'dispatch'>('chart');
|
||||||
const historyRequestRef = useRef(0);
|
const historyRequestRef = useRef(0);
|
||||||
/** When set, switching the active detail scrolls to the lab dispatch panel. */
|
/** When set, switching the active detail scrolls to the lab dispatch panel. */
|
||||||
const pendingScrollToLabRef = useRef(false);
|
const pendingScrollToLabRef = useRef(false);
|
||||||
@@ -704,6 +741,20 @@ export function TreatmentWorkspace({
|
|||||||
[activeDetail, labDependentCodes],
|
[activeDetail, labDependentCodes],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (labDependentCodes.size === 0) return;
|
||||||
|
setDetails((prev) => {
|
||||||
|
const next = pruneLabDependentDetailsToJobs(
|
||||||
|
prev,
|
||||||
|
labCaseDraftsRef.current,
|
||||||
|
labDependentCodes,
|
||||||
|
);
|
||||||
|
if (serializeDetails(next) === serializeDetails(prev)) return prev;
|
||||||
|
detailsRef.current = next;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [labDependentCodes]);
|
||||||
|
|
||||||
const activeTypeSelected = Boolean(activeDetail && isDetailTypeSelected(activeDetail));
|
const activeTypeSelected = Boolean(activeDetail && isDetailTypeSelected(activeDetail));
|
||||||
const activeLocked = Boolean(activeDetail && isDetailLocked(activeDetail));
|
const activeLocked = Boolean(activeDetail && isDetailLocked(activeDetail));
|
||||||
const activeHasArchJobs = hasArchJobs(
|
const activeHasArchJobs = hasArchJobs(
|
||||||
@@ -846,7 +897,14 @@ export function TreatmentWorkspace({
|
|||||||
treatment: PastTreatment,
|
treatment: PastTreatment,
|
||||||
options?: { seedBlankIfEmpty?: boolean },
|
options?: { seedBlankIfEmpty?: boolean },
|
||||||
) => {
|
) => {
|
||||||
const mapped = treatment.details.map(mapDetailFromApi);
|
const mappedLabCases = withoutEmptyLabCaseDrafts(
|
||||||
|
(treatment.labCases ?? []).map(mapLabCaseDraftFromApi),
|
||||||
|
);
|
||||||
|
const mapped = pruneLabDependentDetailsToJobs(
|
||||||
|
treatment.details.map(mapDetailFromApi),
|
||||||
|
mappedLabCases,
|
||||||
|
labDependentCodes,
|
||||||
|
);
|
||||||
const nextDetails =
|
const nextDetails =
|
||||||
mapped.length > 0
|
mapped.length > 0
|
||||||
? mapped
|
? mapped
|
||||||
@@ -859,9 +917,6 @@ export function TreatmentWorkspace({
|
|||||||
return stillExists ? prev : (nextDetails[0]?.clientId ?? '');
|
return stillExists ? prev : (nextDetails[0]?.clientId ?? '');
|
||||||
});
|
});
|
||||||
setSavedSnapshot(serializeDetails(nextDetails));
|
setSavedSnapshot(serializeDetails(nextDetails));
|
||||||
const mappedLabCases = withoutEmptyLabCaseDrafts(
|
|
||||||
(treatment.labCases ?? []).map(mapLabCaseDraftFromApi),
|
|
||||||
);
|
|
||||||
const labSnap = serializeLabCases(mappedLabCases);
|
const labSnap = serializeLabCases(mappedLabCases);
|
||||||
labCaseDraftsRef.current = mappedLabCases;
|
labCaseDraftsRef.current = mappedLabCases;
|
||||||
savedLabCasesSnapshotRef.current = labSnap;
|
savedLabCasesSnapshotRef.current = labSnap;
|
||||||
@@ -870,7 +925,7 @@ export function TreatmentWorkspace({
|
|||||||
setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null);
|
setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null);
|
||||||
setOrganizationSearch('');
|
setOrganizationSearch('');
|
||||||
setSaveStatus('idle');
|
setSaveStatus('idle');
|
||||||
}, []);
|
}, [labDependentCodes]);
|
||||||
|
|
||||||
const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]);
|
const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]);
|
||||||
const connectedSelectedTeeth = useMemo(
|
const connectedSelectedTeeth = useMemo(
|
||||||
@@ -929,12 +984,19 @@ export function TreatmentWorkspace({
|
|||||||
setOrganizationSearch('');
|
setOrganizationSearch('');
|
||||||
if (pendingScrollToLabRef.current) {
|
if (pendingScrollToLabRef.current) {
|
||||||
pendingScrollToLabRef.current = false;
|
pendingScrollToLabRef.current = false;
|
||||||
|
setLabWorkspaceView('dispatch');
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
scrollWithinMainScrollContainer(labPanelRef.current);
|
scrollWithinMainScrollContainer(labPanelRef.current);
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
setLabWorkspaceView('chart');
|
||||||
}
|
}
|
||||||
}, [activeDetailId]);
|
}, [activeDetailId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showLabDispatch) setLabWorkspaceView('chart');
|
||||||
|
}, [showLabDispatch]);
|
||||||
|
|
||||||
// Sync active lab shipment when the selected treatment detail changes.
|
// Sync active lab shipment when the selected treatment detail changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const match = labCaseDraftForDetail(labCaseDrafts, activeDetailId);
|
const match = labCaseDraftForDetail(labCaseDrafts, activeDetailId);
|
||||||
@@ -1201,8 +1263,16 @@ export function TreatmentWorkspace({
|
|||||||
: await treatmentsApi.getDraftByTreatment(treatmentId!);
|
: await treatmentsApi.getDraftByTreatment(treatmentId!);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const mappedLabCases = withoutEmptyLabCaseDrafts(
|
||||||
|
(response.data?.labCases ?? []).map(mapLabCaseDraftFromApi),
|
||||||
|
);
|
||||||
|
|
||||||
if (response.data?.details?.length) {
|
if (response.data?.details?.length) {
|
||||||
const mapped = response.data.details.map(mapDetailFromApi);
|
const mapped = pruneLabDependentDetailsToJobs(
|
||||||
|
response.data.details.map(mapDetailFromApi),
|
||||||
|
mappedLabCases,
|
||||||
|
labDependentCodes,
|
||||||
|
);
|
||||||
setDetails(mapped);
|
setDetails(mapped);
|
||||||
setActiveDetailId((prev) => {
|
setActiveDetailId((prev) => {
|
||||||
const stillExists = mapped.some((d) => d.clientId === prev);
|
const stillExists = mapped.some((d) => d.clientId === prev);
|
||||||
@@ -1222,9 +1292,6 @@ export function TreatmentWorkspace({
|
|||||||
setSavedSnapshot(serializeDetails([first]));
|
setSavedSnapshot(serializeDetails([first]));
|
||||||
}
|
}
|
||||||
|
|
||||||
const mappedLabCases = withoutEmptyLabCaseDrafts(
|
|
||||||
(response.data?.labCases ?? []).map(mapLabCaseDraftFromApi),
|
|
||||||
);
|
|
||||||
const labSnap = serializeLabCases(mappedLabCases);
|
const labSnap = serializeLabCases(mappedLabCases);
|
||||||
labCaseDraftsRef.current = mappedLabCases;
|
labCaseDraftsRef.current = mappedLabCases;
|
||||||
savedLabCasesSnapshotRef.current = labSnap;
|
savedLabCasesSnapshotRef.current = labSnap;
|
||||||
@@ -1253,6 +1320,7 @@ export function TreatmentWorkspace({
|
|||||||
selectedStandalone?.id,
|
selectedStandalone?.id,
|
||||||
workspaceMode,
|
workspaceMode,
|
||||||
treatmentCatalog,
|
treatmentCatalog,
|
||||||
|
labDependentCodes,
|
||||||
showError,
|
showError,
|
||||||
t,
|
t,
|
||||||
tErrors,
|
tErrors,
|
||||||
@@ -1270,7 +1338,15 @@ export function TreatmentWorkspace({
|
|||||||
const patientId = selectedAppointment?.patientId ?? selectedStandalone!.patientId;
|
const patientId = selectedAppointment?.patientId ?? selectedStandalone!.patientId;
|
||||||
const treatmentAt = selectedAppointment?.startAt ?? selectedStandalone!.treatmentAt;
|
const treatmentAt = selectedAppointment?.startAt ?? selectedStandalone!.treatmentAt;
|
||||||
|
|
||||||
const currentDetails = detailsRef.current;
|
const currentDetails = pruneLabDependentDetailsToJobs(
|
||||||
|
detailsRef.current,
|
||||||
|
labCaseDraftsRef.current,
|
||||||
|
labDependentCodes,
|
||||||
|
);
|
||||||
|
if (serializeDetails(currentDetails) !== serializeDetails(detailsRef.current)) {
|
||||||
|
detailsRef.current = currentDetails;
|
||||||
|
setDetails(currentDetails);
|
||||||
|
}
|
||||||
const dirty = isDetailsDirty(currentDetails, savedSnapshotRef.current);
|
const dirty = isDetailsDirty(currentDetails, savedSnapshotRef.current);
|
||||||
|
|
||||||
if (!options?.force && !dirty) {
|
if (!options?.force && !dirty) {
|
||||||
@@ -1333,7 +1409,7 @@ export function TreatmentWorkspace({
|
|||||||
}
|
}
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
[selectedAppointment, selectedStandalone, t],
|
[labDependentCodes, selectedAppointment, selectedStandalone, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const persistLabCases = useCallback(
|
const persistLabCases = useCallback(
|
||||||
@@ -1881,8 +1957,11 @@ export function TreatmentWorkspace({
|
|||||||
(item: LabDispatchAttentionItem) => {
|
(item: LabDispatchAttentionItem) => {
|
||||||
if (item.isCurrentDraft) {
|
if (item.isCurrentDraft) {
|
||||||
exitBrowse();
|
exitBrowse();
|
||||||
pendingScrollToLabRef.current = true;
|
setLabWorkspaceView('dispatch');
|
||||||
setActiveDetailId(item.detailClientId);
|
if (item.detailClientId !== activeDetailId) {
|
||||||
|
pendingScrollToLabRef.current = true;
|
||||||
|
setActiveDetailId(item.detailClientId);
|
||||||
|
}
|
||||||
const linked = labCaseDrafts.find(
|
const linked = labCaseDrafts.find(
|
||||||
(lc) => !lc.sentAt && lc.detailClientId === item.detailClientId,
|
(lc) => !lc.sentAt && lc.detailClientId === item.detailClientId,
|
||||||
);
|
);
|
||||||
@@ -1901,7 +1980,7 @@ export function TreatmentWorkspace({
|
|||||||
if (!treatment) return;
|
if (!treatment) return;
|
||||||
void loadTreatmentIntoWorkspace(treatment, item.detailClientId);
|
void loadTreatmentIntoWorkspace(treatment, item.detailClientId);
|
||||||
},
|
},
|
||||||
[exitBrowse, history, historyPanelItems, labCaseDrafts, loadTreatmentIntoWorkspace],
|
[exitBrowse, activeDetailId, history, historyPanelItems, labCaseDrafts, loadTreatmentIntoWorkspace],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelectPatientLabCase = useCallback(
|
const handleSelectPatientLabCase = useCallback(
|
||||||
@@ -1949,8 +2028,11 @@ export function TreatmentWorkspace({
|
|||||||
workspaceMode === 'live' &&
|
workspaceMode === 'live' &&
|
||||||
!isBrowsing
|
!isBrowsing
|
||||||
) {
|
) {
|
||||||
pendingScrollToLabRef.current = true;
|
setLabWorkspaceView('dispatch');
|
||||||
setActiveDetailId(item.detailClientId);
|
if (item.detailClientId !== activeDetailId) {
|
||||||
|
pendingScrollToLabRef.current = true;
|
||||||
|
setActiveDetailId(item.detailClientId);
|
||||||
|
}
|
||||||
const matchingDraft = labCaseDrafts.find((lc) => lc.id === item.labCaseId);
|
const matchingDraft = labCaseDrafts.find((lc) => lc.id === item.labCaseId);
|
||||||
if (matchingDraft) {
|
if (matchingDraft) {
|
||||||
setActiveLabCaseId(matchingDraft.clientId);
|
setActiveLabCaseId(matchingDraft.clientId);
|
||||||
@@ -1967,6 +2049,7 @@ export function TreatmentWorkspace({
|
|||||||
})();
|
})();
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
activeDetailId,
|
||||||
activePatientId,
|
activePatientId,
|
||||||
currentDraftPreview,
|
currentDraftPreview,
|
||||||
handleLabCaseMarkedRead,
|
handleLabCaseMarkedRead,
|
||||||
@@ -2244,6 +2327,7 @@ export function TreatmentWorkspace({
|
|||||||
if (detail.treatmentType === nextType) return;
|
if (detail.treatmentType === nextType) return;
|
||||||
|
|
||||||
const leavingLab = labDependentCodes.has(detail.treatmentType);
|
const leavingLab = labDependentCodes.has(detail.treatmentType);
|
||||||
|
const enteringLab = labDependentCodes.has(nextType);
|
||||||
const jobs =
|
const jobs =
|
||||||
labCaseDraftForDetail(labCaseDrafts, activeDetailId)?.toothProsthesis ?? [];
|
labCaseDraftForDetail(labCaseDrafts, activeDetailId)?.toothProsthesis ?? [];
|
||||||
const hasWork =
|
const hasWork =
|
||||||
@@ -2257,7 +2341,7 @@ export function TreatmentWorkspace({
|
|||||||
|
|
||||||
const nextDetails = details.map((d) => {
|
const nextDetails = details.map((d) => {
|
||||||
if (d.clientId !== activeDetailId) return d;
|
if (d.clientId !== activeDetailId) return d;
|
||||||
if (leavingLab) {
|
if (leavingLab || enteringLab) {
|
||||||
return {
|
return {
|
||||||
...d,
|
...d,
|
||||||
treatmentType: nextType,
|
treatmentType: nextType,
|
||||||
@@ -2495,6 +2579,39 @@ export function TreatmentWorkspace({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const labDispatchPanel = (
|
||||||
|
<LabCasesDispatchPanel
|
||||||
|
details={details}
|
||||||
|
activeDetailId={activeDetailId}
|
||||||
|
labCases={labCaseDrafts}
|
||||||
|
labDependentCodes={labDependentCodes}
|
||||||
|
treatmentCatalog={treatmentCatalog}
|
||||||
|
prosthesisCatalog={prosthesisCatalog}
|
||||||
|
labCaseSummary={activeLabCaseSummary}
|
||||||
|
locale={locale}
|
||||||
|
onLabCaseSummaryChange={handleLabCaseSummaryChange}
|
||||||
|
onLabCaseMarkedRead={handleLabCaseMarkedRead}
|
||||||
|
onLabCaseActivityChange={() => {
|
||||||
|
if (historyPatientId) {
|
||||||
|
void refreshPatientLabCases(historyPatientId, { silent: true });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
activeLabCaseId={activeLabCaseId}
|
||||||
|
onLabCasesChange={handleLabCasesChange}
|
||||||
|
disabled={!canEditTreatmentForDay}
|
||||||
|
canEdit={canEdit}
|
||||||
|
orgs={orgs}
|
||||||
|
organizationSearch={organizationSearch}
|
||||||
|
onOrganizationSearchChange={setOrganizationSearch}
|
||||||
|
recentOrganizationIds={recentOrganizationIds}
|
||||||
|
sendBusyId={sendBusyId}
|
||||||
|
onSendLabCase={(lc, comment) => handleSendLabCase(lc, comment)}
|
||||||
|
onCommentError={showError}
|
||||||
|
canInviteLab={canAccessOrganizations}
|
||||||
|
onInviteLab={() => router.push('/organizations?action=invite-lab')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
if (!canView) {
|
if (!canView) {
|
||||||
return (
|
return (
|
||||||
<div className="surface-card p-6 max-w-xl">
|
<div className="surface-card p-6 max-w-xl">
|
||||||
@@ -2784,10 +2901,19 @@ export function TreatmentWorkspace({
|
|||||||
showFields
|
showFields
|
||||||
hasArchJobs={activeHasArchJobs}
|
hasArchJobs={activeHasArchJobs}
|
||||||
voice={voiceForEditor}
|
voice={voiceForEditor}
|
||||||
chartLocked={!activeTypeSelected && !showWholeTreatmentPlan}
|
chartLocked={!activeTypeSelected && !showWholeTreatmentPlan && labWorkspaceView !== 'dispatch'}
|
||||||
chartLockMessage={t('selectTypeBeforeTeeth')}
|
chartLockMessage={t('selectTypeBeforeTeeth')}
|
||||||
|
showLabDispatchToggle={showLabDispatch}
|
||||||
|
labWorkspaceView={labWorkspaceView}
|
||||||
|
onLabWorkspaceViewChange={(view) => {
|
||||||
|
setShowWholeTreatmentPlan(false);
|
||||||
|
setLabWorkspaceView(view);
|
||||||
|
}}
|
||||||
chart={
|
chart={
|
||||||
prosthesisAssignActive ? (
|
<div ref={labPanelRef} className="min-w-0 w-full">
|
||||||
|
{showLabDispatch && labWorkspaceView === 'dispatch' ? (
|
||||||
|
labDispatchPanel
|
||||||
|
) : prosthesisAssignActive ? (
|
||||||
<ProsthesisAssignChart
|
<ProsthesisAssignChart
|
||||||
className="w-full"
|
className="w-full"
|
||||||
groups={
|
groups={
|
||||||
@@ -2803,22 +2929,11 @@ export function TreatmentWorkspace({
|
|||||||
catalog={prosthesisCatalog}
|
catalog={prosthesisCatalog}
|
||||||
disabled={!canEditTreatmentForDay || activeLocked}
|
disabled={!canEditTreatmentForDay || activeLocked}
|
||||||
headerControl={
|
headerControl={
|
||||||
<button
|
<WholePlanToggle
|
||||||
type="button"
|
pressed={showWholeTreatmentPlan}
|
||||||
onClick={() => setShowWholeTreatmentPlan((open) => !open)}
|
label={t('toothChartWholePlan')}
|
||||||
aria-pressed={showWholeTreatmentPlan}
|
onToggle={() => setShowWholeTreatmentPlan((open) => !open)}
|
||||||
className={`
|
/>
|
||||||
rounded-[var(--radius-md)] border px-2.5 py-1 text-[11px] leading-none transition-colors
|
|
||||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
|
|
||||||
${
|
|
||||||
showWholeTreatmentPlan
|
|
||||||
? 'border-primary bg-primary-soft font-medium text-text-primary'
|
|
||||||
: 'border-border/70 text-text-secondary hover:border-border hover:bg-background-card/50'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{t('toothChartWholePlan')}
|
|
||||||
</button>
|
|
||||||
}
|
}
|
||||||
onToggleLink={(a, b) => {
|
onToggleLink={(a, b) => {
|
||||||
if (
|
if (
|
||||||
@@ -2868,35 +2983,34 @@ export function TreatmentWorkspace({
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
onChange={({ groups, toothProsthesis }) => {
|
onChange={({ groups, toothProsthesis }) => {
|
||||||
setDetails((prev) =>
|
const nextDetails = details.map((d) =>
|
||||||
prev.map((d) =>
|
d.clientId !== activeDetailId
|
||||||
d.clientId !== activeDetailId
|
? d
|
||||||
? d
|
: {
|
||||||
: {
|
...d,
|
||||||
...d,
|
toothSelectionGroups: groups,
|
||||||
toothSelectionGroups: groups,
|
teeth: deriveTeethFromGroups(groups),
|
||||||
teeth: deriveTeethFromGroups(groups),
|
},
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
setLabCaseDrafts((prev) => {
|
detailsRef.current = nextDetails;
|
||||||
const existing = prev.find(
|
setDetails(nextDetails);
|
||||||
(lc) => lc.detailClientId === activeDetailId && !lc.sentAt,
|
const prev = labCaseDraftsRef.current;
|
||||||
);
|
const existing = prev.find(
|
||||||
if (existing) {
|
(lc) => lc.detailClientId === activeDetailId && !lc.sentAt,
|
||||||
return prev.map((lc) =>
|
);
|
||||||
|
const nextDrafts = existing
|
||||||
|
? prev.map((lc) =>
|
||||||
lc.clientId === existing.clientId ? { ...lc, toothProsthesis } : lc,
|
lc.clientId === existing.clientId ? { ...lc, toothProsthesis } : lc,
|
||||||
);
|
)
|
||||||
}
|
: [
|
||||||
return [
|
...prev,
|
||||||
...prev,
|
{
|
||||||
{
|
...newLabCaseDraft(),
|
||||||
...newLabCaseDraft(),
|
detailClientId: activeDetailId,
|
||||||
detailClientId: activeDetailId,
|
toothProsthesis,
|
||||||
toothProsthesis,
|
},
|
||||||
},
|
];
|
||||||
];
|
handleLabCasesChange(nextDrafts);
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -2908,22 +3022,11 @@ export function TreatmentWorkspace({
|
|||||||
toothColors={chartToothColors}
|
toothColors={chartToothColors}
|
||||||
readOnly={showWholeTreatmentPlan}
|
readOnly={showWholeTreatmentPlan}
|
||||||
headerControl={
|
headerControl={
|
||||||
<button
|
<WholePlanToggle
|
||||||
type="button"
|
pressed={showWholeTreatmentPlan}
|
||||||
onClick={() => setShowWholeTreatmentPlan((open) => !open)}
|
label={t('toothChartWholePlan')}
|
||||||
aria-pressed={showWholeTreatmentPlan}
|
onToggle={() => setShowWholeTreatmentPlan((open) => !open)}
|
||||||
className={`
|
/>
|
||||||
rounded-[var(--radius-md)] border px-2.5 py-1 text-[11px] leading-none transition-colors
|
|
||||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
|
|
||||||
${
|
|
||||||
showWholeTreatmentPlan
|
|
||||||
? 'border-primary bg-primary-soft font-medium text-text-primary'
|
|
||||||
: 'border-border/70 text-text-secondary hover:border-border hover:bg-background-card/50'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
{t('toothChartWholePlan')}
|
|
||||||
</button>
|
|
||||||
}
|
}
|
||||||
onToggle={(fdi, event) => {
|
onToggle={(fdi, event) => {
|
||||||
if (
|
if (
|
||||||
@@ -3084,43 +3187,10 @@ export function TreatmentWorkspace({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showLabDispatch ? (
|
|
||||||
<div ref={labPanelRef} className="space-y-3">
|
|
||||||
<LabCasesDispatchPanel
|
|
||||||
details={details}
|
|
||||||
activeDetailId={activeDetailId}
|
|
||||||
labCases={labCaseDrafts}
|
|
||||||
labDependentCodes={labDependentCodes}
|
|
||||||
treatmentCatalog={treatmentCatalog}
|
|
||||||
prosthesisCatalog={prosthesisCatalog}
|
|
||||||
labCaseSummary={activeLabCaseSummary}
|
|
||||||
locale={locale}
|
|
||||||
onLabCaseSummaryChange={handleLabCaseSummaryChange}
|
|
||||||
onLabCaseMarkedRead={handleLabCaseMarkedRead}
|
|
||||||
onLabCaseActivityChange={() => {
|
|
||||||
if (historyPatientId) {
|
|
||||||
void refreshPatientLabCases(historyPatientId, { silent: true });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
activeLabCaseId={activeLabCaseId}
|
|
||||||
onLabCasesChange={handleLabCasesChange}
|
|
||||||
disabled={!canEditTreatmentForDay}
|
|
||||||
canEdit={canEdit}
|
|
||||||
orgs={orgs}
|
|
||||||
organizationSearch={organizationSearch}
|
|
||||||
onOrganizationSearchChange={setOrganizationSearch}
|
|
||||||
recentOrganizationIds={recentOrganizationIds}
|
|
||||||
sendBusyId={sendBusyId}
|
|
||||||
onSendLabCase={(lc, comment) => handleSendLabCase(lc, comment)}
|
|
||||||
onCommentError={showError}
|
|
||||||
canInviteLab={canAccessOrganizations}
|
|
||||||
onInviteLab={() => router.push('/organizations?action=invite-lab')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,10 +6,10 @@
|
|||||||
--radius-md: 6px;
|
--radius-md: 6px;
|
||||||
--radius-lg: 8px;
|
--radius-lg: 8px;
|
||||||
|
|
||||||
--color-background-primary: #f6f9fc;
|
--color-background-primary: #f2f3f5;
|
||||||
--color-background-secondary: #ffffff;
|
--color-background-secondary: #f7f8f9;
|
||||||
--color-background-card: #ffffff;
|
--color-background-card: #f7f8f9;
|
||||||
--color-surface-elevated: #f8fbff;
|
--color-surface-elevated: #eceef1;
|
||||||
|
|
||||||
--color-text-primary: #0f172a;
|
--color-text-primary: #0f172a;
|
||||||
--color-text-secondary: #475569;
|
--color-text-secondary: #475569;
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
--color-border: #d2dcec;
|
--color-border: #d2dcec;
|
||||||
--color-border-strong: #aec0de;
|
--color-border-strong: #aec0de;
|
||||||
|
|
||||||
--color-card-background: #ffffff;
|
--color-card-background: #f7f8f9;
|
||||||
--color-card-foreground: #0f172a;
|
--color-card-foreground: #0f172a;
|
||||||
--color-card-muted: #475569;
|
--color-card-muted: #475569;
|
||||||
--color-card-border: #d2dcec;
|
--color-card-border: #d2dcec;
|
||||||
@@ -397,7 +397,7 @@ select option {
|
|||||||
@keyframes picker-expand {
|
@keyframes picker-expand {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: scaleX(0.88);
|
transform: translateY(4px) scale(0.98);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -406,7 +406,7 @@ select option {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.picker-expand {
|
.picker-expand {
|
||||||
transform-origin: inline-start;
|
transform-origin: top center;
|
||||||
animation: picker-expand 180ms ease-out;
|
animation: picker-expand 180ms ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,5 +16,4 @@ export interface ProsthesisCatalogEntry {
|
|||||||
subcategory: string;
|
subcategory: string;
|
||||||
chartRegion: 'crown' | 'root' | 'arch' | string;
|
chartRegion: 'crown' | 'root' | 'arch' | string;
|
||||||
stackGroup: 'restoration' | 'implant' | 'post_core' | 'arch' | string;
|
stackGroup: 'restoration' | 'implant' | 'post_core' | 'arch' | string;
|
||||||
addonKind: '' | 'implant' | 'post_core' | string;
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user