improvement/v1-demo-improvements #64
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 card’s 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 A–B + B–C = one bridge.
|
||||
- **Shift+click:** inclusive same-arch range → all selected as **singles** (empty circles); overlapping existing bridges are absorbed as singles too. Midline neighbors (11–21, 41–31) allowed.
|
||||
@@ -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)
|
||||
|
||||
|
||||
18
AGENTS.md
18
AGENTS.md
@@ -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 appointment’s 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 11–21 / 41–31 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 appointment’s patient. New treatment seeds one blank detail; a persisted empty plan loads as `[]` until Add.
|
||||
- **Unscheduled strip cards** use the same treatment-type banner as appointments 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 11–21 / 41–31 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 |
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "قوس بالا",
|
||||
|
||||
@@ -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",
|
||||
|
||||
15
frontend/src/components/treatment/detailChipLabel.ts
Normal file
15
frontend/src/components/treatment/detailChipLabel.ts
Normal 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}`;
|
||||
}
|
||||
73
frontend/src/components/treatment/labDispatchDefaults.ts
Normal file
73
frontend/src/components/treatment/labDispatchDefaults.ts
Normal 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;
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -219,10 +219,25 @@ export function AppointmentsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<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">
|
||||
<div className="xl:col-span-1 space-y-4">
|
||||
@@ -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}
|
||||
|
||||
@@ -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,6 +343,15 @@ 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>
|
||||
<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"
|
||||
@@ -329,6 +361,7 @@ export function CaseCreatePanel({
|
||||
{starting ? tCommon('loading') : t('startCase')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="text-xs font-medium text-text-muted">
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,11 +56,23 @@ 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>
|
||||
<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}
|
||||
@@ -69,15 +81,6 @@ export function AppointmentsStrip({
|
||||
{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>
|
||||
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -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,6 +99,7 @@ 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"
|
||||
@@ -111,3 +113,38 @@ export function DayStripCard({
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={bannerStyle}
|
||||
className={`inline-flex items-stretch overflow-hidden rounded-[var(--radius-sm)] border transition-shadow ${selectedClass} ${shellClass}`}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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="rounded-[var(--radius-sm)] p-0.5"
|
||||
aria-hidden={!isSel}
|
||||
className={`pointer-events-none ${isDisabled ? 'opacity-50' : ''}`}
|
||||
aria-hidden
|
||||
>
|
||||
<div
|
||||
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,7 +451,9 @@ export function FdiToothChart({
|
||||
</div>
|
||||
);
|
||||
|
||||
const cardClass = compact
|
||||
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';
|
||||
|
||||
@@ -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
|
||||
{FDI_UPPER_LEFT_TO_RIGHT.map((fdi) => (
|
||||
<ToothNumber
|
||||
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={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
|
||||
{FDI_LOWER_LEFT_TO_RIGHT.map((fdi) => (
|
||||
<ToothNumber
|
||||
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={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>
|
||||
)}
|
||||
{!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 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">
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: toothProsthesisForRows(prosthesisRows, code) });
|
||||
if (activeLabCase.destinationOrganizationId) {
|
||||
rememberLastProsthesisType(
|
||||
clinicOrganizationId,
|
||||
activeLabCase.destinationOrganizationId,
|
||||
code,
|
||||
);
|
||||
updateActiveLabCase({ toothProsthesis: next });
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -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
|
||||
>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 = Boolean(activeDetail && isDetailLocked(activeDetail));
|
||||
const readOnly = !activeDetail || disabled || locked;
|
||||
const showMissingTeethLabBlock = Boolean(
|
||||
activeDetail &&
|
||||
isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes),
|
||||
);
|
||||
|
||||
const locked = isDetailLocked(activeDetail);
|
||||
const readOnly = disabled || locked;
|
||||
const treatmentTypeTextColor = isDetailTypeSelected(activeDetail)
|
||||
if (!showChrome && !showFields && !stepper) return null;
|
||||
|
||||
const treatmentTypeTextColor =
|
||||
activeDetail && 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,
|
||||
);
|
||||
|
||||
if (!showChrome && !showFields) return null;
|
||||
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>
|
||||
<TreatmentDetailAttachmentsStrip
|
||||
attachments={activeDetail.attachmentMetas}
|
||||
disabled={readOnly}
|
||||
uploadBusy={uploadBusy}
|
||||
onUploadFiles={onUploadFiles}
|
||||
onRemoveAttachment={readOnly ? undefined : onRemoveAttachment}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="block text-xs font-medium text-text-secondary">
|
||||
{t('comments')}
|
||||
<textarea
|
||||
{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}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
disabled={readOnly}
|
||||
label={t('comments')}
|
||||
placeholder={t('commentsPlaceholder')}
|
||||
onChange={(v) => {
|
||||
onDetailsChange(
|
||||
details.map((d) => (d.clientId === activeDetailId ? { ...d, comment: v } : d)),
|
||||
);
|
||||
}}
|
||||
placeholder={t('commentsPlaceholder')}
|
||||
rows={2}
|
||||
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]"
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showFields && canEdit && saveStatus !== 'idle' && (
|
||||
<p
|
||||
className={`text-xs pt-2 border-t border-border/60 ${autosaveStatusClass(saveStatus)}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{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}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,81 +2351,74 @@ export function TreatmentWorkspace({
|
||||
);
|
||||
setDetails((prev) => [...prev, next]);
|
||||
setActiveDetailId(next.clientId);
|
||||
setEntryStep('teeth');
|
||||
setEntryStep('treatment');
|
||||
}}
|
||||
onRemoveDetail={handleRemoveDetail}
|
||||
onUploadFiles={(files) => void uploadForDetail(activeDetailId, files ?? [])}
|
||||
showChrome
|
||||
showFields={false}
|
||||
/>
|
||||
|
||||
<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)
|
||||
onUploadFiles={(files, onProgress) =>
|
||||
uploadForDetail(activeDetailId, files, onProgress)
|
||||
}
|
||||
onClick={() => {
|
||||
if (entryStep === 'teeth') setEntryStep('content');
|
||||
else if (entryStep === 'content' && showLabWizardStep) setEntryStep('lab');
|
||||
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,
|
||||
),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t('entryStepNext')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{entryStep === 'teeth' ? (
|
||||
showChrome
|
||||
showFields={entryStep === 'treatment'}
|
||||
chartLocked={
|
||||
entryStep === 'treatment' && !activeTypeSelected && !showWholeTreatmentPlan
|
||||
}
|
||||
chartLockMessage={t('selectTypeBeforeTeeth')}
|
||||
chart={
|
||||
entryStep === 'treatment' ? (
|
||||
<FdiToothChart
|
||||
className="w-full"
|
||||
selected={chartSelectedTeeth}
|
||||
linkedEdges={showWholeTreatmentPlan ? undefined : linkedToothEdges}
|
||||
linkedEdges={chartLinkedEdges}
|
||||
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
|
||||
<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 ||
|
||||
isDetailLocked(activeDetail) ||
|
||||
activeLocked ||
|
||||
showWholeTreatmentPlan ||
|
||||
!activeDetailId
|
||||
!activeDetailId ||
|
||||
!activeTypeSelected
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -2347,7 +2434,6 @@ export function TreatmentWorkspace({
|
||||
|
||||
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;
|
||||
@@ -2389,9 +2475,10 @@ export function TreatmentWorkspace({
|
||||
onToggleLink={(a, b) => {
|
||||
if (
|
||||
!canEditTreatmentForDay ||
|
||||
isDetailLocked(activeDetail) ||
|
||||
activeLocked ||
|
||||
showWholeTreatmentPlan ||
|
||||
!activeDetailId
|
||||
!activeDetailId ||
|
||||
!activeTypeSelected
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -2433,42 +2520,73 @@ export function TreatmentWorkspace({
|
||||
}),
|
||||
);
|
||||
}}
|
||||
disabled={!canEditTreatmentForDay || isDetailLocked(activeDetail)}
|
||||
disabled={
|
||||
!canEditTreatmentForDay ||
|
||||
activeLocked ||
|
||||
!activeTypeSelected
|
||||
}
|
||||
/>
|
||||
) : 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');
|
||||
) : 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);
|
||||
}}
|
||||
onUploadFiles={(files) => void uploadForDetail(activeDetailId, files ?? [])}
|
||||
showChrome={false}
|
||||
showFields
|
||||
/>
|
||||
) : null}
|
||||
<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
|
||||
}
|
||||
/>
|
||||
|
||||
{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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user