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.
|
||||
- **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.
|
||||
- **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).
|
||||
@@ -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.
|
||||
- **Patient search:** `PatientSearchCombobox` in the **page header** (workspace-wide). Opens today’s strip visit if any, else latest history. No visit/history → inline editor empty state pointing to **New treatment** in the rail (never a dialog, never auto-create). **New treatment** stays at the top of the left rail; selected-patient card sits under it. Picker: Walk-in first, then a matching full-width card for the current named patient (name + mobile/email, else hint), then search. Never auto-create from the appointment card.
|
||||
- **Lab dispatch attention:** `LabDispatchAttentionPanel` — unsent lab-dependent details; quick jump to dispatch.
|
||||
- **History API:** patient-scoped; non-owners filtered by provider on treatment or appointment; org owners see all.
|
||||
- **History 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`.
|
||||
- **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`).
|
||||
|
||||
@@ -28,7 +28,7 @@ Thin route: `app/[locale]/(dashboard)/treatment/page.tsx` (supports `?appointmen
|
||||
|
||||
3. **Treatment history** — `PastTreatmentsPanel.tsx` (past saved plans for patient; **client-side** filters in `treatmentHistoryFilters.ts`)
|
||||
|
||||
4. **Editor** — detail chips + Add (`TreatmentDetailsEditor`); type-first form with chart + notes; lab send sheet 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 |
|
||||
|-------|-----|------|
|
||||
| **Treatment** | Type dropdown + `TreatmentDetailAttachmentsStrip`, `FdiToothChart` / `ProsthesisAssignChart`, full-width Notes | Always |
|
||||
| **Lab** | `LabCasesDispatchPanel` under the form | When active detail type is lab-dependent. Auto-ensures a shipment draft (teeth/arch not required to create the draft). **No default lab or prosthesis type** on a new detail (including siblings in the same plan). Last **3 sent** labs appear as chips under search — pick is explicit. Comments stay on the dispatch panel. |
|
||||
| **Treatment** | Type dropdown + `TreatmentDetailAttachmentsStrip`, `FdiToothChart` / `ProsthesisAssignChart`, full-width Notes | Default |
|
||||
| **Lab** | `LabCasesDispatchPanel` in the chart slot | Lab-dependent type + user clicks **Lab dispatch** (or rail / Go to dispatch). Auto-ensures a shipment draft (teeth/arch not required to create the draft). **No default lab or prosthesis type** on a new detail (including siblings in the same plan). Last **3 sent** labs appear as chips under search — pick is explicit. Comments stay on the dispatch panel. |
|
||||
|
||||
- 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 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.
|
||||
|
||||
|
||||
@@ -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`).
|
||||
|
||||
- **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.
|
||||
- **Shift+click:** inclusive same-arch range → all selected as **singles** (empty circles); overlapping existing bridges are absorbed as singles too. Midline neighbors (11–21, 41–31) allowed.
|
||||
- **Prosthesis assign chart:** Shift = range then assign to the span; Ctrl/Cmd or drag copies the last type-set. 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.
|
||||
- **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 (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).
|
||||
- On group change, prune/remap `labCase.toothProsthesis` via `pruneToothProsthesisForGroups`.
|
||||
- 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.
|
||||
- `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.
|
||||
- **Unscheduled strip cards** use the same treatment-type banner as appointments from the **first detail’s type only** (`unscheduledStripColorCode`; live draft for the open card). Empty first line → chip theming even if later lines are typed. Strip trash only when every line is blank (no type/teeth/notes/attachments, including `[]`); typed cards need chip-delete first. Backend `DELETE /treatments/:id` is empty-only (`TREATMENT_HAS_DETAILS`).
|
||||
- Sent-to-lab detail locks that line; **Add detail** still OK same day; **Remove detail** = trash on chip (unsent, including last line; day/edit gates apply). Empty details persist as `[]`. Attachment upload blocked when sent (`TREATMENT_DETAIL_SENT`).
|
||||
- **Entry:** Type dropdown + compact attachments strip (`TreatmentDetailAttachmentsStrip`) on one row, then bordered FDI chart (Cases chrome/scale), then full-width auto-growing **Notes**. Prosthesis types are assigned on the chart (teeth, or Upper/Lower arch labels for jaw products). Lab dispatch sits under chart+notes when the active type is lab-dependent — no Treatment→Lab stepper. Detail chips show type + teeth; lab-dependent chips use colored sent/unsent text (sent date on dispatch). Lab-rail / Go to dispatch **scrolls** to the panel (`pendingScrollToLabRef`). Lab dispatch keeps comments.
|
||||
- **Tooth selection:** Hit target is the unrotated cell (`pointerdown` only — do not also bind `click`). Glyph is `pointer-events-none`; nest hover/selected scale inside the rotate wrapper. Neighbor empty/filled circles between selected adjacent teeth connect/disconnect bridges; Shift+range selects only (empty circles; overlap absorbs as singles); midline 11–21 / 41–31 allowed. Plain click selects/deselects (deselect splits bridges). Never a 1-tooth connected. Prosthesis assign chart: Shift = range then assign; Ctrl/Cmd or drag = copy last type-set. Helpers: `toothSelectionGroups.ts`. Connected label: `ConnectedSelectionBadge`. 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.
|
||||
- **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; 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).
|
||||
- **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.
|
||||
- Detail treatment type need **not** match appointment purpose — purpose only seeds the **first** line of an empty **appointment** draft (first open, and **Add detail** when the plan is `[]`). Further **Add detail** starts with an empty type. Unscheduled / New treatment still seeds a blank first line.
|
||||
- **History filters** are client-side only (`treatmentHistoryFilters.ts`): “Not shipped to lab” + single date on already-fetched patient history; includes live current draft when filtering.
|
||||
- **Lab shipments rail**: unified list with scope toggle **This patient** vs **All updates** (unread across org for **this clinician's cases only**, includes patient name). Opening a case from the rail **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).
|
||||
- **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`.
|
||||
|
||||
@@ -72,7 +72,6 @@ export const LAB_WORKFLOW_STEPS = [
|
||||
|
||||
export type ProsthesisChartRegion = 'crown' | 'root' | 'arch';
|
||||
export type ProsthesisStackGroup = 'restoration' | 'implant' | 'post_core' | 'arch';
|
||||
export type ProsthesisAddonKind = '' | 'implant' | 'post_core';
|
||||
|
||||
export type ProsthesisTypeSeed = {
|
||||
code: string;
|
||||
@@ -83,7 +82,6 @@ export type ProsthesisTypeSeed = {
|
||||
subcategory?: string;
|
||||
chartRegion: ProsthesisChartRegion;
|
||||
stackGroup: ProsthesisStackGroup;
|
||||
addonKind?: ProsthesisAddonKind;
|
||||
/** Manufacturing sequence. Packing and shipping are appended except for smile_design. */
|
||||
manufacturingSteps: readonly string[];
|
||||
};
|
||||
@@ -138,7 +136,7 @@ function crown(
|
||||
};
|
||||
}
|
||||
|
||||
function implantAddon(
|
||||
function implant(
|
||||
code: string,
|
||||
sortOrder: number,
|
||||
manufacturingSteps: readonly string[],
|
||||
@@ -150,7 +148,6 @@ function implantAddon(
|
||||
category: 'implant',
|
||||
chartRegion: 'root',
|
||||
stackGroup: 'implant',
|
||||
addonKind: 'implant',
|
||||
manufacturingSteps,
|
||||
...extra,
|
||||
};
|
||||
@@ -230,7 +227,6 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [
|
||||
category: 'post_core',
|
||||
chartRegion: 'root',
|
||||
stackGroup: 'post_core',
|
||||
addonKind: 'post_core',
|
||||
manufacturingSteps: POST_CORE,
|
||||
},
|
||||
{
|
||||
@@ -239,29 +235,28 @@ export const PROSTHESIS_TYPES: ProsthesisTypeSeed[] = [
|
||||
category: 'post_core',
|
||||
chartRegion: 'root',
|
||||
stackGroup: 'post_core',
|
||||
addonKind: 'post_core',
|
||||
manufacturingSteps: POST_CORE,
|
||||
},
|
||||
implantAddon('prefabricated_abutment', 40, [
|
||||
implant('prefabricated_abutment', 40, [
|
||||
'intraoral_scan',
|
||||
'choosing_abutment',
|
||||
'model_create',
|
||||
'polish_prep',
|
||||
]),
|
||||
implantAddon('ti_base_abutment', 41, ['intraoral_scan', 'model_create', 'polish_prep']),
|
||||
implantAddon('multi_unit_abutment', 42, [
|
||||
implant('ti_base_abutment', 41, ['intraoral_scan', 'model_create', 'polish_prep']),
|
||||
implant('multi_unit_abutment', 42, [
|
||||
'intraoral_scan',
|
||||
'choosing_abutment',
|
||||
'model_create',
|
||||
'polish_prep',
|
||||
]),
|
||||
implantAddon('customized_abutment', 43, [
|
||||
implant('customized_abutment', 43, [
|
||||
...SCAN_DESIGN_MODEL,
|
||||
'milling_wet',
|
||||
'polish_prep',
|
||||
]),
|
||||
implantAddon('zirconia_abutment', 44, [...SCAN_DESIGN_MODEL, 'milling_dry', 'sinter']),
|
||||
implantAddon(
|
||||
implant('zirconia_abutment', 44, [...SCAN_DESIGN_MODEL, 'milling_dry', 'sinter']),
|
||||
implant(
|
||||
'screw_retained',
|
||||
45,
|
||||
[...SCAN_DESIGN_MODEL, 'milling_wet', 'polish_prep'],
|
||||
|
||||
@@ -41,25 +41,31 @@ const STACK_RULES = [
|
||||
'restoration',
|
||||
'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).'],
|
||||
['post_core', 'One post & core, only if a restoration is already present. Blocked when screw-retained is on the tooth.'],
|
||||
['implant', 'One implant type per tooth. Disabled when post & core is on the tooth.'],
|
||||
[
|
||||
'post_core',
|
||||
'One post & core. Disabled when a restoration or implant is already on the tooth.',
|
||||
],
|
||||
[
|
||||
'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',
|
||||
'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',
|
||||
'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 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.'],
|
||||
];
|
||||
|
||||
@@ -198,7 +204,6 @@ function buildWorkbook(): Buffer {
|
||||
'label_nl',
|
||||
'chartRegion',
|
||||
'stackGroup',
|
||||
'addonKind',
|
||||
'active',
|
||||
],
|
||||
];
|
||||
@@ -215,7 +220,6 @@ function buildWorkbook(): Buffer {
|
||||
labels.nl ?? '',
|
||||
type.chartRegion,
|
||||
type.stackGroup,
|
||||
type.addonKind ?? '',
|
||||
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")
|
||||
/// Mutual exclusion group: restoration | implant | post_core | arch
|
||||
stackGroup String @default("restoration")
|
||||
/// Also listed as an add-on: implant | post_core | empty
|
||||
addonKind String @default("")
|
||||
|
||||
steps ProsthesisTypeStep[]
|
||||
|
||||
|
||||
@@ -191,7 +191,6 @@ async function main() {
|
||||
subcategory: type.subcategory ?? '',
|
||||
chartRegion: type.chartRegion,
|
||||
stackGroup: type.stackGroup,
|
||||
addonKind: type.addonKind ?? '',
|
||||
};
|
||||
const prosthesisType = await prisma.prosthesisType.upsert({
|
||||
where: { code: type.code },
|
||||
|
||||
@@ -16,7 +16,6 @@ export type ProsthesisTypeCatalogEntry = {
|
||||
subcategory: string;
|
||||
chartRegion: string;
|
||||
stackGroup: string;
|
||||
addonKind: string;
|
||||
};
|
||||
|
||||
type CatalogRow = {
|
||||
@@ -25,7 +24,6 @@ type CatalogRow = {
|
||||
subcategory: string;
|
||||
chartRegion: string;
|
||||
stackGroup: string;
|
||||
addonKind: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -53,7 +51,6 @@ export class ProsthesisCatalogService implements OnModuleInit {
|
||||
subcategory: true,
|
||||
chartRegion: true,
|
||||
stackGroup: true,
|
||||
addonKind: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -66,7 +63,6 @@ export class ProsthesisCatalogService implements OnModuleInit {
|
||||
subcategory: row.subcategory,
|
||||
chartRegion: row.chartRegion,
|
||||
stackGroup: row.stackGroup,
|
||||
addonKind: row.addonKind,
|
||||
},
|
||||
]),
|
||||
);
|
||||
@@ -94,7 +90,6 @@ export class ProsthesisCatalogService implements OnModuleInit {
|
||||
subcategory: row.subcategory,
|
||||
chartRegion: row.chartRegion,
|
||||
stackGroup: row.stackGroup,
|
||||
addonKind: row.addonKind,
|
||||
};
|
||||
})
|
||||
.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.",
|
||||
"sendThisCase": "Send this case",
|
||||
"labDispatchTitle": "Lab dispatch",
|
||||
"showLabDispatch": "Lab dispatch",
|
||||
"showChart": "Chart",
|
||||
"labDispatchSubtitle": "Choose a lab and send.",
|
||||
"labDispatchSendHint": "Shipment grouping is saved when you send to the lab.",
|
||||
"addLabShipment": "Add lab shipment",
|
||||
@@ -898,6 +900,7 @@
|
||||
"selectedArchLower": "Lower arch",
|
||||
"upperArch": "Upper arch",
|
||||
"lowerArch": "Lower arch",
|
||||
"archAssignHint": "Click to assign a jaw-level product",
|
||||
"toothAria": "FDI tooth {fdi}",
|
||||
"toothSelectedSuffix": ", selected",
|
||||
"sentToAt": "Sent to {orgName} at {datetime}",
|
||||
@@ -1120,6 +1123,7 @@
|
||||
"sub_partial_denture": "Partial denture",
|
||||
"sub_night_guard": "Night guard",
|
||||
"addonNone": "None",
|
||||
"addonCrownCanBeAdded": "Crown can be added",
|
||||
"clear": "Clear this tooth",
|
||||
"done": "Done",
|
||||
"pickerTeeth": "Teeth {teeth}",
|
||||
@@ -1133,8 +1137,10 @@
|
||||
"removeJob": "Remove this type",
|
||||
"regionCrown": "Crown",
|
||||
"pickerTooth": "Tooth {tooth}",
|
||||
"slotImplant": "Implant add-on",
|
||||
"slotPostCore": "Post & core"
|
||||
"slotCrown": "Crown",
|
||||
"suggestionsLabel": "Suggested add-ons",
|
||||
"pickerBack": "Back",
|
||||
"categoryDisabledHint": "This category cannot be combined with the types already on this tooth."
|
||||
},
|
||||
"errors": {
|
||||
"GENERIC": "Something went wrong. Please try again.",
|
||||
|
||||
@@ -758,6 +758,8 @@
|
||||
"noOrgInvitePermission": "شما مجوز دعوت از آزمایشگاه را ندارید. با مالک سازمان تماس بگیرید.",
|
||||
"sendThisCase": "ارسال این پرونده",
|
||||
"labDispatchTitle": "ارسال به لابراتوار",
|
||||
"showLabDispatch": "ارسال به لابراتوار",
|
||||
"showChart": "نمودار",
|
||||
"labDispatchSubtitle": "لابراتوار را انتخاب کنید و ارسال کنید.",
|
||||
"labDispatchSendHint": "گروهبندی محموله هنگام ارسال به لاب ذخیره میشود.",
|
||||
"addLabShipment": "افزودن محموله لاب",
|
||||
@@ -899,6 +901,7 @@
|
||||
"selectedArchLower": "قوس پایین",
|
||||
"upperArch": "قوس بالا",
|
||||
"lowerArch": "قوس پایین",
|
||||
"archAssignHint": "برای اختصاص محصول فکی کلیک کنید",
|
||||
"toothAria": "دندان FDI {fdi}",
|
||||
"toothSelectedSuffix": "، انتخاب شده",
|
||||
"sentToAt": "ارسال به {orgName} در {datetime}",
|
||||
@@ -1121,6 +1124,7 @@
|
||||
"sub_partial_denture": "پارسیل",
|
||||
"sub_night_guard": "نایت گارد",
|
||||
"addonNone": "هیچکدام",
|
||||
"addonCrownCanBeAdded": "روکش میتواند افزوده شود",
|
||||
"clear": "پاک کردن این دندان",
|
||||
"done": "تمام",
|
||||
"pickerTeeth": "دندانهای {teeth}",
|
||||
@@ -1134,8 +1138,10 @@
|
||||
"removeJob": "حذف این نوع",
|
||||
"regionCrown": "تاج",
|
||||
"pickerTooth": "دندان {tooth}",
|
||||
"slotImplant": "افزونه ایمپلنت",
|
||||
"slotPostCore": "پست و کور"
|
||||
"slotCrown": "روکش",
|
||||
"suggestionsLabel": "افزودنیهای پیشنهادی",
|
||||
"pickerBack": "بازگشت",
|
||||
"categoryDisabledHint": "این دسته با انواعی که روی این دندان هستند قابل ترکیب نیست."
|
||||
},
|
||||
"errors": {
|
||||
"GENERIC": "مشکلی پیش آمد. لطفاً دوباره تلاش کنید.",
|
||||
|
||||
@@ -757,6 +757,8 @@
|
||||
"noOrgInvitePermission": "U heeft geen toestemming om labs uit te nodigen. Neem contact op met de organisatie-eigenaar.",
|
||||
"sendThisCase": "Verzend deze case",
|
||||
"labDispatchTitle": "Lab-dispatch",
|
||||
"showLabDispatch": "Lab-dispatch",
|
||||
"showChart": "Gebitskaart",
|
||||
"labDispatchSubtitle": "Kies een lab en verstuur.",
|
||||
"labDispatchSendHint": "Groepering wordt opgeslagen wanneer u naar het lab verzendt.",
|
||||
"addLabShipment": "Labzending toevoegen",
|
||||
@@ -898,6 +900,7 @@
|
||||
"selectedArchLower": "Onderboog",
|
||||
"upperArch": "Bovenboog",
|
||||
"lowerArch": "Onderboog",
|
||||
"archAssignHint": "Klik om een boogproduct toe te wijzen",
|
||||
"toothAria": "FDI-tand {fdi}",
|
||||
"toothSelectedSuffix": ", geselecteerd",
|
||||
"sentToAt": "Verzonden naar {orgName} op {datetime}",
|
||||
@@ -1120,6 +1123,7 @@
|
||||
"sub_partial_denture": "Partiële prothese",
|
||||
"sub_night_guard": "Nachtbeugel",
|
||||
"addonNone": "Geen",
|
||||
"addonCrownCanBeAdded": "Kroon kan worden toegevoegd",
|
||||
"clear": "Deze tand wissen",
|
||||
"done": "Klaar",
|
||||
"pickerTeeth": "Tanden {teeth}",
|
||||
@@ -1133,8 +1137,10 @@
|
||||
"removeJob": "Dit type verwijderen",
|
||||
"regionCrown": "Kroon",
|
||||
"pickerTooth": "Tand {tooth}",
|
||||
"slotImplant": "Implantaat-add-on",
|
||||
"slotPostCore": "Stiftopbouw"
|
||||
"slotCrown": "Kroon",
|
||||
"suggestionsLabel": "Voorgestelde toevoegingen",
|
||||
"pickerBack": "Terug",
|
||||
"categoryDisabledHint": "Deze categorie kan niet worden gecombineerd met de types die al op deze tand staan."
|
||||
},
|
||||
"errors": {
|
||||
"GENERIC": "Er is iets misgegaan. Probeer het opnieuw.",
|
||||
|
||||
@@ -20,61 +20,124 @@ export const TREATMENT_TYPE_COLORS: Record<string, string> = {
|
||||
continue_treatment: '#99f6e4',
|
||||
};
|
||||
|
||||
/** Dedicated prosthesis palette — one distinct pastel per catalog code. */
|
||||
export const PROSTHESIS_TYPE_COLORS: Record<string, string> = {
|
||||
pfm_crown: '#e2e8f0',
|
||||
pfz_crown: '#bbf7d0',
|
||||
monolithic_zirconia: '#bae6fd',
|
||||
glass_ceramic_crown: '#fef08a',
|
||||
full_metal_crown: '#d4d4d8',
|
||||
temporary_resin_crown: '#ddd6fe',
|
||||
pmma: '#fcd34d',
|
||||
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 — heaviest (darkest) pastel of each family; leaves lighten from here. */
|
||||
export const PROSTHESIS_CATEGORY_COLORS: Record<string, string> = {
|
||||
crown: '#e6c84f',
|
||||
indirect: '#f59e4c',
|
||||
implant: '#38bdf8',
|
||||
post_core: '#a8a29e',
|
||||
removable: '#a78bfa',
|
||||
appliance: '#d8b4fe',
|
||||
digital: '#f472b6',
|
||||
};
|
||||
|
||||
/** Category tiles in the assign overlay (not per-leaf). */
|
||||
export const PROSTHESIS_CATEGORY_COLORS: Record<string, string> = {
|
||||
crown: '#bae6fd',
|
||||
indirect: '#fdba74',
|
||||
implant: '#a5b4fc',
|
||||
post_core: '#d6d3d1',
|
||||
removable: '#c4b5fd',
|
||||
appliance: '#e9d5ff',
|
||||
digital: '#f9a8d4',
|
||||
function hexToRgbChannels(hex: string): [number, number, number] | null {
|
||||
const raw = hex.replace('#', '').trim();
|
||||
if (raw.length !== 6) return null;
|
||||
const n = Number.parseInt(raw, 16);
|
||||
if (Number.isNaN(n)) return null;
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
}
|
||||
|
||||
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 = [
|
||||
|
||||
@@ -2,7 +2,11 @@ import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
||||
import type { FdiToothId, LabCaseToothProsthesisDraft, ToothSelectionGroup } from '@/types/treatment';
|
||||
import { PROSTHESIS_CATEGORY_COLORS } from '@/components/shared/catalog-type-colors';
|
||||
import { prosthesisTypeColorFromCatalog } from '@/components/treatment/prosthesisTypeDisplay';
|
||||
import { groupsFromFlatTeeth, newGroupId } from '@/components/treatment/toothSelectionGroups';
|
||||
import {
|
||||
deriveTeethFromGroups,
|
||||
groupsFromFlatTeeth,
|
||||
newGroupId,
|
||||
} from '@/components/treatment/toothSelectionGroups';
|
||||
|
||||
const PROSTHESIS_CATEGORY_ORDER = [
|
||||
'implant',
|
||||
@@ -111,13 +115,35 @@ export function leavesFor(
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder || a.code.localeCompare(b.code));
|
||||
}
|
||||
|
||||
export function addonLeaves(
|
||||
catalog: readonly ProsthesisCatalogEntry[],
|
||||
kind: 'implant' | 'post_core',
|
||||
): ProsthesisCatalogEntry[] {
|
||||
return catalog
|
||||
.filter((e) => e.addonKind === kind)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
export const SCREW_RETAINED_CODE = 'screw_retained';
|
||||
|
||||
export type AddonSlot = 'crown';
|
||||
|
||||
export function jobsHaveStackGroup(
|
||||
jobCodes: readonly string[],
|
||||
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(
|
||||
@@ -127,36 +153,115 @@ export function applyLeafToJobs(
|
||||
): string[] {
|
||||
const next = byCode.get(nextCode);
|
||||
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);
|
||||
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);
|
||||
return jobs;
|
||||
}
|
||||
|
||||
export function jobsAllowImplantAddon(
|
||||
export function crownRestorationCode(
|
||||
jobCodes: readonly string[],
|
||||
byCode: Map<string, ProsthesisCatalogEntry>,
|
||||
): boolean {
|
||||
const hasArchBlocking = jobCodes.some((code) => {
|
||||
const e = byCode.get(code);
|
||||
if (!e || e.stackGroup !== 'arch') return false;
|
||||
return e.code !== 'overdenture' && e.code !== PARTIAL_DENTURE_CODE;
|
||||
): string | undefined {
|
||||
return jobCodes.find((code) => {
|
||||
const entry = byCode.get(code);
|
||||
return entry?.category === 'crown' && entry.stackGroup === 'restoration';
|
||||
});
|
||||
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[],
|
||||
byCode: Map<string, ProsthesisCatalogEntry>,
|
||||
): boolean {
|
||||
if (jobCodes.includes('screw_retained')) return false;
|
||||
return jobCodes.some((code) => byCode.get(code)?.stackGroup === 'restoration');
|
||||
if (jobCodes.length === 0) return false;
|
||||
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(
|
||||
@@ -295,9 +400,8 @@ export function writeArchJobs(
|
||||
arch: ArchTarget,
|
||||
code: string,
|
||||
): LabCaseToothProsthesisDraft[] {
|
||||
const replace = new Set(archSentinels(arch));
|
||||
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) => ({
|
||||
detailClientId,
|
||||
@@ -308,6 +412,23 @@ export function writeArchJobs(
|
||||
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(
|
||||
rows: readonly LabCaseToothProsthesisDraft[],
|
||||
detailClientId: string,
|
||||
|
||||
@@ -3,15 +3,19 @@ import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
||||
import {
|
||||
PROSTHESIS_FALLBACK_COLORS,
|
||||
PROSTHESIS_TYPE_COLORS,
|
||||
prosthesisCategoryFamilyColor,
|
||||
resolveCatalogTypeColor,
|
||||
} from '@/components/shared/catalog-type-colors';
|
||||
|
||||
/**
|
||||
* 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';
|
||||
|
||||
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. */
|
||||
export function prosthesisCatalogColorIndex(
|
||||
code: string,
|
||||
catalog: readonly Pick<ProsthesisCatalogEntry, 'code' | 'sortOrder'>[],
|
||||
catalog: ProsthesisColorCatalog,
|
||||
): number {
|
||||
const entry = catalog.find((e) => e.code === primaryProsthesisCode(code));
|
||||
if (entry) return Math.max(0, entry.sortOrder - 1);
|
||||
@@ -44,9 +48,18 @@ export function prosthesisTypeColor(code: string, index = 0): string {
|
||||
|
||||
export function prosthesisTypeColorFromCatalog(
|
||||
code: string,
|
||||
catalog: readonly Pick<ProsthesisCatalogEntry, 'code' | 'sortOrder'>[],
|
||||
catalog: ProsthesisColorCatalog,
|
||||
): 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 {
|
||||
@@ -118,7 +131,7 @@ function prosthesisTypeLabelSwatch(hex: string): string {
|
||||
*/
|
||||
export function prosthesisTypeLabelStyleFromCatalog(
|
||||
code: string,
|
||||
catalog: readonly Pick<ProsthesisCatalogEntry, 'code' | 'sortOrder'>[],
|
||||
catalog: ProsthesisColorCatalog,
|
||||
): CSSProperties {
|
||||
const swatch = prosthesisTypeLabelSwatch(prosthesisTypeColorFromCatalog(code, catalog));
|
||||
return {
|
||||
@@ -134,9 +147,12 @@ export function prosthesisTypeSwatchStyle(code: string, index = 0): CSSPropertie
|
||||
|
||||
export function prosthesisTypeSwatchStyleFromCatalog(
|
||||
code: string,
|
||||
catalog: readonly Pick<ProsthesisCatalogEntry, 'code' | 'sortOrder'>[],
|
||||
catalog: ProsthesisColorCatalog,
|
||||
): 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). */
|
||||
@@ -150,9 +166,13 @@ export function prosthesisTypeBadgeStyle(code: string, index = 0): CSSProperties
|
||||
|
||||
export function prosthesisTypeBadgeStyleFromCatalog(
|
||||
code: string,
|
||||
catalog: readonly Pick<ProsthesisCatalogEntry, 'code' | 'sortOrder'>[],
|
||||
catalog: ProsthesisColorCatalog,
|
||||
): 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(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useId, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Info } from 'lucide-react';
|
||||
import { Info, Plus } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/shared/Button';
|
||||
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
|
||||
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 {
|
||||
selected: ReadonlySet<FdiToothId>;
|
||||
/**
|
||||
@@ -129,7 +199,7 @@ interface FdiToothChartProps {
|
||||
linkedEdges?: ReadonlySet<string>;
|
||||
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.
|
||||
*/
|
||||
onAssignPointer?: (
|
||||
@@ -148,7 +218,7 @@ interface FdiToothChartProps {
|
||||
crownColors?: Partial<Record<FdiToothId, string>>;
|
||||
rootColors?: Partial<Record<FdiToothId, string>>;
|
||||
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;
|
||||
/** Centered overlay on the odontogram (prosthesis picker). */
|
||||
overlay?: ReactNode;
|
||||
@@ -540,22 +610,15 @@ export function FdiToothChart({
|
||||
|
||||
const chartBody = (
|
||||
<>
|
||||
{onArchClick && !readOnly ? (
|
||||
<button
|
||||
type="button"
|
||||
<ArchLabelRow
|
||||
placement="upper"
|
||||
interactive={Boolean(onArchClick && !readOnly)}
|
||||
label={t('upperArch')}
|
||||
hint={t('archAssignHint')}
|
||||
highlighted={archHighlight === 'upper' || archHighlight === 'both'}
|
||||
disabled={isDisabled}
|
||||
onClick={() => onArchClick('upper')}
|
||||
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 ${
|
||||
archHighlight === 'upper' || archHighlight === 'both'
|
||||
? 'text-primary font-semibold'
|
||||
: '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>
|
||||
)}
|
||||
onClick={() => onArchClick?.('upper')}
|
||||
/>
|
||||
|
||||
<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">
|
||||
@@ -657,28 +720,21 @@ export function FdiToothChart({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{onArchClick && !readOnly ? (
|
||||
<button
|
||||
type="button"
|
||||
<ArchLabelRow
|
||||
placement="lower"
|
||||
interactive={Boolean(onArchClick && !readOnly)}
|
||||
label={t('lowerArch')}
|
||||
hint={t('archAssignHint')}
|
||||
highlighted={archHighlight === 'lower' || archHighlight === 'both'}
|
||||
disabled={isDisabled}
|
||||
onClick={() => onArchClick('lower')}
|
||||
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 ${
|
||||
archHighlight === 'lower' || archHighlight === 'both'
|
||||
? 'text-primary font-semibold'
|
||||
: '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>
|
||||
)}
|
||||
onClick={() => onArchClick?.('lower')}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<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">
|
||||
<h3 className="text-sm font-semibold text-text-primary">
|
||||
{compact || embedded ? t('toothChartTitleCompact') : t('toothChartTitle')}
|
||||
@@ -694,18 +750,24 @@ export function FdiToothChart({
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex justify-center">{headerControl}</div>
|
||||
<div className="flex min-w-0 flex-col items-end gap-1">
|
||||
<div className="flex min-w-0 flex-col items-end gap-1.5">
|
||||
{headerControl || (onReset && !readOnly) ? (
|
||||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||||
{headerControl}
|
||||
{onReset && !readOnly ? (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={CHART_TOOL_BTN_CLASS}
|
||||
disabled={resetDisabled || isDisabled || !(hasResetWork ?? selected.size > 0)}
|
||||
onClick={onReset}
|
||||
aria-label={t('resetChartAria')}
|
||||
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"
|
||||
>
|
||||
{t('resetChart')}
|
||||
</button>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<p className="min-w-0 text-[11px] text-text-secondary tabular-nums text-end">
|
||||
{t('selectedLabel')}{' '}
|
||||
|
||||
@@ -19,22 +19,22 @@ import {
|
||||
ensureTeethInGroups,
|
||||
hasArchJobs,
|
||||
isArchSentinel,
|
||||
jobsAllowImplantAddon,
|
||||
jobsAllowPostCoreAddon,
|
||||
jobsAllowCrownAddon,
|
||||
removeTeethFromGroups,
|
||||
retargetArchJobs,
|
||||
toothRegionColors,
|
||||
uniqueCodesOnGroup,
|
||||
writeArchJobs,
|
||||
writeJobsForTeeth,
|
||||
type AddonSlot,
|
||||
type ArchTarget,
|
||||
type PickerScope,
|
||||
} from '@/components/treatment/prosthesisTree';
|
||||
import {
|
||||
applyShiftRange,
|
||||
deriveTeethFromGroups,
|
||||
groupsFromFlatTeeth,
|
||||
linkedEdgesFromGroups,
|
||||
shiftRangeTeeth,
|
||||
teethBetweenInclusive,
|
||||
} from '@/components/treatment/toothSelectionGroups';
|
||||
|
||||
interface ProsthesisAssignChartProps {
|
||||
@@ -64,7 +64,6 @@ export function ProsthesisAssignChart({
|
||||
onChange,
|
||||
}: ProsthesisAssignChartProps) {
|
||||
const tTreatment = useTranslations('treatment');
|
||||
const tProsthesis = useTranslations('prosthesis');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [mobile, setMobile] = useState(false);
|
||||
const [anchor, setAnchor] = useState<FdiToothId | null>(null);
|
||||
@@ -73,8 +72,6 @@ export function ProsthesisAssignChart({
|
||||
const [lockedArch, setLockedArch] = useState<ArchTarget>('upper');
|
||||
const brushRef = useRef<string[]>([]);
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -102,12 +99,11 @@ export function ProsthesisAssignChart({
|
||||
const archHighlight = open && scope === 'arch' ? lockedArch : persistedArch;
|
||||
const archWork = hasArchJobs(toothProsthesis, detailClientId);
|
||||
|
||||
const focusTooth = anchor ?? [...selected][0] ?? null;
|
||||
const jobCodes =
|
||||
scope === 'arch'
|
||||
? uniqueCodesOnGroup(toothProsthesis, detailClientId, archSentinels(lockedArch))
|
||||
: focusTooth
|
||||
? codesOnTooth(toothProsthesis, detailClientId, focusTooth)
|
||||
: anchor
|
||||
? codesOnTooth(toothProsthesis, detailClientId, anchor)
|
||||
: [];
|
||||
|
||||
function groupIdForTooth(tooth: string, nextGroups: ToothSelectionGroup[]): string {
|
||||
@@ -119,6 +115,7 @@ export function ProsthesisAssignChart({
|
||||
}
|
||||
|
||||
function applyCodes(teeth: FdiToothId[], codes: string[]) {
|
||||
if (codes.length === 0 || teeth.length === 0) return;
|
||||
const nextGroups = ensureTeethInGroups(groups, teeth);
|
||||
const nextRows = writeJobsForTeeth(
|
||||
toothProsthesis,
|
||||
@@ -127,24 +124,21 @@ export function ProsthesisAssignChart({
|
||||
codes,
|
||||
(tooth) => groupIdForTooth(tooth, nextGroups),
|
||||
);
|
||||
brushRef.current = codes;
|
||||
commit(nextGroups, nextRows);
|
||||
}
|
||||
|
||||
function setRange(next: FdiToothId[] | null) {
|
||||
pendingRangeRef.current = next;
|
||||
setPendingRange(next);
|
||||
function rememberBrushFromTooth(tooth: FdiToothId | null) {
|
||||
if (!tooth) return;
|
||||
const codes = codesOnTooth(toothProsthesis, detailClientId, tooth);
|
||||
if (codes.length === 0) return;
|
||||
brushRef.current = codes;
|
||||
rangeAnchorRef.current = tooth;
|
||||
}
|
||||
|
||||
function openToothPicker(fdi: FdiToothId) {
|
||||
const pending = pendingRangeRef.current;
|
||||
if (pending && !pending.includes(fdi)) {
|
||||
setRange(null);
|
||||
}
|
||||
setScope('tooth');
|
||||
setAnchor(fdi);
|
||||
setStep({ kind: 'category' });
|
||||
rangeAnchorRef.current = fdi;
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
@@ -155,44 +149,21 @@ export function ProsthesisAssignChart({
|
||||
if (disabled) return;
|
||||
if (event.paint || event.ctrlKey) {
|
||||
if (brushRef.current.length === 0) return;
|
||||
setRange(null);
|
||||
applyCodes([fdi], brushRef.current);
|
||||
return;
|
||||
}
|
||||
if (event.shiftKey) {
|
||||
if (brushRef.current.length === 0) return;
|
||||
const start = rangeAnchorRef.current;
|
||||
if (!start || start === fdi) {
|
||||
rangeAnchorRef.current = fdi;
|
||||
applyCodes([fdi], brushRef.current);
|
||||
return;
|
||||
}
|
||||
const nextGroups = applyShiftRange(groups, start, fdi);
|
||||
const union = shiftRangeTeeth(groups, start, fdi);
|
||||
const span = teethBetweenInclusive(start, fdi);
|
||||
if (!span || span.length === 0) return;
|
||||
applyCodes(span, brushRef.current);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -201,45 +172,55 @@ export function ProsthesisAssignChart({
|
||||
|
||||
function handleArchClick(arch: 'upper' | 'lower') {
|
||||
if (disabled) return;
|
||||
setRange(null);
|
||||
setScope('arch');
|
||||
setLockedArch(arch);
|
||||
setLockedArch(archTargetFromJobs(toothProsthesis, detailClientId) ?? arch);
|
||||
setStep({ kind: 'category' });
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
function handlePickLeaf(code: string, arch?: ArchTarget) {
|
||||
if (scope === 'arch' || arch) {
|
||||
const target = arch ?? lockedArch;
|
||||
commit(groups, writeArchJobs(toothProsthesis, detailClientId, target, code));
|
||||
brushRef.current = [code];
|
||||
function handleLockedArchChange(next: ArchTarget) {
|
||||
if (next === lockedArch) return;
|
||||
const previous = lockedArch;
|
||||
setLockedArch(next);
|
||||
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' });
|
||||
return;
|
||||
}
|
||||
const pending = pendingRangeRef.current;
|
||||
const targets =
|
||||
pending && pending.length > 0 && anchor && pending.includes(anchor)
|
||||
? pending
|
||||
: anchor
|
||||
? [anchor]
|
||||
: [];
|
||||
if (targets.length === 0) return;
|
||||
const base = codesOnTooth(toothProsthesis, detailClientId, targets[0]);
|
||||
if (!anchor) return;
|
||||
const base = codesOnTooth(toothProsthesis, detailClientId, anchor);
|
||||
const nextCodes = applyLeafToJobs(base, code, byCode);
|
||||
applyCodes(targets, nextCodes);
|
||||
setRange(null);
|
||||
applyCodes([anchor], nextCodes);
|
||||
setStep({ kind: 'category' });
|
||||
}
|
||||
|
||||
function handlePickAddon(code: string | null, kind: 'implant' | 'post_core') {
|
||||
const tooth = focusTooth;
|
||||
if (!tooth || scope === 'arch') return;
|
||||
const current = codesOnTooth(toothProsthesis, detailClientId, tooth).filter((c) => {
|
||||
const entry = byCode.get(c);
|
||||
return entry?.addonKind !== kind;
|
||||
function handlePickAddon(code: string | null, kind: AddonSlot) {
|
||||
if (!anchor || scope === 'arch' || kind !== 'crown') return;
|
||||
const current = codesOnTooth(toothProsthesis, detailClientId, anchor).filter((c) => {
|
||||
return byCode.get(c)?.stackGroup !== 'restoration';
|
||||
});
|
||||
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' });
|
||||
}
|
||||
|
||||
@@ -249,14 +230,14 @@ export function ProsthesisAssignChart({
|
||||
if (rest.length === 0) {
|
||||
commit(
|
||||
groups,
|
||||
clearJobsForTeeth(toothProsthesis, detailClientId, archSentinels(lockedArch)),
|
||||
clearJobsForTeeth(toothProsthesis, detailClientId, archSentinels('both')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
commit(groups, writeArchJobs(toothProsthesis, detailClientId, lockedArch, rest[0]));
|
||||
return;
|
||||
}
|
||||
const tooth = focusTooth;
|
||||
const tooth = anchor;
|
||||
if (!tooth) return;
|
||||
const next = codesOnTooth(toothProsthesis, detailClientId, tooth).filter((c) => c !== code);
|
||||
if (next.length === 0) {
|
||||
@@ -264,7 +245,10 @@ export function ProsthesisAssignChart({
|
||||
removeTeethFromGroups(groups, [tooth]),
|
||||
clearJobsForTeeth(toothProsthesis, detailClientId, [tooth]),
|
||||
);
|
||||
if (rangeAnchorRef.current === tooth) {
|
||||
brushRef.current = [];
|
||||
rangeAnchorRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
applyCodes([tooth], next);
|
||||
@@ -274,27 +258,28 @@ export function ProsthesisAssignChart({
|
||||
if (scope === 'arch') {
|
||||
commit(
|
||||
groups,
|
||||
clearJobsForTeeth(toothProsthesis, detailClientId, archSentinels(lockedArch)),
|
||||
clearJobsForTeeth(toothProsthesis, detailClientId, archSentinels('both')),
|
||||
);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
const pending = pendingRangeRef.current;
|
||||
const teeth =
|
||||
pending && pending.length > 0
|
||||
? pending
|
||||
: focusTooth
|
||||
? [focusTooth]
|
||||
: [];
|
||||
const teeth = anchor ? [anchor] : [];
|
||||
if (teeth.length === 0) return;
|
||||
commit(
|
||||
removeTeethFromGroups(groups, teeth),
|
||||
clearJobsForTeeth(toothProsthesis, detailClientId, teeth),
|
||||
);
|
||||
brushRef.current = [];
|
||||
rangeAnchorRef.current = null;
|
||||
setOpen(false);
|
||||
setAnchor(null);
|
||||
setRange(null);
|
||||
}
|
||||
|
||||
function handleClosePicker() {
|
||||
if (scope === 'tooth') {
|
||||
rememberBrushFromTooth(anchor);
|
||||
}
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
@@ -307,9 +292,9 @@ export function ProsthesisAssignChart({
|
||||
);
|
||||
commit(groupsFromFlatTeeth([]), nextRows);
|
||||
brushRef.current = [];
|
||||
rangeAnchorRef.current = null;
|
||||
setOpen(false);
|
||||
setAnchor(null);
|
||||
setRange(null);
|
||||
}
|
||||
|
||||
const selectedSummary = (() => {
|
||||
@@ -349,26 +334,15 @@ export function ProsthesisAssignChart({
|
||||
scope={scope}
|
||||
lockedArch={scope === 'arch' ? lockedArch : undefined}
|
||||
jobCodes={jobCodes}
|
||||
tooth={focusTooth}
|
||||
rangeLabel={
|
||||
pendingRange && pendingRange.length > 1 ? pendingRange.join(', ') : null
|
||||
}
|
||||
allowImplantAddon={
|
||||
scope === 'tooth' && jobsAllowImplantAddon(jobCodes, byCode)
|
||||
}
|
||||
allowPostCoreAddon={
|
||||
scope === 'tooth' && jobsAllowPostCoreAddon(jobCodes, byCode)
|
||||
}
|
||||
tooth={anchor}
|
||||
allowCrownAddon={scope === 'tooth' && jobsAllowCrownAddon(jobCodes, byCode)}
|
||||
onStep={setStep}
|
||||
onLockedArchChange={setLockedArch}
|
||||
onLockedArchChange={handleLockedArchChange}
|
||||
onPickLeaf={handlePickLeaf}
|
||||
onPickAddon={handlePickAddon}
|
||||
onRemoveJob={handleRemoveJob}
|
||||
onClear={handleClear}
|
||||
onClose={() => {
|
||||
setOpen(false);
|
||||
setRange(null);
|
||||
}}
|
||||
onClose={handleClosePicker}
|
||||
/>
|
||||
}
|
||||
onAssignPointer={handleAssignPointer}
|
||||
|
||||
@@ -2,36 +2,48 @@
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
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 {
|
||||
ResponsiveDialogOverlay,
|
||||
ResponsiveDialogPanel,
|
||||
} from '@/components/ui/shared/ResponsiveDialog';
|
||||
import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
||||
import { prosthesisFamilyTone } from '@/components/shared/catalog-type-colors';
|
||||
import { prosthesisTypeColorFromCatalog, prosthesisTypeFillAccent } from '@/components/treatment/prosthesisTypeDisplay';
|
||||
import {
|
||||
addonLeaves,
|
||||
catalogByCode,
|
||||
categoryCodesInCatalog,
|
||||
categoryDisabledForJobs,
|
||||
categoryTileColor,
|
||||
crownRestorationCode,
|
||||
leavesFor,
|
||||
subcategoriesFor,
|
||||
type AddonSlot,
|
||||
type ArchTarget,
|
||||
type PickerScope,
|
||||
} from '@/components/treatment/prosthesisTree';
|
||||
|
||||
const CHIP_INK = '#14253d';
|
||||
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 =
|
||||
'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]';
|
||||
const HEADER_BTN_CLASS = 'h-auto min-h-8 px-2.5 py-1 text-xs leading-tight';
|
||||
'w-full whitespace-normal break-words text-center text-xs font-medium leading-snug sm:text-[13px]';
|
||||
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 =
|
||||
| { kind: 'category' }
|
||||
| { kind: 'subcategory'; category: string; arch?: ArchTarget }
|
||||
| { kind: 'leaves'; category: string; subcategory?: string; arch?: ArchTarget }
|
||||
| { kind: 'addon'; slot: 'implant' | 'post_core' };
|
||||
| { kind: 'addon'; slot: AddonSlot };
|
||||
|
||||
interface ProsthesisJobPopoverProps {
|
||||
open: boolean;
|
||||
@@ -42,13 +54,11 @@ interface ProsthesisJobPopoverProps {
|
||||
lockedArch?: ArchTarget;
|
||||
jobCodes: string[];
|
||||
tooth: string | null;
|
||||
rangeLabel?: string | null;
|
||||
allowImplantAddon: boolean;
|
||||
allowPostCoreAddon: boolean;
|
||||
allowCrownAddon: boolean;
|
||||
onStep: (step: PickerStep) => void;
|
||||
onLockedArchChange?: (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;
|
||||
onClear: () => void;
|
||||
onClose: () => void;
|
||||
@@ -56,54 +66,47 @@ interface ProsthesisJobPopoverProps {
|
||||
|
||||
function Tile({
|
||||
label,
|
||||
caption,
|
||||
color,
|
||||
active,
|
||||
disabled,
|
||||
hint,
|
||||
className = TILE_CLASS,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
caption?: string;
|
||||
color?: string;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
hint?: string;
|
||||
className?: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={caption ? `${caption}: ${label}` : label}
|
||||
title={hint ?? label}
|
||||
onClick={onClick}
|
||||
className={`${TILE_CLASS} ${
|
||||
active
|
||||
? 'border-primary ring-1 ring-primary/30'
|
||||
: 'border-border/70 hover:border-border hover:bg-background-card/70'
|
||||
}`}
|
||||
disabled={disabled}
|
||||
className={`${className} ${tileSurfaceClass(active)}`}
|
||||
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>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function VRule() {
|
||||
return <div className="w-px shrink-0 self-stretch bg-border-strong" aria-hidden />;
|
||||
}
|
||||
|
||||
function parentStep(step: PickerStep): PickerStep | null {
|
||||
if (step.kind === 'category') return null;
|
||||
if (step.kind === 'addon') return { kind: 'category' };
|
||||
if (step.kind === 'leaves') {
|
||||
if (step.subcategory) {
|
||||
return { kind: 'subcategory', category: step.category, arch: step.arch };
|
||||
}
|
||||
switch (step.kind) {
|
||||
case 'category':
|
||||
return null;
|
||||
case 'addon':
|
||||
case 'subcategory':
|
||||
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({
|
||||
@@ -115,9 +118,7 @@ export function ProsthesisJobPopover({
|
||||
lockedArch,
|
||||
jobCodes,
|
||||
tooth,
|
||||
rangeLabel,
|
||||
allowImplantAddon,
|
||||
allowPostCoreAddon,
|
||||
allowCrownAddon,
|
||||
onStep,
|
||||
onLockedArchChange,
|
||||
onPickLeaf,
|
||||
@@ -130,14 +131,10 @@ export function ProsthesisJobPopover({
|
||||
if (!open) return null;
|
||||
|
||||
const categories = categoryCodesInCatalog(catalog, scope);
|
||||
const implantAddons = addonLeaves(catalog, 'implant');
|
||||
const postCoreAddons = addonLeaves(catalog, 'post_core');
|
||||
const currentImplant =
|
||||
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 byCode = catalogByCode(catalog);
|
||||
const crownLeaves = leavesFor(catalog, 'crown', undefined, 'tooth');
|
||||
const currentCrown = crownRestorationCode(jobCodes, byCode) ?? '';
|
||||
const hasLeaf = jobCodes.length > 0;
|
||||
const byCode = new Map(catalog.map((e) => [e.code, e]));
|
||||
const archForLeaves =
|
||||
step.kind === 'leaves' || step.kind === 'subcategory' ? step.arch : lockedArch;
|
||||
|
||||
@@ -151,6 +148,7 @@ export function ProsthesisJobPopover({
|
||||
const back = parentStep(step);
|
||||
|
||||
function openCategory(code: string) {
|
||||
if (categoryDisabledForJobs(code, jobCodes, byCode)) return;
|
||||
const subs = subcategoriesFor(catalog, code, scope);
|
||||
const arch = scope === 'arch' ? lockedArch : undefined;
|
||||
if (subs.length > 0) {
|
||||
@@ -173,53 +171,38 @@ export function ProsthesisJobPopover({
|
||||
}
|
||||
|
||||
const addonTiles: ReactNode[] = [];
|
||||
if (scope === 'tooth' && hasLeaf) {
|
||||
if (allowImplantAddon && implantAddons.length > 0) {
|
||||
if (scope === 'tooth' && allowCrownAddon && crownLeaves.length > 0) {
|
||||
const addonActive = step.kind === 'addon' && step.slot === 'crown';
|
||||
const openAddon = () =>
|
||||
onStep(addonActive ? { kind: 'category' } : { kind: 'addon', slot: 'crown' });
|
||||
if (currentCrown) {
|
||||
addonTiles.push(
|
||||
<Tile
|
||||
key="slot-implant"
|
||||
caption={t('slotImplant')}
|
||||
label={
|
||||
currentImplant
|
||||
? (byCode.get(currentImplant)?.label ?? currentImplant)
|
||||
: t('addonNone')
|
||||
}
|
||||
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' },
|
||||
)
|
||||
}
|
||||
key="slot-crown"
|
||||
className={`${TILE_CLASS} h-full`}
|
||||
label={byCode.get(currentCrown)?.label ?? currentCrown}
|
||||
color={prosthesisTypeColorFromCatalog(currentCrown, catalog)}
|
||||
active={addonActive}
|
||||
onClick={openAddon}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
if (allowPostCoreAddon && postCoreAddons.length > 0) {
|
||||
} else {
|
||||
addonTiles.push(
|
||||
<Tile
|
||||
key="slot-post"
|
||||
caption={t('slotPostCore')}
|
||||
label={
|
||||
currentPost ? (byCode.get(currentPost)?.label ?? currentPost) : t('addonNone')
|
||||
}
|
||||
color={
|
||||
currentPost ? prosthesisTypeColorFromCatalog(currentPost, catalog) : undefined
|
||||
}
|
||||
active={step.kind === 'addon' && step.slot === 'post_core'}
|
||||
onClick={() =>
|
||||
onStep(
|
||||
step.kind === 'addon' && step.slot === 'post_core'
|
||||
? { kind: 'category' }
|
||||
: { kind: 'addon', slot: 'post_core' },
|
||||
)
|
||||
}
|
||||
/>,
|
||||
<button
|
||||
key="slot-crown"
|
||||
type="button"
|
||||
title={t('addonCrownCanBeAdded')}
|
||||
aria-pressed={addonActive}
|
||||
onClick={openAddon}
|
||||
className={`${TILE_CLASS} h-full ${tileSurfaceClass(addonActive)}`}
|
||||
>
|
||||
<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">
|
||||
<Plus className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
<span className="whitespace-normal break-words text-center leading-tight">
|
||||
{t('addonCrownCanBeAdded')}
|
||||
</span>
|
||||
</span>
|
||||
</button>,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -228,41 +211,73 @@ export function ProsthesisJobPopover({
|
||||
let childTiles: ReactNode[] = [];
|
||||
let childrenKey = 'root';
|
||||
|
||||
if (step.kind === 'category') {
|
||||
childTiles = categories.map((code) => (
|
||||
switch (step.kind) {
|
||||
case 'category':
|
||||
childTiles = categories.map((code) => {
|
||||
const disabled = categoryDisabledForJobs(code, jobCodes, byCode);
|
||||
return (
|
||||
<Tile
|
||||
key={code}
|
||||
label={categoryLabel(code)}
|
||||
color={categoryTileColor(code)}
|
||||
disabled={disabled}
|
||||
hint={disabled ? t('categoryDisabledHint') : undefined}
|
||||
onClick={() => openCategory(code)}
|
||||
/>
|
||||
));
|
||||
} else if (step.kind === 'addon') {
|
||||
selectedNode = {
|
||||
label: step.slot === 'implant' ? t('slotImplant') : t('slotPostCore'),
|
||||
};
|
||||
childrenKey = `addon-${step.slot}`;
|
||||
const options = step.slot === 'implant' ? implantAddons : postCoreAddons;
|
||||
const current = step.slot === 'implant' ? currentImplant : currentPost;
|
||||
);
|
||||
});
|
||||
break;
|
||||
case 'addon':
|
||||
selectedNode = { label: t('slotCrown'), color: categoryTileColor('crown') };
|
||||
childrenKey = 'addon-crown';
|
||||
childTiles = [
|
||||
<Tile
|
||||
key="none"
|
||||
label={t('addonNone')}
|
||||
active={!current}
|
||||
onClick={() => onPickAddon(null, step.slot)}
|
||||
active={!currentCrown}
|
||||
onClick={() => onPickAddon(null, 'crown')}
|
||||
/>,
|
||||
...options.map((opt) => (
|
||||
...crownLeaves.map((opt) => (
|
||||
<Tile
|
||||
key={opt.code}
|
||||
label={opt.label}
|
||||
color={prosthesisTypeColorFromCatalog(opt.code, catalog)}
|
||||
active={current === opt.code}
|
||||
onClick={() => onPickAddon(opt.code, step.slot)}
|
||||
active={currentCrown === opt.code}
|
||||
onClick={() => onPickAddon(opt.code, 'crown')}
|
||||
/>
|
||||
)),
|
||||
];
|
||||
} else if (step.kind === 'leaves' && step.subcategory) {
|
||||
selectedNode = { label: subLabel(step.subcategory) };
|
||||
break;
|
||||
case 'subcategory': {
|
||||
const parentFill = categoryTileColor(step.category);
|
||||
selectedNode = { label: categoryLabel(step.category), color: parentFill };
|
||||
childrenKey = `sub-${step.category}`;
|
||||
childTiles = [
|
||||
...subcategoriesFor(catalog, step.category, scope).map((sub, i) => (
|
||||
<Tile
|
||||
key={sub}
|
||||
label={subLabel(sub)}
|
||||
color={prosthesisFamilyTone(parentFill, i + 1)}
|
||||
onClick={() =>
|
||||
onStep({
|
||||
kind: 'leaves',
|
||||
category: step.category,
|
||||
subcategory: sub,
|
||||
arch: step.arch,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)),
|
||||
...leavesFor(catalog, step.category, undefined, scope).map(leafTile),
|
||||
];
|
||||
break;
|
||||
}
|
||||
case 'leaves':
|
||||
if (step.subcategory) {
|
||||
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 {
|
||||
@@ -270,61 +285,81 @@ export function ProsthesisJobPopover({
|
||||
label: categoryLabel(step.category),
|
||||
color: categoryTileColor(step.category),
|
||||
};
|
||||
childrenKey = `${step.kind}-${step.category}`;
|
||||
const arch = step.arch;
|
||||
const subs =
|
||||
step.kind === 'subcategory'
|
||||
? subcategoriesFor(catalog, step.category, scope).map((sub) => (
|
||||
<Tile
|
||||
key={sub}
|
||||
label={subLabel(sub)}
|
||||
onClick={() =>
|
||||
onStep({
|
||||
kind: 'leaves',
|
||||
category: step.category,
|
||||
subcategory: sub,
|
||||
arch,
|
||||
})
|
||||
childrenKey = `leaves-${step.category}`;
|
||||
childTiles = leavesFor(catalog, step.category, undefined, scope).map(leafTile);
|
||||
}
|
||||
/>
|
||||
))
|
||||
: [];
|
||||
const leaves = leavesFor(
|
||||
catalog,
|
||||
step.category,
|
||||
step.kind === 'leaves' ? step.subcategory : undefined,
|
||||
scope,
|
||||
).map(leafTile);
|
||||
childTiles = [...subs, ...leaves];
|
||||
break;
|
||||
}
|
||||
|
||||
const tileRow = (
|
||||
<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">
|
||||
{selectedNode ? (
|
||||
<div className="flex h-full min-h-0 shrink-0 flex-row items-stretch gap-4">
|
||||
<Tile
|
||||
label={selectedNode.label}
|
||||
color={selectedNode.color}
|
||||
active
|
||||
onClick={() => back && onStep(back)}
|
||||
/>
|
||||
<VRule />
|
||||
const tileGrid = (
|
||||
<div
|
||||
key={childrenKey}
|
||||
className="picker-expand flex h-full min-h-0 shrink-0 flex-row items-stretch gap-2"
|
||||
className={`picker-expand min-w-0 flex-1 ${TILE_GRID_CLASS}${
|
||||
selectedNode ? '' : ' h-full min-h-0 [grid-auto-rows:minmax(3.25rem,1fr)]'
|
||||
}`}
|
||||
>
|
||||
{childTiles}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
childTiles
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const title = rangeLabel
|
||||
? t('pickerTeeth', { teeth: rangeLabel })
|
||||
: scope === 'arch'
|
||||
const branchColor = selectedNode?.color ?? 'var(--color-border-strong)';
|
||||
const tileRow = selectedNode ? (
|
||||
<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'
|
||||
? t('pickerArchBoth')
|
||||
: lockedArch === 'lower'
|
||||
@@ -334,51 +369,8 @@ export function ProsthesisJobPopover({
|
||||
? t('pickerTooth', { tooth })
|
||||
: t('pickerTitle');
|
||||
|
||||
const header = (
|
||||
<div className="space-y-1.5 shrink-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="min-w-0 text-xs font-semibold text-text-primary">{title}</p>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={HEADER_BTN_CLASS}
|
||||
disabled={!hasLeaf}
|
||||
onClick={onClear}
|
||||
>
|
||||
{scope === 'arch' ? t('clearArch') : t('clear')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className={HEADER_BTN_CLASS}
|
||||
onClick={onClose}
|
||||
>
|
||||
{t('done')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{scope === 'arch' && onLockedArchChange ? (
|
||||
<div className="flex gap-1">
|
||||
{(['upper', 'lower', 'both'] as const).map((arch) => (
|
||||
<button
|
||||
key={arch}
|
||||
type="button"
|
||||
onClick={() => onLockedArchChange(arch)}
|
||||
className={`flex-1 min-h-9 rounded-[var(--radius-sm)] border text-xs font-medium ${
|
||||
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 ? (
|
||||
const jobChips =
|
||||
jobCodes.length === 0 ? (
|
||||
<p className="text-xs text-text-muted">{t('noneYet')}</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
@@ -423,27 +415,73 @@ export function ProsthesisJobPopover({
|
||||
);
|
||||
})}
|
||||
</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 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
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={HEADER_BTN_CLASS}
|
||||
disabled={!hasLeaf}
|
||||
onClick={onClear}
|
||||
>
|
||||
{scope === 'arch' ? t('clearArch') : t('clear')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className={HEADER_BTN_CLASS}
|
||||
onClick={onClose}
|
||||
>
|
||||
{t('done')}
|
||||
</Button>
|
||||
</div>
|
||||
{addonTiles.length > 0 ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-1">
|
||||
<p className="shrink-0 text-[10px] font-medium leading-tight text-text-muted">
|
||||
{t('suggestionsLabel')}
|
||||
</p>
|
||||
<div className="min-h-0 flex-1">{addonTiles}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -453,7 +491,7 @@ export function ProsthesisJobPopover({
|
||||
<ResponsiveDialogPanel
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
maxWidthClass="sm:max-w-lg"
|
||||
maxWidthClass="sm:max-w-2xl"
|
||||
className="max-h-[90dvh] flex flex-col"
|
||||
>
|
||||
<h4 className="sr-only">{t('pickerTitle')}</h4>
|
||||
@@ -472,7 +510,7 @@ export function ProsthesisJobPopover({
|
||||
onClick={onClose}
|
||||
/>
|
||||
<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"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
|
||||
@@ -53,6 +53,9 @@ interface TreatmentDetailsEditorProps {
|
||||
onTreatmentTypeChange?: (nextType: string) => void;
|
||||
/** Arch-level jobs count as work for the missing-teeth lab banner. */
|
||||
hasArchJobs?: boolean;
|
||||
showLabDispatchToggle?: boolean;
|
||||
labWorkspaceView?: 'chart' | 'dispatch';
|
||||
onLabWorkspaceViewChange?: (view: 'chart' | 'dispatch') => void;
|
||||
}
|
||||
|
||||
export function TreatmentDetailsEditor({
|
||||
@@ -79,6 +82,9 @@ export function TreatmentDetailsEditor({
|
||||
chartLockMessage,
|
||||
onTreatmentTypeChange,
|
||||
hasArchJobs = false,
|
||||
showLabDispatchToggle = false,
|
||||
labWorkspaceView = 'chart',
|
||||
onLabWorkspaceViewChange,
|
||||
}: TreatmentDetailsEditorProps) {
|
||||
const t = useTranslations('treatment');
|
||||
const tCommon = useTranslations('common');
|
||||
@@ -229,7 +235,7 @@ export function TreatmentDetailsEditor({
|
||||
|
||||
{showFields && activeDetail ? (
|
||||
<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
|
||||
label={t('treatmentType')}
|
||||
value={activeDetail.treatmentType}
|
||||
@@ -253,6 +259,32 @@ export function TreatmentDetailsEditor({
|
||||
onUploadFiles={onUploadFiles}
|
||||
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>
|
||||
|
||||
{chart ? (
|
||||
|
||||
@@ -60,7 +60,7 @@ import {
|
||||
toggleToothInGroups,
|
||||
unlinkAdjacentTeeth,
|
||||
} 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 { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention';
|
||||
import {
|
||||
@@ -114,6 +114,19 @@ function labCaseDraftForDetail(
|
||||
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(
|
||||
labCaseDrafts: LabCaseDraft[],
|
||||
details: TreatmentDetailDraft[],
|
||||
@@ -422,6 +435,29 @@ interface TreatmentWorkspaceProps {
|
||||
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({
|
||||
userId,
|
||||
currentOrganization,
|
||||
@@ -518,6 +554,7 @@ export function TreatmentWorkspace({
|
||||
const pendingAppointmentIdRef = useRef<string | null>(initialAppointmentId);
|
||||
const pendingLabCaseIdRef = useRef<string | null>(initialLabCaseId);
|
||||
const labPanelRef = useRef<HTMLDivElement>(null);
|
||||
const [labWorkspaceView, setLabWorkspaceView] = useState<'chart' | 'dispatch'>('chart');
|
||||
const historyRequestRef = useRef(0);
|
||||
/** When set, switching the active detail scrolls to the lab dispatch panel. */
|
||||
const pendingScrollToLabRef = useRef(false);
|
||||
@@ -704,6 +741,20 @@ export function TreatmentWorkspace({
|
||||
[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 activeLocked = Boolean(activeDetail && isDetailLocked(activeDetail));
|
||||
const activeHasArchJobs = hasArchJobs(
|
||||
@@ -846,7 +897,14 @@ export function TreatmentWorkspace({
|
||||
treatment: PastTreatment,
|
||||
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 =
|
||||
mapped.length > 0
|
||||
? mapped
|
||||
@@ -859,9 +917,6 @@ export function TreatmentWorkspace({
|
||||
return stillExists ? prev : (nextDetails[0]?.clientId ?? '');
|
||||
});
|
||||
setSavedSnapshot(serializeDetails(nextDetails));
|
||||
const mappedLabCases = withoutEmptyLabCaseDrafts(
|
||||
(treatment.labCases ?? []).map(mapLabCaseDraftFromApi),
|
||||
);
|
||||
const labSnap = serializeLabCases(mappedLabCases);
|
||||
labCaseDraftsRef.current = mappedLabCases;
|
||||
savedLabCasesSnapshotRef.current = labSnap;
|
||||
@@ -870,7 +925,7 @@ export function TreatmentWorkspace({
|
||||
setActiveLabCaseId(mappedLabCases[0]?.clientId ?? null);
|
||||
setOrganizationSearch('');
|
||||
setSaveStatus('idle');
|
||||
}, []);
|
||||
}, [labDependentCodes]);
|
||||
|
||||
const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]);
|
||||
const connectedSelectedTeeth = useMemo(
|
||||
@@ -929,12 +984,19 @@ export function TreatmentWorkspace({
|
||||
setOrganizationSearch('');
|
||||
if (pendingScrollToLabRef.current) {
|
||||
pendingScrollToLabRef.current = false;
|
||||
setLabWorkspaceView('dispatch');
|
||||
requestAnimationFrame(() => {
|
||||
scrollWithinMainScrollContainer(labPanelRef.current);
|
||||
});
|
||||
} else {
|
||||
setLabWorkspaceView('chart');
|
||||
}
|
||||
}, [activeDetailId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showLabDispatch) setLabWorkspaceView('chart');
|
||||
}, [showLabDispatch]);
|
||||
|
||||
// Sync active lab shipment when the selected treatment detail changes.
|
||||
useEffect(() => {
|
||||
const match = labCaseDraftForDetail(labCaseDrafts, activeDetailId);
|
||||
@@ -1201,8 +1263,16 @@ export function TreatmentWorkspace({
|
||||
: await treatmentsApi.getDraftByTreatment(treatmentId!);
|
||||
if (cancelled) return;
|
||||
|
||||
const mappedLabCases = withoutEmptyLabCaseDrafts(
|
||||
(response.data?.labCases ?? []).map(mapLabCaseDraftFromApi),
|
||||
);
|
||||
|
||||
if (response.data?.details?.length) {
|
||||
const mapped = response.data.details.map(mapDetailFromApi);
|
||||
const mapped = pruneLabDependentDetailsToJobs(
|
||||
response.data.details.map(mapDetailFromApi),
|
||||
mappedLabCases,
|
||||
labDependentCodes,
|
||||
);
|
||||
setDetails(mapped);
|
||||
setActiveDetailId((prev) => {
|
||||
const stillExists = mapped.some((d) => d.clientId === prev);
|
||||
@@ -1222,9 +1292,6 @@ export function TreatmentWorkspace({
|
||||
setSavedSnapshot(serializeDetails([first]));
|
||||
}
|
||||
|
||||
const mappedLabCases = withoutEmptyLabCaseDrafts(
|
||||
(response.data?.labCases ?? []).map(mapLabCaseDraftFromApi),
|
||||
);
|
||||
const labSnap = serializeLabCases(mappedLabCases);
|
||||
labCaseDraftsRef.current = mappedLabCases;
|
||||
savedLabCasesSnapshotRef.current = labSnap;
|
||||
@@ -1253,6 +1320,7 @@ export function TreatmentWorkspace({
|
||||
selectedStandalone?.id,
|
||||
workspaceMode,
|
||||
treatmentCatalog,
|
||||
labDependentCodes,
|
||||
showError,
|
||||
t,
|
||||
tErrors,
|
||||
@@ -1270,7 +1338,15 @@ export function TreatmentWorkspace({
|
||||
const patientId = selectedAppointment?.patientId ?? selectedStandalone!.patientId;
|
||||
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);
|
||||
|
||||
if (!options?.force && !dirty) {
|
||||
@@ -1333,7 +1409,7 @@ export function TreatmentWorkspace({
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
[selectedAppointment, selectedStandalone, t],
|
||||
[labDependentCodes, selectedAppointment, selectedStandalone, t],
|
||||
);
|
||||
|
||||
const persistLabCases = useCallback(
|
||||
@@ -1881,8 +1957,11 @@ export function TreatmentWorkspace({
|
||||
(item: LabDispatchAttentionItem) => {
|
||||
if (item.isCurrentDraft) {
|
||||
exitBrowse();
|
||||
setLabWorkspaceView('dispatch');
|
||||
if (item.detailClientId !== activeDetailId) {
|
||||
pendingScrollToLabRef.current = true;
|
||||
setActiveDetailId(item.detailClientId);
|
||||
}
|
||||
const linked = labCaseDrafts.find(
|
||||
(lc) => !lc.sentAt && lc.detailClientId === item.detailClientId,
|
||||
);
|
||||
@@ -1901,7 +1980,7 @@ export function TreatmentWorkspace({
|
||||
if (!treatment) return;
|
||||
void loadTreatmentIntoWorkspace(treatment, item.detailClientId);
|
||||
},
|
||||
[exitBrowse, history, historyPanelItems, labCaseDrafts, loadTreatmentIntoWorkspace],
|
||||
[exitBrowse, activeDetailId, history, historyPanelItems, labCaseDrafts, loadTreatmentIntoWorkspace],
|
||||
);
|
||||
|
||||
const handleSelectPatientLabCase = useCallback(
|
||||
@@ -1949,8 +2028,11 @@ export function TreatmentWorkspace({
|
||||
workspaceMode === 'live' &&
|
||||
!isBrowsing
|
||||
) {
|
||||
setLabWorkspaceView('dispatch');
|
||||
if (item.detailClientId !== activeDetailId) {
|
||||
pendingScrollToLabRef.current = true;
|
||||
setActiveDetailId(item.detailClientId);
|
||||
}
|
||||
const matchingDraft = labCaseDrafts.find((lc) => lc.id === item.labCaseId);
|
||||
if (matchingDraft) {
|
||||
setActiveLabCaseId(matchingDraft.clientId);
|
||||
@@ -1967,6 +2049,7 @@ export function TreatmentWorkspace({
|
||||
})();
|
||||
},
|
||||
[
|
||||
activeDetailId,
|
||||
activePatientId,
|
||||
currentDraftPreview,
|
||||
handleLabCaseMarkedRead,
|
||||
@@ -2244,6 +2327,7 @@ export function TreatmentWorkspace({
|
||||
if (detail.treatmentType === nextType) return;
|
||||
|
||||
const leavingLab = labDependentCodes.has(detail.treatmentType);
|
||||
const enteringLab = labDependentCodes.has(nextType);
|
||||
const jobs =
|
||||
labCaseDraftForDetail(labCaseDrafts, activeDetailId)?.toothProsthesis ?? [];
|
||||
const hasWork =
|
||||
@@ -2257,7 +2341,7 @@ export function TreatmentWorkspace({
|
||||
|
||||
const nextDetails = details.map((d) => {
|
||||
if (d.clientId !== activeDetailId) return d;
|
||||
if (leavingLab) {
|
||||
if (leavingLab || enteringLab) {
|
||||
return {
|
||||
...d,
|
||||
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) {
|
||||
return (
|
||||
<div className="surface-card p-6 max-w-xl">
|
||||
@@ -2784,10 +2901,19 @@ export function TreatmentWorkspace({
|
||||
showFields
|
||||
hasArchJobs={activeHasArchJobs}
|
||||
voice={voiceForEditor}
|
||||
chartLocked={!activeTypeSelected && !showWholeTreatmentPlan}
|
||||
chartLocked={!activeTypeSelected && !showWholeTreatmentPlan && labWorkspaceView !== 'dispatch'}
|
||||
chartLockMessage={t('selectTypeBeforeTeeth')}
|
||||
showLabDispatchToggle={showLabDispatch}
|
||||
labWorkspaceView={labWorkspaceView}
|
||||
onLabWorkspaceViewChange={(view) => {
|
||||
setShowWholeTreatmentPlan(false);
|
||||
setLabWorkspaceView(view);
|
||||
}}
|
||||
chart={
|
||||
prosthesisAssignActive ? (
|
||||
<div ref={labPanelRef} className="min-w-0 w-full">
|
||||
{showLabDispatch && labWorkspaceView === 'dispatch' ? (
|
||||
labDispatchPanel
|
||||
) : prosthesisAssignActive ? (
|
||||
<ProsthesisAssignChart
|
||||
className="w-full"
|
||||
groups={
|
||||
@@ -2803,22 +2929,11 @@ export function TreatmentWorkspace({
|
||||
catalog={prosthesisCatalog}
|
||||
disabled={!canEditTreatmentForDay || activeLocked}
|
||||
headerControl={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowWholeTreatmentPlan((open) => !open)}
|
||||
aria-pressed={showWholeTreatmentPlan}
|
||||
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>
|
||||
<WholePlanToggle
|
||||
pressed={showWholeTreatmentPlan}
|
||||
label={t('toothChartWholePlan')}
|
||||
onToggle={() => setShowWholeTreatmentPlan((open) => !open)}
|
||||
/>
|
||||
}
|
||||
onToggleLink={(a, b) => {
|
||||
if (
|
||||
@@ -2868,8 +2983,7 @@ export function TreatmentWorkspace({
|
||||
);
|
||||
}}
|
||||
onChange={({ groups, toothProsthesis }) => {
|
||||
setDetails((prev) =>
|
||||
prev.map((d) =>
|
||||
const nextDetails = details.map((d) =>
|
||||
d.clientId !== activeDetailId
|
||||
? d
|
||||
: {
|
||||
@@ -2877,18 +2991,18 @@ export function TreatmentWorkspace({
|
||||
toothSelectionGroups: groups,
|
||||
teeth: deriveTeethFromGroups(groups),
|
||||
},
|
||||
),
|
||||
);
|
||||
setLabCaseDrafts((prev) => {
|
||||
detailsRef.current = nextDetails;
|
||||
setDetails(nextDetails);
|
||||
const prev = labCaseDraftsRef.current;
|
||||
const existing = prev.find(
|
||||
(lc) => lc.detailClientId === activeDetailId && !lc.sentAt,
|
||||
);
|
||||
if (existing) {
|
||||
return prev.map((lc) =>
|
||||
const nextDrafts = existing
|
||||
? prev.map((lc) =>
|
||||
lc.clientId === existing.clientId ? { ...lc, toothProsthesis } : lc,
|
||||
);
|
||||
}
|
||||
return [
|
||||
)
|
||||
: [
|
||||
...prev,
|
||||
{
|
||||
...newLabCaseDraft(),
|
||||
@@ -2896,7 +3010,7 @@ export function TreatmentWorkspace({
|
||||
toothProsthesis,
|
||||
},
|
||||
];
|
||||
});
|
||||
handleLabCasesChange(nextDrafts);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
@@ -2908,22 +3022,11 @@ export function TreatmentWorkspace({
|
||||
toothColors={chartToothColors}
|
||||
readOnly={showWholeTreatmentPlan}
|
||||
headerControl={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowWholeTreatmentPlan((open) => !open)}
|
||||
aria-pressed={showWholeTreatmentPlan}
|
||||
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>
|
||||
<WholePlanToggle
|
||||
pressed={showWholeTreatmentPlan}
|
||||
label={t('toothChartWholePlan')}
|
||||
onToggle={() => setShowWholeTreatmentPlan((open) => !open)}
|
||||
/>
|
||||
}
|
||||
onToggle={(fdi, event) => {
|
||||
if (
|
||||
@@ -3085,42 +3188,9 @@ export function TreatmentWorkspace({
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{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>
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
|
||||
--color-background-primary: #f6f9fc;
|
||||
--color-background-secondary: #ffffff;
|
||||
--color-background-card: #ffffff;
|
||||
--color-surface-elevated: #f8fbff;
|
||||
--color-background-primary: #f2f3f5;
|
||||
--color-background-secondary: #f7f8f9;
|
||||
--color-background-card: #f7f8f9;
|
||||
--color-surface-elevated: #eceef1;
|
||||
|
||||
--color-text-primary: #0f172a;
|
||||
--color-text-secondary: #475569;
|
||||
@@ -18,7 +18,7 @@
|
||||
--color-border: #d2dcec;
|
||||
--color-border-strong: #aec0de;
|
||||
|
||||
--color-card-background: #ffffff;
|
||||
--color-card-background: #f7f8f9;
|
||||
--color-card-foreground: #0f172a;
|
||||
--color-card-muted: #475569;
|
||||
--color-card-border: #d2dcec;
|
||||
@@ -397,7 +397,7 @@ select option {
|
||||
@keyframes picker-expand {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scaleX(0.88);
|
||||
transform: translateY(4px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
@@ -406,7 +406,7 @@ select option {
|
||||
}
|
||||
|
||||
.picker-expand {
|
||||
transform-origin: inline-start;
|
||||
transform-origin: top center;
|
||||
animation: picker-expand 180ms ease-out;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,5 +16,4 @@ export interface ProsthesisCatalogEntry {
|
||||
subcategory: string;
|
||||
chartRegion: 'crown' | 'root' | 'arch' | string;
|
||||
stackGroup: 'restoration' | 'implant' | 'post_core' | 'arch' | string;
|
||||
addonKind: '' | 'implant' | 'post_core' | string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user