improvement/v1-demo-improvements #64

Merged
rameen merged 8 commits from improvement/v1-demo-improvements into master 2026-08-20 11:26:42 +03:30
33 changed files with 1651 additions and 744 deletions
Showing only changes of commit d2f07c0ed3 - Show all commits

View File

@@ -12,4 +12,5 @@ alwaysApply: false
- **Past days:** new bookings stay blocked. Existing appointments **without** treatment can be edited/deleted; with treatment → toast `infoEditBlockedHasTreatment` (no modal). Banner clicks are not gated by `canBook` (slots still are).
- **Working hours / create:** interpret wall-clock in the **client IANA time zone** (`timeZone` on create/update, e.g. `Asia/Tehran`). Do not use Node `Date#getHours()` / `getDay()` on the server (UTC Docker vs clinic local). Helper: `zoned-civil-time.ts`.
- **Disabled feedback:** use `title` + click `toast.showInfo` with `common.readOnlyAccess` (same key as Staff/Patients) or the domain reason (`infoPastViewOnly`, `infoEditBlockedHasTreatment`).
- **Header date:** `ScheduleDayPicker` `compact` centered in a 3-col page header (same pattern as Treatment `AppointmentsStrip`).
- Prisma: `Treatment.appointmentId` is `onDelete: SetNull` — deleting an appointment does **not** cascade-delete treatments/cases/tasks; do not rely on cascade for cleanup.

View File

@@ -24,7 +24,7 @@ Monorepo: `backend/` (NestJS + Prisma), `frontend/` (Next.js + next-intl), `infr
All user-visible strings: `frontend/messages/en.json`, `fa.json`, `nl.json` — add keys to **all three**.
Dates/times/numbers: `frontend/src/lib/i18n/format.ts` + `useLocale()`. **Form date fields:** `AppDateInput` only (no native `<input type="date">`); wire format `YYYY-MM-DD`. **Filter selects:** `FORM_SELECT_CLASS` from `components/shared/formSelectStyles.ts` (chevron via `globals.css`). **Tables:** `components/ui/shared/Table.tsx` — use `text-start`/`text-end`/`text-center`, never physical `text-left`/`text-right`. Appointments: `ScheduleDayPicker`. Skill: `.cursor/skills/i18n-formatting/SKILL.md`.
Dates/times/numbers: `frontend/src/lib/i18n/format.ts` + `useLocale()`. **Form date fields:** `AppDateInput` only (no native `<input type="date">`); wire format `YYYY-MM-DD`. **Filter selects:** `FORM_SELECT_CLASS` from `components/shared/formSelectStyles.ts` (chevron via `globals.css`). **Tables:** `components/ui/shared/Table.tsx` — use `text-start`/`text-end`/`text-center`, never physical `text-left`/`text-right`. Treatment/Appointments headers: `ScheduleDayPicker` `compact`. Skill: `.cursor/skills/i18n-formatting/SKILL.md`.
## Treatment / appointment colors

View File

@@ -13,5 +13,6 @@ alwaysApply: false
- **Share link:** QR + URL when case is sent; token API + focus page — see `.cursor/skills/lab-case-share-link/SKILL.md`.
- **Case Sheet PDF:** client A4 via `jspdf` + `html2canvas`; print layout must stay **hex-only** (Tailwind `lab()`/`oklch` breaks capture). Optional `externalCode` replaces PDF order number when set.
- **Detail chrome:** Important Case checkbox (`labelPosition="start"`) then external-code input (no field title; placeholder only).
- **Add case / Start / Delete draft:** `TAB_CASES_EDIT`. Delete only unstarted lab-origin drafts (`origin === LAB_INTERNAL && !startedAt`).
Skill: `.cursor/skills/lab-cases/SKILL.md`

View File

@@ -7,12 +7,13 @@ alwaysApply: false
# Treatment workspace
- **Browse mode** (`selectedPreviewId` set): preview only; banner + **Load into workspace**; form unchanged until load.
- **Current draft** preview: heading “Current draft”; no load button while editing live.
- **Entry wizard:** Teeth → Content → Lab via `WizardStepper` (not detail chips). Lab step only for lab-dependent (prosthesis) active detail; entering Lab auto-opens shipment draft. Add detail ungated. Appointment purpose does not gate type or Next. Detail-chip switches reset to Teeth unless `pendingEntryStepRef` requests Lab (shipments rail open).
- **Tooth groups:** Adjacent selected teeth show an empty circle between them — click to connect / filled to disconnect; Shift+range selects only (empty circles; overlap absorbs as singles; midline OK). Plain click select/deselect (deselect splits bridges). Never 1-tooth connected. `toothSelectionGroups.ts`; prune lab `toothProsthesis` on change. Cases/Tasks merge by prosthesis type after send.
- **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. No stepper unless prosthesis — then `WizardStepper` Treatment → Lab with Back/Next. Chip switches reset to the treatment form unless `pendingEntryStepRef` requests Lab (shipments rail open). 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 once typed (sync live draft onto the open card); untyped keep 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). Prosthesis type: stacked below `md`, 50/50 same-row from `md`. Content clinical field = **Notes** (not case comments).
- **Lab comments:** shared `LabCaseCommentsPanel` (newest-first; sent=`justify-start`, received=`justify-end`; `viewerSide`) across Treatment / Cases / Tasks / share. Use logical `text-start`/`text-end`, not left/right.
- **Detail chrome:** chips + Add at top; **Remove** = trash on chip (unsent, >1 detail; disabled when day-locked / no edit / uploading). No delete in Content step.
- **Detail chrome:** chips (type + teeth) + Add at top; **Remove** = trash on chip (unsent, including last line; disabled when day-locked / no edit / uploading). Empty details persist as `[]`. New treatment seeds one blank detail; load of empty stays `[]`. No delete in type/notes fields.
- **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 filters (client-side):** `PastTreatmentsPanel` — “Not shipped to lab” + single date; helpers in `treatmentHistoryFilters.ts`.

View File

@@ -49,8 +49,8 @@ Use when adding or changing user-visible dates/times/numbers, RTL layout, or loc
| Component | Use for |
|-----------|---------|
| `ScheduleDayPicker` | Appointments strip — nav arrows + today toggle + expandable panel |
| `CalendarDaySelect` | Navigator wrapper (arrows + panel) |
| `ScheduleDayPicker` | Appointments / Treatment day headers — nav arrows + today toggle + expandable panel. **`compact`**: no field label; date centered; Today on the navigator row |
| `CalendarDaySelect` | Navigator wrapper (arrows + panel). Empty `label` + `showHeader={false}` puts Today beside the date (used by `compact`) |
| `CalendarDayPartsPanel` | Year / month / day row — used by schedule picker and `AppDateInput` |
Persian (`fa`): Jalali calendar + `arabext` digits via Intl (`usesPersianCalendar`). Internal model stays **`Date` at local midnight** (Gregorian) — APIs unchanged.

View File

@@ -14,7 +14,7 @@ description: Lab Cases tab — list, filters, detail panel, assignment, share QR
- **Default sort:** `startedAt` / `sentAt` desc (newest first). Lab-origin drafts (`origin === LAB_INTERNAL && !startedAt`) show a **Draft** badge. All list/detail cards also show **Received** (`CLINIC_DISPATCH`) or **Generated** (`LAB_INTERNAL`) via `LabCaseOriginBadge`.
- **Page size:** `PAGE_SIZE = 10` in `CasesPage.tsx`.
- **Auto-select:** On tab open / after filter reload, select first list item if none selected; keep selection when still in list; `?caseId=` URL wins.
- **Add case:** `TAB_CASES_EDIT``POST /cases` then replace the **right pane** with `CaseCreatePanel` (inline wizard, not a modal). Prosthesis-only lines, interactive `FdiToothChart` (same connect/Shift rules as Treatment). Header: referring clinic/dentist, patient name/mobile, optional ACTIVE partner clinic, due date (`AppDateInput`). **Start** (`POST /cases/:id/start`) generates tasks; no `LabCaseSend`, no clinic inbox/`CASE_SENT`.
- **Add case:** `TAB_CASES_EDIT``POST /cases` then replace the **right pane** with `CaseCreatePanel` (inline wizard, not a modal). Prosthesis-only lines, interactive `FdiToothChart` (same connect/Shift rules as Treatment). Header: referring clinic/dentist, patient name/mobile, optional ACTIVE partner clinic, due date (`AppDateInput`). **Start** (`POST /cases/:id/start`) generates tasks; no `LabCaseSend`, no clinic inbox/`CASE_SENT`. **Delete** (`DELETE /cases/:id`) only for unstarted lab-origin drafts (`LAB_INTERNAL` && `!startedAt`); confirm in the create-panel header next to Start. Received clinic cases and started generated cases cannot be deleted.
- **Right panel:** Draft lab-origin → wizard; started/received → `CaseDetailPanel`.
- **Left rail layout:** section is `flex flex-col` + `lg:min-h-[420px]` / `h-full` with `lg:items-stretch` on the grid; case list wrapper is `flex-1 min-h-0 overflow-y-auto` (do **not** use a fixed `max-h-[55vh]` — that leaves empty space above pagination).
@@ -34,7 +34,7 @@ Filter options: `GET /cases/filter-options` → `clinics`, `prosthesisTypes` (di
## List card UI
Match Treatment shipment cards: patient name, clinic, **colored prosthesis groups + teeth** (`LabCaseProsthesisGroupsList`), sent date, progress bar, due-date badge, unread dot. **No patient mobile** on list cards.
Match Treatment shipment cards: patient name, clinic, **colored prosthesis groups + teeth** (`LabCaseProsthesisGroupsList`), sent date, progress bar, due-date badge, unread dot. **No patient mobile** on list cards. Prosthesis row order: **type · teeth**, then Connected badge when that group is a bridge.
List item shape: `prosthesisGroups: { prosthesisTypeCode, teeth[] }[]` from task teeth aggregation.

View File

@@ -2,7 +2,7 @@
name: dyolink-treatment-workspace
description: Treatment tab workspace — appointments strip, preview vs form, history, load flow, draft autosave, entry wizard, connected teeth, lab dispatch. Use when changing treatment UX, preview/history, or lab dispatch in TreatmentWorkspace.
description: Treatment tab workspace — appointments strip, preview vs form, history, load flow, draft autosave, type-first entry, connected teeth, lab dispatch. Use when changing treatment UX, preview/history, or lab dispatch in TreatmentWorkspace.
---
@@ -22,31 +22,32 @@ Thin route: `app/[locale]/(dashboard)/treatment/page.tsx` (supports `?appointmen
1. **Day strip**`AppointmentsStrip.tsx` renders `DayStripItem[]` (`appointment` | `unscheduled`) via `DayStripCard`. Timed appointments keep treatment-type pastel banners. Standalone cards keep the same size/content but use **detail-chip theming** (neutral border, selected `border-primary bg-primary-soft`, trash with chip delete hover). Empty unscheduled cards (`details.length === 0`) can be deleted (`DELETE /treatments/:id`). Workspace fetches `GET /appointments` **and** `GET /treatments/day`. **New treatment** is one shared `Button`: it opens `NewTreatmentPatientPicker` (Walk-in always first, then search). Creating happens only after an explicit patient choice — never from the selected appointment card.
1. **Day strip**`AppointmentsStrip.tsx` renders `DayStripItem[]` (`appointment` | `unscheduled`) via `DayStripCard`. Header uses **`ScheduleDayPicker` `compact`**: date is centered in a 3-col grid; no “Schedule date” label; **Today** sits on the navigator (`CalendarDaySelect` when the label row is hidden). Timed appointments keep treatment-type pastel banners. Unscheduled cards use the same banner once a treatment type is selected (live draft for the open card; `draftHydratingRef` must be set **before** strip/appointment pick so overlay does not paint the previous cards type). Until typed they keep chip theming. Trash inherits banner ink on typed cards. Strip trash only when `areUnscheduledDetailsStripDeletable` (no type/teeth/notes/attachments, including `[]`). Workspace fetches `GET /appointments` **and** `GET /treatments/day`. **New treatment** is one shared `Button`: it opens `NewTreatmentPatientPicker` (Walk-in always first, then search). Creating happens only after an explicit patient choice — never from the selected appointment card. New treatment seeds one blank detail so the type field is ready; a persisted empty plan hydrates as `[]` until Add.
2. **Treatment preview**`TreatmentPreviewCard.tsx` (read-only summary; no load button for current draft)
2. **Treatment preview**`TreatmentPreviewCard.tsx` (history browse only; omitted for the live draft)
3. **Treatment history**`PastTreatmentsPanel.tsx` (past saved plans for patient; **client-side** filters in `treatmentHistoryFilters.ts`)
4. **Editor** — detail chips + Add (`TreatmentDetailsEditor` chrome); entry wizard (`WizardStepper`); step panels: `FdiToothChart`, Content fields, `LabCasesDispatchPanel`
4. **Editor** — detail chips + Add (`TreatmentDetailsEditor`); type-first form with chart + notes; lab send sheet only for prosthesis (`LabCasesDispatchPanel`)
## Entry wizard (Teeth / Content / Lab)
## Entry (type-first form / optional lab sheet)
Right-column entry uses `WizardStepper` (`components/ui/shared/WizardStepper.tsx`) — numbered nodes + connector rail. **Do not** reuse detail-chip tab styling for steps.
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 tab.
| Step | UI | Notes |
|------|-----|--------|
| **Teeth** | `FdiToothChart` | Neighbor circles connect/disconnect; Shift+range selects (empty circles); plain click select/deselect |
| **Content** | `TreatmentDetailsEditor` fields only (`showChrome={false}`) | Type / **Notes** / attachments — **no delete button** |
| **Lab** | `LabCasesDispatchPanel` | Shown in stepper **only** when active detail type is lab-dependent (`labDependentCodes`). Entering Lab **auto-ensures** a shipment draft (no “Add lab shipment” click). Add detail stays ungated. Due date beside title (RTL via logical end). Prosthesis dropdown same-row from `md`. |
| Stage | UI | When |
|-------|-----|------|
| **Treatment** | Type dropdown + `TreatmentDetailAttachmentsStrip`, `FdiToothChart` (Cases scale), full-width Notes | Always |
| **Lab** | `LabCasesDispatchPanel` | Only when active detail type is lab-dependent. Entering Lab auto-ensures a shipment draft. Last-used lab and prosthesis type are remembered. Comments stay on the dispatch panel. |
- Prosthesis uses `WizardStepper` (Treatment → Lab) with Back/Next. Lab dispatch keeps comments.
- 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 only defaults new details).
- Next/Back navigate visible steps; leaving prosthesis while on Lab returns to Content.
- Switching `activeDetailId` resets the wizard to **Teeth**, unless `pendingEntryStepRef` is set to `lab` first (lab shipments rail / “Go to dispatch” / load-with-focus). That ref prevents the reset effect from overwriting Lab on the first click.
- Switching `activeDetailId` resets to the treatment form, unless `pendingEntryStepRef` is set to `lab` first (lab shipments rail / “Go to dispatch” / load-with-focus).
- Live draft is **not** duplicated in the left rail preview; preview is for history browse only.
@@ -56,6 +57,8 @@ Right-column entry uses `WizardStepper` (`components/ui/shared/WizardStepper.tsx
Helpers: `frontend/src/components/treatment/toothSelectionGroups.ts`. Persisted as `toothSelectionGroups` on the detail. Lab **dispatch** rows are 1:1 with selection groups; after send, Cases/Tasks **merge** teeth by prosthesis type (not by selection group).
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).
- **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 AB + BC = one bridge.
- **Shift+click:** inclusive same-arch range → all selected as **singles** (empty circles); overlapping existing bridges are absorbed as singles too. Midline neighbors (1121, 4131) allowed.
@@ -105,7 +108,7 @@ Helpers: `frontend/src/components/treatment/toothSelectionGroups.ts`. Persisted
- **Not shipped to lab** — show treatments that have at least one lab-dependent detail (prosthesis via `labDependentCodes`) with `!sentAt`.
- **Date** — filter on `treatmentAt` matching that local calendar day.
- When **not shipped** is on, filters **already-fetched** history only (excludes the active appointment row).
- **Previous treatments rail** excludes the active appointment; live draft stays in **Current draft** preview only. Refresh history after lab send.
- **Previous treatments rail** excludes the active appointment; live draft stays in the editor only. Refresh history after lab send.
Helpers: `frontend/src/components/treatment/treatmentHistoryFilters.ts`.
@@ -144,7 +147,7 @@ On today: in-progress slot first, else nearest start time to `now`. Other days:
**Today checkbox** (`ScheduleDayPicker`): unchecked when `selectedDay` is not today (e.g. after loading a historical treatment). Checking Today calls `onSelectDay(today)` which unlocks selection (`selectionLocked = false`) and resets browse/historical context; appointments reload and auto-select nearest to now.
**Today checkbox** (`ScheduleDayPicker`, including `compact` on the Treatment strip and Appointments page headers): unchecked when `selectedDay` is not today (e.g. after loading a historical treatment). Checking Today calls `onSelectDay(today)` which unlocks selection (`selectionLocked = false`) and resets browse/historical context; appointments reload and auto-select nearest to now.
@@ -186,7 +189,7 @@ Use shared `Checkbox` (not native `<input type="checkbox">`) to avoid focus-driv
| `POST /treatments` | Create standalone `{ patientId?, walkIn?, treatmentAt }` |
| `DELETE /treatments/:id` | Empty standalone only (`appointmentId` null, no details) |
| `DELETE /treatments/:id` | Empty standalone only (`appointmentId` null, no detail rows). UI may `PUT` `{ details: [] }` first when the strip looks blank but autosave has not finished. |
| `GET /treatments/patients/:patientId/history` | History (patient + org; filtered by provider) |
@@ -219,7 +222,7 @@ canEditTreatmentForDay = canEdit && hasLiveContext && !isViewingPastDay && works
### Add / remove details
- **Add detail** stays enabled whenever `canEditTreatmentForDay` — even if sibling details are already sent.
- **Remove detail:** trash icon on each **detail chip** in chrome (`TreatmentDetailsEditor` `showChrome`). Same gates as before: only when `details.length > 1`; disabled when `!canEdit`, day/workspace `disabled`, detail sent (`isDetailLocked`), or `uploadBusy`. Confirm via `confirmRemoveDetail`. Drop linked unsent lab drafts with the detail. **Do not** put a delete control in the Content wizard step.
- **Remove detail:** trash icon on each **detail chip** in chrome (`TreatmentDetailsEditor` `showChrome`), including the last remaining line. Disabled when `!canEdit`, day/workspace `disabled`, detail sent (`isDetailLocked`), or `uploadBusy`. Confirm via `confirmRemoveDetail`. Drop linked unsent lab drafts with the detail. Empty details persist as `[]` (`SaveTreatmentDraftDto` has no `@ArrayMinSize`; `areDetailsPersistable` is `details.every(isDetailTypeSelected)` so `[]` saves). Unscheduled strip-card trash uses `areUnscheduledDetailsStripDeletable` (blank lines), then `PUT` `{ details: [] }` and `DELETE /treatments/:id` (backend still refuses when any detail row remains — `TREATMENT_HAS_DETAILS`). **Do not** put a delete control in the type/notes fields.
### Sent lab shipment fields (vs detail)

View File

@@ -39,22 +39,24 @@ frontend/src/
**Example thin page:** `app/.../treatment/page.tsx` → imports `TreatmentWorkspace` from `components/ui/treatment/`.
**i18n formatting:** Display dates/times/numbers via `lib/i18n/format.ts` + `useLocale()`. Form dates: **`AppDateInput`** (all locales — same component, masked typing + calendar popup). Appointments strip: **`ScheduleDayPicker`**. Filter `<select>`s: **`FORM_SELECT_CLASS`**; data tables: **`Table`** with logical `text-start`/`text-end` (not `text-left`/`text-right`). Skill: `.cursor/skills/i18n-formatting/SKILL.md`.
**i18n formatting:** Display dates/times/numbers via `lib/i18n/format.ts` + `useLocale()`. Form dates: **`AppDateInput`** (all locales — same component, masked typing + calendar popup). Appointments / Treatment headers: **`ScheduleDayPicker`** (`compact` centers the date). Filter `<select>`s: **`FORM_SELECT_CLASS`**; data tables: **`Table`** with logical `text-start`/`text-end` (not `text-left`/`text-right`). Skill: `.cursor/skills/i18n-formatting/SKILL.md`.
**Treatment tab:** Preview and editable form are **separate** until the user clicks **Load into workspace** on a history item. See `.cursor/skills/treatment-workspace/SKILL.md` before changing that flow.
**Treatment edit / details (quick ref):**
- Day/mode gate: editable only for live draft on today/future (`canEditTreatmentForDay`). Past day / historical load → read-only form. Empty **no-appointment** strip cards can be deleted (trash; no details). **New treatment** opens a patient picker (Walk-in always visible); it does not copy the selected appointments patient.
- Sent-to-lab detail locks that line; **Add detail** still OK same day; **Remove detail** = trash icon on each detail chip (not in the wizard Content step) — only unsent and not the last line. Attachment upload blocked when sent (`TREATMENT_DETAIL_SENT`).
- **Entry wizard:** `WizardStepper` — Teeth → Content → Lab; Lab step only when active detail type is lab-dependent (prosthesis); entering Lab auto-opens shipment draft (no Add-shipment CTA). Content notes field label is **Notes** (clinical). Detail chips ≠ wizard chrome. Switching detail chips resets wizard to Teeth unless `pendingEntryStepRef` requests Lab (e.g. opening a case from the lab shipments rail).
- **Tooth selection:** Neighbor empty/filled circles between selected adjacent teeth connect/disconnect bridges; Shift+range selects only (empty circles; overlap absorbs as singles); midline 1121 / 4131 allowed. Plain click selects/deselects (deselect splits bridges). Never a 1-tooth connected. Helpers: `toothSelectionGroups.ts`. Connected label: `ConnectedSelectionBadge`. After send, Cases/Tasks merge teeth by prosthesis type.
- Day/mode gate: editable only for live draft on today/future (`canEditTreatmentForDay`). Past day / historical load → read-only form. **New treatment** opens a patient picker (Walk-in always visible); it does not copy the selected appointments 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 once a type is selected (live draft for the open card). 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**. No wizard for non-lab types. Prosthesis: `WizardStepper` Treatment → Lab with Back/Next. Detail chips show type + teeth; lab-dependent chips use colored sent/unsent text (sent date on Lab tab). Switching chips resets to the treatment form unless `pendingEntryStepRef` requests Lab (shipments rail). 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 1121 / 4131 allowed. Plain click selects/deselects (deselect splits bridges). Never a 1-tooth connected. Helpers: `toothSelectionGroups.ts`. Connected label: `ConnectedSelectionBadge`. After send, Cases/Tasks merge teeth by prosthesis type.
- **Lab dispatch layout:** due date beside title (`justify-between`, logical start/end for RTL); prosthesis type on same row as teeth from `md:` up (stacked on mobile).
- **Schedule date:** Treatment strip and Appointments page headers use `ScheduleDayPicker` `compact` (date centered in a 3-col header; no “Schedule date” label; Today on the navigator).
**Treatment lab rules (quick ref):**
- Lab-dependent details (e.g. prosthesis) **without teeth** can save but **cannot ship** — show `LabShipmentBlockedNotice` + inline banner; toast on dispatch add.
- Detail treatment type need **not** match appointment purpose — purpose only pre-fills new details.
- **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 jumps to the entry wizard **Lab** step.
- **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 jumps to the **Lab** send sheet.
- **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`.
@@ -63,7 +65,7 @@ frontend/src/
**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; filter by case source (`origin`: received vs generated); prosthesis colors from catalog; task assignment in **Cases** (compact row: status + assignee + last update); on **Tasks**, all staff see every task but only assignee (or unassigned pool) can change status — others see “Assigned to {name}” instead of the status dropdown; **case due dates** set/edited in clinic Treatment lab dispatch, shown on lab Cases/Tasks with overdue filter + sort; completing **`intraoral_scan`** completes every scan task in that case (case-scoped; catalog first step for all prosthesis types); **mobile:** larger task status controls, sticky case header when grouped; **tab badges:** `LabCaseActivity` + `GET /notifications/tab-counts` (lab Cases/Tasks split, clinic Treatment) — live via inbox Socket.IO → `notifyTabBadgesChanged()` + soft list refresh — see `.cursor/skills/lab-tasks/SKILL.md`, `.cursor/skills/tab-badges/SKILL.md`, `.cursor/skills/notifications-inbox/SKILL.md`.
**Lab Cases tab:** Filter by **prosthesis type** (not treatment type); auto-select newest case on open; list **10 per page**; left rail list fills column height (`flex-1 overflow-y-auto`); list cards use `LabCaseProsthesisGroupsList` (colored type + teeth, shared with Treatment rail) plus **Received** / **Generated** origin badges. Deep link: `?caseId=`, `?clinicOrganizationId=`. **Share link:** QR + URL on sent cases (attachment left, QR right); opens `/lab-case/[token]` focus page. **Case Sheet PDF:** client A4 (`jspdf`/`html2canvas`); hex-only print layout; optional `externalCode` replaces order number. Lab-origin Start has no clinic send; comments and mark-read still work (no clinic-visibility toggle). **Live:** inbox Socket.IO → `notifyTabBadgesChanged()` soft-refreshes list + selected detail (no remount). See `.cursor/skills/lab-cases/SKILL.md` and `.cursor/skills/lab-case-share-link/SKILL.md`.
**Lab Cases tab:** Filter by **prosthesis type** (not treatment type); auto-select newest case on open; list **10 per page**; left rail list fills column height (`flex-1 overflow-y-auto`); list cards use `LabCaseProsthesisGroupsList` (colored type + teeth, shared with Treatment rail) plus **Received** / **Generated** origin badges. Deep link: `?caseId=`, `?clinicOrganizationId=`. **Share link:** QR + URL on sent cases (attachment left, QR right); opens `/lab-case/[token]` focus page. **Case Sheet PDF:** client A4 (`jspdf`/`html2canvas`); hex-only print layout; optional `externalCode` replaces order number. Lab-origin Start has no clinic send; comments and mark-read still work (no clinic-visibility toggle). Unstarted generated drafts can be deleted (`DELETE /cases/:id`). **Live:** inbox Socket.IO → `notifyTabBadgesChanged()` soft-refreshes list + selected detail (no remount). See `.cursor/skills/lab-cases/SKILL.md` and `.cursor/skills/lab-case-share-link/SKILL.md`.
**Lab case share link (quick ref):**
- Token on first ship → `/{locale}/lab-case/{token}` after login.
@@ -94,7 +96,7 @@ Errors: `AppException` + `ErrorCode` → frontend `getUserFacingError()`. Never
| Skill | When to use |
|-------|-------------|
| `.cursor/skills/add-feature/` | New tab, API module, or end-to-end feature |
| `.cursor/skills/treatment-workspace/` | Treatment tab: preview vs form, history, load flow, drafts, entry wizard, connected teeth |
| `.cursor/skills/treatment-workspace/` | Treatment tab: preview vs form, history, load flow, drafts, type-first entry, connected teeth |
| `.cursor/skills/lab-tasks/` | Lab Tasks tab: sort, case grouping, step-completed filter, prosthesis colors |
| `.cursor/skills/lab-cases/` | Lab Cases tab: prosthesis filter, auto-select, Case Sheet PDF, external code |
| `.cursor/skills/lab-case-share-link/` | Case QR share link: access token, focus page, auth redirect, access rules |

View File

@@ -1,6 +1,7 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
@@ -92,6 +93,13 @@ export class CasesController {
return this.casesService.startInternal(id, organizationId, req.user.id, req.user.language);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete an unstarted lab-origin draft (TAB_CASES_EDIT)' })
deleteDraft(@Param('id') id: string, @Req() req) {
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
return this.casesService.deleteInternalDraft(id, organizationId, req.user.id);
}
@Post(':id/lines/:lineClientKey/attachments')
@ApiOperation({ summary: 'Upload attachments for a lab-origin draft line' })
@ApiConsumes('multipart/form-data')

View File

@@ -4,7 +4,7 @@ import {
} from '@nestjs/common';
import { AppException, ErrorCode } from '../../common/errors';
import { randomUUID } from 'crypto';
import { createReadStream, existsSync, mkdirSync } from 'fs';
import { createReadStream, existsSync, mkdirSync, unlinkSync } from 'fs';
import { join } from 'path';
import { CatalogEntityKind, LabCaseActivityType, LabCaseOrigin, LabTaskStatus, LinkStatus, Prisma, UserNotificationType } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
@@ -565,6 +565,56 @@ export class CasesService {
return { success: true, data: await this.mapLabCaseDetail(refreshed, localeInput) };
}
async deleteInternalDraft(
labCaseId: string,
labOrganizationId: string,
actorUserId: string,
) {
await this.assertCanEditCases(actorUserId, labOrganizationId);
const existing = await this.prisma.labCase.findFirst({
where: {
id: labCaseId,
origin: LabCaseOrigin.LAB_INTERNAL,
destinationOrganizationId: labOrganizationId,
},
select: {
id: true,
startedAt: true,
attachments: { include: { attachment: { select: { id: true, storagePath: true } } } },
},
});
if (!existing) {
throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
}
if (existing.startedAt) {
throw new AppException(ErrorCode.LAB_CASE_ALREADY_STARTED, HttpStatus.CONFLICT);
}
const orphanAttachments = existing.attachments.map((row) => row.attachment);
const orphanIds = orphanAttachments.map((row) => row.id);
await this.prisma.$transaction(async (tx) => {
await tx.labCase.delete({ where: { id: labCaseId } });
if (orphanIds.length > 0) {
await tx.treatmentDetailAttachment.deleteMany({
where: { id: { in: orphanIds }, detailId: null },
});
}
});
for (const attachment of orphanAttachments) {
try {
if (attachment.storagePath && existsSync(attachment.storagePath)) {
unlinkSync(attachment.storagePath);
}
} catch {
// Best-effort disk cleanup; the draft row is already gone.
}
}
return { success: true };
}
async uploadInternalAttachments(
labCaseId: string,
lineClientKey: string,

View File

@@ -1,5 +1,4 @@
import {
ArrayMinSize,
IsArray,
IsBoolean,
IsDateString,
@@ -61,7 +60,6 @@ export class SaveTreatmentDetailDto {
export class SaveTreatmentDraftDto {
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => SaveTreatmentDetailDto)
details: SaveTreatmentDetailDto[];

View File

@@ -513,7 +513,9 @@
"removeLineAria": "Remove line {n}",
"errorCreateCase": "Could not create the case.",
"errorSaveCase": "Could not save the case.",
"errorStartCase": "Could not start the case."
"errorStartCase": "Could not start the case.",
"confirmDeleteDraftCase": "Delete this draft case? This cannot be undone.",
"errorDeleteCase": "Could not delete the case."
},
"labCaseAccess": {
"pageTitle": "Case tasks",
@@ -676,7 +678,6 @@
"noPermissionBody": "You do not have permission to view the Treatment tab for this organization.",
"title": "Treatment",
"subtitleEdit": "Document cases for your appointments, save drafts, and send work to linked organizations.",
"subtitleEditPhase4": "Plan treatment details first, then group lab-dependent work into shipments in the lab dispatch panel.",
"subtitleReadOnly": "View-only access — you can review appointments and treatment history but cannot edit.",
"pastDayNotice": "Past days are view-only. You can review appointments and history, but treatment cases cannot be added or changed.",
"selectedPatient": "Selected patient",
@@ -714,12 +715,13 @@
"casesTitle": "Treatment cases",
"casesSubtitle": "Each case has its own teeth, notes, attachments, and destinations for send.",
"detailsTitle": "Treatment details",
"detailsSubtitle": "Plan teeth, type, notes, and attachments for each detail line.",
"addDetail": "Add detail",
"confirmRemoveDetail": "Remove this treatment detail?",
"removeDetailAria": "Remove detail {n}",
"detailLabel": "Detail {n}",
"detailSentBadge": "sent",
"detailUnsentBadge": "unsent",
"attachmentPreviewUnavailable": "Preview unavailable",
"detailLockedInShipment": "This detail was sent to a lab and can no longer be edited.",
"detailsSaveHint": "Lab dispatch is configured separately below.",
"addCase": "Add case",
@@ -745,7 +747,7 @@
"noOrgInvitePermission": "You do not have permission to invite labs. Contact your organization owner.",
"sendThisCase": "Send this case",
"labDispatchTitle": "Lab dispatch",
"labDispatchSubtitle": "Group lab-dependent details into shipments and send them to linked labs.",
"labDispatchSubtitle": "Choose a lab, set prosthesis types, and send.",
"labDispatchSendHint": "Shipment grouping is saved when you send to the lab.",
"addLabShipment": "Add lab shipment",
"labShipmentLabel": "Shipment {n}",
@@ -861,11 +863,19 @@
"entryStepTeeth": "Teeth",
"entryStepContent": "Content",
"entryStepLab": "Lab",
"entryStepTreatment": "Treatment",
"entryStepBack": "Back",
"entryStepNext": "Next",
"continueToLab": "Continue to lab",
"backToTreatment": "Back to treatment",
"selectTypeBeforeTeeth": "Choose a treatment type before selecting teeth.",
"labDispatchNotSent": "Not sent",
"labDispatchLastLab": "Last lab: {name}",
"labCommentsMovedHint": "Progress and comments are in Lab shipments.",
"entryStepLabUnavailable": "Lab dispatch is available after you save a lab-dependent detail with teeth selected.",
"toothChartTitleCompact": "Tooth chart",
"toothChartHint": "Tap teeth to select or deselect. When two neighbors are selected, an empty circle appears between them — tap it to connect (or tap a filled circle to disconnect). Shift-click a range to select all in between (not connected until you fill the circles).",
"toothChartHelpAria": "How to select teeth",
"toothConnectedHint": "Connected selection",
"toothLinkHint": "Connect these teeth",
"toothUnlinkHint": "Disconnect these teeth",
@@ -876,7 +886,7 @@
"prosthesisConnectedLabel": "Connected teeth prosthesis type",
"toothChartWholePlan": "Full View",
"labShipmentAttachments": "Files for the lab",
"labShipmentAttachmentsHint": "Select which attachments from this detail are included in this shipment. None are sent by default.",
"labShipmentAttachmentsHint": "All files are included. Uncheck any you do not want to send.",
"selectedLabel": "Selected:",
"selectedEmpty": "—",
"upperArch": "Upper arch",

View File

@@ -514,7 +514,9 @@
"removeLineAria": "حذف خط {n}",
"errorCreateCase": "ایجاد پرونده ممکن نشد.",
"errorSaveCase": "ذخیره پرونده ممکن نشد.",
"errorStartCase": "شروع پرونده ممکن نشد."
"errorStartCase": "شروع پرونده ممکن نشد.",
"confirmDeleteDraftCase": "این پیش‌نویس پرونده حذف شود؟ این کار قابل بازگشت نیست.",
"errorDeleteCase": "حذف پرونده ممکن نشد."
},
"labCaseAccess": {
"pageTitle": "وظایف پرونده",
@@ -677,7 +679,6 @@
"noPermissionBody": "شما مجوز مشاهده برگه درمان برای این سازمان را ندارید.",
"title": "درمان",
"subtitleEdit": "پرونده‌های نوبت‌های خود را مستند کنید، پیش‌نویس‌ها را ذخیره کنید و کار را به سازمان‌های مرتبط ارسال کنید.",
"subtitleEditPhase4": "ابتدا جزئیات درمان را برنامه‌ریزی کنید، سپس کار وابسته به لابراتوار را در بخش ارسال لاب گروه‌بندی کنید.",
"subtitleReadOnly": "دسترسی فقط خواندنی — می‌توانید نوبت‌ها و تاریخچه درمان را بررسی کنید اما نمی‌توانید ویرایش کنید.",
"pastDayNotice": "روزهای گذشته فقط قابل مشاهده هستند. می‌توانید نوبت‌ها و تاریخچه را بررسی کنید، اما پرونده‌های درمانی قابل اضافه یا تغییر نیستند.",
"selectedPatient": "بیمار انتخاب شده",
@@ -715,12 +716,13 @@
"casesTitle": "پرونده‌های درمانی",
"casesSubtitle": "هر پرونده دارای دندان‌ها، یادداشت‌ها، پیوست‌ها و مقصدهای ارسال خود است.",
"detailsTitle": "جزئیات درمان",
"detailsSubtitle": "دندان‌ها، نوع، یادداشت و پیوست‌ها را برای هر خط جزئیات برنامه‌ریزی کنید.",
"addDetail": "افزودن جزئیات",
"confirmRemoveDetail": "این جزئیات درمان حذف شود؟",
"removeDetailAria": "حذف جزئیات {n}",
"detailLabel": "جزئیات {n}",
"detailSentBadge": "ارسال‌شده",
"detailUnsentBadge": "ارسال‌نشده",
"attachmentPreviewUnavailable": "پیش‌نمایش در دسترس نیست",
"detailLockedInShipment": "این جزئیات به لابراتوار ارسال شده و دیگر قابل ویرایش نیست.",
"detailsSaveHint": "ارسال لاب در بخش جداگانه زیر پیکربندی می‌شود.",
"addCase": "افزودن پرونده",
@@ -746,7 +748,7 @@
"noOrgInvitePermission": "شما مجوز دعوت از آزمایشگاه را ندارید. با مالک سازمان تماس بگیرید.",
"sendThisCase": "ارسال این پرونده",
"labDispatchTitle": "ارسال به لابراتوار",
"labDispatchSubtitle": "جزئیات وابسته به لاب را در محموله‌ها گروه‌بندی کرده و به لابراتوارهای متصل ارسال کنید.",
"labDispatchSubtitle": "لابراتوار را انتخاب کنید، نوع پروتز را مشخص کنید و ارسال کنید.",
"labDispatchSendHint": "گروه‌بندی محموله هنگام ارسال به لاب ذخیره می‌شود.",
"addLabShipment": "افزودن محموله لاب",
"labShipmentLabel": "محموله {n}",
@@ -860,6 +862,7 @@
"toothChartTitle": "نمودار دندان‌ها FDI",
"toothChartTitleCompact": "نمودار دندان",
"toothChartHint": "برای انتخاب یا لغو انتخاب روی دندان ضربه بزنید. وقتی دو دندان همسایه انتخاب شوند، دایرهٔ خالی بینشان ظاهر می‌شود — برای اتصال روی آن بزنید (یا روی دایرهٔ پر برای قطع اتصال). Shift+کلیک روی یک بازه، همه را انتخاب می‌کند (تا وقتی دایره‌ها را پر نکنید متصل نیستند).",
"toothChartHelpAria": "راهنمای انتخاب دندان",
"toothConnectedHint": "انتخاب متصل",
"toothLinkHint": "اتصال این دندان‌ها",
"toothUnlinkHint": "قطع اتصال این دندان‌ها",
@@ -872,12 +875,19 @@
"entryStepTeeth": "دندان‌ها",
"entryStepContent": "محتوا",
"entryStepLab": "لابراتوار",
"entryStepTreatment": "درمان",
"entryStepBack": "قبلی",
"entryStepNext": "بعدی",
"continueToLab": "ادامه به لابراتوار",
"backToTreatment": "بازگشت به درمان",
"selectTypeBeforeTeeth": "قبل از انتخاب دندان، نوع درمان را انتخاب کنید.",
"labDispatchNotSent": "ارسال نشده",
"labDispatchLastLab": "آخرین لاب: {name}",
"labCommentsMovedHint": "پیشرفت و پیام‌ها در بخش محموله‌های لاب هستند.",
"entryStepLabUnavailable": "ارسال به لابراتوار پس از ذخیره جزئیات وابسته به لاب با انتخاب دندان در دسترس است.",
"toothChartWholePlan": "نمایش کامل",
"labShipmentAttachments": "فایل‌ها برای لابراتوار",
"labShipmentAttachmentsHint": "انتخاب کنید کدام پیوست‌های این جزئیات در این محموله ارسال شوند. پیش‌فرض هیچ‌کدام نیست.",
"labShipmentAttachmentsHint": "همه فایل‌ها پیوست می‌شوند. برای حذف از ارسال، تیک را بردارید.",
"selectedLabel": "انتخاب شده:",
"selectedEmpty": "—",
"upperArch": "قوس بالا",

View File

@@ -514,7 +514,9 @@
"removeLineAria": "Lijn {n} verwijderen",
"errorCreateCase": "Case kon niet worden aangemaakt.",
"errorSaveCase": "Case kon niet worden opgeslagen.",
"errorStartCase": "Case kon niet worden gestart."
"errorStartCase": "Case kon niet worden gestart.",
"confirmDeleteDraftCase": "Dit concept verwijderen? Dit kan niet ongedaan worden gemaakt.",
"errorDeleteCase": "Case kon niet worden verwijderd."
},
"labCaseAccess": {
"pageTitle": "Dossiertaken",
@@ -676,7 +678,6 @@
"noPermissionBody": "U heeft geen toestemming om het tabblad Behandeling voor deze organisatie te bekijken.",
"title": "Behandeling",
"subtitleEdit": "Documenteer casussen voor uw afspraken, sla concepten op en stuur werk naar gekoppelde organisaties.",
"subtitleEditPhase4": "Plan eerst behandeldetails, groepeer daarna lab-afhankelijk werk in het lab-dispatchpaneel.",
"subtitleReadOnly": "Alleen-lezen toegang — u kunt afspraken en behandelgeschiedenis bekijken, maar niet bewerken.",
"pastDayNotice": "Dagen in het verleden zijn alleen-lezen. U kunt afspraken en geschiedenis bekijken, maar behandelcasussen kunnen niet worden toegevoegd of gewijzigd.",
"selectedPatient": "Geselecteerde patiënt",
@@ -714,12 +715,13 @@
"casesTitle": "Behandelcasussen",
"casesSubtitle": "Elke case heeft zijn eigen tanden, notities, bijlagen en verzendbestemmingen.",
"detailsTitle": "Behandeldetails",
"detailsSubtitle": "Plan tanden, type, notities en bijlagen per detailregel.",
"addDetail": "Detail toevoegen",
"confirmRemoveDetail": "Dit behandelingsdetail verwijderen?",
"removeDetailAria": "Detail {n} verwijderen",
"detailLabel": "Detail {n}",
"detailSentBadge": "verzonden",
"detailUnsentBadge": "niet verzonden",
"attachmentPreviewUnavailable": "Voorbeeld niet beschikbaar",
"detailLockedInShipment": "Dit detail is naar het lab verzonden en kan niet meer worden bewerkt.",
"detailsSaveHint": "Lab-dispatch wordt hieronder apart geconfigureerd.",
"addCase": "Case toevoegen",
@@ -745,7 +747,7 @@
"noOrgInvitePermission": "U heeft geen toestemming om labs uit te nodigen. Neem contact op met de organisatie-eigenaar.",
"sendThisCase": "Verzend deze case",
"labDispatchTitle": "Lab-dispatch",
"labDispatchSubtitle": "Groepeer lab-afhankelijke details in zendingen en stuur ze naar gekoppelde labs.",
"labDispatchSubtitle": "Kies een lab, stel prothesetypes in en verstuur.",
"labDispatchSendHint": "Groepering wordt opgeslagen wanneer u naar het lab verzendt.",
"addLabShipment": "Labzending toevoegen",
"labShipmentLabel": "Zending {n}",
@@ -859,6 +861,7 @@
"toothChartTitle": "FDI-tanddiagram",
"toothChartTitleCompact": "Tanddiagram",
"toothChartHint": "Tik op tanden om te selecteren of deselecteren. Bij twee geselecteerde buren verschijnt een lege cirkel ertussen — tik om te verbinden (of tik op een gevulde cirkel om te ontkoppelen). Shift-klik een bereik om alles ertussen te selecteren (nog niet verbonden tot je de cirkels vult).",
"toothChartHelpAria": "Tanden selecteren",
"toothConnectedHint": "Verbonden selectie",
"toothLinkHint": "Deze tanden verbinden",
"toothUnlinkHint": "Deze tanden ontkoppelen",
@@ -871,12 +874,19 @@
"entryStepTeeth": "Tanden",
"entryStepContent": "Inhoud",
"entryStepLab": "Lab",
"entryStepTreatment": "Behandeling",
"entryStepBack": "Terug",
"entryStepNext": "Volgende",
"continueToLab": "Verder naar lab",
"backToTreatment": "Terug naar behandeling",
"selectTypeBeforeTeeth": "Kies een behandeltype voordat u tanden selecteert.",
"labDispatchNotSent": "Niet verzonden",
"labDispatchLastLab": "Laatste lab: {name}",
"labCommentsMovedHint": "Voortgang en berichten staan bij Labzendingen.",
"entryStepLabUnavailable": "Labverzending is beschikbaar nadat u een lab-afhankelijk detail met geselecteerde tanden hebt opgeslagen.",
"toothChartWholePlan": "Volledig overzicht",
"labShipmentAttachments": "Bestanden voor het lab",
"labShipmentAttachmentsHint": "Kies welke bijlagen van dit detail bij deze zending horen. Standaard worden er geen meegestuurd.",
"labShipmentAttachmentsHint": "Alle bestanden gaan mee. Vink uit wat u niet wilt meesturen.",
"selectedLabel": "Geselecteerd:",
"selectedEmpty": "—",
"upperArch": "Bovenboog",

View File

@@ -0,0 +1,15 @@
import { isDetailTypeSelected } from '@/components/treatment/treatmentDetailRules';
import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
export function formatDetailChipLabel(
detail: { treatmentType: string; teeth: readonly string[] },
catalog: TreatmentCatalogEntry[],
fallback: string,
): string {
if (!isDetailTypeSelected(detail)) return fallback;
const typeLabel = treatmentTypeLabelFromCatalog(detail.treatmentType, catalog);
if (detail.teeth.length === 0) return typeLabel;
const teeth = [...detail.teeth].sort().join(', ');
return `${typeLabel} ${teeth}`;
}

View File

@@ -0,0 +1,73 @@
const STORAGE_PREFIX = 'dyolink.labDispatchDefaults.';
export type LabDispatchDefaults = {
lastLabId: string | null;
lastProsthesisByLab: Record<string, string>;
};
const EMPTY: LabDispatchDefaults = {
lastLabId: null,
lastProsthesisByLab: {},
};
function storageKey(clinicOrganizationId: string): string {
return `${STORAGE_PREFIX}${clinicOrganizationId}`;
}
export function loadLabDispatchDefaults(clinicOrganizationId: string | null | undefined): LabDispatchDefaults {
if (!clinicOrganizationId || typeof window === 'undefined') return EMPTY;
try {
const raw = window.localStorage.getItem(storageKey(clinicOrganizationId));
if (!raw) return EMPTY;
const parsed = JSON.parse(raw) as Partial<LabDispatchDefaults>;
return {
lastLabId: typeof parsed.lastLabId === 'string' ? parsed.lastLabId : null,
lastProsthesisByLab:
parsed.lastProsthesisByLab && typeof parsed.lastProsthesisByLab === 'object'
? parsed.lastProsthesisByLab
: {},
};
} catch {
return EMPTY;
}
}
function writeDefaults(clinicOrganizationId: string, next: LabDispatchDefaults): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(storageKey(clinicOrganizationId), JSON.stringify(next));
} catch {
// Ignore quota / private-mode failures.
}
}
export function rememberLastLab(clinicOrganizationId: string | null | undefined, labId: string): void {
if (!clinicOrganizationId || !labId) return;
const current = loadLabDispatchDefaults(clinicOrganizationId);
writeDefaults(clinicOrganizationId, { ...current, lastLabId: labId });
}
export function rememberLastProsthesisType(
clinicOrganizationId: string | null | undefined,
labId: string,
prosthesisTypeCode: string,
): void {
if (!clinicOrganizationId || !labId || !prosthesisTypeCode) return;
const current = loadLabDispatchDefaults(clinicOrganizationId);
writeDefaults(clinicOrganizationId, {
...current,
lastLabId: labId,
lastProsthesisByLab: {
...current.lastProsthesisByLab,
[labId]: prosthesisTypeCode,
},
});
}
export function lastProsthesisTypeForLab(
clinicOrganizationId: string | null | undefined,
labId: string | null | undefined,
): string | null {
if (!clinicOrganizationId || !labId) return null;
return loadLabDispatchDefaults(clinicOrganizationId).lastProsthesisByLab[labId] ?? null;
}

View File

@@ -44,15 +44,39 @@ export function isDetailTypeSelected(detail: Pick<DetailLike, 'treatmentType'>):
}
export function isEmptyDraftDetail(detail: TreatmentDetailDraft): boolean {
return isBlankTreatmentDetail(detail);
}
/** Strip-card delete: no type, teeth, notes, or attachments — including an empty list. */
export function isBlankTreatmentDetail(detail: {
treatmentType?: string | null;
teeth?: readonly string[] | null;
comment?: string | null;
notes?: string | null;
attachmentMetas?: readonly unknown[] | null;
}): boolean {
return (
!isDetailTypeSelected(detail) &&
detail.teeth.length === 0 &&
!detail.comment.trim()
!detail.treatmentType?.trim() &&
(detail.teeth?.length ?? 0) === 0 &&
!`${detail.comment ?? ''}${detail.notes ?? ''}`.trim() &&
(detail.attachmentMetas?.length ?? 0) === 0
);
}
export function areUnscheduledDetailsStripDeletable(
details: readonly {
treatmentType?: string | null;
teeth?: readonly string[] | null;
comment?: string | null;
notes?: string | null;
attachmentMetas?: readonly unknown[] | null;
}[],
): boolean {
return details.every(isBlankTreatmentDetail);
}
export function areDetailsPersistable(details: TreatmentDetailDraft[]): boolean {
return details.length > 0 && details.every((d) => isDetailTypeSelected(d));
return details.every((d) => isDetailTypeSelected(d));
}
/** Lab dispatch UI applies only to persisted prosthesis (or lab-dependent) lines with teeth. */

View File

@@ -219,9 +219,24 @@ export function AppointmentsPage() {
return (
<div className="space-y-6">
<div className="flex flex-col gap-1">
<h1 className="text-xl sm:text-2xl font-semibold text-text-primary">{t('title')}</h1>
<p className="text-sm text-text-secondary">{t('subtitle')}</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] sm:items-center">
<div className="flex min-w-0 flex-col gap-1">
<h1 className="text-xl sm:text-2xl font-semibold text-text-primary">{t('title')}</h1>
<p className="text-sm text-text-secondary">{t('subtitle')}</p>
</div>
<div className="flex min-w-0 justify-center">
<ScheduleDayPicker
compact
className="max-w-sm"
value={scheduleDate}
onChange={(d) => setScheduleDate(startOfLocalDay(d))}
/>
</div>
<div className="flex min-w-0 items-center justify-end">
{loadingSchedule ? (
<p className="text-sm text-text-muted">{t('loadingSchedule')}</p>
) : null}
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
@@ -248,16 +263,6 @@ export function AppointmentsPage() {
<div className="xl:col-span-2 space-y-4">
<AppointmentScheduleLegend treatmentCatalog={treatmentCatalog} />
<div className="flex flex-col sm:flex-row sm:items-end gap-4 sm:justify-between">
<ScheduleDayPicker
value={scheduleDate}
onChange={(d) => setScheduleDate(startOfLocalDay(d))}
/>
{loadingSchedule && (
<p className="text-sm text-text-muted pb-2">{t('loadingSchedule')}</p>
)}
</div>
<AppointmentScheduleGrid
day={scheduleDate}
providers={providers}

View File

@@ -96,6 +96,7 @@ interface CaseCreatePanelProps {
canEdit: boolean;
onSaved: (detail: LabCaseDetail) => void;
onStarted: (detail: LabCaseDetail) => void;
onDeleted: () => void;
onError: (message: string) => void;
}
@@ -104,6 +105,7 @@ export function CaseCreatePanel({
canEdit,
onSaved,
onStarted,
onDeleted,
onError,
}: CaseCreatePanelProps) {
const t = useTranslations('cases');
@@ -136,11 +138,13 @@ export function CaseCreatePanel({
const [applyAllProsthesis, setApplyAllProsthesis] = useState('');
const [saving, setSaving] = useState(false);
const [starting, setStarting] = useState(false);
const [deleting, setDeleting] = useState(false);
const [uploadBusy, setUploadBusy] = useState(false);
const rangeAnchorRef = useRef<FdiToothId | null>(null);
const hydratedIdRef = useRef(labCase.id);
const skipSaveRef = useRef(true);
const startingRef = useRef(false);
const deletingRef = useRef(false);
const allowPersistWhileStartingRef = useRef(false);
const attachmentInputRef = useRef<HTMLInputElement>(null);
@@ -221,6 +225,9 @@ export function CaseCreatePanel({
);
const persist = useCallback(async () => {
if (deletingRef.current) {
return null;
}
if (startingRef.current && !allowPersistWhileStartingRef.current) {
return null;
}
@@ -251,7 +258,7 @@ export function CaseCreatePanel({
skipSaveRef.current = false;
return;
}
if (!canEdit || startingRef.current) return;
if (!canEdit || startingRef.current || deletingRef.current) return;
const timeout = setTimeout(() => {
void persistRef.current();
}, 500);
@@ -264,6 +271,22 @@ export function CaseCreatePanel({
);
}
async function handleDelete() {
if (!canEdit) return;
if (!window.confirm(t('confirmDeleteDraftCase'))) return;
deletingRef.current = true;
skipSaveRef.current = true;
setDeleting(true);
try {
await casesApi.deleteDraft(labCase.id);
onDeleted();
} catch (error: unknown) {
deletingRef.current = false;
setDeleting(false);
onError(getUserFacingError(error, tErrors, t('errorDeleteCase')));
}
}
async function handleStart() {
if (!canEdit) return;
setStarting(true);
@@ -311,7 +334,7 @@ export function CaseCreatePanel({
}
}
const disabled = !canEdit || starting;
const disabled = !canEdit || starting || deleting;
return (
<div className="space-y-4">
@@ -320,14 +343,24 @@ export function CaseCreatePanel({
<h2 className="text-lg font-semibold text-text-primary">{t('addCaseTitle')}</h2>
<p className="text-sm text-text-muted mt-0.5">{t('addCaseSubtitle')}</p>
</div>
<Button
type="button"
variant="primary"
disabled={disabled || saving}
onClick={() => void handleStart()}
>
{starting ? tCommon('loading') : t('startCase')}
</Button>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="danger"
disabled={disabled || saving}
onClick={() => handleDelete()}
>
{tCommon('delete')}
</Button>
<Button
type="button"
variant="primary"
disabled={disabled || saving}
onClick={() => void handleStart()}
>
{starting ? tCommon('loading') : t('startCase')}
</Button>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">

View File

@@ -397,8 +397,17 @@ export function CasesPage() {
mobileDetailOpen && selectedCaseId ? 'hidden lg:flex' : 'flex'
}`}
>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 flex-1">
{canEdit ? (
<Button
type="button"
size="lg"
fullWidth
disabled={creatingCase}
onClick={() => void handleAddCase()}
>
{t('addCase')}
</Button>
) : null}
<SearchBar
embedded
value={search}
@@ -408,19 +417,6 @@ export function CasesPage() {
}}
placeholder={t('searchPlaceholder')}
/>
</div>
{canEdit ? (
<Button
variant="primary"
size="sm"
className="shrink-0"
disabled={creatingCase}
onClick={() => void handleAddCase()}
>
{t('addCase')}
</Button>
) : null}
</div>
<div className="grid gap-2 sm:grid-cols-2">
<label className="space-y-1">
@@ -661,6 +657,25 @@ export function CasesPage() {
page,
});
}}
onDeleted={() => {
const remainingOnPage = cases.filter((item) => item.id !== selectedCase.id);
const nextPage = remainingOnPage.length === 0 && page > 1 ? page - 1 : page;
setSelectedCaseId(null);
setSelectedCase(null);
setMobileDetailOpen(false);
if (nextPage !== page) {
setPage(nextPage);
return;
}
void loadCases({
q: search,
clinicOrganizationId: clinicId,
prosthesisTypeCode,
sentFrom,
sentTo,
page: nextPage,
});
}}
onError={toast.showError}
/>
) : (

View File

@@ -40,9 +40,6 @@ export function LabCaseProsthesisGroupsList({
color: prosthesisTypeColorFromCatalog(group.prosthesisTypeCode, prosthesisCatalog),
}}
>
{group.connected ? (
<ConnectedSelectionBadge className="me-1 align-middle text-[9px] px-1 py-px" />
) : null}
<span className="font-medium">
{prosthesisLabel(group.prosthesisTypeCode, prosthesisCatalog)}
</span>
@@ -52,6 +49,9 @@ export function LabCaseProsthesisGroupsList({
{formatToothList(group.teeth)}
</span>
) : null}
{group.connected ? (
<ConnectedSelectionBadge className="ms-1 align-middle text-[9px] px-1 py-px" />
) : null}
</li>
))}
</ul>

View File

@@ -61,6 +61,20 @@ export function CalendarDaySelect({
const today = startOfLocalDay(new Date());
const isTodaySelected = !isEmpty && compareLocalDayStart(normalizedValue, today) === 0;
const resolvedLabel = label ?? t('defaultLabel');
const showLabelRow = showHeader && Boolean(resolvedLabel);
const todayToggle = showTodayToggle ? (
<Checkbox
checked={isTodaySelected}
onChange={(checked) => {
if (checked) {
onChange(today);
setPanelOpen(false);
}
}}
label={t('today')}
className="shrink-0"
/>
) : null;
const labelText = isEmpty
? (emptyLabel ?? t('chooseDate'))
: formatAppPickerDateLabel(normalizedValue, locale);
@@ -121,22 +135,10 @@ export function CalendarDaySelect({
return (
<div ref={rootRef} className={`relative w-full ${className ?? 'max-w-md'}`}>
{showHeader ? (
{showLabelRow ? (
<div className="mb-2 flex items-center justify-between gap-3">
<p className="text-sm font-medium text-text-secondary">{resolvedLabel}</p>
{showTodayToggle ? (
<Checkbox
checked={isTodaySelected}
onChange={(checked) => {
if (checked) {
onChange(today);
setPanelOpen(false);
}
}}
label={t('today')}
className="shrink-0"
/>
) : null}
{todayToggle}
</div>
) : null}
@@ -170,6 +172,7 @@ export function CalendarDaySelect({
<NextIcon className="h-4 w-4 icon-flat" />
</button>
) : null}
{!showLabelRow && todayToggle ? <div className="pe-1">{todayToggle}</div> : null}
</div>
{panelOpen && !disabled ? (

View File

@@ -8,6 +8,9 @@ interface ScheduleDayPickerProps {
label?: string;
/** Show a "Today" toggle that jumps to the current local day when enabled. Default true. */
showTodayToggle?: boolean;
/** One-line control (no “Schedule date” label); Today sits on the navigator. */
compact?: boolean;
className?: string;
}
/**
@@ -19,15 +22,18 @@ export function ScheduleDayPicker({
onChange,
label,
showTodayToggle = true,
compact = false,
className,
}: ScheduleDayPickerProps) {
return (
<CalendarDaySelect
value={value}
onChange={onChange}
label={label}
showHeader
label={compact ? '' : label}
showHeader={!compact}
showTodayToggle={showTodayToggle}
showNavArrows
className={className}
/>
);
}

View File

@@ -56,28 +56,31 @@ export function AppointmentsStrip({
return (
<Card className="space-y-4">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2 min-w-0">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] sm:items-center">
<div className="flex min-w-0 items-center gap-2">
<CalendarDays className="w-5 h-5 text-text-muted shrink-0 icon-flat" aria-hidden />
<h2 className="text-sm font-semibold text-text-primary truncate">{t('appointmentsTitle')}</h2>
</div>
<button
type="button"
onClick={onToggleStripHidden}
className="text-sm font-medium text-primary hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 rounded-[var(--radius-sm)]"
>
{t('hideAppointments')}
</button>
</div>
<div className="flex flex-col sm:flex-row sm:items-end gap-4 sm:justify-between">
<ScheduleDayPicker
value={selectedDay}
onChange={(d) => onSelectDay(startOfLocalDay(d))}
/>
{loading && (
<p className="text-sm text-text-muted pb-2">{t('loadingAppointments')}</p>
)}
<div className="flex min-w-0 justify-center">
<ScheduleDayPicker
compact
className="max-w-sm"
value={selectedDay}
onChange={(d) => onSelectDay(startOfLocalDay(d))}
/>
</div>
<div className="flex min-w-0 items-center justify-end gap-3">
{loading ? (
<p className="text-sm text-text-muted">{t('loadingAppointments')}</p>
) : null}
<button
type="button"
onClick={onToggleStripHidden}
className="text-sm font-medium text-primary hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 rounded-[var(--radius-sm)]"
>
{t('hideAppointments')}
</button>
</div>
</div>
<div className="space-y-3">

View File

@@ -48,8 +48,10 @@ export function DayStripCard({
text-start w-full sm:w-auto sm:min-w-[200px] sm:max-w-[280px] min-h-[52px]
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
`;
const showDelete = item.kind === 'unscheduled' && Boolean(canDelete && onDelete);
const useTypeBanner = Boolean(item.colorCode);
if (item.kind === 'unscheduled') {
if (item.kind === 'unscheduled' && !useTypeBanner) {
const selectedClass = selected
? 'border-primary bg-primary-soft'
: 'border-border/70 hover:border-border hover:bg-background-card/50';
@@ -57,7 +59,6 @@ export function DayStripCard({
const trashClass = selected
? 'border-primary/30 text-text-muted hover:bg-red-500/15 hover:text-red-600'
: 'border-border/60 text-text-muted hover:bg-red-500/15 hover:text-red-600';
const showDelete = Boolean(canDelete && onDelete);
return (
<div
@@ -98,16 +99,52 @@ export function DayStripCard({
? 'ring-2 ring-primary ring-offset-2 ring-offset-background-secondary shadow-[inset_0_1px_0_rgba(255,255,255,0.06)]'
: 'hover:brightness-110';
if (item.kind === 'appointment') {
return (
<Card
as="button"
type="button"
onClick={onSelect}
padding="none"
style={bannerStyle}
className={`${shellClass} rounded-[var(--radius-sm)] transition-shadow px-3 py-2 ${selectedClass}`}
>
<StripCardBody item={item} />
</Card>
);
}
return (
<Card
as="button"
type="button"
onClick={onSelect}
padding="none"
<div
style={bannerStyle}
className={`${shellClass} rounded-[var(--radius-sm)] transition-shadow px-3 py-2 ${selectedClass}`}
className={`inline-flex items-stretch overflow-hidden rounded-[var(--radius-sm)] border transition-shadow ${selectedClass} ${shellClass}`}
>
<StripCardBody item={item} />
</Card>
<button
type="button"
onClick={onSelect}
className="min-w-0 flex-1 text-start px-3 py-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary/45"
>
<StripCardBody item={item} />
</button>
{showDelete ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onDelete?.();
}}
aria-label={deleteAriaLabel}
title={deleteAriaLabel}
className="
shrink-0 inline-flex items-center justify-center border-s px-2 transition-colors
text-inherit border-black/20
hover:bg-red-500/15 hover:text-red-600
focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-red-500/40
"
>
<Trash2 className="h-3.5 w-3.5" aria-hidden />
</button>
) : null}
</div>
);
}

View File

@@ -1,7 +1,14 @@
'use client';
import { useId, type CSSProperties, type ReactNode } from 'react';
import { useId, useState, type CSSProperties, type ReactNode } from 'react';
import { useTranslations } from 'next-intl';
import { Info } from 'lucide-react';
import { Button } from '@/components/ui/shared/Button';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import {
ResponsiveDialogOverlay,
ResponsiveDialogPanel,
} from '@/components/ui/shared/ResponsiveDialog';
import { FDI_LOWER_LEFT_TO_RIGHT, FDI_UPPER_LEFT_TO_RIGHT, getToothShapeKind } from '@/components/treatment/fdiToothMeta';
import {
getToothColumnWidthRem,
@@ -17,8 +24,6 @@ const TOOTH_NUMBER_GAP = 'mt-1';
const REALISTIC_NUMBER_GAP = '2mm';
/** Tight interproximal gap between tooth columns. */
const TOOTH_GAP = 'gap-x-px';
/** Fixed row cell height — must fit tallest scaled canine/root. */
const TOOTH_CELL_H = 'h-[7.25rem]';
function quadrantMirrored(fdi: FdiToothId): boolean {
const q = fdi[0];
@@ -47,6 +52,65 @@ function toothSize(fdi: FdiToothId): {
};
}
function ToothNumber({
fdi,
selected,
widthRem,
colorClass,
style,
interactive,
disabled,
onActivate,
}: {
fdi: FdiToothId;
selected: boolean;
widthRem: number;
colorClass: string;
style?: CSSProperties;
interactive: boolean;
disabled: boolean;
onActivate: (shiftKey: boolean) => void;
}) {
const className = `text-[10px] tabular-nums text-center leading-none ${colorClass}`;
const boxStyle: CSSProperties = { width: `${widthRem}rem`, ...style };
if (!interactive) {
return (
<span className={className} style={boxStyle}>
{fdi}
</span>
);
}
return (
<button
type="button"
disabled={disabled}
data-fdi={fdi}
onMouseDown={(e) => {
if (e.shiftKey) e.preventDefault();
}}
onPointerDown={(e) => {
if (e.button !== 0) return;
e.preventDefault();
onActivate(e.shiftKey);
}}
onKeyDown={(e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
onActivate(e.shiftKey);
}}
aria-pressed={selected}
className={`${className} bg-transparent select-none touch-manipulation focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 ${
disabled ? 'cursor-not-allowed' : 'cursor-pointer'
}`}
style={boxStyle}
>
{fdi}
</button>
);
}
interface FdiToothChartProps {
selected: ReadonlySet<FdiToothId>;
/**
@@ -71,6 +135,8 @@ interface FdiToothChartProps {
headerControl?: ReactNode;
/** Compact card for embedded case detail panels. */
compact?: boolean;
/** Nested in the treatment editor — no extra card chrome. */
embedded?: boolean;
className?: string;
}
@@ -126,10 +192,13 @@ export function FdiToothChart({
toothColors,
headerControl,
compact = false,
embedded = false,
className = '',
}: FdiToothChartProps) {
const t = useTranslations('treatment');
const tCommon = useTranslations('common');
const uid = useId().replace(/:/g, '');
const [hintOpen, setHintOpen] = useState(false);
const archPeak = 8;
const interactive = !readOnly && Boolean(onToggle);
const linkInteractive = interactive && Boolean(onToggleLink);
@@ -293,6 +362,13 @@ export function FdiToothChart({
/>
);
/** Cover crown/root shift toward the numbers without overlapping neighbors. */
const hitOverflowPx = Math.abs(offsetY);
const activateTooth = (shiftKey: boolean) => {
if (!interactive || isDisabled) return;
onToggle?.(fdi, { shiftKey });
};
return (
<div
key={fdi}
@@ -302,42 +378,66 @@ export function FdiToothChart({
{!upper && realistic ? (
<div style={{ height: REALISTIC_NUMBER_GAP }} aria-hidden />
) : null}
<div className={`${TOOTH_CELL_H} flex ${alignItems} justify-center`}>
<div
className={`group relative box-content flex ${alignItems} justify-center`}
style={{
height: '7.25rem',
paddingBottom: upper ? hitOverflowPx : 0,
paddingTop: upper ? 0 : hitOverflowPx,
}}
>
{interactive ? (
<button
type="button"
disabled={isDisabled}
data-fdi={fdi}
onMouseDown={(e) => {
if (e.shiftKey) e.preventDefault();
}}
onClick={(e) => {
onPointerDown={(e) => {
if (e.button !== 0) return;
e.preventDefault();
onToggle?.(fdi, { shiftKey: e.shiftKey });
e.stopPropagation();
activateTooth(e.shiftKey);
}}
onKeyDown={(e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
activateTooth(e.shiftKey);
}}
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
className={`
rounded-[var(--radius-sm)] p-0.5 transition-transform select-none
absolute inset-y-[6%] z-10 rounded-[var(--radius-sm)] bg-transparent select-none touch-manipulation
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50
${isDisabled ? 'opacity-50 cursor-not-allowed' : 'hover:scale-105 active:scale-95'}
${isDisabled ? 'cursor-not-allowed' : 'cursor-pointer'}
`}
style={{ left: '22%', right: '22%' }}
aria-pressed={isSel}
aria-label={
isSel
? `${t('toothAria', { fdi })}${t('toothSelectedSuffix')}`
: t('toothAria', { fdi })
}
>
{glyph}
</button>
) : (
/>
) : null}
<div
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
className={`pointer-events-none ${isDisabled ? 'opacity-50' : ''}`}
aria-hidden
>
<div
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
className="rounded-[var(--radius-sm)] p-0.5"
aria-hidden={!isSel}
className={`
rounded-[var(--radius-sm)] p-0.5 transition-transform duration-150
${isSel ? 'scale-105' : ''}
${
interactive && !isDisabled
? '[@media(hover:hover)_and_(pointer:fine)]:group-hover:scale-105'
: ''
}
`}
>
{glyph}
</div>
)}
</div>
</div>
{upper && realistic ? (
<div style={{ height: REALISTIC_NUMBER_GAP }} aria-hidden />
@@ -351,9 +451,11 @@ export function FdiToothChart({
</div>
);
const cardClass = compact
? 'rounded-lg border border-border/60 bg-background-secondary/30 p-2 space-y-2'
: 'surface-card p-3 space-y-3';
const cardClass = embedded
? 'space-y-2'
: compact
? 'rounded-lg border border-border/60 bg-background-secondary/30 p-2 space-y-2'
: 'surface-card p-3 space-y-3';
const chartBody = (
<>
@@ -368,44 +470,38 @@ export function FdiToothChart({
<div className="relative z-10 space-y-0">
<Row teeth={FDI_UPPER_LEFT_TO_RIGHT} upper />
<div className={`flex flex-nowrap justify-center ${TOOTH_GAP} ${TOOTH_NUMBER_GAP}`}>
{FDI_UPPER_LEFT_TO_RIGHT.map((fdi) => {
const size = toothSizeClass(fdi);
const isSel = selected.has(fdi);
return (
<span
key={`u-${fdi}`}
className={`text-[10px] tabular-nums text-center leading-none ${numberColorClass(fdi, isSel)}`}
style={{
width: `${size.columnWidthRem}rem`,
...(numberStyle(fdi, isSel) ?? {}),
}}
>
{fdi}
</span>
);
})}
{FDI_UPPER_LEFT_TO_RIGHT.map((fdi) => (
<ToothNumber
key={`u-${fdi}`}
fdi={fdi}
selected={selected.has(fdi)}
widthRem={toothSizeClass(fdi).columnWidthRem}
colorClass={numberColorClass(fdi, selected.has(fdi))}
style={numberStyle(fdi, selected.has(fdi))}
interactive={interactive}
disabled={isDisabled}
onActivate={(shiftKey) => onToggle?.(fdi, { shiftKey })}
/>
))}
</div>
<div className="my-2 h-px w-full bg-border/70" role="separator" aria-hidden />
<div className="pt-0.5">
<div className={`flex flex-nowrap justify-center ${TOOTH_GAP}`}>
{FDI_LOWER_LEFT_TO_RIGHT.map((fdi) => {
const size = toothSizeClass(fdi);
const isSel = selected.has(fdi);
return (
<span
key={`l-${fdi}`}
className={`text-[10px] tabular-nums text-center leading-none ${numberColorClass(fdi, isSel)}`}
style={{
width: `${size.columnWidthRem}rem`,
...(numberStyle(fdi, isSel) ?? {}),
}}
>
{fdi}
</span>
);
})}
{FDI_LOWER_LEFT_TO_RIGHT.map((fdi) => (
<ToothNumber
key={`l-${fdi}`}
fdi={fdi}
selected={selected.has(fdi)}
widthRem={toothSizeClass(fdi).columnWidthRem}
colorClass={numberColorClass(fdi, selected.has(fdi))}
style={numberStyle(fdi, selected.has(fdi))}
interactive={interactive}
disabled={isDisabled}
onActivate={(shiftKey) => onToggle?.(fdi, { shiftKey })}
/>
))}
</div>
<div className={TOOTH_NUMBER_GAP}>
<Row teeth={FDI_LOWER_LEFT_TO_RIGHT} />
@@ -421,31 +517,63 @@ export function FdiToothChart({
return (
<div className={`${cardClass} ${className}`}>
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-x-2">
<div className="flex min-w-0 items-center gap-1">
<h3 className="text-sm font-semibold text-text-primary">
{compact ? t('toothChartTitleCompact') : t('toothChartTitle')}
{compact || embedded ? t('toothChartTitleCompact') : t('toothChartTitle')}
</h3>
{!compact && (
<p className="text-[11px] text-text-muted mt-0.5">{t('toothChartHint')}</p>
)}
</div>
<div className="flex flex-col items-start gap-1.5 sm:items-end shrink-0">
{headerControl}
<p className="text-[11px] text-text-secondary tabular-nums sm:text-end">
{t('selectedLabel')}{' '}
{selected.size === 0 ? t('selectedEmpty') : [...selected].sort().join(', ')}
</p>
{!compact && !embedded ? (
<button
type="button"
onClick={() => setHintOpen(true)}
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-text-muted hover:bg-background-card hover:text-text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
aria-label={t('toothChartHelpAria')}
>
<Info className="h-4 w-4" aria-hidden />
</button>
) : null}
</div>
<div className="flex justify-center">{headerControl}</div>
<p className="min-w-0 text-[11px] text-text-secondary tabular-nums text-end">
{t('selectedLabel')}{' '}
{selected.size === 0 ? t('selectedEmpty') : [...selected].sort().join(', ')}
</p>
</div>
{scale !== 1 ? (
<div style={{ zoom: scale }} className="origin-top-left w-fit">
<div style={{ zoom: scale }} className="mx-auto w-fit origin-top">
{chartBody}
</div>
) : (
chartBody
)}
{hintOpen ? (
<ResponsiveDialogOverlay onBackdropClick={() => setHintOpen(false)}>
<ResponsiveDialogPanel
role="dialog"
aria-modal="true"
aria-labelledby={`${uid}-chart-hint-title`}
maxWidthClass="sm:max-w-md"
>
<div className="flex items-start justify-between gap-2 mb-3">
<h4
id={`${uid}-chart-hint-title`}
className="text-sm font-semibold text-text-primary pe-2"
>
{t('toothChartHelpAria')}
</h4>
<DialogCloseButton onClick={() => setHintOpen(false)} />
</div>
<p className="text-sm text-text-secondary leading-relaxed">{t('toothChartHint')}</p>
<div className="mt-4">
<Button type="button" variant="outline" fullWidth onClick={() => setHintOpen(false)}>
{tCommon('close')}
</Button>
</div>
</ResponsiveDialogPanel>
</ResponsiveDialogOverlay>
) : null}
</div>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/shared/Button';
import { Checkbox } from '@/components/ui/shared/Checkbox';
@@ -16,7 +16,12 @@ import { LabCaseTrackerCard } from '@/components/ui/treatment/LabCaseTrackerCard
import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay';
import { treatmentsApi } from '@/lib/api/treatments';
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
import { prosthesisTypeColorFromCatalog } from '@/components/treatment/prosthesisTypeDisplay';
import {
lastProsthesisTypeForLab,
loadLabDispatchDefaults,
rememberLastLab,
rememberLastProsthesisType,
} from '@/components/treatment/labDispatchDefaults';
import type { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog';
import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment';
import type { PatientLabCaseSummary } from '@/types/lab-case-activity';
@@ -29,6 +34,7 @@ interface LabCasesDispatchPanelProps {
labCases: LabCaseDraft[];
labDependentCodes: Set<string>;
treatmentCatalog: TreatmentCatalogEntry[];
clinicOrganizationId?: string | null;
labCaseSummary?: PatientLabCaseSummary | null;
locale: string;
onLabCaseSummaryChange?: (summary: PatientLabCaseSummary) => void;
@@ -98,12 +104,27 @@ function isProsthesisMapComplete(
);
}
function toothProsthesisForRows(
rows: ProsthesisGroupRow[],
prosthesisTypeCode: string,
): LabCaseDraft['toothProsthesis'] {
return rows.flatMap((row) =>
row.teeth.map((tooth) => ({
detailClientId: row.detailClientId,
tooth,
prosthesisTypeCode,
selectionGroupId: row.groupId,
})),
);
}
export function LabCasesDispatchPanel({
details,
activeDetailId,
labCases,
labDependentCodes,
treatmentCatalog,
clinicOrganizationId,
labCaseSummary,
locale,
onLabCaseSummaryChange,
@@ -129,6 +150,7 @@ export function LabCasesDispatchPanel({
const [prosthesisOptions, setProsthesisOptions] = useState<ProsthesisCatalogEntry[]>([]);
const [applyAllProsthesis, setApplyAllProsthesis] = useState('');
const [pendingComment, setPendingComment] = useState('');
const autoFilledCaseRef = useRef<string | null>(null);
const hasTrackerSummary = Boolean(labCaseSummary && labCaseSummary.labCaseId);
const activeLinkedOrganizations = orgs.filter((o) => o.active);
@@ -148,8 +170,6 @@ export function LabCasesDispatchPanel({
labCaseForActiveDetail ??
(activeLabCaseId ? labCases.find((lc) => lc.clientId === activeLabCaseId) : null);
const detailAlreadyInShipment = Boolean(labCaseForActiveDetail);
const sent = Boolean(activeLabCase?.sentAt);
const activeDetailNumber = details.findIndex((d) => d.clientId === activeDetailId) + 1;
@@ -190,16 +210,6 @@ export function LabCasesDispatchPanel({
setPendingComment('');
}, [activeLabCase?.clientId]);
if (!activeDetail || !isLabDependentDetail) {
return null;
}
function detailSummary(d: TreatmentDetailDraft) {
const typeLabel = treatmentTypeLabelFromCatalog(d.treatmentType, treatmentCatalog);
const teeth = d.teeth.length ? d.teeth.join(', ') : t('teethNone');
return `${t('detailLabel', { n: activeDetailNumber })} · ${typeLabel} · ${teeth}`;
}
function updateActiveLabCase(patch: Partial<LabCaseDraft>) {
if (!activeLabCase) return;
onLabCasesChange(
@@ -207,6 +217,58 @@ export function LabCasesDispatchPanel({
);
}
useEffect(() => {
if (!activeLabCase || sent || !activeDetail) return;
const ids = activeDetail.attachmentMetas.map((a) => a.id);
const missing = ids.filter((id) => !activeLabCase.attachmentIds.includes(id));
if (missing.length === 0) return;
updateActiveLabCase({ attachmentIds: [...activeLabCase.attachmentIds, ...missing] });
// eslint-disable-next-line react-hooks/exhaustive-deps -- only sync newly uploaded files
}, [activeDetail?.attachmentMetas, activeLabCase?.clientId, sent]);
useEffect(() => {
if (!activeLabCase || sent || activeLabCase.destinationOrganizationId) return;
const lastLabId = loadLabDispatchDefaults(clinicOrganizationId).lastLabId;
const lastLab = lastLabId
? activeLinkedOrganizations.find((o) => o.id === lastLabId)
: undefined;
if (!lastLab) return;
updateActiveLabCase({ destinationOrganizationId: lastLab.id, toothProsthesis: [] });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeLabCase?.clientId, clinicOrganizationId, sent]);
useEffect(() => {
if (!activeLabCase || sent) return;
if (!activeLabCase.destinationOrganizationId) return;
if (prosthesisOptions.length === 0 || prosthesisRows.length === 0) return;
const fillKey = `${activeLabCase.clientId}:${prosthesisRows.length}`;
if (autoFilledCaseRef.current === fillKey) return;
if (isProsthesisMapComplete(activeLabCase, prosthesisRows)) {
autoFilledCaseRef.current = fillKey;
return;
}
const lastCode = lastProsthesisTypeForLab(
clinicOrganizationId,
activeLabCase.destinationOrganizationId,
);
if (!lastCode || !prosthesisOptions.some((opt) => opt.code === lastCode)) return;
autoFilledCaseRef.current = fillKey;
setApplyAllProsthesis(lastCode);
updateActiveLabCase({ toothProsthesis: toothProsthesisForRows(prosthesisRows, lastCode) });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
activeLabCase?.clientId,
activeLabCase?.destinationOrganizationId,
clinicOrganizationId,
prosthesisOptions,
prosthesisRows.length,
sent,
]);
if (!activeDetail || !isLabDependentDetail) {
return null;
}
function setGroupProsthesis(row: ProsthesisGroupRow, prosthesisTypeCode: string) {
if (!activeLabCase) return;
const toothSet = new Set(row.teeth);
@@ -225,19 +287,25 @@ export function LabCasesDispatchPanel({
]
: rest;
updateActiveLabCase({ toothProsthesis: next });
if (prosthesisTypeCode && activeLabCase.destinationOrganizationId) {
rememberLastProsthesisType(
clinicOrganizationId,
activeLabCase.destinationOrganizationId,
prosthesisTypeCode,
);
}
}
function applyProsthesisToAll(code: string) {
if (!activeLabCase || !code) return;
const next = prosthesisRows.flatMap((row) =>
row.teeth.map((tooth) => ({
detailClientId: row.detailClientId,
tooth,
prosthesisTypeCode: code,
selectionGroupId: row.groupId,
})),
);
updateActiveLabCase({ toothProsthesis: next });
updateActiveLabCase({ toothProsthesis: toothProsthesisForRows(prosthesisRows, code) });
if (activeLabCase.destinationOrganizationId) {
rememberLastProsthesisType(
clinicOrganizationId,
activeLabCase.destinationOrganizationId,
code,
);
}
}
function toggleAttachmentInActiveLabCase(attachmentId: string, checked: boolean) {
@@ -249,17 +317,17 @@ export function LabCasesDispatchPanel({
}
function handleSelectOrganization(org: LinkedOrganizationOption) {
autoFilledCaseRef.current = null;
updateActiveLabCase({
destinationOrganizationId: org.id,
toothProsthesis: [],
});
setApplyAllProsthesis('');
rememberLastLab(clinicOrganizationId, org.id);
}
const caseFullyComplete = isLabCaseCompleted(activeLabCase?.taskProgress);
const canEditDueDate = canEdit && !disabled && (!sent || !caseFullyComplete);
// Comments/progress belong to the shipment context, even when the treatment is opened from history.
// Do not block commenting just because the treatment editor is read-only.
const canPostComments = canEdit && !caseFullyComplete;
const canShowComments = Boolean(activeLabCase?.id);
const commentsDeferSubmit = Boolean(!sent);
@@ -314,67 +382,8 @@ export function LabCasesDispatchPanel({
);
}
function renderIncludedDetailSummary() {
if (!activeDetail) return null;
if (activeDetail.treatmentType !== 'prosthesis' || !activeLabCase) {
return (
<p className="text-sm text-text-primary rounded-[var(--radius-sm)] border border-border/50 bg-background-secondary/50 px-3 py-2">
{detailSummary(activeDetail)}
</p>
);
}
const selectionGroups =
activeDetail.toothSelectionGroups.length > 0
? activeDetail.toothSelectionGroups
: groupsFromFlatTeeth(activeDetail.teeth);
const rows = selectionGroups.map((group) => {
const codes = new Set(
activeLabCase.toothProsthesis
.filter(
(tp) =>
tp.detailClientId === activeDetail.clientId &&
group.teeth.includes(tp.tooth as never) &&
tp.prosthesisTypeCode,
)
.map((tp) => tp.prosthesisTypeCode),
);
const code = codes.size === 1 ? [...codes][0] : '';
return {
groupId: group.groupId,
kind: group.kind,
teeth: group.teeth,
code,
label: code
? prosthesisOptions.find((p) => p.code === code)?.label ?? code
: t('prosthesisUnassigned'),
};
});
return (
<div className="text-sm text-text-primary rounded-[var(--radius-sm)] border border-border/50 bg-background-secondary/50 px-3 py-2 space-y-1">
{rows.map((g) => (
<p
key={g.groupId}
className="text-[13px]"
style={{
color: g.code
? prosthesisTypeColorFromCatalog(g.code, prosthesisOptions)
: undefined,
}}
>
{g.kind === 'connected' ? (
<ConnectedSelectionBadge className="me-1 align-middle" />
) : null}
{g.label}: <span className="text-text-primary">{g.teeth.join(', ')}</span>
</p>
))}
</div>
);
}
const typeLabel = treatmentTypeLabelFromCatalog(activeDetail.treatmentType, treatmentCatalog);
const teethLabel = activeDetail.teeth.length ? [...activeDetail.teeth].sort().join(', ') : t('teethNone');
const activeDetailAttachments = activeDetail.attachmentMetas ?? [];
return (
@@ -383,36 +392,16 @@ export function LabCasesDispatchPanel({
<div className="min-w-0">
<h3 className="text-sm font-semibold text-text-primary">{t('labDispatchTitle')}</h3>
<p className="text-xs text-text-muted mt-0.5">
{t('labDispatchSubtitle')} {t('labDispatchSendHint')}
{typeLabel} · {teethLabel}
</p>
</div>
{detailAlreadyInShipment ? renderDueDateField() : null}
{activeLabCase ? renderDueDateField() : null}
</div>
{activeLabCase ? (
<div className="space-y-4 border border-border/60 rounded-[var(--radius-md)] p-3 sm:p-4 bg-background-secondary/30 min-w-0">
{sent ? (
<>
{renderIncludedDetailSummary()}
{hasTrackerSummary && labCaseSummary ? (
<LabCaseTrackerCard
summary={labCaseSummary}
locale={locale}
onSummaryChange={onLabCaseSummaryChange}
onMarkedRead={onLabCaseMarkedRead}
/>
) : null}
{canShowComments && activeLabCase?.id ? (
<DetailLabCaseCommentsSection
labCaseId={activeLabCase.id}
canPost={canPostComments}
onError={onCommentError}
onMarkRead={onLabCaseMarkedRead}
onActivityChange={onLabCaseActivityChange}
/>
) : null}
{activeLabOrgName ? (
<div>
<p className="text-xs font-medium text-text-secondary">{t('selectLab')}</p>
@@ -431,30 +420,6 @@ export function LabCasesDispatchPanel({
}}
orgs={orgs}
/>
</>
) : (
<>
{renderIncludedDetailSummary()}
{!sent && activeDetailAttachments.length > 0 ? (
<div>
<p className="text-xs font-medium text-text-secondary mb-2">
{t('labShipmentAttachments')}
</p>
<p className="text-[11px] text-text-muted mb-2">{t('labShipmentAttachmentsHint')}</p>
<div className="flex flex-col gap-2">
{activeDetailAttachments.map((att) => (
<Checkbox
key={att.id}
checked={activeLabCase.attachmentIds.includes(att.id)}
disabled={disabled}
onChange={(next) => toggleAttachmentInActiveLabCase(att.id, next)}
label={`${att.fileName} (${(att.sizeBytes / 1024).toFixed(1)} KB)`}
/>
))}
</div>
</div>
) : null}
{hasTrackerSummary && labCaseSummary ? (
<LabCaseTrackerCard
@@ -469,15 +434,14 @@ export function LabCasesDispatchPanel({
<DetailLabCaseCommentsSection
labCaseId={activeLabCase.id}
canPost={canPostComments}
deferSubmit={commentsDeferSubmit}
composerValue={pendingComment}
onComposerValueChange={setPendingComment}
onError={onCommentError}
onMarkRead={onLabCaseMarkedRead}
onActivityChange={onLabCaseActivityChange}
/>
) : null}
</>
) : (
<>
<div className="space-y-2">
<p className="text-xs font-medium text-text-secondary">{t('selectLab')}</p>
<LinkedOrganizationSearchCombobox
@@ -499,7 +463,10 @@ export function LabCasesDispatchPanel({
key={o.id}
type="button"
disabled={disabled}
onClick={() => onRecentOrganizationPick(o.id)}
onClick={() => {
handleSelectOrganization(o);
onRecentOrganizationPick(o.id);
}}
className="text-xs rounded-[var(--radius-sm)] border border-border/70 px-2 py-1 text-text-secondary hover:text-text-primary hover:border-border focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/35 disabled:opacity-50"
>
{o.name}
@@ -514,7 +481,7 @@ export function LabCasesDispatchPanel({
<p className="text-xs font-medium text-text-secondary">
{t('prosthesisTypesTitle')}
</p>
{flatToothCount > 1 && prosthesisRows.every((r) => r.kind === 'single') ? (
{flatToothCount > 1 ? (
<label className="block text-xs text-text-muted space-y-1">
{t('prosthesisApplyAll')}
<select
@@ -593,6 +560,48 @@ export function LabCasesDispatchPanel({
</div>
) : null}
{activeDetailAttachments.length > 0 ? (
<div>
<p className="text-xs font-medium text-text-secondary mb-1">
{t('labShipmentAttachments')}
</p>
<p className="text-[11px] text-text-muted mb-2">{t('labShipmentAttachmentsHint')}</p>
<div className="flex flex-col gap-2">
{activeDetailAttachments.map((att) => (
<Checkbox
key={att.id}
checked={activeLabCase.attachmentIds.includes(att.id)}
disabled={disabled}
onChange={(next) => toggleAttachmentInActiveLabCase(att.id, next)}
label={`${att.fileName} (${(att.sizeBytes / 1024).toFixed(1)} KB)`}
/>
))}
</div>
</div>
) : null}
{hasTrackerSummary && labCaseSummary ? (
<LabCaseTrackerCard
summary={labCaseSummary}
locale={locale}
onSummaryChange={onLabCaseSummaryChange}
onMarkedRead={onLabCaseMarkedRead}
/>
) : null}
{canShowComments && activeLabCase?.id ? (
<DetailLabCaseCommentsSection
labCaseId={activeLabCase.id}
canPost={canPostComments}
deferSubmit={commentsDeferSubmit}
composerValue={pendingComment}
onComposerValueChange={setPendingComment}
onError={onCommentError}
onMarkRead={onLabCaseMarkedRead}
onActivityChange={onLabCaseActivityChange}
/>
) : null}
<div className="flex flex-wrap items-center gap-3 pt-1">
<Button
type="button"
@@ -606,7 +615,26 @@ export function LabCasesDispatchPanel({
!prosthesisComplete
}
isLoading={sendBusyId === activeLabCase.clientId}
onClick={() => onSendLabCase(activeLabCase, pendingComment.trim())}
onClick={() => {
if (activeLabCase.destinationOrganizationId) {
rememberLastLab(clinicOrganizationId, activeLabCase.destinationOrganizationId);
const codes = [
...new Set(
activeLabCase.toothProsthesis
.map((tp) => tp.prosthesisTypeCode)
.filter(Boolean),
),
];
if (codes.length === 1) {
rememberLastProsthesisType(
clinicOrganizationId,
activeLabCase.destinationOrganizationId,
codes[0],
);
}
}
return onSendLabCase(activeLabCase, pendingComment.trim());
}}
>
{t('sendToLab')}
</Button>

View File

@@ -176,7 +176,7 @@ export const ToothGlyph = memo(function ToothGlyph({
<svg
viewBox={realistic.viewBox}
preserveAspectRatio={preserveAspectRatio}
className={`${className} shrink-0`}
className={`${className} shrink-0 pointer-events-none`}
style={svgStyle}
aria-hidden
>
@@ -203,7 +203,7 @@ export const ToothGlyph = memo(function ToothGlyph({
if (!model) {
return (
<svg viewBox="0 0 36 78" className={`${className} shrink-0`} style={style} aria-hidden>
<svg viewBox="0 0 36 78" className={`${className} shrink-0 pointer-events-none`} style={style} aria-hidden>
<text x="4" y="40" fontSize="8" fill="currentColor" opacity="0.35">
?
</text>
@@ -228,7 +228,7 @@ export const ToothGlyph = memo(function ToothGlyph({
return (
<svg
viewBox="0 0 36 78"
className={`${className} shrink-0`}
className={`${className} shrink-0 pointer-events-none`}
style={svgStyle}
aria-hidden
>

View File

@@ -0,0 +1,269 @@
'use client';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { useTranslations } from 'next-intl';
import { FileText, Paperclip } from 'lucide-react';
import { Button } from '@/components/ui/shared/Button';
import { DialogCloseButton } from '@/components/ui/shared/DialogCloseButton';
import {
ResponsiveDialogOverlay,
ResponsiveDialogPanel,
} from '@/components/ui/shared/ResponsiveDialog';
import { treatmentsApi } from '@/lib/api/treatments';
import type { TreatmentAttachmentMeta } from '@/types/treatment';
/** Match Dropdown control height (`py-2.5 sm:py-2` + text-base/sm + border). */
const CONTROL_H = 'h-[2.875rem] sm:h-9';
const THUMB_CLASS =
'relative aspect-square h-full shrink-0 overflow-hidden rounded-[var(--radius-sm)] bg-background-secondary';
type AttachmentKind = 'image' | 'pdf' | 'file';
const ATTACHMENT_KIND_ORDER: AttachmentKind[] = ['image', 'pdf', 'file'];
function attachmentKind(mimeType: string): AttachmentKind {
if (mimeType.startsWith('image/')) return 'image';
if (mimeType === 'application/pdf') return 'pdf';
return 'file';
}
function groupAttachmentsByKind(attachments: TreatmentAttachmentMeta[]) {
const byKind: Record<AttachmentKind, TreatmentAttachmentMeta[]> = {
image: [],
pdf: [],
file: [],
};
for (const item of attachments) {
byKind[attachmentKind(item.mimeType)].push(item);
}
return ATTACHMENT_KIND_ORDER.filter((kind) => byKind[kind].length > 0).map((kind) => ({
kind,
items: byKind[kind],
}));
}
function AttachmentGroup({ children }: { children: ReactNode }) {
return (
<div className="flex shrink-0 items-stretch gap-2 border-s border-border/60 px-2">
{children}
</div>
);
}
type TreatmentDetailAttachmentsStripProps = {
attachments: TreatmentAttachmentMeta[];
disabled: boolean;
uploadBusy: boolean;
onUploadFiles: (files: File[], onProgress: (percent: number) => void) => Promise<void>;
onRemoveAttachment?: (attachmentId: string) => void;
};
export function TreatmentDetailAttachmentsStrip({
attachments,
disabled,
uploadBusy,
onUploadFiles,
onRemoveAttachment,
}: TreatmentDetailAttachmentsStripProps) {
const t = useTranslations('treatment');
const tCommon = useTranslations('common');
const inputRef = useRef<HTMLInputElement>(null);
const [uploadPercent, setUploadPercent] = useState<number | null>(null);
const [previewId, setPreviewId] = useState<string | null>(null);
const preview = attachments.find((item) => item.id === previewId) ?? null;
const attachmentGroups = groupAttachmentsByKind(attachments);
async function handleFiles(list: FileList | null) {
if (!list?.length || disabled || uploadBusy) return;
setUploadPercent(0);
try {
await onUploadFiles(Array.from(list), (percent) => setUploadPercent(percent));
} finally {
setUploadPercent(null);
if (inputRef.current) inputRef.current.value = '';
}
}
return (
<div className="min-w-0">
<p className="block text-sm font-medium text-text-secondary mb-1">{t('attachments')}</p>
<div
className={`
flex min-w-0 items-stretch overflow-hidden rounded-[var(--radius-md)] border border-border
bg-background-card ${CONTROL_H}
${disabled ? 'opacity-50' : ''}
`}
>
<input
ref={inputRef}
type="file"
multiple
disabled={disabled || uploadBusy}
onChange={(e) => void handleFiles(e.target.files)}
className="sr-only"
aria-label={t('attachFiles')}
/>
<button
type="button"
disabled={disabled || uploadBusy}
onClick={() => inputRef.current?.click()}
title={t('chooseFiles')}
aria-label={t('chooseFiles')}
className={`
inline-flex aspect-square h-full shrink-0 items-center justify-center
text-text-secondary hover:bg-background-card/80 hover:text-text-primary
focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary/35
disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-inherit
`}
>
<Paperclip className="h-4 w-4" aria-hidden />
</button>
<div className="flex min-w-0 flex-1 items-stretch overflow-x-auto py-1 pe-0.5">
{uploadBusy ? (
<AttachmentGroup>
<div
className={THUMB_CLASS}
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={uploadPercent ?? 0}
aria-label={t('saveStatusSaving')}
>
<div className="absolute inset-0 flex items-center justify-center p-1.5">
<div className="h-1.5 w-full overflow-hidden rounded-full bg-border">
<div
className={`h-full rounded-full bg-primary ${uploadPercent ? 'transition-[width]' : 'animate-pulse'}`}
style={{ width: `${Math.max(uploadPercent ?? 8, 8)}%` }}
/>
</div>
</div>
</div>
</AttachmentGroup>
) : null}
{attachmentGroups.map((group) => (
<AttachmentGroup key={group.kind}>
{group.items.map((file) => (
<button
key={file.id}
type="button"
title={file.fileName}
onClick={() => setPreviewId(file.id)}
className={`${THUMB_CLASS} focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary/35`}
>
<AttachmentThumb attachment={file} />
</button>
))}
</AttachmentGroup>
))}
</div>
</div>
{preview ? (
<ResponsiveDialogOverlay onBackdropClick={() => setPreviewId(null)}>
<ResponsiveDialogPanel
role="dialog"
aria-modal="true"
aria-labelledby="treatment-attachment-preview-title"
maxWidthClass="sm:max-w-lg"
>
<div className="mb-3 flex items-start justify-between gap-2">
<h4
id="treatment-attachment-preview-title"
className="min-w-0 truncate text-sm font-semibold text-text-primary pe-2"
title={preview.fileName}
>
{preview.fileName}
</h4>
<DialogCloseButton onClick={() => setPreviewId(null)} />
</div>
<AttachmentPreviewBody attachment={preview} />
<div className="mt-4 flex flex-wrap justify-end gap-2">
{onRemoveAttachment && !disabled ? (
<Button
type="button"
variant="danger"
onClick={() => {
onRemoveAttachment(preview.id);
setPreviewId(null);
}}
>
{tCommon('delete')}
</Button>
) : null}
<Button type="button" variant="outline" onClick={() => setPreviewId(null)}>
{tCommon('close')}
</Button>
</div>
</ResponsiveDialogPanel>
</ResponsiveDialogOverlay>
) : null}
</div>
);
}
function AttachmentThumb({ attachment }: { attachment: TreatmentAttachmentMeta }) {
const { url, failed } = useAttachmentUrl(attachment.id);
if (url && attachment.mimeType.startsWith('image/') && !failed) {
return <img src={url} alt="" className="h-full w-full object-cover" />;
}
return (
<span className="flex h-full w-full items-center justify-center text-text-muted">
<FileText className="h-4 w-4" aria-hidden />
</span>
);
}
function AttachmentPreviewBody({ attachment }: { attachment: TreatmentAttachmentMeta }) {
const t = useTranslations('treatment');
const { url, failed } = useAttachmentUrl(attachment.id);
if (failed || !url) {
return <p className="text-sm text-text-muted">{t('attachmentPreviewUnavailable')}</p>;
}
if (attachment.mimeType.startsWith('image/')) {
return (
<img
src={url}
alt={attachment.fileName}
className="max-h-[min(24rem,60vh)] w-full rounded-[var(--radius-md)] object-contain bg-background-secondary"
/>
);
}
if (attachment.mimeType === 'application/pdf') {
return (
<iframe
src={url}
title={attachment.fileName}
className="h-[min(24rem,60vh)] w-full rounded-[var(--radius-md)] border-0 bg-background-secondary"
/>
);
}
return <p className="text-sm text-text-muted">{attachment.fileName}</p>;
}
function useAttachmentUrl(attachmentId: string) {
const [url, setUrl] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let cancelled = false;
let objectUrl: string | null = null;
void (async () => {
try {
const blob = await treatmentsApi.getAttachmentFileBlob(attachmentId);
if (cancelled) return;
objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
setFailed(false);
} catch {
if (!cancelled) setFailed(true);
}
})();
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [attachmentId]);
return { url, failed };
}

View File

@@ -1,21 +1,17 @@
'use client';
import { useRef } from 'react';
import { useEffect, useRef, type ReactNode, type RefObject } from 'react';
import { useTranslations } from 'next-intl';
import { Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/shared/Button';
import { Dropdown } from '@/components/ui/shared/Dropdown';
import {
autosaveStatusClass,
labPendingBannerClass,
labSentBannerClass,
labBlockedBannerClass,
} from '@/components/treatment/treatmentStatusStyles';
import { formatDetailChipLabel } from '@/components/treatment/detailChipLabel';
import { autosaveStatusClass, labBlockedBannerClass } from '@/components/treatment/treatmentStatusStyles';
import { TreatmentDetailAttachmentsStrip } from '@/components/ui/treatment/TreatmentDetailAttachmentsStrip';
import type { TreatmentDetailDraft } from '@/types/treatment';
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
import { treatmentTypeColor } from '@/components/shared/treatmentTypeDisplay';
import { treatmentTypeColor, treatmentTypeOptionStyle } from '@/components/shared/treatmentTypeDisplay';
import {
isDetailReadyForLabDispatch,
isDetailTypeSelected,
isLabDependentDetailMissingTeeth,
} from '@/components/treatment/treatmentDetailRules';
@@ -34,11 +30,21 @@ interface TreatmentDetailsEditorProps {
uploadBusy: boolean;
onAddDetail: () => void;
onRemoveDetail?: (detailClientId: string) => void;
onUploadFiles: (files: FileList | null) => void;
onUploadFiles: (files: File[], onProgress: (percent: number) => void) => Promise<void>;
onRemoveAttachment?: (attachmentId: string) => void;
/** Detail chips + Add detail (default true). */
showChrome?: boolean;
/** Type / notes / attachments fields (default true). */
showFields?: boolean;
/** FDI chart (or other) rendered beside notes on wide screens. */
chart?: ReactNode;
/** Shown below chrome (e.g. prosthesis wizard). */
stepper?: ReactNode;
/** Shown below type + chart + notes (e.g. Continue to lab). */
footer?: ReactNode;
/** Dim the chart until a treatment type is chosen. */
chartLocked?: boolean;
chartLockMessage?: string;
}
export function TreatmentDetailsEditor({
@@ -56,32 +62,44 @@ export function TreatmentDetailsEditor({
onAddDetail,
onRemoveDetail,
onUploadFiles,
onRemoveAttachment,
showChrome = true,
showFields = true,
chart,
stepper,
footer,
chartLocked = false,
chartLockMessage,
}: TreatmentDetailsEditorProps) {
const t = useTranslations('treatment');
const tCommon = useTranslations('common');
const attachmentInputRef = useRef<HTMLInputElement>(null);
const notesRef = useRef<HTMLTextAreaElement>(null);
const activeDetail = details.find((d) => d.clientId === activeDetailId) ?? details[0];
if (!activeDetail) return null;
const locked = isDetailLocked(activeDetail);
const readOnly = disabled || locked;
const treatmentTypeTextColor = isDetailTypeSelected(activeDetail)
? treatmentTypeColor(
activeDetail.treatmentType,
treatmentCatalog.findIndex((e) => e.code === activeDetail.treatmentType),
)
: undefined;
const showPendingLabHint =
isDetailReadyForLabDispatch(activeDetail, labDependentCodes) && !locked && !readOnly;
const showMissingTeethLabBlock = isLabDependentDetailMissingTeeth(
activeDetail,
labDependentCodes,
const locked = Boolean(activeDetail && isDetailLocked(activeDetail));
const readOnly = !activeDetail || disabled || locked;
const showMissingTeethLabBlock = Boolean(
activeDetail &&
isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes),
);
if (!showChrome && !showFields) return null;
if (!showChrome && !showFields && !stepper) return null;
const treatmentTypeTextColor =
activeDetail && isDetailTypeSelected(activeDetail)
? treatmentTypeColor(
activeDetail.treatmentType,
treatmentCatalog.findIndex((e) => e.code === activeDetail.treatmentType),
)
: undefined;
function setActiveType(nextType: string) {
onDetailsChange(
details.map((d) =>
d.clientId === activeDetailId ? { ...d, treatmentType: nextType } : d,
),
);
}
return (
<div className="surface-card p-3 sm:p-4 space-y-4">
@@ -90,7 +108,6 @@ export function TreatmentDetailsEditor({
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
<div>
<h3 className="text-sm font-semibold text-text-primary">{t('detailsTitle')}</h3>
<p className="text-xs text-text-muted mt-0.5">{t('detailsSubtitle')}</p>
</div>
<Button
type="button"
@@ -108,16 +125,20 @@ export function TreatmentDetailsEditor({
{details.map((d, idx) => {
const detailLocked = isDetailLocked(d);
const isActive = d.clientId === activeDetailId;
// Same rules as the former Content-step delete button:
// only when more than one detail remains; disabled if no edit, day-locked, sent, or uploading.
const showRemoveAction = details.length > 1;
const removeDisabled =
!canEdit || disabled || detailLocked || uploadBusy;
const showRemoveAction = Boolean(onRemoveDetail);
const removeDisabled = !canEdit || disabled || detailLocked || uploadBusy;
const chipLabel = formatDetailChipLabel(
d,
treatmentCatalog,
t('detailLabel', { n: idx + 1 }),
);
const showLabStatus =
isDetailTypeSelected(d) && labDependentCodes.has(d.treatmentType);
return (
<div
key={d.clientId}
className={`
inline-flex items-stretch overflow-hidden rounded-[var(--radius-md)] border
inline-flex items-stretch overflow-hidden rounded-[var(--radius-md)] border max-w-full
${
isActive
? 'border-primary bg-primary-soft'
@@ -128,14 +149,25 @@ export function TreatmentDetailsEditor({
<button
type="button"
onClick={() => onActiveDetailChange(d.clientId)}
title={chipLabel}
className={`
px-3 py-1.5 text-sm transition-colors
inline-flex max-w-[20rem] items-center gap-1.5 px-3 py-1.5 text-sm transition-colors
focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary/45
${isActive ? 'font-medium text-text-primary' : 'text-text-secondary'}
`}
>
{t('detailLabel', { n: idx + 1 })}
{detailLocked ? ` · ${t('detailSentBadge')}` : ''}
<span className="truncate">{chipLabel}</span>
{showLabStatus ? (
<span
className={`shrink-0 ${
detailLocked
? 'text-emerald-600 dark:text-emerald-400'
: 'text-amber-600 dark:text-amber-400'
}`}
>
· {detailLocked ? t('detailSentBadge') : t('detailUnsentBadge')}
</span>
) : null}
</button>
{showRemoveAction ? (
<button
@@ -169,114 +201,112 @@ export function TreatmentDetailsEditor({
</>
) : null}
{showFields ? (
<div className="space-y-4 border border-border/60 rounded-[var(--radius-md)] p-4 bg-background-secondary/30">
{locked && <p className={labSentBannerClass}>{t('detailLockedInShipment')}</p>}
{showPendingLabHint && (
<p className={labPendingBannerClass}>{t('detailPendingLabSend')}</p>
)}
{stepper && activeDetail ? <div className="pt-1">{stepper}</div> : null}
{showFields && activeDetail ? (
<div className="space-y-3">
{showMissingTeethLabBlock && (
<p className={labBlockedBannerClass}>{t('labShipmentBlockedBody')}</p>
)}
<div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:items-end">
<Dropdown
label={t('treatmentType')}
value={activeDetail.treatmentType}
onChange={(e) => {
const nextType = e.target.value as TreatmentDetailDraft['treatmentType'];
onDetailsChange(
details.map((d) =>
d.clientId === activeDetailId ? { ...d, treatmentType: nextType } : d,
),
);
}}
onChange={(e) => setActiveType(e.target.value)}
disabled={readOnly}
style={{ color: treatmentTypeTextColor }}
>
<option value="">{t('treatmentTypePlaceholder')}</option>
{treatmentCatalog.map((entry, index) => (
<option
key={entry.code}
value={entry.code}
style={{
color: treatmentTypeColor(entry.code, index),
backgroundColor: '#14253d',
}}
>
<option key={entry.code} value={entry.code} style={treatmentTypeOptionStyle(entry.code, index)}>
{entry.label}
</option>
))}
</Dropdown>
</div>
<label className="block text-xs font-medium text-text-secondary">
{t('comments')}
<textarea
value={activeDetail.comment}
onChange={(e) => {
const v = e.target.value;
onDetailsChange(
details.map((d) => (d.clientId === activeDetailId ? { ...d, comment: v } : d)),
);
}}
placeholder={t('commentsPlaceholder')}
rows={2}
<TreatmentDetailAttachmentsStrip
attachments={activeDetail.attachmentMetas}
disabled={readOnly}
className="mt-1.5 w-full rounded-[var(--radius-md)] border border-border bg-background-secondary/90 text-text-primary text-sm px-3 py-2 placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/35 resize-y min-h-[60px]"
uploadBusy={uploadBusy}
onUploadFiles={onUploadFiles}
onRemoveAttachment={readOnly ? undefined : onRemoveAttachment}
/>
</label>
<div>
<p className="text-xs font-medium text-text-secondary mb-2">{t('attachments')}</p>
<input
ref={attachmentInputRef}
id="treatment-detail-attachments"
type="file"
multiple
disabled={readOnly || uploadBusy}
onChange={(e) => {
onUploadFiles(e.target.files);
e.target.value = '';
}}
className="sr-only"
aria-label={t('attachFiles')}
/>
<Button
type="button"
variant="primary"
disabled={readOnly || uploadBusy}
isLoading={uploadBusy}
onClick={() => attachmentInputRef.current?.click()}
aria-controls="treatment-detail-attachments"
>
{t('chooseFiles')}
</Button>
{activeDetail.attachmentMetas.length > 0 && (
<ul className="mt-2 space-y-1 text-xs text-text-muted">
{activeDetail.attachmentMetas.map((f) => (
<li key={f.id} className="truncate">
{f.fileName} ({(f.sizeBytes / 1024).toFixed(1)} KB)
</li>
))}
</ul>
)}
</div>
{chart ? (
<div className="relative min-w-0 w-full">
{chartLocked ? (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-[var(--radius-md)] bg-background-card/70 px-4">
<p className="text-sm text-text-secondary text-center">{chartLockMessage}</p>
</div>
) : null}
<div className={chartLocked ? 'pointer-events-none opacity-40' : undefined}>{chart}</div>
</div>
) : null}
<NotesField
textareaRef={notesRef}
value={activeDetail.comment}
disabled={readOnly}
label={t('comments')}
placeholder={t('commentsPlaceholder')}
onChange={(v) => {
onDetailsChange(
details.map((d) => (d.clientId === activeDetailId ? { ...d, comment: v } : d)),
);
}}
/>
{canEdit && saveStatus !== 'idle' && (
<p className={`text-xs ${autosaveStatusClass(saveStatus)}`} role="status" aria-live="polite">
{saveStatus === 'dirty' && t('unsavedChanges')}
{saveStatus === 'saving' && t('saveStatusSaving')}
{saveStatus === 'saved' && t('saveStatusSaved')}
{saveStatus === 'error' && t('saveStatusError')}
</p>
)}
{footer ? <div className="pt-1">{footer}</div> : null}
</div>
) : null}
{showFields && canEdit && saveStatus !== 'idle' && (
<p
className={`text-xs pt-2 border-t border-border/60 ${autosaveStatusClass(saveStatus)}`}
role="status"
aria-live="polite"
>
{saveStatus === 'dirty' && t('unsavedChanges')}
{saveStatus === 'saving' && t('saveStatusSaving')}
{saveStatus === 'saved' && t('saveStatusSaved')}
{saveStatus === 'error' && t('saveStatusError')}
</p>
)}
</div>
);
}
function NotesField({
textareaRef,
value,
disabled,
label,
placeholder,
onChange,
}: {
textareaRef: RefObject<HTMLTextAreaElement | null>;
value: string;
disabled: boolean;
label: string;
placeholder: string;
onChange: (value: string) => void;
}) {
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = '0px';
el.style.height = `${el.scrollHeight}px`;
}, [textareaRef, value]);
return (
<label className="block text-xs font-medium text-text-secondary min-w-0">
{label}
<textarea
ref={textareaRef}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
rows={1}
disabled={disabled}
className="mt-1.5 w-full min-h-[2.875rem] sm:min-h-9 resize-none overflow-hidden rounded-[var(--radius-md)] border border-border bg-background-secondary/90 px-3 py-2.5 sm:py-2 text-base sm:text-sm leading-tight text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/35"
/>
</label>
);
}

View File

@@ -4,7 +4,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useRouter } from '@/i18n/navigation';
import { Button } from '@/components/ui/shared/Button';
import { Checkbox } from '@/components/ui/shared/Checkbox';
import { WizardStepper } from '@/components/ui/shared/WizardStepper';
import { PatientSearchCombobox } from '@/components/ui/patient/PatientSearchCombobox';
import {
@@ -39,8 +38,10 @@ import {
areDetailsPersistable,
defaultTreatmentTypeForAppointment,
isDetailReadyForLabDispatch,
isDetailTypeSelected,
isEmptyDraftDetail,
isLabDependentDetailMissingTeeth,
areUnscheduledDetailsStripDeletable,
} from '@/components/treatment/treatmentDetailRules';
import {
applyShiftRange,
@@ -56,6 +57,7 @@ import {
} from '@/components/treatment/toothSelectionGroups';
import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention';
import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention';
import { loadLabDispatchDefaults, rememberLastLab } from '@/components/treatment/labDispatchDefaults';
import { canEditTreatment, canViewTreatment, canAccessDashboardRoute } from '@/components/shared/permissions';
import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithinMain';
import { useMarkTabReadOnVisit, useTabBadgeCounts } from '@/lib/hooks/useTabBadgeCounts';
@@ -77,15 +79,14 @@ import type {
PastLabCase,
PastTreatment,
PastTreatmentCase,
PastTreatmentDetail,
TreatmentAppointment,
TreatmentDetailDraft,
} from '@/types/treatment';
import type { PatientLabCaseSummary } from '@/types/lab-case-activity';
type WorkspaceMode = 'live' | 'historical';
type EntryStep = 'teeth' | 'content' | 'lab';
const ENTRY_STEPS: EntryStep[] = ['teeth', 'content', 'lab'];
type EntryStep = 'treatment' | 'lab';
function isTreatmentDayHistorical(treatmentAt: string, todayStart: Date): boolean {
return compareLocalDayStart(new Date(treatmentAt), todayStart) < 0;
@@ -228,6 +229,22 @@ function mapDetailFromApi(d: PastTreatmentCase): TreatmentDetailDraft {
};
}
function draftToPastDetail(d: TreatmentDetailDraft): PastTreatmentDetail {
return {
id: d.id ?? d.clientId,
clientId: d.clientId,
treatmentType: d.treatmentType,
teeth: d.teeth,
toothSelectionGroups: d.toothSelectionGroups,
notes: d.comment,
attachmentMetas: d.attachmentMetas,
labCaseId: d.labCaseId ?? null,
taskProgress: d.taskProgress ?? null,
sends: d.sends ?? [],
sentAt: d.sentAt ?? null,
};
}
function mapLabCaseDraftFromApi(lc: PastLabCase): LabCaseDraft {
return {
clientId: lc.clientId,
@@ -419,7 +436,7 @@ export function TreatmentWorkspace({
const pendingLabCaseIdRef = useRef<string | null>(initialLabCaseId);
const labPanelRef = useRef<HTMLDivElement>(null);
const historyRequestRef = useRef(0);
/** When set, activeDetailId effect opens this wizard step instead of resetting to teeth. */
/** When set, activeDetailId effect opens this step instead of resetting to treatment. */
const pendingEntryStepRef = useRef<EntryStep | null>(null);
useEffect(() => {
@@ -439,7 +456,7 @@ export function TreatmentWorkspace({
const [organizationSearch, setOrganizationSearch] = useState('');
const [recentOrganizationIds, setRecentOrganizationIds] = useState<string[]>([]);
const [showWholeTreatmentPlan, setShowWholeTreatmentPlan] = useState(false);
const [entryStep, setEntryStep] = useState<EntryStep>('teeth');
const [entryStep, setEntryStep] = useState<EntryStep>('treatment');
const isDetailLocked = useCallback(
(detail: TreatmentDetailDraft) =>
@@ -560,20 +577,12 @@ export function TreatmentWorkspace({
[details, labDependentCodes],
);
/** Lab wizard step only for prosthesis (lab-dependent) treatment types on the active detail. */
/** Lab send sheet only for prosthesis (lab-dependent) types on the active detail. */
const showLabWizardStep = useMemo(
() => Boolean(activeDetail && labDependentCodes.has(activeDetail.treatmentType)),
[activeDetail, labDependentCodes],
);
const visibleEntrySteps = useMemo(
() =>
showLabWizardStep
? ENTRY_STEPS
: (ENTRY_STEPS.filter((step) => step !== 'lab') as EntryStep[]),
[showLabWizardStep],
);
const showLabShipmentBlocked = useMemo(
() =>
Boolean(
@@ -582,6 +591,30 @@ export function TreatmentWorkspace({
[activeDetail, labDependentCodes],
);
const activeTypeSelected = Boolean(activeDetail && isDetailTypeSelected(activeDetail));
const activeLocked = Boolean(activeDetail && isDetailLocked(activeDetail));
useEffect(() => {
if (!selectedStandaloneId || draftHydratingRef.current) return;
const nextDetails = details.map(draftToPastDetail);
setStandaloneTreatments((prev) => {
const current = prev.find((row) => row.id === selectedStandaloneId);
if (!current) return prev;
const prevColor = current.details.find((d) => d.treatmentType?.trim())?.treatmentType?.trim() ?? '';
const nextColor = nextDetails.find((d) => d.treatmentType?.trim())?.treatmentType?.trim() ?? '';
if (
prevColor === nextColor &&
areUnscheduledDetailsStripDeletable(current.details) ===
areUnscheduledDetailsStripDeletable(nextDetails)
) {
return prev;
}
return prev.map((row) =>
row.id === selectedStandaloneId ? { ...row, details: nextDetails } : row,
);
});
}, [selectedStandaloneId, details]);
const dayStripItems = useMemo<DayStripItem[]>(() => {
const timed: DayStripItem[] = appointments.map((a) => ({
kind: 'appointment',
@@ -596,7 +629,11 @@ export function TreatmentWorkspace({
}));
const unscheduled: DayStripItem[] = standaloneTreatments.map((tr) => {
const isWalkIn = Boolean(tr.patient?.isWalkIn);
const colorCode = tr.details[0]?.treatmentType || 'visit';
const sourceDetails = tr.id === selectedStandaloneId && !draftHydratingRef.current
? details
: tr.details;
const typedDetail = sourceDetails.find((d) => Boolean(d.treatmentType?.trim()));
const colorCode = typedDetail?.treatmentType?.trim() ?? '';
return {
kind: 'unscheduled' as const,
id: tr.id,
@@ -607,11 +644,19 @@ export function TreatmentWorkspace({
colorCode,
timeLabel: null,
subtitle: t('noAppointment'),
canDelete: tr.details.length === 0,
canDelete: areUnscheduledDetailsStripDeletable(sourceDetails),
};
});
return [...timed, ...unscheduled];
}, [appointments, standaloneTreatments, locale, treatmentCatalog, t]);
}, [
appointments,
standaloneTreatments,
selectedStandaloneId,
details,
locale,
treatmentCatalog,
t,
]);
const selectedStripItemId = selectedAppointmentId ?? selectedStandaloneId;
@@ -681,16 +726,21 @@ export function TreatmentWorkspace({
[labDependentCodes, currentDraftPreview, history, selectedAppointmentId],
);
const hydrateFromTreatment = useCallback((treatment: PastTreatment) => {
const hydrateFromTreatment = useCallback((
treatment: PastTreatment,
options?: { seedBlankIfEmpty?: boolean },
) => {
const mapped = treatment.details.map(mapDetailFromApi);
const nextDetails =
mapped.length > 0
? mapped
: [newDetail(defaultTreatmentTypeForAppointment(undefined, treatmentCatalog))];
: options?.seedBlankIfEmpty
? [newDetail(defaultTreatmentTypeForAppointment(undefined, treatmentCatalog))]
: [];
setDetails(nextDetails);
setActiveDetailId((prev) => {
const stillExists = nextDetails.some((d) => d.clientId === prev);
return stillExists ? prev : nextDetails[0].clientId;
return stillExists ? prev : (nextDetails[0]?.clientId ?? '');
});
setSavedSnapshot(serializeDetails(nextDetails));
const mappedLabCases = withoutEmptyLabCaseDrafts(
@@ -734,23 +784,34 @@ export function TreatmentWorkspace({
return colors;
}, [details, treatmentCatalog]);
const wholePlanLinkedEdges = useMemo(() => {
const edges = new Set<string>();
for (const detail of details) {
for (const key of linkedEdgesFromGroups(detail.toothSelectionGroups ?? [])) {
edges.add(key);
}
}
return edges;
}, [details]);
const chartSelectedTeeth = showWholeTreatmentPlan ? wholePlanTeethSet : selectedTeethSet;
const chartToothColors = showWholeTreatmentPlan ? wholePlanToothColors : undefined;
const chartLinkedEdges = showWholeTreatmentPlan ? wholePlanLinkedEdges : linkedToothEdges;
// Reset whole-plan overview when switching details.
// Prefer pendingEntryStepRef (e.g. Lab shipments → Lab step) over defaulting to teeth.
// Prefer pendingEntryStepRef (e.g. Lab shipments → Lab step) over defaulting to treatment.
useEffect(() => {
setShowWholeTreatmentPlan(false);
rangeAnchorRef.current = null;
const pending = pendingEntryStepRef.current;
pendingEntryStepRef.current = null;
setEntryStep(pending ?? 'teeth');
setEntryStep(pending ?? 'treatment');
}, [activeDetailId]);
// Leave Lab step if the active detail is no longer prosthesis / lab-dependent.
useEffect(() => {
if (entryStep === 'lab' && !showLabWizardStep) {
setEntryStep('content');
setEntryStep('treatment');
}
}, [entryStep, showLabWizardStep]);
@@ -849,6 +910,12 @@ export function TreatmentWorkspace({
setLabDependentCodes(
new Set(catalogResponse.data.filter((entry) => entry.labDependent).map((entry) => entry.code)),
);
const lastLabId = loadLabDispatchDefaults(currentOrganization?.id).lastLabId;
if (lastLabId && orgsResponse.data.some((o) => o.id === lastLabId && o.active)) {
setRecentOrganizationIds((prev) =>
prev.includes(lastLabId) ? prev : [lastLabId, ...prev].slice(0, 10),
);
}
} catch (error: unknown) {
if (!cancelled) {
showError(getUserFacingError(error, tErrors, t('errorLoadOrgs')));
@@ -858,7 +925,7 @@ export function TreatmentWorkspace({
return () => {
cancelled = true;
};
}, [showError, t]);
}, [showError, t, currentOrganization?.id]);
useEffect(() => {
if (!activePatientId) {
@@ -915,6 +982,19 @@ export function TreatmentWorkspace({
setUnreadLabCases((prev) => prev.filter((item) => item.labCaseId !== labCaseId));
}, []);
const activeLabCaseSummary = useMemo(() => {
if (activeSentLabCaseId) {
return patientLabCases.find((item) => item.labCaseId === activeSentLabCaseId) ?? null;
}
return patientLabCases.find((item) => item.detailClientId === activeDetailId) ?? null;
}, [patientLabCases, activeSentLabCaseId, activeDetailId]);
const handleLabCaseSummaryChange = useCallback((summary: PatientLabCaseSummary) => {
setPatientLabCases((prev) =>
prev.map((item) => (item.labCaseId === summary.labCaseId ? summary : item)),
);
}, []);
useEffect(() => {
void refreshUnreadLabCases();
}, [refreshUnreadLabCases]);
@@ -1003,6 +1083,10 @@ export function TreatmentWorkspace({
return stillExists ? prev : mapped[0].clientId;
});
setSavedSnapshot(serializeDetails(mapped));
} else if (response.data) {
setDetails([]);
setActiveDetailId('');
setSavedSnapshot(serializeDetails([]));
} else {
const first = newDetail(
defaultTreatmentTypeForAppointment(selectedAppointment?.purpose, treatmentCatalog),
@@ -1101,7 +1185,7 @@ export function TreatmentWorkspace({
setDetails(mapped);
setActiveDetailId((prev) => {
const stillExists = mapped.some((d) => d.clientId === prev);
return stillExists ? prev : mapped[0]?.clientId ?? prev;
return stillExists ? prev : (mapped[0]?.clientId ?? '');
});
setSavedSnapshot(serializeDetails(mapped));
} else {
@@ -1110,7 +1194,7 @@ export function TreatmentWorkspace({
setDetails(merged);
setActiveDetailId((prev) => {
const stillExists = merged.some((d) => d.clientId === prev);
return stillExists ? prev : merged[0]?.clientId ?? prev;
return stillExists ? prev : (merged[0]?.clientId ?? '');
});
// Keep dirty so the queued autosave persists the newer local state.
}
@@ -1229,6 +1313,7 @@ export function TreatmentWorkspace({
void (async () => {
const ok = await flushDraftSave();
if (!ok) return;
draftHydratingRef.current = true;
resetToLiveContext();
setSearchedPatient(null);
setSelectionLocked(true);
@@ -1244,6 +1329,7 @@ export function TreatmentWorkspace({
void (async () => {
const ok = await flushDraftSave();
if (!ok) return;
draftHydratingRef.current = true;
resetToLiveContext();
setSearchedPatient(null);
setSelectionLocked(true);
@@ -1281,7 +1367,7 @@ export function TreatmentWorkspace({
);
skipNextGetDraftRef.current = true;
draftHydratingRef.current = true;
hydrateFromTreatment(created.data);
hydrateFromTreatment(created.data, { seedBlankIfEmpty: true });
draftHydratingRef.current = false;
if (created.data.patientId && !created.data.patient?.isWalkIn) {
await refreshHistory(created.data.patientId);
@@ -1313,15 +1399,28 @@ export function TreatmentWorkspace({
clearTimeout(autosaveTimerRef.current);
autosaveTimerRef.current = null;
}
const wasSelected = selectedStandaloneId === item.id;
if (wasSelected) {
draftHydratingRef.current = true;
}
try {
while (saveInFlightRef.current) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
// Chip-delete / cleared type can look empty in the strip before autosave
// finishes. Persist [] first so DELETE /treatments/:id (empty-only) succeeds.
await treatmentsApi.saveDraftByTreatment(item.id, { details: [] });
await treatmentsApi.deleteStandalone(item.id);
setStandaloneTreatments((prev) => prev.filter((row) => row.id !== item.id));
if (selectedStandaloneId === item.id) {
if (wasSelected) {
resetToLiveContext();
setSelectedStandaloneId(null);
setSelectedAppointmentId(null);
}
} catch (error: unknown) {
if (wasSelected) {
draftHydratingRef.current = false;
}
showError(getUserFacingError(error, tErrors, t('errorDeleteTreatment')));
}
},
@@ -1522,19 +1621,6 @@ export function TreatmentWorkspace({
[exitBrowse, history, historyPanelItems, labCaseDrafts, loadTreatmentIntoWorkspace],
);
const activeLabCaseSummary = useMemo(() => {
if (activeSentLabCaseId) {
return patientLabCases.find((item) => item.labCaseId === activeSentLabCaseId) ?? null;
}
return patientLabCases.find((item) => item.detailClientId === activeDetailId) ?? null;
}, [patientLabCases, activeSentLabCaseId, activeDetailId]);
const handleLabCaseSummaryChange = useCallback((summary: PatientLabCaseSummary) => {
setPatientLabCases((prev) =>
prev.map((item) => (item.labCaseId === summary.labCaseId ? summary : item)),
);
}, []);
const handleSelectPatientLabCase = useCallback(
(item: PatientLabCaseSummary) => {
void (async () => {
@@ -1635,7 +1721,11 @@ export function TreatmentWorkspace({
}, [activeDetailId, patientLabCases]);
const uploadForDetail = useCallback(
async (detailClientId: string, files: FileList | File[]) => {
async (
detailClientId: string,
files: FileList | File[],
onProgress?: (percent: number) => void,
) => {
if (!canEditTreatmentForDay || !hasLiveContext) return;
const target = detailsRef.current.find((d) => d.clientId === detailClientId);
if (target && isDetailLocked(target)) return;
@@ -1645,15 +1735,17 @@ export function TreatmentWorkspace({
setUploadBusyDetailId(detailClientId);
try {
const uploaded = selectedAppointment
? await treatmentsApi.uploadCaseAttachments(
? await treatmentsApi.uploadDetailAttachments(
selectedAppointment.id,
detailClientId,
list,
onProgress,
)
: await treatmentsApi.uploadDetailAttachmentsByTreatment(
selectedStandalone!.id,
detailClientId,
list,
onProgress,
);
setDetails((prev) =>
prev.map((d) =>
@@ -1790,20 +1882,20 @@ export function TreatmentWorkspace({
const idx = details.findIndex((d) => d.clientId === detailClientId);
if (idx < 0) return;
const target = details[idx];
if (!target || isDetailLocked(target) || details.length <= 1) return;
if (!target || isDetailLocked(target)) return;
if (!window.confirm(t('confirmRemoveDetail'))) return;
const nextDetails = details.filter((d) => d.clientId !== detailClientId);
const nextActive =
activeDetailId === detailClientId
? (nextDetails[Math.min(idx, nextDetails.length - 1)]?.clientId ??
nextDetails[0]?.clientId)
? (nextDetails[Math.min(idx, nextDetails.length - 1)]?.clientId ?? '')
: activeDetailId;
// Sync ref before any persist triggered by lab-case cleanup (same tick).
detailsRef.current = nextDetails;
setDetails(nextDetails);
if (nextActive) setActiveDetailId(nextActive);
setActiveDetailId(nextActive);
if (nextDetails.length === 0) setEntryStep('treatment');
if (labCaseDrafts.some((lc) => lc.detailClientId === detailClientId)) {
handleLabCasesChange(
@@ -1820,6 +1912,7 @@ export function TreatmentWorkspace({
handleLabCasesChange,
isDetailLocked,
labCaseDrafts,
setEntryStep,
t,
],
);
@@ -1866,9 +1959,15 @@ export function TreatmentWorkspace({
return;
}
const lastLabId = loadLabDispatchDefaults(currentOrganization?.id).lastLabId;
const lastLabStillActive = lastLabId
? orgs.some((o) => o.id === lastLabId && o.active)
: false;
const next: LabCaseDraft = {
...newLabCaseDraft(),
detailClientId: shouldIncludeActive ? activeDetailId : null,
destinationOrganizationId: lastLabStillActive ? lastLabId! : null,
attachmentIds: activeDetail?.attachmentMetas.map((a) => a.id) ?? [],
};
const updatedLabCases = [...cleaned, next];
setLabCaseDrafts(updatedLabCases);
@@ -1883,14 +1982,17 @@ export function TreatmentWorkspace({
}, [
activeDetailId,
canEditTreatmentForDay,
currentOrganization?.id,
details,
labCaseDrafts,
labDependentCodes,
orgs,
persistDraft,
persistLabCases,
selectedAppointment,
showError,
t,
tErrors,
]);
// Auto-open shipment draft when entering Lab (no manual "Add lab shipment" click).
@@ -1964,7 +2066,7 @@ export function TreatmentWorkspace({
setDetails(mapped);
setActiveDetailId((prev) => {
const stillExists = mapped.some((d) => d.clientId === prev);
return stillExists ? prev : mapped[0]?.clientId ?? prev;
return stillExists ? prev : (mapped[0]?.clientId ?? '');
});
setSavedSnapshot(serializeDetails(mapped));
} else {
@@ -2004,6 +2106,7 @@ export function TreatmentWorkspace({
setRecentOrganizationIds((prev) => {
const orgId = labCase.destinationOrganizationId!;
rememberLastLab(currentOrganization?.id, orgId);
return [orgId, ...prev.filter((id) => id !== orgId)].slice(0, 10);
});
showSuccess(t('successCaseSent'));
@@ -2030,6 +2133,7 @@ export function TreatmentWorkspace({
showError,
t,
tErrors,
currentOrganization?.id,
],
);
@@ -2046,9 +2150,9 @@ export function TreatmentWorkspace({
<div className="space-y-4">
<header className="space-y-1">
<h1 className="text-xl sm:text-2xl font-semibold text-text-primary">{t('title')}</h1>
<p className="text-sm text-text-secondary">
{canEdit ? t('subtitleEditPhase4') : t('subtitleReadOnly')}
</p>
{!canEdit ? (
<p className="text-sm text-text-secondary">{t('subtitleReadOnly')}</p>
) : null}
</header>
<AppointmentsStrip
@@ -2132,16 +2236,6 @@ export function TreatmentWorkspace({
)}
</div>
{workspaceMode === 'live' && !isBrowsing && hasLiveContext ? (
<TreatmentPreviewCard
treatment={currentDraftPreview}
heading={t('previewCurrentDraft')}
labDependentCodes={labDependentCodes}
treatmentCatalog={treatmentCatalog}
orgs={orgs}
/>
) : null}
{isBrowsing && previewTreatment ? (
<>
<div className="rounded-[var(--radius-md)] border border-primary/40 bg-primary/5 px-3 py-2 space-y-2">
@@ -2257,218 +2351,242 @@ export function TreatmentWorkspace({
);
setDetails((prev) => [...prev, next]);
setActiveDetailId(next.clientId);
setEntryStep('teeth');
setEntryStep('treatment');
}}
onRemoveDetail={handleRemoveDetail}
onUploadFiles={(files) => void uploadForDetail(activeDetailId, files ?? [])}
onUploadFiles={(files, onProgress) =>
uploadForDetail(activeDetailId, files, onProgress)
}
onRemoveAttachment={(attachmentId) => {
setDetails((prev) =>
prev.map((d) =>
d.clientId === activeDetailId
? {
...d,
attachmentMetas: d.attachmentMetas.filter((a) => a.id !== attachmentId),
}
: d,
),
);
setLabCaseDrafts((prev) =>
prev.map((lc) =>
lc.detailClientId === activeDetailId && !lc.sentAt
? {
...lc,
attachmentIds: lc.attachmentIds.filter((id) => id !== attachmentId),
}
: lc,
),
);
}}
showChrome
showFields={false}
showFields={entryStep === 'treatment'}
chartLocked={
entryStep === 'treatment' && !activeTypeSelected && !showWholeTreatmentPlan
}
chartLockMessage={t('selectTypeBeforeTeeth')}
chart={
entryStep === 'treatment' ? (
<FdiToothChart
className="w-full"
selected={chartSelectedTeeth}
linkedEdges={chartLinkedEdges}
connectedTeeth={showWholeTreatmentPlan ? undefined : connectedSelectedTeeth}
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>
}
onToggle={(fdi, event) => {
if (
!canEditTreatmentForDay ||
activeLocked ||
showWholeTreatmentPlan ||
!activeDetailId ||
!activeTypeSelected
) {
return;
}
const detail = details.find((d) => d.clientId === activeDetailId);
if (!detail) return;
const currentGroups =
detail.toothSelectionGroups.length > 0
? detail.toothSelectionGroups
: groupsFromFlatTeeth(detail.teeth);
let nextGroups: ReturnType<typeof toggleToothInGroups> | null = null;
if (event.shiftKey) {
const anchor = rangeAnchorRef.current;
if (!anchor || anchor === fdi) {
rangeAnchorRef.current = fdi;
return;
}
nextGroups = applyShiftRange(currentGroups, anchor, fdi);
rangeAnchorRef.current = fdi;
if (!nextGroups) return;
} else {
nextGroups = toggleToothInGroups(currentGroups, fdi);
rangeAnchorRef.current = fdi;
}
setDetails((prev) =>
prev.map((d) =>
d.clientId !== activeDetailId
? d
: {
...d,
toothSelectionGroups: nextGroups!,
teeth: deriveTeethFromGroups(nextGroups!),
},
),
);
setLabCaseDrafts((prev) =>
prev.map((lc) => {
if (lc.detailClientId !== activeDetailId) return lc;
return {
...lc,
toothProsthesis: pruneToothProsthesisForGroups(
lc.toothProsthesis,
activeDetailId,
nextGroups!,
),
};
}),
);
}}
onToggleLink={(a, b) => {
if (
!canEditTreatmentForDay ||
activeLocked ||
showWholeTreatmentPlan ||
!activeDetailId ||
!activeTypeSelected
) {
return;
}
const detail = details.find((d) => d.clientId === activeDetailId);
if (!detail) return;
const currentGroups =
detail.toothSelectionGroups.length > 0
? detail.toothSelectionGroups
: groupsFromFlatTeeth(detail.teeth);
const edgeLinked = linkedEdgesFromGroups(currentGroups).has(toothEdgeKey(a, b));
const nextGroups = edgeLinked
? unlinkAdjacentTeeth(currentGroups, a, b)
: linkAdjacentTeeth(currentGroups, a, b);
if (!nextGroups) return;
setDetails((prev) =>
prev.map((d) =>
d.clientId !== activeDetailId
? d
: {
...d,
toothSelectionGroups: nextGroups,
teeth: deriveTeethFromGroups(nextGroups),
},
),
);
setLabCaseDrafts((prev) =>
prev.map((lc) => {
if (lc.detailClientId !== activeDetailId) return lc;
return {
...lc,
toothProsthesis: pruneToothProsthesisForGroups(
lc.toothProsthesis,
activeDetailId,
nextGroups,
),
};
}),
);
}}
disabled={
!canEditTreatmentForDay ||
activeLocked ||
!activeTypeSelected
}
/>
) : undefined
}
stepper={
showLabWizardStep ? (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<WizardStepper
className="min-w-0 flex-1"
aria-label={t('entryWizardLabel')}
steps={[
{ id: 'treatment', label: t('entryStepTreatment') },
{ id: 'lab', label: t('entryStepLab') },
]}
currentStepId={entryStep}
onStepChange={(stepId) => {
const next = stepId as EntryStep;
if (next === 'lab' && canEditTreatmentForDay) {
void persistDraft({ force: true })
.then(() => setEntryStep('lab'))
.catch((error: unknown) => {
showError(getUserFacingError(error, tErrors, t('errorSaveDraft')));
});
return;
}
setEntryStep(next);
}}
/>
<div className="flex flex-wrap gap-2 sm:justify-end shrink-0">
<Button
type="button"
variant="ghost"
disabled={entryStep === 'treatment'}
onClick={() => setEntryStep('treatment')}
>
{t('entryStepBack')}
</Button>
<Button
type="button"
variant="primary"
disabled={entryStep === 'lab'}
onClick={async () => {
if (canEditTreatmentForDay) {
try {
await persistDraft({ force: true });
} catch (error: unknown) {
showError(getUserFacingError(error, tErrors, t('errorSaveDraft')));
return;
}
}
setEntryStep('lab');
}}
>
{t('entryStepNext')}
</Button>
</div>
</div>
) : undefined
}
/>
<div className="surface-card px-3 py-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<WizardStepper
className="min-w-0 flex-1"
aria-label={t('entryWizardLabel')}
steps={visibleEntrySteps.map((step) => ({
id: step,
label:
step === 'teeth'
? t('entryStepTeeth')
: step === 'content'
? t('entryStepContent')
: t('entryStepLab'),
}))}
currentStepId={entryStep}
onStepChange={(stepId) => setEntryStep(stepId as EntryStep)}
/>
<div className="flex flex-wrap gap-2 sm:justify-end shrink-0">
<Button
type="button"
variant="ghost"
disabled={entryStep === 'teeth'}
onClick={() => {
const idx = visibleEntrySteps.indexOf(entryStep);
if (idx > 0) setEntryStep(visibleEntrySteps[idx - 1]);
}}
>
{t('entryStepBack')}
</Button>
<Button
type="button"
variant="primary"
disabled={
entryStep === 'lab' || (entryStep === 'content' && !showLabWizardStep)
}
onClick={() => {
if (entryStep === 'teeth') setEntryStep('content');
else if (entryStep === 'content' && showLabWizardStep) setEntryStep('lab');
}}
>
{t('entryStepNext')}
</Button>
</div>
</div>
{entryStep === 'teeth' ? (
<FdiToothChart
selected={chartSelectedTeeth}
linkedEdges={showWholeTreatmentPlan ? undefined : linkedToothEdges}
connectedTeeth={showWholeTreatmentPlan ? undefined : connectedSelectedTeeth}
toothColors={chartToothColors}
readOnly={showWholeTreatmentPlan}
headerControl={
details.length > 1 ? (
<Checkbox
checked={showWholeTreatmentPlan}
onChange={setShowWholeTreatmentPlan}
label={t('toothChartWholePlan')}
className="text-[11px] [&_span:last-child]:text-[11px] [&_span:last-child]:text-text-muted"
/>
) : undefined
}
onToggle={(fdi, event) => {
if (
!canEditTreatmentForDay ||
isDetailLocked(activeDetail) ||
showWholeTreatmentPlan ||
!activeDetailId
) {
return;
}
const detail = details.find((d) => d.clientId === activeDetailId);
if (!detail) return;
const currentGroups =
detail.toothSelectionGroups.length > 0
? detail.toothSelectionGroups
: groupsFromFlatTeeth(detail.teeth);
let nextGroups: ReturnType<typeof toggleToothInGroups> | null = null;
if (event.shiftKey) {
const anchor = rangeAnchorRef.current;
// Need a prior click as range start; shift alone on one tooth does nothing.
if (!anchor || anchor === fdi) {
rangeAnchorRef.current = fdi;
return;
}
nextGroups = applyShiftRange(currentGroups, anchor, fdi);
rangeAnchorRef.current = fdi;
if (!nextGroups) return;
} else {
nextGroups = toggleToothInGroups(currentGroups, fdi);
rangeAnchorRef.current = fdi;
}
setDetails((prev) =>
prev.map((d) =>
d.clientId !== activeDetailId
? d
: {
...d,
toothSelectionGroups: nextGroups!,
teeth: deriveTeethFromGroups(nextGroups!),
},
),
);
setLabCaseDrafts((prev) =>
prev.map((lc) => {
if (lc.detailClientId !== activeDetailId) return lc;
return {
...lc,
toothProsthesis: pruneToothProsthesisForGroups(
lc.toothProsthesis,
activeDetailId,
nextGroups!,
),
};
}),
);
}}
onToggleLink={(a, b) => {
if (
!canEditTreatmentForDay ||
isDetailLocked(activeDetail) ||
showWholeTreatmentPlan ||
!activeDetailId
) {
return;
}
const detail = details.find((d) => d.clientId === activeDetailId);
if (!detail) return;
const currentGroups =
detail.toothSelectionGroups.length > 0
? detail.toothSelectionGroups
: groupsFromFlatTeeth(detail.teeth);
const edgeLinked = linkedEdgesFromGroups(currentGroups).has(toothEdgeKey(a, b));
const nextGroups = edgeLinked
? unlinkAdjacentTeeth(currentGroups, a, b)
: linkAdjacentTeeth(currentGroups, a, b);
if (!nextGroups) return;
setDetails((prev) =>
prev.map((d) =>
d.clientId !== activeDetailId
? d
: {
...d,
toothSelectionGroups: nextGroups,
teeth: deriveTeethFromGroups(nextGroups),
},
),
);
setLabCaseDrafts((prev) =>
prev.map((lc) => {
if (lc.detailClientId !== activeDetailId) return lc;
return {
...lc,
toothProsthesis: pruneToothProsthesisForGroups(
lc.toothProsthesis,
activeDetailId,
nextGroups,
),
};
}),
);
}}
disabled={!canEditTreatmentForDay || isDetailLocked(activeDetail)}
/>
) : null}
{entryStep === 'content' ? (
<TreatmentDetailsEditor
details={details}
activeDetailId={activeDetailId}
onActiveDetailChange={setActiveDetailId}
onDetailsChange={setDetails}
isDetailLocked={isDetailLocked}
labDependentCodes={labDependentCodes}
treatmentCatalog={treatmentDropdownCatalog}
disabled={!canEditTreatmentForDay}
canEdit={canEdit}
saveStatus={saveStatus}
uploadBusy={uploadBusyDetailId === activeDetailId}
onAddDetail={() => {
const next = newDetail(
defaultTreatmentTypeForAppointment(
selectedAppointment?.purpose,
treatmentCatalog,
),
);
setDetails((prev) => [...prev, next]);
setActiveDetailId(next.clientId);
setEntryStep('teeth');
}}
onUploadFiles={(files) => void uploadForDetail(activeDetailId, files ?? [])}
showChrome={false}
showFields
/>
) : null}
{entryStep === 'lab' ? (
<div ref={labPanelRef}>
<div ref={labPanelRef} className="space-y-3">
{showLabShipmentBlocked ? <LabShipmentBlockedNotice /> : null}
{showLabDispatchPanel ? (
<LabCasesDispatchPanel
@@ -2477,6 +2595,7 @@ export function TreatmentWorkspace({
labCases={labCaseDrafts}
labDependentCodes={labDependentCodes}
treatmentCatalog={treatmentCatalog}
clinicOrganizationId={currentOrganization?.id}
labCaseSummary={activeLabCaseSummary}
locale={locale}
onLabCaseSummaryChange={handleLabCaseSummaryChange}

View File

@@ -61,6 +61,11 @@ export const casesApi = {
return response.data;
},
deleteDraft: async (id: string): Promise<{ success: boolean }> => {
const response = await apiClient.delete(`/cases/${id}`);
return response.data;
},
uploadLineAttachments: async (
caseId: string,
lineClientId: string,

View File

@@ -95,6 +95,7 @@ export const treatmentsApi = {
treatmentId: string,
detailClientId: string,
files: File[],
onUploadProgress?: (percent: number) => void,
): Promise<{ success: boolean; data: import('@/types/treatment').TreatmentAttachmentMeta[] }> => {
const form = new FormData();
for (const file of files) {
@@ -103,7 +104,16 @@ export const treatmentsApi = {
const response = await apiClient.post(
`/treatments/${treatmentId}/details/${encodeURIComponent(detailClientId)}/attachments`,
form,
{ headers: { 'Content-Type': 'multipart/form-data' }, timeout: 120_000 },
{
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 120_000,
onUploadProgress: onUploadProgress
? (event) => {
if (!event.total) return;
onUploadProgress(Math.round((event.loaded / event.total) * 100));
}
: undefined,
},
);
return response.data;
},
@@ -131,6 +141,7 @@ export const treatmentsApi = {
appointmentId: string,
detailClientId: string,
files: File[],
onUploadProgress?: (percent: number) => void,
): Promise<{ success: boolean; data: import('@/types/treatment').TreatmentAttachmentMeta[] }> => {
const form = new FormData();
for (const file of files) {
@@ -139,7 +150,16 @@ export const treatmentsApi = {
const response = await apiClient.post(
`/treatments/appointments/${appointmentId}/details/${encodeURIComponent(detailClientId)}/attachments`,
form,
{ headers: { 'Content-Type': 'multipart/form-data' }, timeout: 120_000 },
{
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 120_000,
onUploadProgress: onUploadProgress
? (event) => {
if (!event.total) return;
onUploadProgress(Math.round((event.loaded / event.total) * 100));
}
: undefined,
},
);
return response.data;
},