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).
|
- **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`.
|
- **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`).
|
- **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.
|
- 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**.
|
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
|
## 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`.
|
- **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.
|
- **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).
|
- **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`
|
Skill: `.cursor/skills/lab-cases/SKILL.md`
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ alwaysApply: false
|
|||||||
# Treatment workspace
|
# Treatment workspace
|
||||||
|
|
||||||
- **Browse mode** (`selectedPreviewId` set): preview only; banner + **Load into workspace**; form unchanged until load.
|
- **Browse mode** (`selectedPreviewId` set): preview only; banner + **Load into workspace**; form unchanged until load.
|
||||||
- **Current draft** preview: heading “Current draft”; no load button while editing live.
|
- **Current draft** preview: omitted in live editing (form is the source). History browse still uses preview + **Load into workspace**.
|
||||||
- **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).
|
- **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 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.
|
- **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 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.
|
- **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.
|
- **Lab dispatch attention:** `LabDispatchAttentionPanel` — unsent lab-dependent details; quick jump to dispatch.
|
||||||
- **History API:** patient-scoped; non-owners filtered by provider on treatment or appointment; org owners see all.
|
- **History API:** patient-scoped; 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`.
|
- **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 |
|
| Component | Use for |
|
||||||
|-----------|---------|
|
|-----------|---------|
|
||||||
| `ScheduleDayPicker` | Appointments strip — nav arrows + today toggle + expandable 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) |
|
| `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` |
|
| `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.
|
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`.
|
- **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`.
|
- **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.
|
- **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`.
|
- **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).
|
- **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
|
## 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.
|
List item shape: `prosthesisGroups: { prosthesisTypeCode, teeth[] }[]` from task teeth aggregation.
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
name: dyolink-treatment-workspace
|
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`)
|
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 |
|
| Stage | UI | When |
|
||||||
|------|-----|--------|
|
|-------|-----|------|
|
||||||
| **Teeth** | `FdiToothChart` | Neighbor circles connect/disconnect; Shift+range selects (empty circles); plain click select/deselect |
|
| **Treatment** | Type dropdown + `TreatmentDetailAttachmentsStrip`, `FdiToothChart` (Cases scale), full-width Notes | Always |
|
||||||
| **Content** | `TreatmentDetailsEditor` fields only (`showChrome={false}`) | Type / **Notes** / attachments — **no delete button** |
|
| **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. |
|
||||||
| **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`. |
|
|
||||||
|
|
||||||
|
- 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).
|
- 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 to the treatment form, unless `pendingEntryStepRef` is set to `lab` first (lab shipments rail / “Go to dispatch” / load-with-focus).
|
||||||
- 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.
|
- 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).
|
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).
|
- **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.
|
- **Neighbor circles:** when two arch-adjacent teeth are both selected, an empty circle appears **between** them (not per-tooth). Click empty → connect; click filled → disconnect (teeth stay selected). Transitive A–B + B–C = one bridge.
|
||||||
- **Shift+click:** inclusive same-arch range → all selected as **singles** (empty circles); overlapping existing bridges are absorbed as singles too. Midline neighbors (11–21, 41–31) allowed.
|
- **Shift+click:** 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`.
|
- **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.
|
- **Date** — filter on `treatmentAt` matching that local calendar day.
|
||||||
- When **not shipped** is on, filters **already-fetched** history only (excludes the active appointment row).
|
- 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`.
|
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 }` |
|
| `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) |
|
| `GET /treatments/patients/:patientId/history` | History (patient + org; filtered by provider) |
|
||||||
|
|
||||||
@@ -219,7 +222,7 @@ canEditTreatmentForDay = canEdit && hasLiveContext && !isViewingPastDay && works
|
|||||||
### Add / remove details
|
### Add / remove details
|
||||||
|
|
||||||
- **Add detail** stays enabled whenever `canEditTreatmentForDay` — even if sibling details are already sent.
|
- **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)
|
### 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/`.
|
**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 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):**
|
**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.
|
- 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.
|
||||||
- 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`).
|
- **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`).
|
||||||
- **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).
|
- 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`).
|
||||||
- **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.
|
- **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).
|
- **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):**
|
**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.
|
- 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.
|
- 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.
|
- **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).
|
- **Unread semantics**: Treatment tab badge = count of unread cases **for the user's own treatment plans** (per-case read cursor) and clears when a case is opened/marked read (not on tab visit).
|
||||||
- **Live lab rail**: `notification.created` → `notifyTabBadgesChanged()` silently refreshes patient lab cases + unread rail (does **not** clear draft/form state).
|
- **Live lab rail**: `notification.created` → `notifyTabBadgesChanged()` silently refreshes patient lab cases + unread rail (does **not** clear draft/form state).
|
||||||
- **Lab shipment progress + comments**: shown in **Lab dispatch panel** for the active shipment; expanding activity / opening comments marks that case read. Shared UI: `LabCaseCommentsPanel` — newest first; sent = start / received = end (`text-start`/`justify-start`, RTL-safe); pass `viewerSide`.
|
- **Lab shipment progress + comments**: shown in **Lab dispatch panel** for the active shipment; expanding activity / opening comments marks that case read. Shared UI: `LabCaseCommentsPanel` — newest first; sent = start / received = end (`text-start`/`justify-start`, RTL-safe); pass `viewerSide`.
|
||||||
@@ -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 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):**
|
**Lab case share link (quick ref):**
|
||||||
- Token on first ship → `/{locale}/lab-case/{token}` after login.
|
- Token on first ship → `/{locale}/lab-case/{token}` after login.
|
||||||
@@ -94,7 +96,7 @@ Errors: `AppException` + `ErrorCode` → frontend `getUserFacingError()`. Never
|
|||||||
| Skill | When to use |
|
| Skill | When to use |
|
||||||
|-------|-------------|
|
|-------|-------------|
|
||||||
| `.cursor/skills/add-feature/` | New tab, API module, or end-to-end feature |
|
| `.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-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-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 |
|
| `.cursor/skills/lab-case-share-link/` | Case QR share link: access token, focus page, auth redirect, access rules |
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
@@ -92,6 +93,13 @@ export class CasesController {
|
|||||||
return this.casesService.startInternal(id, organizationId, req.user.id, req.user.language);
|
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')
|
@Post(':id/lines/:lineClientKey/attachments')
|
||||||
@ApiOperation({ summary: 'Upload attachments for a lab-origin draft line' })
|
@ApiOperation({ summary: 'Upload attachments for a lab-origin draft line' })
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes('multipart/form-data')
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AppException, ErrorCode } from '../../common/errors';
|
import { AppException, ErrorCode } from '../../common/errors';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { createReadStream, existsSync, mkdirSync } from 'fs';
|
import { createReadStream, existsSync, mkdirSync, unlinkSync } from 'fs';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { CatalogEntityKind, LabCaseActivityType, LabCaseOrigin, LabTaskStatus, LinkStatus, Prisma, UserNotificationType } from '@prisma/client';
|
import { CatalogEntityKind, LabCaseActivityType, LabCaseOrigin, LabTaskStatus, LinkStatus, Prisma, UserNotificationType } from '@prisma/client';
|
||||||
import { PrismaService } from '../../../prisma/prisma.service';
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
@@ -565,6 +565,56 @@ export class CasesService {
|
|||||||
return { success: true, data: await this.mapLabCaseDetail(refreshed, localeInput) };
|
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(
|
async uploadInternalAttachments(
|
||||||
labCaseId: string,
|
labCaseId: string,
|
||||||
lineClientKey: string,
|
lineClientKey: string,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
ArrayMinSize,
|
|
||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsDateString,
|
IsDateString,
|
||||||
@@ -61,7 +60,6 @@ export class SaveTreatmentDetailDto {
|
|||||||
|
|
||||||
export class SaveTreatmentDraftDto {
|
export class SaveTreatmentDraftDto {
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ArrayMinSize(1)
|
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@Type(() => SaveTreatmentDetailDto)
|
@Type(() => SaveTreatmentDetailDto)
|
||||||
details: SaveTreatmentDetailDto[];
|
details: SaveTreatmentDetailDto[];
|
||||||
|
|||||||
@@ -513,7 +513,9 @@
|
|||||||
"removeLineAria": "Remove line {n}",
|
"removeLineAria": "Remove line {n}",
|
||||||
"errorCreateCase": "Could not create the case.",
|
"errorCreateCase": "Could not create the case.",
|
||||||
"errorSaveCase": "Could not save 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": {
|
"labCaseAccess": {
|
||||||
"pageTitle": "Case tasks",
|
"pageTitle": "Case tasks",
|
||||||
@@ -676,7 +678,6 @@
|
|||||||
"noPermissionBody": "You do not have permission to view the Treatment tab for this organization.",
|
"noPermissionBody": "You do not have permission to view the Treatment tab for this organization.",
|
||||||
"title": "Treatment",
|
"title": "Treatment",
|
||||||
"subtitleEdit": "Document cases for your appointments, save drafts, and send work to linked organizations.",
|
"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.",
|
"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.",
|
"pastDayNotice": "Past days are view-only. You can review appointments and history, but treatment cases cannot be added or changed.",
|
||||||
"selectedPatient": "Selected patient",
|
"selectedPatient": "Selected patient",
|
||||||
@@ -714,12 +715,13 @@
|
|||||||
"casesTitle": "Treatment cases",
|
"casesTitle": "Treatment cases",
|
||||||
"casesSubtitle": "Each case has its own teeth, notes, attachments, and destinations for send.",
|
"casesSubtitle": "Each case has its own teeth, notes, attachments, and destinations for send.",
|
||||||
"detailsTitle": "Treatment details",
|
"detailsTitle": "Treatment details",
|
||||||
"detailsSubtitle": "Plan teeth, type, notes, and attachments for each detail line.",
|
|
||||||
"addDetail": "Add detail",
|
"addDetail": "Add detail",
|
||||||
"confirmRemoveDetail": "Remove this treatment detail?",
|
"confirmRemoveDetail": "Remove this treatment detail?",
|
||||||
"removeDetailAria": "Remove detail {n}",
|
"removeDetailAria": "Remove detail {n}",
|
||||||
"detailLabel": "Detail {n}",
|
"detailLabel": "Detail {n}",
|
||||||
"detailSentBadge": "sent",
|
"detailSentBadge": "sent",
|
||||||
|
"detailUnsentBadge": "unsent",
|
||||||
|
"attachmentPreviewUnavailable": "Preview unavailable",
|
||||||
"detailLockedInShipment": "This detail was sent to a lab and can no longer be edited.",
|
"detailLockedInShipment": "This detail was sent to a lab and can no longer be edited.",
|
||||||
"detailsSaveHint": "Lab dispatch is configured separately below.",
|
"detailsSaveHint": "Lab dispatch is configured separately below.",
|
||||||
"addCase": "Add case",
|
"addCase": "Add case",
|
||||||
@@ -745,7 +747,7 @@
|
|||||||
"noOrgInvitePermission": "You do not have permission to invite labs. Contact your organization owner.",
|
"noOrgInvitePermission": "You do not have permission to invite labs. Contact your organization owner.",
|
||||||
"sendThisCase": "Send this case",
|
"sendThisCase": "Send this case",
|
||||||
"labDispatchTitle": "Lab dispatch",
|
"labDispatchTitle": "Lab dispatch",
|
||||||
"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.",
|
"labDispatchSendHint": "Shipment grouping is saved when you send to the lab.",
|
||||||
"addLabShipment": "Add lab shipment",
|
"addLabShipment": "Add lab shipment",
|
||||||
"labShipmentLabel": "Shipment {n}",
|
"labShipmentLabel": "Shipment {n}",
|
||||||
@@ -861,11 +863,19 @@
|
|||||||
"entryStepTeeth": "Teeth",
|
"entryStepTeeth": "Teeth",
|
||||||
"entryStepContent": "Content",
|
"entryStepContent": "Content",
|
||||||
"entryStepLab": "Lab",
|
"entryStepLab": "Lab",
|
||||||
|
"entryStepTreatment": "Treatment",
|
||||||
"entryStepBack": "Back",
|
"entryStepBack": "Back",
|
||||||
"entryStepNext": "Next",
|
"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.",
|
"entryStepLabUnavailable": "Lab dispatch is available after you save a lab-dependent detail with teeth selected.",
|
||||||
"toothChartTitleCompact": "Tooth chart",
|
"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).",
|
"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",
|
"toothConnectedHint": "Connected selection",
|
||||||
"toothLinkHint": "Connect these teeth",
|
"toothLinkHint": "Connect these teeth",
|
||||||
"toothUnlinkHint": "Disconnect these teeth",
|
"toothUnlinkHint": "Disconnect these teeth",
|
||||||
@@ -876,7 +886,7 @@
|
|||||||
"prosthesisConnectedLabel": "Connected teeth prosthesis type",
|
"prosthesisConnectedLabel": "Connected teeth prosthesis type",
|
||||||
"toothChartWholePlan": "Full View",
|
"toothChartWholePlan": "Full View",
|
||||||
"labShipmentAttachments": "Files for the lab",
|
"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:",
|
"selectedLabel": "Selected:",
|
||||||
"selectedEmpty": "—",
|
"selectedEmpty": "—",
|
||||||
"upperArch": "Upper arch",
|
"upperArch": "Upper arch",
|
||||||
|
|||||||
@@ -514,7 +514,9 @@
|
|||||||
"removeLineAria": "حذف خط {n}",
|
"removeLineAria": "حذف خط {n}",
|
||||||
"errorCreateCase": "ایجاد پرونده ممکن نشد.",
|
"errorCreateCase": "ایجاد پرونده ممکن نشد.",
|
||||||
"errorSaveCase": "ذخیره پرونده ممکن نشد.",
|
"errorSaveCase": "ذخیره پرونده ممکن نشد.",
|
||||||
"errorStartCase": "شروع پرونده ممکن نشد."
|
"errorStartCase": "شروع پرونده ممکن نشد.",
|
||||||
|
"confirmDeleteDraftCase": "این پیشنویس پرونده حذف شود؟ این کار قابل بازگشت نیست.",
|
||||||
|
"errorDeleteCase": "حذف پرونده ممکن نشد."
|
||||||
},
|
},
|
||||||
"labCaseAccess": {
|
"labCaseAccess": {
|
||||||
"pageTitle": "وظایف پرونده",
|
"pageTitle": "وظایف پرونده",
|
||||||
@@ -677,7 +679,6 @@
|
|||||||
"noPermissionBody": "شما مجوز مشاهده برگه درمان برای این سازمان را ندارید.",
|
"noPermissionBody": "شما مجوز مشاهده برگه درمان برای این سازمان را ندارید.",
|
||||||
"title": "درمان",
|
"title": "درمان",
|
||||||
"subtitleEdit": "پروندههای نوبتهای خود را مستند کنید، پیشنویسها را ذخیره کنید و کار را به سازمانهای مرتبط ارسال کنید.",
|
"subtitleEdit": "پروندههای نوبتهای خود را مستند کنید، پیشنویسها را ذخیره کنید و کار را به سازمانهای مرتبط ارسال کنید.",
|
||||||
"subtitleEditPhase4": "ابتدا جزئیات درمان را برنامهریزی کنید، سپس کار وابسته به لابراتوار را در بخش ارسال لاب گروهبندی کنید.",
|
|
||||||
"subtitleReadOnly": "دسترسی فقط خواندنی — میتوانید نوبتها و تاریخچه درمان را بررسی کنید اما نمیتوانید ویرایش کنید.",
|
"subtitleReadOnly": "دسترسی فقط خواندنی — میتوانید نوبتها و تاریخچه درمان را بررسی کنید اما نمیتوانید ویرایش کنید.",
|
||||||
"pastDayNotice": "روزهای گذشته فقط قابل مشاهده هستند. میتوانید نوبتها و تاریخچه را بررسی کنید، اما پروندههای درمانی قابل اضافه یا تغییر نیستند.",
|
"pastDayNotice": "روزهای گذشته فقط قابل مشاهده هستند. میتوانید نوبتها و تاریخچه را بررسی کنید، اما پروندههای درمانی قابل اضافه یا تغییر نیستند.",
|
||||||
"selectedPatient": "بیمار انتخاب شده",
|
"selectedPatient": "بیمار انتخاب شده",
|
||||||
@@ -715,12 +716,13 @@
|
|||||||
"casesTitle": "پروندههای درمانی",
|
"casesTitle": "پروندههای درمانی",
|
||||||
"casesSubtitle": "هر پرونده دارای دندانها، یادداشتها، پیوستها و مقصدهای ارسال خود است.",
|
"casesSubtitle": "هر پرونده دارای دندانها، یادداشتها، پیوستها و مقصدهای ارسال خود است.",
|
||||||
"detailsTitle": "جزئیات درمان",
|
"detailsTitle": "جزئیات درمان",
|
||||||
"detailsSubtitle": "دندانها، نوع، یادداشت و پیوستها را برای هر خط جزئیات برنامهریزی کنید.",
|
|
||||||
"addDetail": "افزودن جزئیات",
|
"addDetail": "افزودن جزئیات",
|
||||||
"confirmRemoveDetail": "این جزئیات درمان حذف شود؟",
|
"confirmRemoveDetail": "این جزئیات درمان حذف شود؟",
|
||||||
"removeDetailAria": "حذف جزئیات {n}",
|
"removeDetailAria": "حذف جزئیات {n}",
|
||||||
"detailLabel": "جزئیات {n}",
|
"detailLabel": "جزئیات {n}",
|
||||||
"detailSentBadge": "ارسالشده",
|
"detailSentBadge": "ارسالشده",
|
||||||
|
"detailUnsentBadge": "ارسالنشده",
|
||||||
|
"attachmentPreviewUnavailable": "پیشنمایش در دسترس نیست",
|
||||||
"detailLockedInShipment": "این جزئیات به لابراتوار ارسال شده و دیگر قابل ویرایش نیست.",
|
"detailLockedInShipment": "این جزئیات به لابراتوار ارسال شده و دیگر قابل ویرایش نیست.",
|
||||||
"detailsSaveHint": "ارسال لاب در بخش جداگانه زیر پیکربندی میشود.",
|
"detailsSaveHint": "ارسال لاب در بخش جداگانه زیر پیکربندی میشود.",
|
||||||
"addCase": "افزودن پرونده",
|
"addCase": "افزودن پرونده",
|
||||||
@@ -746,7 +748,7 @@
|
|||||||
"noOrgInvitePermission": "شما مجوز دعوت از آزمایشگاه را ندارید. با مالک سازمان تماس بگیرید.",
|
"noOrgInvitePermission": "شما مجوز دعوت از آزمایشگاه را ندارید. با مالک سازمان تماس بگیرید.",
|
||||||
"sendThisCase": "ارسال این پرونده",
|
"sendThisCase": "ارسال این پرونده",
|
||||||
"labDispatchTitle": "ارسال به لابراتوار",
|
"labDispatchTitle": "ارسال به لابراتوار",
|
||||||
"labDispatchSubtitle": "جزئیات وابسته به لاب را در محمولهها گروهبندی کرده و به لابراتوارهای متصل ارسال کنید.",
|
"labDispatchSubtitle": "لابراتوار را انتخاب کنید، نوع پروتز را مشخص کنید و ارسال کنید.",
|
||||||
"labDispatchSendHint": "گروهبندی محموله هنگام ارسال به لاب ذخیره میشود.",
|
"labDispatchSendHint": "گروهبندی محموله هنگام ارسال به لاب ذخیره میشود.",
|
||||||
"addLabShipment": "افزودن محموله لاب",
|
"addLabShipment": "افزودن محموله لاب",
|
||||||
"labShipmentLabel": "محموله {n}",
|
"labShipmentLabel": "محموله {n}",
|
||||||
@@ -860,6 +862,7 @@
|
|||||||
"toothChartTitle": "نمودار دندانها FDI",
|
"toothChartTitle": "نمودار دندانها FDI",
|
||||||
"toothChartTitleCompact": "نمودار دندان",
|
"toothChartTitleCompact": "نمودار دندان",
|
||||||
"toothChartHint": "برای انتخاب یا لغو انتخاب روی دندان ضربه بزنید. وقتی دو دندان همسایه انتخاب شوند، دایرهٔ خالی بینشان ظاهر میشود — برای اتصال روی آن بزنید (یا روی دایرهٔ پر برای قطع اتصال). Shift+کلیک روی یک بازه، همه را انتخاب میکند (تا وقتی دایرهها را پر نکنید متصل نیستند).",
|
"toothChartHint": "برای انتخاب یا لغو انتخاب روی دندان ضربه بزنید. وقتی دو دندان همسایه انتخاب شوند، دایرهٔ خالی بینشان ظاهر میشود — برای اتصال روی آن بزنید (یا روی دایرهٔ پر برای قطع اتصال). Shift+کلیک روی یک بازه، همه را انتخاب میکند (تا وقتی دایرهها را پر نکنید متصل نیستند).",
|
||||||
|
"toothChartHelpAria": "راهنمای انتخاب دندان",
|
||||||
"toothConnectedHint": "انتخاب متصل",
|
"toothConnectedHint": "انتخاب متصل",
|
||||||
"toothLinkHint": "اتصال این دندانها",
|
"toothLinkHint": "اتصال این دندانها",
|
||||||
"toothUnlinkHint": "قطع اتصال این دندانها",
|
"toothUnlinkHint": "قطع اتصال این دندانها",
|
||||||
@@ -872,12 +875,19 @@
|
|||||||
"entryStepTeeth": "دندانها",
|
"entryStepTeeth": "دندانها",
|
||||||
"entryStepContent": "محتوا",
|
"entryStepContent": "محتوا",
|
||||||
"entryStepLab": "لابراتوار",
|
"entryStepLab": "لابراتوار",
|
||||||
|
"entryStepTreatment": "درمان",
|
||||||
"entryStepBack": "قبلی",
|
"entryStepBack": "قبلی",
|
||||||
"entryStepNext": "بعدی",
|
"entryStepNext": "بعدی",
|
||||||
|
"continueToLab": "ادامه به لابراتوار",
|
||||||
|
"backToTreatment": "بازگشت به درمان",
|
||||||
|
"selectTypeBeforeTeeth": "قبل از انتخاب دندان، نوع درمان را انتخاب کنید.",
|
||||||
|
"labDispatchNotSent": "ارسال نشده",
|
||||||
|
"labDispatchLastLab": "آخرین لاب: {name}",
|
||||||
|
"labCommentsMovedHint": "پیشرفت و پیامها در بخش محمولههای لاب هستند.",
|
||||||
"entryStepLabUnavailable": "ارسال به لابراتوار پس از ذخیره جزئیات وابسته به لاب با انتخاب دندان در دسترس است.",
|
"entryStepLabUnavailable": "ارسال به لابراتوار پس از ذخیره جزئیات وابسته به لاب با انتخاب دندان در دسترس است.",
|
||||||
"toothChartWholePlan": "نمایش کامل",
|
"toothChartWholePlan": "نمایش کامل",
|
||||||
"labShipmentAttachments": "فایلها برای لابراتوار",
|
"labShipmentAttachments": "فایلها برای لابراتوار",
|
||||||
"labShipmentAttachmentsHint": "انتخاب کنید کدام پیوستهای این جزئیات در این محموله ارسال شوند. پیشفرض هیچکدام نیست.",
|
"labShipmentAttachmentsHint": "همه فایلها پیوست میشوند. برای حذف از ارسال، تیک را بردارید.",
|
||||||
"selectedLabel": "انتخاب شده:",
|
"selectedLabel": "انتخاب شده:",
|
||||||
"selectedEmpty": "—",
|
"selectedEmpty": "—",
|
||||||
"upperArch": "قوس بالا",
|
"upperArch": "قوس بالا",
|
||||||
|
|||||||
@@ -514,7 +514,9 @@
|
|||||||
"removeLineAria": "Lijn {n} verwijderen",
|
"removeLineAria": "Lijn {n} verwijderen",
|
||||||
"errorCreateCase": "Case kon niet worden aangemaakt.",
|
"errorCreateCase": "Case kon niet worden aangemaakt.",
|
||||||
"errorSaveCase": "Case kon niet worden opgeslagen.",
|
"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": {
|
"labCaseAccess": {
|
||||||
"pageTitle": "Dossiertaken",
|
"pageTitle": "Dossiertaken",
|
||||||
@@ -676,7 +678,6 @@
|
|||||||
"noPermissionBody": "U heeft geen toestemming om het tabblad Behandeling voor deze organisatie te bekijken.",
|
"noPermissionBody": "U heeft geen toestemming om het tabblad Behandeling voor deze organisatie te bekijken.",
|
||||||
"title": "Behandeling",
|
"title": "Behandeling",
|
||||||
"subtitleEdit": "Documenteer casussen voor uw afspraken, sla concepten op en stuur werk naar gekoppelde organisaties.",
|
"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.",
|
"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.",
|
"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",
|
"selectedPatient": "Geselecteerde patiënt",
|
||||||
@@ -714,12 +715,13 @@
|
|||||||
"casesTitle": "Behandelcasussen",
|
"casesTitle": "Behandelcasussen",
|
||||||
"casesSubtitle": "Elke case heeft zijn eigen tanden, notities, bijlagen en verzendbestemmingen.",
|
"casesSubtitle": "Elke case heeft zijn eigen tanden, notities, bijlagen en verzendbestemmingen.",
|
||||||
"detailsTitle": "Behandeldetails",
|
"detailsTitle": "Behandeldetails",
|
||||||
"detailsSubtitle": "Plan tanden, type, notities en bijlagen per detailregel.",
|
|
||||||
"addDetail": "Detail toevoegen",
|
"addDetail": "Detail toevoegen",
|
||||||
"confirmRemoveDetail": "Dit behandelingsdetail verwijderen?",
|
"confirmRemoveDetail": "Dit behandelingsdetail verwijderen?",
|
||||||
"removeDetailAria": "Detail {n} verwijderen",
|
"removeDetailAria": "Detail {n} verwijderen",
|
||||||
"detailLabel": "Detail {n}",
|
"detailLabel": "Detail {n}",
|
||||||
"detailSentBadge": "verzonden",
|
"detailSentBadge": "verzonden",
|
||||||
|
"detailUnsentBadge": "niet verzonden",
|
||||||
|
"attachmentPreviewUnavailable": "Voorbeeld niet beschikbaar",
|
||||||
"detailLockedInShipment": "Dit detail is naar het lab verzonden en kan niet meer worden bewerkt.",
|
"detailLockedInShipment": "Dit detail is naar het lab verzonden en kan niet meer worden bewerkt.",
|
||||||
"detailsSaveHint": "Lab-dispatch wordt hieronder apart geconfigureerd.",
|
"detailsSaveHint": "Lab-dispatch wordt hieronder apart geconfigureerd.",
|
||||||
"addCase": "Case toevoegen",
|
"addCase": "Case toevoegen",
|
||||||
@@ -745,7 +747,7 @@
|
|||||||
"noOrgInvitePermission": "U heeft geen toestemming om labs uit te nodigen. Neem contact op met de organisatie-eigenaar.",
|
"noOrgInvitePermission": "U heeft geen toestemming om labs uit te nodigen. Neem contact op met de organisatie-eigenaar.",
|
||||||
"sendThisCase": "Verzend deze case",
|
"sendThisCase": "Verzend deze case",
|
||||||
"labDispatchTitle": "Lab-dispatch",
|
"labDispatchTitle": "Lab-dispatch",
|
||||||
"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.",
|
"labDispatchSendHint": "Groepering wordt opgeslagen wanneer u naar het lab verzendt.",
|
||||||
"addLabShipment": "Labzending toevoegen",
|
"addLabShipment": "Labzending toevoegen",
|
||||||
"labShipmentLabel": "Zending {n}",
|
"labShipmentLabel": "Zending {n}",
|
||||||
@@ -859,6 +861,7 @@
|
|||||||
"toothChartTitle": "FDI-tanddiagram",
|
"toothChartTitle": "FDI-tanddiagram",
|
||||||
"toothChartTitleCompact": "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).",
|
"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",
|
"toothConnectedHint": "Verbonden selectie",
|
||||||
"toothLinkHint": "Deze tanden verbinden",
|
"toothLinkHint": "Deze tanden verbinden",
|
||||||
"toothUnlinkHint": "Deze tanden ontkoppelen",
|
"toothUnlinkHint": "Deze tanden ontkoppelen",
|
||||||
@@ -871,12 +874,19 @@
|
|||||||
"entryStepTeeth": "Tanden",
|
"entryStepTeeth": "Tanden",
|
||||||
"entryStepContent": "Inhoud",
|
"entryStepContent": "Inhoud",
|
||||||
"entryStepLab": "Lab",
|
"entryStepLab": "Lab",
|
||||||
|
"entryStepTreatment": "Behandeling",
|
||||||
"entryStepBack": "Terug",
|
"entryStepBack": "Terug",
|
||||||
"entryStepNext": "Volgende",
|
"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.",
|
"entryStepLabUnavailable": "Labverzending is beschikbaar nadat u een lab-afhankelijk detail met geselecteerde tanden hebt opgeslagen.",
|
||||||
"toothChartWholePlan": "Volledig overzicht",
|
"toothChartWholePlan": "Volledig overzicht",
|
||||||
"labShipmentAttachments": "Bestanden voor het lab",
|
"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:",
|
"selectedLabel": "Geselecteerd:",
|
||||||
"selectedEmpty": "—",
|
"selectedEmpty": "—",
|
||||||
"upperArch": "Bovenboog",
|
"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 {
|
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 (
|
return (
|
||||||
!isDetailTypeSelected(detail) &&
|
!detail.treatmentType?.trim() &&
|
||||||
detail.teeth.length === 0 &&
|
(detail.teeth?.length ?? 0) === 0 &&
|
||||||
!detail.comment.trim()
|
!`${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 {
|
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. */
|
/** Lab dispatch UI applies only to persisted prosthesis (or lab-dependent) lines with teeth. */
|
||||||
|
|||||||
@@ -219,10 +219,25 @@ export function AppointmentsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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>
|
<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>
|
<p className="text-sm text-text-secondary">{t('subtitle')}</p>
|
||||||
</div>
|
</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="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||||
<div className="xl:col-span-1 space-y-4">
|
<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">
|
<div className="xl:col-span-2 space-y-4">
|
||||||
<AppointmentScheduleLegend treatmentCatalog={treatmentCatalog} />
|
<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
|
<AppointmentScheduleGrid
|
||||||
day={scheduleDate}
|
day={scheduleDate}
|
||||||
providers={providers}
|
providers={providers}
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ interface CaseCreatePanelProps {
|
|||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
onSaved: (detail: LabCaseDetail) => void;
|
onSaved: (detail: LabCaseDetail) => void;
|
||||||
onStarted: (detail: LabCaseDetail) => void;
|
onStarted: (detail: LabCaseDetail) => void;
|
||||||
|
onDeleted: () => void;
|
||||||
onError: (message: string) => void;
|
onError: (message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +105,7 @@ export function CaseCreatePanel({
|
|||||||
canEdit,
|
canEdit,
|
||||||
onSaved,
|
onSaved,
|
||||||
onStarted,
|
onStarted,
|
||||||
|
onDeleted,
|
||||||
onError,
|
onError,
|
||||||
}: CaseCreatePanelProps) {
|
}: CaseCreatePanelProps) {
|
||||||
const t = useTranslations('cases');
|
const t = useTranslations('cases');
|
||||||
@@ -136,11 +138,13 @@ export function CaseCreatePanel({
|
|||||||
const [applyAllProsthesis, setApplyAllProsthesis] = useState('');
|
const [applyAllProsthesis, setApplyAllProsthesis] = useState('');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [starting, setStarting] = useState(false);
|
const [starting, setStarting] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [uploadBusy, setUploadBusy] = useState(false);
|
const [uploadBusy, setUploadBusy] = useState(false);
|
||||||
const rangeAnchorRef = useRef<FdiToothId | null>(null);
|
const rangeAnchorRef = useRef<FdiToothId | null>(null);
|
||||||
const hydratedIdRef = useRef(labCase.id);
|
const hydratedIdRef = useRef(labCase.id);
|
||||||
const skipSaveRef = useRef(true);
|
const skipSaveRef = useRef(true);
|
||||||
const startingRef = useRef(false);
|
const startingRef = useRef(false);
|
||||||
|
const deletingRef = useRef(false);
|
||||||
const allowPersistWhileStartingRef = useRef(false);
|
const allowPersistWhileStartingRef = useRef(false);
|
||||||
const attachmentInputRef = useRef<HTMLInputElement>(null);
|
const attachmentInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -221,6 +225,9 @@ export function CaseCreatePanel({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const persist = useCallback(async () => {
|
const persist = useCallback(async () => {
|
||||||
|
if (deletingRef.current) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (startingRef.current && !allowPersistWhileStartingRef.current) {
|
if (startingRef.current && !allowPersistWhileStartingRef.current) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -251,7 +258,7 @@ export function CaseCreatePanel({
|
|||||||
skipSaveRef.current = false;
|
skipSaveRef.current = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!canEdit || startingRef.current) return;
|
if (!canEdit || startingRef.current || deletingRef.current) return;
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
void persistRef.current();
|
void persistRef.current();
|
||||||
}, 500);
|
}, 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() {
|
async function handleStart() {
|
||||||
if (!canEdit) return;
|
if (!canEdit) return;
|
||||||
setStarting(true);
|
setStarting(true);
|
||||||
@@ -311,7 +334,7 @@ export function CaseCreatePanel({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const disabled = !canEdit || starting;
|
const disabled = !canEdit || starting || deleting;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -320,6 +343,15 @@ export function CaseCreatePanel({
|
|||||||
<h2 className="text-lg font-semibold text-text-primary">{t('addCaseTitle')}</h2>
|
<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>
|
<p className="text-sm text-text-muted mt-0.5">{t('addCaseSubtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="danger"
|
||||||
|
disabled={disabled || saving}
|
||||||
|
onClick={() => handleDelete()}
|
||||||
|
>
|
||||||
|
{tCommon('delete')}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@@ -329,6 +361,7 @@ export function CaseCreatePanel({
|
|||||||
{starting ? tCommon('loading') : t('startCase')}
|
{starting ? tCommon('loading') : t('startCase')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<label className="text-xs font-medium text-text-muted">
|
<label className="text-xs font-medium text-text-muted">
|
||||||
|
|||||||
@@ -397,8 +397,17 @@ export function CasesPage() {
|
|||||||
mobileDetailOpen && selectedCaseId ? 'hidden lg:flex' : 'flex'
|
mobileDetailOpen && selectedCaseId ? 'hidden lg:flex' : 'flex'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2">
|
{canEdit ? (
|
||||||
<div className="min-w-0 flex-1">
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="lg"
|
||||||
|
fullWidth
|
||||||
|
disabled={creatingCase}
|
||||||
|
onClick={() => void handleAddCase()}
|
||||||
|
>
|
||||||
|
{t('addCase')}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<SearchBar
|
<SearchBar
|
||||||
embedded
|
embedded
|
||||||
value={search}
|
value={search}
|
||||||
@@ -408,19 +417,6 @@ export function CasesPage() {
|
|||||||
}}
|
}}
|
||||||
placeholder={t('searchPlaceholder')}
|
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">
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
<label className="space-y-1">
|
<label className="space-y-1">
|
||||||
@@ -661,6 +657,25 @@ export function CasesPage() {
|
|||||||
page,
|
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}
|
onError={toast.showError}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -40,9 +40,6 @@ export function LabCaseProsthesisGroupsList({
|
|||||||
color: prosthesisTypeColorFromCatalog(group.prosthesisTypeCode, prosthesisCatalog),
|
color: prosthesisTypeColorFromCatalog(group.prosthesisTypeCode, prosthesisCatalog),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{group.connected ? (
|
|
||||||
<ConnectedSelectionBadge className="me-1 align-middle text-[9px] px-1 py-px" />
|
|
||||||
) : null}
|
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
{prosthesisLabel(group.prosthesisTypeCode, prosthesisCatalog)}
|
{prosthesisLabel(group.prosthesisTypeCode, prosthesisCatalog)}
|
||||||
</span>
|
</span>
|
||||||
@@ -52,6 +49,9 @@ export function LabCaseProsthesisGroupsList({
|
|||||||
{formatToothList(group.teeth)}
|
{formatToothList(group.teeth)}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
{group.connected ? (
|
||||||
|
<ConnectedSelectionBadge className="ms-1 align-middle text-[9px] px-1 py-px" />
|
||||||
|
) : null}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -61,6 +61,20 @@ export function CalendarDaySelect({
|
|||||||
const today = startOfLocalDay(new Date());
|
const today = startOfLocalDay(new Date());
|
||||||
const isTodaySelected = !isEmpty && compareLocalDayStart(normalizedValue, today) === 0;
|
const isTodaySelected = !isEmpty && compareLocalDayStart(normalizedValue, today) === 0;
|
||||||
const resolvedLabel = label ?? t('defaultLabel');
|
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
|
const labelText = isEmpty
|
||||||
? (emptyLabel ?? t('chooseDate'))
|
? (emptyLabel ?? t('chooseDate'))
|
||||||
: formatAppPickerDateLabel(normalizedValue, locale);
|
: formatAppPickerDateLabel(normalizedValue, locale);
|
||||||
@@ -121,22 +135,10 @@ export function CalendarDaySelect({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={rootRef} className={`relative w-full ${className ?? 'max-w-md'}`}>
|
<div ref={rootRef} className={`relative w-full ${className ?? 'max-w-md'}`}>
|
||||||
{showHeader ? (
|
{showLabelRow ? (
|
||||||
<div className="mb-2 flex items-center justify-between gap-3">
|
<div className="mb-2 flex items-center justify-between gap-3">
|
||||||
<p className="text-sm font-medium text-text-secondary">{resolvedLabel}</p>
|
<p className="text-sm font-medium text-text-secondary">{resolvedLabel}</p>
|
||||||
{showTodayToggle ? (
|
{todayToggle}
|
||||||
<Checkbox
|
|
||||||
checked={isTodaySelected}
|
|
||||||
onChange={(checked) => {
|
|
||||||
if (checked) {
|
|
||||||
onChange(today);
|
|
||||||
setPanelOpen(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
label={t('today')}
|
|
||||||
className="shrink-0"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -170,6 +172,7 @@ export function CalendarDaySelect({
|
|||||||
<NextIcon className="h-4 w-4 icon-flat" />
|
<NextIcon className="h-4 w-4 icon-flat" />
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
|
{!showLabelRow && todayToggle ? <div className="pe-1">{todayToggle}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{panelOpen && !disabled ? (
|
{panelOpen && !disabled ? (
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ interface ScheduleDayPickerProps {
|
|||||||
label?: string;
|
label?: string;
|
||||||
/** Show a "Today" toggle that jumps to the current local day when enabled. Default true. */
|
/** Show a "Today" toggle that jumps to the current local day when enabled. Default true. */
|
||||||
showTodayToggle?: boolean;
|
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,
|
onChange,
|
||||||
label,
|
label,
|
||||||
showTodayToggle = true,
|
showTodayToggle = true,
|
||||||
|
compact = false,
|
||||||
|
className,
|
||||||
}: ScheduleDayPickerProps) {
|
}: ScheduleDayPickerProps) {
|
||||||
return (
|
return (
|
||||||
<CalendarDaySelect
|
<CalendarDaySelect
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
label={label}
|
label={compact ? '' : label}
|
||||||
showHeader
|
showHeader={!compact}
|
||||||
showTodayToggle={showTodayToggle}
|
showTodayToggle={showTodayToggle}
|
||||||
showNavArrows
|
showNavArrows
|
||||||
|
className={className}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,11 +56,23 @@ export function AppointmentsStrip({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="space-y-4">
|
<Card className="space-y-4">
|
||||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] sm:items-center">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<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 />
|
<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>
|
<h2 className="text-sm font-semibold text-text-primary truncate">{t('appointmentsTitle')}</h2>
|
||||||
</div>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onToggleStripHidden}
|
onClick={onToggleStripHidden}
|
||||||
@@ -69,15 +81,6 @@ export function AppointmentsStrip({
|
|||||||
{t('hideAppointments')}
|
{t('hideAppointments')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<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]
|
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
|
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
|
const selectedClass = selected
|
||||||
? 'border-primary bg-primary-soft'
|
? 'border-primary bg-primary-soft'
|
||||||
: 'border-border/70 hover:border-border hover:bg-background-card/50';
|
: 'border-border/70 hover:border-border hover:bg-background-card/50';
|
||||||
@@ -57,7 +59,6 @@ export function DayStripCard({
|
|||||||
const trashClass = selected
|
const trashClass = selected
|
||||||
? 'border-primary/30 text-text-muted hover:bg-red-500/15 hover:text-red-600'
|
? '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';
|
: 'border-border/60 text-text-muted hover:bg-red-500/15 hover:text-red-600';
|
||||||
const showDelete = Boolean(canDelete && onDelete);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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)]'
|
? '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';
|
: 'hover:brightness-110';
|
||||||
|
|
||||||
|
if (item.kind === 'appointment') {
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
as="button"
|
as="button"
|
||||||
@@ -111,3 +113,38 @@ export function DayStripCard({
|
|||||||
</Card>
|
</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';
|
'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 { 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 { FDI_LOWER_LEFT_TO_RIGHT, FDI_UPPER_LEFT_TO_RIGHT, getToothShapeKind } from '@/components/treatment/fdiToothMeta';
|
||||||
import {
|
import {
|
||||||
getToothColumnWidthRem,
|
getToothColumnWidthRem,
|
||||||
@@ -17,8 +24,6 @@ const TOOTH_NUMBER_GAP = 'mt-1';
|
|||||||
const REALISTIC_NUMBER_GAP = '2mm';
|
const REALISTIC_NUMBER_GAP = '2mm';
|
||||||
/** Tight interproximal gap between tooth columns. */
|
/** Tight interproximal gap between tooth columns. */
|
||||||
const TOOTH_GAP = 'gap-x-px';
|
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 {
|
function quadrantMirrored(fdi: FdiToothId): boolean {
|
||||||
const q = fdi[0];
|
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 {
|
interface FdiToothChartProps {
|
||||||
selected: ReadonlySet<FdiToothId>;
|
selected: ReadonlySet<FdiToothId>;
|
||||||
/**
|
/**
|
||||||
@@ -71,6 +135,8 @@ interface FdiToothChartProps {
|
|||||||
headerControl?: ReactNode;
|
headerControl?: ReactNode;
|
||||||
/** Compact card for embedded case detail panels. */
|
/** Compact card for embedded case detail panels. */
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
/** Nested in the treatment editor — no extra card chrome. */
|
||||||
|
embedded?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,10 +192,13 @@ export function FdiToothChart({
|
|||||||
toothColors,
|
toothColors,
|
||||||
headerControl,
|
headerControl,
|
||||||
compact = false,
|
compact = false,
|
||||||
|
embedded = false,
|
||||||
className = '',
|
className = '',
|
||||||
}: FdiToothChartProps) {
|
}: FdiToothChartProps) {
|
||||||
const t = useTranslations('treatment');
|
const t = useTranslations('treatment');
|
||||||
|
const tCommon = useTranslations('common');
|
||||||
const uid = useId().replace(/:/g, '');
|
const uid = useId().replace(/:/g, '');
|
||||||
|
const [hintOpen, setHintOpen] = useState(false);
|
||||||
const archPeak = 8;
|
const archPeak = 8;
|
||||||
const interactive = !readOnly && Boolean(onToggle);
|
const interactive = !readOnly && Boolean(onToggle);
|
||||||
const linkInteractive = interactive && Boolean(onToggleLink);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={fdi}
|
key={fdi}
|
||||||
@@ -302,42 +378,66 @@ export function FdiToothChart({
|
|||||||
{!upper && realistic ? (
|
{!upper && realistic ? (
|
||||||
<div style={{ height: REALISTIC_NUMBER_GAP }} aria-hidden />
|
<div style={{ height: REALISTIC_NUMBER_GAP }} aria-hidden />
|
||||||
) : null}
|
) : 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 ? (
|
{interactive ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
|
data-fdi={fdi}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
if (e.shiftKey) e.preventDefault();
|
if (e.shiftKey) e.preventDefault();
|
||||||
}}
|
}}
|
||||||
onClick={(e) => {
|
onPointerDown={(e) => {
|
||||||
|
if (e.button !== 0) return;
|
||||||
e.preventDefault();
|
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={`
|
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
|
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-pressed={isSel}
|
||||||
aria-label={
|
aria-label={
|
||||||
isSel
|
isSel
|
||||||
? `${t('toothAria', { fdi })}${t('toothSelectedSuffix')}`
|
? `${t('toothAria', { fdi })}${t('toothSelectedSuffix')}`
|
||||||
: t('toothAria', { fdi })
|
: t('toothAria', { fdi })
|
||||||
}
|
}
|
||||||
>
|
/>
|
||||||
{glyph}
|
) : null}
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div
|
<div
|
||||||
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
|
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
|
||||||
className="rounded-[var(--radius-sm)] p-0.5"
|
className={`pointer-events-none ${isDisabled ? 'opacity-50' : ''}`}
|
||||||
aria-hidden={!isSel}
|
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}
|
{glyph}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{upper && realistic ? (
|
{upper && realistic ? (
|
||||||
<div style={{ height: REALISTIC_NUMBER_GAP }} aria-hidden />
|
<div style={{ height: REALISTIC_NUMBER_GAP }} aria-hidden />
|
||||||
@@ -351,7 +451,9 @@ export function FdiToothChart({
|
|||||||
</div>
|
</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'
|
? 'rounded-lg border border-border/60 bg-background-secondary/30 p-2 space-y-2'
|
||||||
: 'surface-card p-3 space-y-3';
|
: 'surface-card p-3 space-y-3';
|
||||||
|
|
||||||
@@ -368,44 +470,38 @@ export function FdiToothChart({
|
|||||||
<div className="relative z-10 space-y-0">
|
<div className="relative z-10 space-y-0">
|
||||||
<Row teeth={FDI_UPPER_LEFT_TO_RIGHT} upper />
|
<Row teeth={FDI_UPPER_LEFT_TO_RIGHT} upper />
|
||||||
<div className={`flex flex-nowrap justify-center ${TOOTH_GAP} ${TOOTH_NUMBER_GAP}`}>
|
<div className={`flex flex-nowrap justify-center ${TOOTH_GAP} ${TOOTH_NUMBER_GAP}`}>
|
||||||
{FDI_UPPER_LEFT_TO_RIGHT.map((fdi) => {
|
{FDI_UPPER_LEFT_TO_RIGHT.map((fdi) => (
|
||||||
const size = toothSizeClass(fdi);
|
<ToothNumber
|
||||||
const isSel = selected.has(fdi);
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
key={`u-${fdi}`}
|
key={`u-${fdi}`}
|
||||||
className={`text-[10px] tabular-nums text-center leading-none ${numberColorClass(fdi, isSel)}`}
|
fdi={fdi}
|
||||||
style={{
|
selected={selected.has(fdi)}
|
||||||
width: `${size.columnWidthRem}rem`,
|
widthRem={toothSizeClass(fdi).columnWidthRem}
|
||||||
...(numberStyle(fdi, isSel) ?? {}),
|
colorClass={numberColorClass(fdi, selected.has(fdi))}
|
||||||
}}
|
style={numberStyle(fdi, selected.has(fdi))}
|
||||||
>
|
interactive={interactive}
|
||||||
{fdi}
|
disabled={isDisabled}
|
||||||
</span>
|
onActivate={(shiftKey) => onToggle?.(fdi, { shiftKey })}
|
||||||
);
|
/>
|
||||||
})}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="my-2 h-px w-full bg-border/70" role="separator" aria-hidden />
|
<div className="my-2 h-px w-full bg-border/70" role="separator" aria-hidden />
|
||||||
|
|
||||||
<div className="pt-0.5">
|
<div className="pt-0.5">
|
||||||
<div className={`flex flex-nowrap justify-center ${TOOTH_GAP}`}>
|
<div className={`flex flex-nowrap justify-center ${TOOTH_GAP}`}>
|
||||||
{FDI_LOWER_LEFT_TO_RIGHT.map((fdi) => {
|
{FDI_LOWER_LEFT_TO_RIGHT.map((fdi) => (
|
||||||
const size = toothSizeClass(fdi);
|
<ToothNumber
|
||||||
const isSel = selected.has(fdi);
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
key={`l-${fdi}`}
|
key={`l-${fdi}`}
|
||||||
className={`text-[10px] tabular-nums text-center leading-none ${numberColorClass(fdi, isSel)}`}
|
fdi={fdi}
|
||||||
style={{
|
selected={selected.has(fdi)}
|
||||||
width: `${size.columnWidthRem}rem`,
|
widthRem={toothSizeClass(fdi).columnWidthRem}
|
||||||
...(numberStyle(fdi, isSel) ?? {}),
|
colorClass={numberColorClass(fdi, selected.has(fdi))}
|
||||||
}}
|
style={numberStyle(fdi, selected.has(fdi))}
|
||||||
>
|
interactive={interactive}
|
||||||
{fdi}
|
disabled={isDisabled}
|
||||||
</span>
|
onActivate={(shiftKey) => onToggle?.(fdi, { shiftKey })}
|
||||||
);
|
/>
|
||||||
})}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className={TOOTH_NUMBER_GAP}>
|
<div className={TOOTH_NUMBER_GAP}>
|
||||||
<Row teeth={FDI_LOWER_LEFT_TO_RIGHT} />
|
<Row teeth={FDI_LOWER_LEFT_TO_RIGHT} />
|
||||||
@@ -421,31 +517,63 @@ export function FdiToothChart({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${cardClass} ${className}`}>
|
<div className={`${cardClass} ${className}`}>
|
||||||
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-start sm:justify-between">
|
<div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-x-2">
|
||||||
<div className="min-w-0">
|
<div className="flex min-w-0 items-center gap-1">
|
||||||
<h3 className="text-sm font-semibold text-text-primary">
|
<h3 className="text-sm font-semibold text-text-primary">
|
||||||
{compact ? t('toothChartTitleCompact') : t('toothChartTitle')}
|
{compact || embedded ? t('toothChartTitleCompact') : t('toothChartTitle')}
|
||||||
</h3>
|
</h3>
|
||||||
{!compact && (
|
{!compact && !embedded ? (
|
||||||
<p className="text-[11px] text-text-muted mt-0.5">{t('toothChartHint')}</p>
|
<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>
|
||||||
<div className="flex flex-col items-start gap-1.5 sm:items-end shrink-0">
|
<div className="flex justify-center">{headerControl}</div>
|
||||||
{headerControl}
|
<p className="min-w-0 text-[11px] text-text-secondary tabular-nums text-end">
|
||||||
<p className="text-[11px] text-text-secondary tabular-nums sm:text-end">
|
|
||||||
{t('selectedLabel')}{' '}
|
{t('selectedLabel')}{' '}
|
||||||
{selected.size === 0 ? t('selectedEmpty') : [...selected].sort().join(', ')}
|
{selected.size === 0 ? t('selectedEmpty') : [...selected].sort().join(', ')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{scale !== 1 ? (
|
{scale !== 1 ? (
|
||||||
<div style={{ zoom: scale }} className="origin-top-left w-fit">
|
<div style={{ zoom: scale }} className="mx-auto w-fit origin-top">
|
||||||
{chartBody}
|
{chartBody}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
chartBody
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
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 { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay';
|
||||||
import { treatmentsApi } from '@/lib/api/treatments';
|
import { treatmentsApi } from '@/lib/api/treatments';
|
||||||
import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog';
|
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 { ProsthesisCatalogEntry, TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||||
import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment';
|
import type { LabCaseDraft, LinkedOrganizationOption, TreatmentDetailDraft } from '@/types/treatment';
|
||||||
import type { PatientLabCaseSummary } from '@/types/lab-case-activity';
|
import type { PatientLabCaseSummary } from '@/types/lab-case-activity';
|
||||||
@@ -29,6 +34,7 @@ interface LabCasesDispatchPanelProps {
|
|||||||
labCases: LabCaseDraft[];
|
labCases: LabCaseDraft[];
|
||||||
labDependentCodes: Set<string>;
|
labDependentCodes: Set<string>;
|
||||||
treatmentCatalog: TreatmentCatalogEntry[];
|
treatmentCatalog: TreatmentCatalogEntry[];
|
||||||
|
clinicOrganizationId?: string | null;
|
||||||
labCaseSummary?: PatientLabCaseSummary | null;
|
labCaseSummary?: PatientLabCaseSummary | null;
|
||||||
locale: string;
|
locale: string;
|
||||||
onLabCaseSummaryChange?: (summary: PatientLabCaseSummary) => void;
|
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({
|
export function LabCasesDispatchPanel({
|
||||||
details,
|
details,
|
||||||
activeDetailId,
|
activeDetailId,
|
||||||
labCases,
|
labCases,
|
||||||
labDependentCodes,
|
labDependentCodes,
|
||||||
treatmentCatalog,
|
treatmentCatalog,
|
||||||
|
clinicOrganizationId,
|
||||||
labCaseSummary,
|
labCaseSummary,
|
||||||
locale,
|
locale,
|
||||||
onLabCaseSummaryChange,
|
onLabCaseSummaryChange,
|
||||||
@@ -129,6 +150,7 @@ export function LabCasesDispatchPanel({
|
|||||||
const [prosthesisOptions, setProsthesisOptions] = useState<ProsthesisCatalogEntry[]>([]);
|
const [prosthesisOptions, setProsthesisOptions] = useState<ProsthesisCatalogEntry[]>([]);
|
||||||
const [applyAllProsthesis, setApplyAllProsthesis] = useState('');
|
const [applyAllProsthesis, setApplyAllProsthesis] = useState('');
|
||||||
const [pendingComment, setPendingComment] = useState('');
|
const [pendingComment, setPendingComment] = useState('');
|
||||||
|
const autoFilledCaseRef = useRef<string | null>(null);
|
||||||
const hasTrackerSummary = Boolean(labCaseSummary && labCaseSummary.labCaseId);
|
const hasTrackerSummary = Boolean(labCaseSummary && labCaseSummary.labCaseId);
|
||||||
|
|
||||||
const activeLinkedOrganizations = orgs.filter((o) => o.active);
|
const activeLinkedOrganizations = orgs.filter((o) => o.active);
|
||||||
@@ -148,8 +170,6 @@ export function LabCasesDispatchPanel({
|
|||||||
labCaseForActiveDetail ??
|
labCaseForActiveDetail ??
|
||||||
(activeLabCaseId ? labCases.find((lc) => lc.clientId === activeLabCaseId) : null);
|
(activeLabCaseId ? labCases.find((lc) => lc.clientId === activeLabCaseId) : null);
|
||||||
|
|
||||||
const detailAlreadyInShipment = Boolean(labCaseForActiveDetail);
|
|
||||||
|
|
||||||
const sent = Boolean(activeLabCase?.sentAt);
|
const sent = Boolean(activeLabCase?.sentAt);
|
||||||
const activeDetailNumber = details.findIndex((d) => d.clientId === activeDetailId) + 1;
|
const activeDetailNumber = details.findIndex((d) => d.clientId === activeDetailId) + 1;
|
||||||
|
|
||||||
@@ -190,16 +210,6 @@ export function LabCasesDispatchPanel({
|
|||||||
setPendingComment('');
|
setPendingComment('');
|
||||||
}, [activeLabCase?.clientId]);
|
}, [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>) {
|
function updateActiveLabCase(patch: Partial<LabCaseDraft>) {
|
||||||
if (!activeLabCase) return;
|
if (!activeLabCase) return;
|
||||||
onLabCasesChange(
|
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) {
|
function setGroupProsthesis(row: ProsthesisGroupRow, prosthesisTypeCode: string) {
|
||||||
if (!activeLabCase) return;
|
if (!activeLabCase) return;
|
||||||
const toothSet = new Set(row.teeth);
|
const toothSet = new Set(row.teeth);
|
||||||
@@ -225,19 +287,25 @@ export function LabCasesDispatchPanel({
|
|||||||
]
|
]
|
||||||
: rest;
|
: rest;
|
||||||
updateActiveLabCase({ toothProsthesis: next });
|
updateActiveLabCase({ toothProsthesis: next });
|
||||||
|
if (prosthesisTypeCode && activeLabCase.destinationOrganizationId) {
|
||||||
|
rememberLastProsthesisType(
|
||||||
|
clinicOrganizationId,
|
||||||
|
activeLabCase.destinationOrganizationId,
|
||||||
|
prosthesisTypeCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyProsthesisToAll(code: string) {
|
function applyProsthesisToAll(code: string) {
|
||||||
if (!activeLabCase || !code) return;
|
if (!activeLabCase || !code) return;
|
||||||
const next = prosthesisRows.flatMap((row) =>
|
updateActiveLabCase({ toothProsthesis: toothProsthesisForRows(prosthesisRows, code) });
|
||||||
row.teeth.map((tooth) => ({
|
if (activeLabCase.destinationOrganizationId) {
|
||||||
detailClientId: row.detailClientId,
|
rememberLastProsthesisType(
|
||||||
tooth,
|
clinicOrganizationId,
|
||||||
prosthesisTypeCode: code,
|
activeLabCase.destinationOrganizationId,
|
||||||
selectionGroupId: row.groupId,
|
code,
|
||||||
})),
|
|
||||||
);
|
);
|
||||||
updateActiveLabCase({ toothProsthesis: next });
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleAttachmentInActiveLabCase(attachmentId: string, checked: boolean) {
|
function toggleAttachmentInActiveLabCase(attachmentId: string, checked: boolean) {
|
||||||
@@ -249,17 +317,17 @@ export function LabCasesDispatchPanel({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleSelectOrganization(org: LinkedOrganizationOption) {
|
function handleSelectOrganization(org: LinkedOrganizationOption) {
|
||||||
|
autoFilledCaseRef.current = null;
|
||||||
updateActiveLabCase({
|
updateActiveLabCase({
|
||||||
destinationOrganizationId: org.id,
|
destinationOrganizationId: org.id,
|
||||||
toothProsthesis: [],
|
toothProsthesis: [],
|
||||||
});
|
});
|
||||||
setApplyAllProsthesis('');
|
setApplyAllProsthesis('');
|
||||||
|
rememberLastLab(clinicOrganizationId, org.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const caseFullyComplete = isLabCaseCompleted(activeLabCase?.taskProgress);
|
const caseFullyComplete = isLabCaseCompleted(activeLabCase?.taskProgress);
|
||||||
const canEditDueDate = canEdit && !disabled && (!sent || !caseFullyComplete);
|
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 canPostComments = canEdit && !caseFullyComplete;
|
||||||
const canShowComments = Boolean(activeLabCase?.id);
|
const canShowComments = Boolean(activeLabCase?.id);
|
||||||
const commentsDeferSubmit = Boolean(!sent);
|
const commentsDeferSubmit = Boolean(!sent);
|
||||||
@@ -314,67 +382,8 @@ export function LabCasesDispatchPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderIncludedDetailSummary() {
|
const typeLabel = treatmentTypeLabelFromCatalog(activeDetail.treatmentType, treatmentCatalog);
|
||||||
if (!activeDetail) return null;
|
const teethLabel = activeDetail.teeth.length ? [...activeDetail.teeth].sort().join(', ') : t('teethNone');
|
||||||
|
|
||||||
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 activeDetailAttachments = activeDetail.attachmentMetas ?? [];
|
const activeDetailAttachments = activeDetail.attachmentMetas ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -383,36 +392,16 @@ export function LabCasesDispatchPanel({
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h3 className="text-sm font-semibold text-text-primary">{t('labDispatchTitle')}</h3>
|
<h3 className="text-sm font-semibold text-text-primary">{t('labDispatchTitle')}</h3>
|
||||||
<p className="text-xs text-text-muted mt-0.5">
|
<p className="text-xs text-text-muted mt-0.5">
|
||||||
{t('labDispatchSubtitle')} {t('labDispatchSendHint')}
|
{typeLabel} · {teethLabel}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{detailAlreadyInShipment ? renderDueDateField() : null}
|
{activeLabCase ? renderDueDateField() : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activeLabCase ? (
|
{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">
|
<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 ? (
|
{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 ? (
|
{activeLabOrgName ? (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-text-secondary">{t('selectLab')}</p>
|
<p className="text-xs font-medium text-text-secondary">{t('selectLab')}</p>
|
||||||
@@ -431,30 +420,6 @@ export function LabCasesDispatchPanel({
|
|||||||
}}
|
}}
|
||||||
orgs={orgs}
|
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 ? (
|
{hasTrackerSummary && labCaseSummary ? (
|
||||||
<LabCaseTrackerCard
|
<LabCaseTrackerCard
|
||||||
@@ -469,15 +434,14 @@ export function LabCasesDispatchPanel({
|
|||||||
<DetailLabCaseCommentsSection
|
<DetailLabCaseCommentsSection
|
||||||
labCaseId={activeLabCase.id}
|
labCaseId={activeLabCase.id}
|
||||||
canPost={canPostComments}
|
canPost={canPostComments}
|
||||||
deferSubmit={commentsDeferSubmit}
|
|
||||||
composerValue={pendingComment}
|
|
||||||
onComposerValueChange={setPendingComment}
|
|
||||||
onError={onCommentError}
|
onError={onCommentError}
|
||||||
onMarkRead={onLabCaseMarkedRead}
|
onMarkRead={onLabCaseMarkedRead}
|
||||||
onActivityChange={onLabCaseActivityChange}
|
onActivityChange={onLabCaseActivityChange}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs font-medium text-text-secondary">{t('selectLab')}</p>
|
<p className="text-xs font-medium text-text-secondary">{t('selectLab')}</p>
|
||||||
<LinkedOrganizationSearchCombobox
|
<LinkedOrganizationSearchCombobox
|
||||||
@@ -499,7 +463,10 @@ export function LabCasesDispatchPanel({
|
|||||||
key={o.id}
|
key={o.id}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
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"
|
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}
|
{o.name}
|
||||||
@@ -514,7 +481,7 @@ export function LabCasesDispatchPanel({
|
|||||||
<p className="text-xs font-medium text-text-secondary">
|
<p className="text-xs font-medium text-text-secondary">
|
||||||
{t('prosthesisTypesTitle')}
|
{t('prosthesisTypesTitle')}
|
||||||
</p>
|
</p>
|
||||||
{flatToothCount > 1 && prosthesisRows.every((r) => r.kind === 'single') ? (
|
{flatToothCount > 1 ? (
|
||||||
<label className="block text-xs text-text-muted space-y-1">
|
<label className="block text-xs text-text-muted space-y-1">
|
||||||
{t('prosthesisApplyAll')}
|
{t('prosthesisApplyAll')}
|
||||||
<select
|
<select
|
||||||
@@ -593,6 +560,48 @@ export function LabCasesDispatchPanel({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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">
|
<div className="flex flex-wrap items-center gap-3 pt-1">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -606,7 +615,26 @@ export function LabCasesDispatchPanel({
|
|||||||
!prosthesisComplete
|
!prosthesisComplete
|
||||||
}
|
}
|
||||||
isLoading={sendBusyId === activeLabCase.clientId}
|
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')}
|
{t('sendToLab')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ export const ToothGlyph = memo(function ToothGlyph({
|
|||||||
<svg
|
<svg
|
||||||
viewBox={realistic.viewBox}
|
viewBox={realistic.viewBox}
|
||||||
preserveAspectRatio={preserveAspectRatio}
|
preserveAspectRatio={preserveAspectRatio}
|
||||||
className={`${className} shrink-0`}
|
className={`${className} shrink-0 pointer-events-none`}
|
||||||
style={svgStyle}
|
style={svgStyle}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
>
|
>
|
||||||
@@ -203,7 +203,7 @@ export const ToothGlyph = memo(function ToothGlyph({
|
|||||||
|
|
||||||
if (!model) {
|
if (!model) {
|
||||||
return (
|
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 x="4" y="40" fontSize="8" fill="currentColor" opacity="0.35">
|
||||||
?
|
?
|
||||||
</text>
|
</text>
|
||||||
@@ -228,7 +228,7 @@ export const ToothGlyph = memo(function ToothGlyph({
|
|||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 36 78"
|
viewBox="0 0 36 78"
|
||||||
className={`${className} shrink-0`}
|
className={`${className} shrink-0 pointer-events-none`}
|
||||||
style={svgStyle}
|
style={svgStyle}
|
||||||
aria-hidden
|
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';
|
'use client';
|
||||||
|
|
||||||
import { useRef } from 'react';
|
import { useEffect, useRef, type ReactNode, type RefObject } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { Trash2 } from 'lucide-react';
|
import { Trash2 } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { Dropdown } from '@/components/ui/shared/Dropdown';
|
import { Dropdown } from '@/components/ui/shared/Dropdown';
|
||||||
import {
|
import { formatDetailChipLabel } from '@/components/treatment/detailChipLabel';
|
||||||
autosaveStatusClass,
|
import { autosaveStatusClass, labBlockedBannerClass } from '@/components/treatment/treatmentStatusStyles';
|
||||||
labPendingBannerClass,
|
import { TreatmentDetailAttachmentsStrip } from '@/components/ui/treatment/TreatmentDetailAttachmentsStrip';
|
||||||
labSentBannerClass,
|
|
||||||
labBlockedBannerClass,
|
|
||||||
} from '@/components/treatment/treatmentStatusStyles';
|
|
||||||
import type { TreatmentDetailDraft } from '@/types/treatment';
|
import type { TreatmentDetailDraft } from '@/types/treatment';
|
||||||
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
import type { TreatmentCatalogEntry } from '@/types/treatment-catalog';
|
||||||
import { treatmentTypeColor } from '@/components/shared/treatmentTypeDisplay';
|
import { treatmentTypeColor, treatmentTypeOptionStyle } from '@/components/shared/treatmentTypeDisplay';
|
||||||
import {
|
import {
|
||||||
isDetailReadyForLabDispatch,
|
|
||||||
isDetailTypeSelected,
|
isDetailTypeSelected,
|
||||||
isLabDependentDetailMissingTeeth,
|
isLabDependentDetailMissingTeeth,
|
||||||
} from '@/components/treatment/treatmentDetailRules';
|
} from '@/components/treatment/treatmentDetailRules';
|
||||||
@@ -34,11 +30,21 @@ interface TreatmentDetailsEditorProps {
|
|||||||
uploadBusy: boolean;
|
uploadBusy: boolean;
|
||||||
onAddDetail: () => void;
|
onAddDetail: () => void;
|
||||||
onRemoveDetail?: (detailClientId: string) => 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). */
|
/** Detail chips + Add detail (default true). */
|
||||||
showChrome?: boolean;
|
showChrome?: boolean;
|
||||||
/** Type / notes / attachments fields (default true). */
|
/** Type / notes / attachments fields (default true). */
|
||||||
showFields?: boolean;
|
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({
|
export function TreatmentDetailsEditor({
|
||||||
@@ -56,32 +62,44 @@ export function TreatmentDetailsEditor({
|
|||||||
onAddDetail,
|
onAddDetail,
|
||||||
onRemoveDetail,
|
onRemoveDetail,
|
||||||
onUploadFiles,
|
onUploadFiles,
|
||||||
|
onRemoveAttachment,
|
||||||
showChrome = true,
|
showChrome = true,
|
||||||
showFields = true,
|
showFields = true,
|
||||||
|
chart,
|
||||||
|
stepper,
|
||||||
|
footer,
|
||||||
|
chartLocked = false,
|
||||||
|
chartLockMessage,
|
||||||
}: TreatmentDetailsEditorProps) {
|
}: TreatmentDetailsEditorProps) {
|
||||||
const t = useTranslations('treatment');
|
const t = useTranslations('treatment');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const attachmentInputRef = useRef<HTMLInputElement>(null);
|
const notesRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const activeDetail = details.find((d) => d.clientId === activeDetailId) ?? details[0];
|
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);
|
if (!showChrome && !showFields && !stepper) return null;
|
||||||
const readOnly = disabled || locked;
|
|
||||||
const treatmentTypeTextColor = isDetailTypeSelected(activeDetail)
|
const treatmentTypeTextColor =
|
||||||
|
activeDetail && isDetailTypeSelected(activeDetail)
|
||||||
? treatmentTypeColor(
|
? treatmentTypeColor(
|
||||||
activeDetail.treatmentType,
|
activeDetail.treatmentType,
|
||||||
treatmentCatalog.findIndex((e) => e.code === activeDetail.treatmentType),
|
treatmentCatalog.findIndex((e) => e.code === activeDetail.treatmentType),
|
||||||
)
|
)
|
||||||
: undefined;
|
: 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 (
|
return (
|
||||||
<div className="surface-card p-3 sm:p-4 space-y-4">
|
<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 className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-text-primary">{t('detailsTitle')}</h3>
|
<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>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -108,16 +125,20 @@ export function TreatmentDetailsEditor({
|
|||||||
{details.map((d, idx) => {
|
{details.map((d, idx) => {
|
||||||
const detailLocked = isDetailLocked(d);
|
const detailLocked = isDetailLocked(d);
|
||||||
const isActive = d.clientId === activeDetailId;
|
const isActive = d.clientId === activeDetailId;
|
||||||
// Same rules as the former Content-step delete button:
|
const showRemoveAction = Boolean(onRemoveDetail);
|
||||||
// only when more than one detail remains; disabled if no edit, day-locked, sent, or uploading.
|
const removeDisabled = !canEdit || disabled || detailLocked || uploadBusy;
|
||||||
const showRemoveAction = details.length > 1;
|
const chipLabel = formatDetailChipLabel(
|
||||||
const removeDisabled =
|
d,
|
||||||
!canEdit || disabled || detailLocked || uploadBusy;
|
treatmentCatalog,
|
||||||
|
t('detailLabel', { n: idx + 1 }),
|
||||||
|
);
|
||||||
|
const showLabStatus =
|
||||||
|
isDetailTypeSelected(d) && labDependentCodes.has(d.treatmentType);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={d.clientId}
|
key={d.clientId}
|
||||||
className={`
|
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
|
isActive
|
||||||
? 'border-primary bg-primary-soft'
|
? 'border-primary bg-primary-soft'
|
||||||
@@ -128,14 +149,25 @@ export function TreatmentDetailsEditor({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onActiveDetailChange(d.clientId)}
|
onClick={() => onActiveDetailChange(d.clientId)}
|
||||||
|
title={chipLabel}
|
||||||
className={`
|
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
|
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'}
|
${isActive ? 'font-medium text-text-primary' : 'text-text-secondary'}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{t('detailLabel', { n: idx + 1 })}
|
<span className="truncate">{chipLabel}</span>
|
||||||
{detailLocked ? ` · ${t('detailSentBadge')}` : ''}
|
{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>
|
</button>
|
||||||
{showRemoveAction ? (
|
{showRemoveAction ? (
|
||||||
<button
|
<button
|
||||||
@@ -169,114 +201,112 @@ export function TreatmentDetailsEditor({
|
|||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{showFields ? (
|
{stepper && activeDetail ? <div className="pt-1">{stepper}</div> : null}
|
||||||
<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>}
|
{showFields && activeDetail ? (
|
||||||
{showPendingLabHint && (
|
<div className="space-y-3">
|
||||||
<p className={labPendingBannerClass}>{t('detailPendingLabSend')}</p>
|
|
||||||
)}
|
|
||||||
{showMissingTeethLabBlock && (
|
{showMissingTeethLabBlock && (
|
||||||
<p className={labBlockedBannerClass}>{t('labShipmentBlockedBody')}</p>
|
<p className={labBlockedBannerClass}>{t('labShipmentBlockedBody')}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:items-end">
|
||||||
<Dropdown
|
<Dropdown
|
||||||
label={t('treatmentType')}
|
label={t('treatmentType')}
|
||||||
value={activeDetail.treatmentType}
|
value={activeDetail.treatmentType}
|
||||||
onChange={(e) => {
|
onChange={(e) => setActiveType(e.target.value)}
|
||||||
const nextType = e.target.value as TreatmentDetailDraft['treatmentType'];
|
|
||||||
onDetailsChange(
|
|
||||||
details.map((d) =>
|
|
||||||
d.clientId === activeDetailId ? { ...d, treatmentType: nextType } : d,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
style={{ color: treatmentTypeTextColor }}
|
style={{ color: treatmentTypeTextColor }}
|
||||||
>
|
>
|
||||||
<option value="">{t('treatmentTypePlaceholder')}</option>
|
<option value="">{t('treatmentTypePlaceholder')}</option>
|
||||||
{treatmentCatalog.map((entry, index) => (
|
{treatmentCatalog.map((entry, index) => (
|
||||||
<option
|
<option key={entry.code} value={entry.code} style={treatmentTypeOptionStyle(entry.code, index)}>
|
||||||
key={entry.code}
|
|
||||||
value={entry.code}
|
|
||||||
style={{
|
|
||||||
color: treatmentTypeColor(entry.code, index),
|
|
||||||
backgroundColor: '#14253d',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{entry.label}
|
{entry.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
<TreatmentDetailAttachmentsStrip
|
||||||
|
attachments={activeDetail.attachmentMetas}
|
||||||
|
disabled={readOnly}
|
||||||
|
uploadBusy={uploadBusy}
|
||||||
|
onUploadFiles={onUploadFiles}
|
||||||
|
onRemoveAttachment={readOnly ? undefined : onRemoveAttachment}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="block text-xs font-medium text-text-secondary">
|
{chart ? (
|
||||||
{t('comments')}
|
<div className="relative min-w-0 w-full">
|
||||||
<textarea
|
{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}
|
value={activeDetail.comment}
|
||||||
onChange={(e) => {
|
disabled={readOnly}
|
||||||
const v = e.target.value;
|
label={t('comments')}
|
||||||
|
placeholder={t('commentsPlaceholder')}
|
||||||
|
onChange={(v) => {
|
||||||
onDetailsChange(
|
onDetailsChange(
|
||||||
details.map((d) => (d.clientId === activeDetailId ? { ...d, comment: v } : d)),
|
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>
|
{canEdit && saveStatus !== 'idle' && (
|
||||||
<p className="text-xs font-medium text-text-secondary mb-2">{t('attachments')}</p>
|
<p className={`text-xs ${autosaveStatusClass(saveStatus)}`} role="status" aria-live="polite">
|
||||||
<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"
|
|
||||||
>
|
|
||||||
{saveStatus === 'dirty' && t('unsavedChanges')}
|
{saveStatus === 'dirty' && t('unsavedChanges')}
|
||||||
{saveStatus === 'saving' && t('saveStatusSaving')}
|
{saveStatus === 'saving' && t('saveStatusSaving')}
|
||||||
{saveStatus === 'saved' && t('saveStatusSaved')}
|
{saveStatus === 'saved' && t('saveStatusSaved')}
|
||||||
{saveStatus === 'error' && t('saveStatusError')}
|
{saveStatus === 'error' && t('saveStatusError')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{footer ? <div className="pt-1">{footer}</div> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</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 { useLocale, useTranslations } from 'next-intl';
|
||||||
import { useRouter } from '@/i18n/navigation';
|
import { useRouter } from '@/i18n/navigation';
|
||||||
import { Button } from '@/components/ui/shared/Button';
|
import { Button } from '@/components/ui/shared/Button';
|
||||||
import { Checkbox } from '@/components/ui/shared/Checkbox';
|
|
||||||
import { WizardStepper } from '@/components/ui/shared/WizardStepper';
|
import { WizardStepper } from '@/components/ui/shared/WizardStepper';
|
||||||
import { PatientSearchCombobox } from '@/components/ui/patient/PatientSearchCombobox';
|
import { PatientSearchCombobox } from '@/components/ui/patient/PatientSearchCombobox';
|
||||||
import {
|
import {
|
||||||
@@ -39,8 +38,10 @@ import {
|
|||||||
areDetailsPersistable,
|
areDetailsPersistable,
|
||||||
defaultTreatmentTypeForAppointment,
|
defaultTreatmentTypeForAppointment,
|
||||||
isDetailReadyForLabDispatch,
|
isDetailReadyForLabDispatch,
|
||||||
|
isDetailTypeSelected,
|
||||||
isEmptyDraftDetail,
|
isEmptyDraftDetail,
|
||||||
isLabDependentDetailMissingTeeth,
|
isLabDependentDetailMissingTeeth,
|
||||||
|
areUnscheduledDetailsStripDeletable,
|
||||||
} from '@/components/treatment/treatmentDetailRules';
|
} from '@/components/treatment/treatmentDetailRules';
|
||||||
import {
|
import {
|
||||||
applyShiftRange,
|
applyShiftRange,
|
||||||
@@ -56,6 +57,7 @@ import {
|
|||||||
} from '@/components/treatment/toothSelectionGroups';
|
} from '@/components/treatment/toothSelectionGroups';
|
||||||
import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention';
|
import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention';
|
||||||
import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention';
|
import { collectLabDispatchAttention } from '@/components/treatment/labDispatchAttention';
|
||||||
|
import { loadLabDispatchDefaults, rememberLastLab } from '@/components/treatment/labDispatchDefaults';
|
||||||
import { canEditTreatment, canViewTreatment, canAccessDashboardRoute } from '@/components/shared/permissions';
|
import { canEditTreatment, canViewTreatment, canAccessDashboardRoute } from '@/components/shared/permissions';
|
||||||
import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithinMain';
|
import { scrollWithinMainScrollContainer } from '@/components/shared/scrollWithinMain';
|
||||||
import { useMarkTabReadOnVisit, useTabBadgeCounts } from '@/lib/hooks/useTabBadgeCounts';
|
import { useMarkTabReadOnVisit, useTabBadgeCounts } from '@/lib/hooks/useTabBadgeCounts';
|
||||||
@@ -77,15 +79,14 @@ import type {
|
|||||||
PastLabCase,
|
PastLabCase,
|
||||||
PastTreatment,
|
PastTreatment,
|
||||||
PastTreatmentCase,
|
PastTreatmentCase,
|
||||||
|
PastTreatmentDetail,
|
||||||
TreatmentAppointment,
|
TreatmentAppointment,
|
||||||
TreatmentDetailDraft,
|
TreatmentDetailDraft,
|
||||||
} from '@/types/treatment';
|
} from '@/types/treatment';
|
||||||
import type { PatientLabCaseSummary } from '@/types/lab-case-activity';
|
import type { PatientLabCaseSummary } from '@/types/lab-case-activity';
|
||||||
|
|
||||||
type WorkspaceMode = 'live' | 'historical';
|
type WorkspaceMode = 'live' | 'historical';
|
||||||
type EntryStep = 'teeth' | 'content' | 'lab';
|
type EntryStep = 'treatment' | 'lab';
|
||||||
|
|
||||||
const ENTRY_STEPS: EntryStep[] = ['teeth', 'content', 'lab'];
|
|
||||||
|
|
||||||
function isTreatmentDayHistorical(treatmentAt: string, todayStart: Date): boolean {
|
function isTreatmentDayHistorical(treatmentAt: string, todayStart: Date): boolean {
|
||||||
return compareLocalDayStart(new Date(treatmentAt), todayStart) < 0;
|
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 {
|
function mapLabCaseDraftFromApi(lc: PastLabCase): LabCaseDraft {
|
||||||
return {
|
return {
|
||||||
clientId: lc.clientId,
|
clientId: lc.clientId,
|
||||||
@@ -419,7 +436,7 @@ export function TreatmentWorkspace({
|
|||||||
const pendingLabCaseIdRef = useRef<string | null>(initialLabCaseId);
|
const pendingLabCaseIdRef = useRef<string | null>(initialLabCaseId);
|
||||||
const labPanelRef = useRef<HTMLDivElement>(null);
|
const labPanelRef = useRef<HTMLDivElement>(null);
|
||||||
const historyRequestRef = useRef(0);
|
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);
|
const pendingEntryStepRef = useRef<EntryStep | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -439,7 +456,7 @@ export function TreatmentWorkspace({
|
|||||||
const [organizationSearch, setOrganizationSearch] = useState('');
|
const [organizationSearch, setOrganizationSearch] = useState('');
|
||||||
const [recentOrganizationIds, setRecentOrganizationIds] = useState<string[]>([]);
|
const [recentOrganizationIds, setRecentOrganizationIds] = useState<string[]>([]);
|
||||||
const [showWholeTreatmentPlan, setShowWholeTreatmentPlan] = useState(false);
|
const [showWholeTreatmentPlan, setShowWholeTreatmentPlan] = useState(false);
|
||||||
const [entryStep, setEntryStep] = useState<EntryStep>('teeth');
|
const [entryStep, setEntryStep] = useState<EntryStep>('treatment');
|
||||||
|
|
||||||
const isDetailLocked = useCallback(
|
const isDetailLocked = useCallback(
|
||||||
(detail: TreatmentDetailDraft) =>
|
(detail: TreatmentDetailDraft) =>
|
||||||
@@ -560,20 +577,12 @@ export function TreatmentWorkspace({
|
|||||||
[details, labDependentCodes],
|
[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(
|
const showLabWizardStep = useMemo(
|
||||||
() => Boolean(activeDetail && labDependentCodes.has(activeDetail.treatmentType)),
|
() => Boolean(activeDetail && labDependentCodes.has(activeDetail.treatmentType)),
|
||||||
[activeDetail, labDependentCodes],
|
[activeDetail, labDependentCodes],
|
||||||
);
|
);
|
||||||
|
|
||||||
const visibleEntrySteps = useMemo(
|
|
||||||
() =>
|
|
||||||
showLabWizardStep
|
|
||||||
? ENTRY_STEPS
|
|
||||||
: (ENTRY_STEPS.filter((step) => step !== 'lab') as EntryStep[]),
|
|
||||||
[showLabWizardStep],
|
|
||||||
);
|
|
||||||
|
|
||||||
const showLabShipmentBlocked = useMemo(
|
const showLabShipmentBlocked = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Boolean(
|
Boolean(
|
||||||
@@ -582,6 +591,30 @@ export function TreatmentWorkspace({
|
|||||||
[activeDetail, labDependentCodes],
|
[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 dayStripItems = useMemo<DayStripItem[]>(() => {
|
||||||
const timed: DayStripItem[] = appointments.map((a) => ({
|
const timed: DayStripItem[] = appointments.map((a) => ({
|
||||||
kind: 'appointment',
|
kind: 'appointment',
|
||||||
@@ -596,7 +629,11 @@ export function TreatmentWorkspace({
|
|||||||
}));
|
}));
|
||||||
const unscheduled: DayStripItem[] = standaloneTreatments.map((tr) => {
|
const unscheduled: DayStripItem[] = standaloneTreatments.map((tr) => {
|
||||||
const isWalkIn = Boolean(tr.patient?.isWalkIn);
|
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 {
|
return {
|
||||||
kind: 'unscheduled' as const,
|
kind: 'unscheduled' as const,
|
||||||
id: tr.id,
|
id: tr.id,
|
||||||
@@ -607,11 +644,19 @@ export function TreatmentWorkspace({
|
|||||||
colorCode,
|
colorCode,
|
||||||
timeLabel: null,
|
timeLabel: null,
|
||||||
subtitle: t('noAppointment'),
|
subtitle: t('noAppointment'),
|
||||||
canDelete: tr.details.length === 0,
|
canDelete: areUnscheduledDetailsStripDeletable(sourceDetails),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return [...timed, ...unscheduled];
|
return [...timed, ...unscheduled];
|
||||||
}, [appointments, standaloneTreatments, locale, treatmentCatalog, t]);
|
}, [
|
||||||
|
appointments,
|
||||||
|
standaloneTreatments,
|
||||||
|
selectedStandaloneId,
|
||||||
|
details,
|
||||||
|
locale,
|
||||||
|
treatmentCatalog,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
const selectedStripItemId = selectedAppointmentId ?? selectedStandaloneId;
|
const selectedStripItemId = selectedAppointmentId ?? selectedStandaloneId;
|
||||||
|
|
||||||
@@ -681,16 +726,21 @@ export function TreatmentWorkspace({
|
|||||||
[labDependentCodes, currentDraftPreview, history, selectedAppointmentId],
|
[labDependentCodes, currentDraftPreview, history, selectedAppointmentId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const hydrateFromTreatment = useCallback((treatment: PastTreatment) => {
|
const hydrateFromTreatment = useCallback((
|
||||||
|
treatment: PastTreatment,
|
||||||
|
options?: { seedBlankIfEmpty?: boolean },
|
||||||
|
) => {
|
||||||
const mapped = treatment.details.map(mapDetailFromApi);
|
const mapped = treatment.details.map(mapDetailFromApi);
|
||||||
const nextDetails =
|
const nextDetails =
|
||||||
mapped.length > 0
|
mapped.length > 0
|
||||||
? mapped
|
? mapped
|
||||||
: [newDetail(defaultTreatmentTypeForAppointment(undefined, treatmentCatalog))];
|
: options?.seedBlankIfEmpty
|
||||||
|
? [newDetail(defaultTreatmentTypeForAppointment(undefined, treatmentCatalog))]
|
||||||
|
: [];
|
||||||
setDetails(nextDetails);
|
setDetails(nextDetails);
|
||||||
setActiveDetailId((prev) => {
|
setActiveDetailId((prev) => {
|
||||||
const stillExists = nextDetails.some((d) => d.clientId === prev);
|
const stillExists = nextDetails.some((d) => d.clientId === prev);
|
||||||
return stillExists ? prev : nextDetails[0].clientId;
|
return stillExists ? prev : (nextDetails[0]?.clientId ?? '');
|
||||||
});
|
});
|
||||||
setSavedSnapshot(serializeDetails(nextDetails));
|
setSavedSnapshot(serializeDetails(nextDetails));
|
||||||
const mappedLabCases = withoutEmptyLabCaseDrafts(
|
const mappedLabCases = withoutEmptyLabCaseDrafts(
|
||||||
@@ -734,23 +784,34 @@ export function TreatmentWorkspace({
|
|||||||
return colors;
|
return colors;
|
||||||
}, [details, treatmentCatalog]);
|
}, [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 chartSelectedTeeth = showWholeTreatmentPlan ? wholePlanTeethSet : selectedTeethSet;
|
||||||
const chartToothColors = showWholeTreatmentPlan ? wholePlanToothColors : undefined;
|
const chartToothColors = showWholeTreatmentPlan ? wholePlanToothColors : undefined;
|
||||||
|
const chartLinkedEdges = showWholeTreatmentPlan ? wholePlanLinkedEdges : linkedToothEdges;
|
||||||
|
|
||||||
// Reset whole-plan overview when switching details.
|
// 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(() => {
|
useEffect(() => {
|
||||||
setShowWholeTreatmentPlan(false);
|
setShowWholeTreatmentPlan(false);
|
||||||
rangeAnchorRef.current = null;
|
rangeAnchorRef.current = null;
|
||||||
const pending = pendingEntryStepRef.current;
|
const pending = pendingEntryStepRef.current;
|
||||||
pendingEntryStepRef.current = null;
|
pendingEntryStepRef.current = null;
|
||||||
setEntryStep(pending ?? 'teeth');
|
setEntryStep(pending ?? 'treatment');
|
||||||
}, [activeDetailId]);
|
}, [activeDetailId]);
|
||||||
|
|
||||||
// Leave Lab step if the active detail is no longer prosthesis / lab-dependent.
|
// Leave Lab step if the active detail is no longer prosthesis / lab-dependent.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (entryStep === 'lab' && !showLabWizardStep) {
|
if (entryStep === 'lab' && !showLabWizardStep) {
|
||||||
setEntryStep('content');
|
setEntryStep('treatment');
|
||||||
}
|
}
|
||||||
}, [entryStep, showLabWizardStep]);
|
}, [entryStep, showLabWizardStep]);
|
||||||
|
|
||||||
@@ -849,6 +910,12 @@ export function TreatmentWorkspace({
|
|||||||
setLabDependentCodes(
|
setLabDependentCodes(
|
||||||
new Set(catalogResponse.data.filter((entry) => entry.labDependent).map((entry) => entry.code)),
|
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) {
|
} catch (error: unknown) {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
showError(getUserFacingError(error, tErrors, t('errorLoadOrgs')));
|
showError(getUserFacingError(error, tErrors, t('errorLoadOrgs')));
|
||||||
@@ -858,7 +925,7 @@ export function TreatmentWorkspace({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [showError, t]);
|
}, [showError, t, currentOrganization?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activePatientId) {
|
if (!activePatientId) {
|
||||||
@@ -915,6 +982,19 @@ export function TreatmentWorkspace({
|
|||||||
setUnreadLabCases((prev) => prev.filter((item) => item.labCaseId !== labCaseId));
|
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(() => {
|
useEffect(() => {
|
||||||
void refreshUnreadLabCases();
|
void refreshUnreadLabCases();
|
||||||
}, [refreshUnreadLabCases]);
|
}, [refreshUnreadLabCases]);
|
||||||
@@ -1003,6 +1083,10 @@ export function TreatmentWorkspace({
|
|||||||
return stillExists ? prev : mapped[0].clientId;
|
return stillExists ? prev : mapped[0].clientId;
|
||||||
});
|
});
|
||||||
setSavedSnapshot(serializeDetails(mapped));
|
setSavedSnapshot(serializeDetails(mapped));
|
||||||
|
} else if (response.data) {
|
||||||
|
setDetails([]);
|
||||||
|
setActiveDetailId('');
|
||||||
|
setSavedSnapshot(serializeDetails([]));
|
||||||
} else {
|
} else {
|
||||||
const first = newDetail(
|
const first = newDetail(
|
||||||
defaultTreatmentTypeForAppointment(selectedAppointment?.purpose, treatmentCatalog),
|
defaultTreatmentTypeForAppointment(selectedAppointment?.purpose, treatmentCatalog),
|
||||||
@@ -1101,7 +1185,7 @@ export function TreatmentWorkspace({
|
|||||||
setDetails(mapped);
|
setDetails(mapped);
|
||||||
setActiveDetailId((prev) => {
|
setActiveDetailId((prev) => {
|
||||||
const stillExists = mapped.some((d) => d.clientId === 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));
|
setSavedSnapshot(serializeDetails(mapped));
|
||||||
} else {
|
} else {
|
||||||
@@ -1110,7 +1194,7 @@ export function TreatmentWorkspace({
|
|||||||
setDetails(merged);
|
setDetails(merged);
|
||||||
setActiveDetailId((prev) => {
|
setActiveDetailId((prev) => {
|
||||||
const stillExists = merged.some((d) => d.clientId === 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.
|
// Keep dirty so the queued autosave persists the newer local state.
|
||||||
}
|
}
|
||||||
@@ -1229,6 +1313,7 @@ export function TreatmentWorkspace({
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
const ok = await flushDraftSave();
|
const ok = await flushDraftSave();
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
draftHydratingRef.current = true;
|
||||||
resetToLiveContext();
|
resetToLiveContext();
|
||||||
setSearchedPatient(null);
|
setSearchedPatient(null);
|
||||||
setSelectionLocked(true);
|
setSelectionLocked(true);
|
||||||
@@ -1244,6 +1329,7 @@ export function TreatmentWorkspace({
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
const ok = await flushDraftSave();
|
const ok = await flushDraftSave();
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
draftHydratingRef.current = true;
|
||||||
resetToLiveContext();
|
resetToLiveContext();
|
||||||
setSearchedPatient(null);
|
setSearchedPatient(null);
|
||||||
setSelectionLocked(true);
|
setSelectionLocked(true);
|
||||||
@@ -1281,7 +1367,7 @@ export function TreatmentWorkspace({
|
|||||||
);
|
);
|
||||||
skipNextGetDraftRef.current = true;
|
skipNextGetDraftRef.current = true;
|
||||||
draftHydratingRef.current = true;
|
draftHydratingRef.current = true;
|
||||||
hydrateFromTreatment(created.data);
|
hydrateFromTreatment(created.data, { seedBlankIfEmpty: true });
|
||||||
draftHydratingRef.current = false;
|
draftHydratingRef.current = false;
|
||||||
if (created.data.patientId && !created.data.patient?.isWalkIn) {
|
if (created.data.patientId && !created.data.patient?.isWalkIn) {
|
||||||
await refreshHistory(created.data.patientId);
|
await refreshHistory(created.data.patientId);
|
||||||
@@ -1313,15 +1399,28 @@ export function TreatmentWorkspace({
|
|||||||
clearTimeout(autosaveTimerRef.current);
|
clearTimeout(autosaveTimerRef.current);
|
||||||
autosaveTimerRef.current = null;
|
autosaveTimerRef.current = null;
|
||||||
}
|
}
|
||||||
|
const wasSelected = selectedStandaloneId === item.id;
|
||||||
|
if (wasSelected) {
|
||||||
|
draftHydratingRef.current = true;
|
||||||
|
}
|
||||||
try {
|
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);
|
await treatmentsApi.deleteStandalone(item.id);
|
||||||
setStandaloneTreatments((prev) => prev.filter((row) => row.id !== item.id));
|
setStandaloneTreatments((prev) => prev.filter((row) => row.id !== item.id));
|
||||||
if (selectedStandaloneId === item.id) {
|
if (wasSelected) {
|
||||||
resetToLiveContext();
|
resetToLiveContext();
|
||||||
setSelectedStandaloneId(null);
|
setSelectedStandaloneId(null);
|
||||||
setSelectedAppointmentId(null);
|
setSelectedAppointmentId(null);
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
if (wasSelected) {
|
||||||
|
draftHydratingRef.current = false;
|
||||||
|
}
|
||||||
showError(getUserFacingError(error, tErrors, t('errorDeleteTreatment')));
|
showError(getUserFacingError(error, tErrors, t('errorDeleteTreatment')));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1522,19 +1621,6 @@ export function TreatmentWorkspace({
|
|||||||
[exitBrowse, history, historyPanelItems, labCaseDrafts, loadTreatmentIntoWorkspace],
|
[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(
|
const handleSelectPatientLabCase = useCallback(
|
||||||
(item: PatientLabCaseSummary) => {
|
(item: PatientLabCaseSummary) => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
@@ -1635,7 +1721,11 @@ export function TreatmentWorkspace({
|
|||||||
}, [activeDetailId, patientLabCases]);
|
}, [activeDetailId, patientLabCases]);
|
||||||
|
|
||||||
const uploadForDetail = useCallback(
|
const uploadForDetail = useCallback(
|
||||||
async (detailClientId: string, files: FileList | File[]) => {
|
async (
|
||||||
|
detailClientId: string,
|
||||||
|
files: FileList | File[],
|
||||||
|
onProgress?: (percent: number) => void,
|
||||||
|
) => {
|
||||||
if (!canEditTreatmentForDay || !hasLiveContext) return;
|
if (!canEditTreatmentForDay || !hasLiveContext) return;
|
||||||
const target = detailsRef.current.find((d) => d.clientId === detailClientId);
|
const target = detailsRef.current.find((d) => d.clientId === detailClientId);
|
||||||
if (target && isDetailLocked(target)) return;
|
if (target && isDetailLocked(target)) return;
|
||||||
@@ -1645,15 +1735,17 @@ export function TreatmentWorkspace({
|
|||||||
setUploadBusyDetailId(detailClientId);
|
setUploadBusyDetailId(detailClientId);
|
||||||
try {
|
try {
|
||||||
const uploaded = selectedAppointment
|
const uploaded = selectedAppointment
|
||||||
? await treatmentsApi.uploadCaseAttachments(
|
? await treatmentsApi.uploadDetailAttachments(
|
||||||
selectedAppointment.id,
|
selectedAppointment.id,
|
||||||
detailClientId,
|
detailClientId,
|
||||||
list,
|
list,
|
||||||
|
onProgress,
|
||||||
)
|
)
|
||||||
: await treatmentsApi.uploadDetailAttachmentsByTreatment(
|
: await treatmentsApi.uploadDetailAttachmentsByTreatment(
|
||||||
selectedStandalone!.id,
|
selectedStandalone!.id,
|
||||||
detailClientId,
|
detailClientId,
|
||||||
list,
|
list,
|
||||||
|
onProgress,
|
||||||
);
|
);
|
||||||
setDetails((prev) =>
|
setDetails((prev) =>
|
||||||
prev.map((d) =>
|
prev.map((d) =>
|
||||||
@@ -1790,20 +1882,20 @@ export function TreatmentWorkspace({
|
|||||||
const idx = details.findIndex((d) => d.clientId === detailClientId);
|
const idx = details.findIndex((d) => d.clientId === detailClientId);
|
||||||
if (idx < 0) return;
|
if (idx < 0) return;
|
||||||
const target = details[idx];
|
const target = details[idx];
|
||||||
if (!target || isDetailLocked(target) || details.length <= 1) return;
|
if (!target || isDetailLocked(target)) return;
|
||||||
if (!window.confirm(t('confirmRemoveDetail'))) return;
|
if (!window.confirm(t('confirmRemoveDetail'))) return;
|
||||||
|
|
||||||
const nextDetails = details.filter((d) => d.clientId !== detailClientId);
|
const nextDetails = details.filter((d) => d.clientId !== detailClientId);
|
||||||
const nextActive =
|
const nextActive =
|
||||||
activeDetailId === detailClientId
|
activeDetailId === detailClientId
|
||||||
? (nextDetails[Math.min(idx, nextDetails.length - 1)]?.clientId ??
|
? (nextDetails[Math.min(idx, nextDetails.length - 1)]?.clientId ?? '')
|
||||||
nextDetails[0]?.clientId)
|
|
||||||
: activeDetailId;
|
: activeDetailId;
|
||||||
|
|
||||||
// Sync ref before any persist triggered by lab-case cleanup (same tick).
|
// Sync ref before any persist triggered by lab-case cleanup (same tick).
|
||||||
detailsRef.current = nextDetails;
|
detailsRef.current = nextDetails;
|
||||||
setDetails(nextDetails);
|
setDetails(nextDetails);
|
||||||
if (nextActive) setActiveDetailId(nextActive);
|
setActiveDetailId(nextActive);
|
||||||
|
if (nextDetails.length === 0) setEntryStep('treatment');
|
||||||
|
|
||||||
if (labCaseDrafts.some((lc) => lc.detailClientId === detailClientId)) {
|
if (labCaseDrafts.some((lc) => lc.detailClientId === detailClientId)) {
|
||||||
handleLabCasesChange(
|
handleLabCasesChange(
|
||||||
@@ -1820,6 +1912,7 @@ export function TreatmentWorkspace({
|
|||||||
handleLabCasesChange,
|
handleLabCasesChange,
|
||||||
isDetailLocked,
|
isDetailLocked,
|
||||||
labCaseDrafts,
|
labCaseDrafts,
|
||||||
|
setEntryStep,
|
||||||
t,
|
t,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -1866,9 +1959,15 @@ export function TreatmentWorkspace({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lastLabId = loadLabDispatchDefaults(currentOrganization?.id).lastLabId;
|
||||||
|
const lastLabStillActive = lastLabId
|
||||||
|
? orgs.some((o) => o.id === lastLabId && o.active)
|
||||||
|
: false;
|
||||||
const next: LabCaseDraft = {
|
const next: LabCaseDraft = {
|
||||||
...newLabCaseDraft(),
|
...newLabCaseDraft(),
|
||||||
detailClientId: shouldIncludeActive ? activeDetailId : null,
|
detailClientId: shouldIncludeActive ? activeDetailId : null,
|
||||||
|
destinationOrganizationId: lastLabStillActive ? lastLabId! : null,
|
||||||
|
attachmentIds: activeDetail?.attachmentMetas.map((a) => a.id) ?? [],
|
||||||
};
|
};
|
||||||
const updatedLabCases = [...cleaned, next];
|
const updatedLabCases = [...cleaned, next];
|
||||||
setLabCaseDrafts(updatedLabCases);
|
setLabCaseDrafts(updatedLabCases);
|
||||||
@@ -1883,14 +1982,17 @@ export function TreatmentWorkspace({
|
|||||||
}, [
|
}, [
|
||||||
activeDetailId,
|
activeDetailId,
|
||||||
canEditTreatmentForDay,
|
canEditTreatmentForDay,
|
||||||
|
currentOrganization?.id,
|
||||||
details,
|
details,
|
||||||
labCaseDrafts,
|
labCaseDrafts,
|
||||||
labDependentCodes,
|
labDependentCodes,
|
||||||
|
orgs,
|
||||||
persistDraft,
|
persistDraft,
|
||||||
persistLabCases,
|
persistLabCases,
|
||||||
selectedAppointment,
|
selectedAppointment,
|
||||||
showError,
|
showError,
|
||||||
t,
|
t,
|
||||||
|
tErrors,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Auto-open shipment draft when entering Lab (no manual "Add lab shipment" click).
|
// Auto-open shipment draft when entering Lab (no manual "Add lab shipment" click).
|
||||||
@@ -1964,7 +2066,7 @@ export function TreatmentWorkspace({
|
|||||||
setDetails(mapped);
|
setDetails(mapped);
|
||||||
setActiveDetailId((prev) => {
|
setActiveDetailId((prev) => {
|
||||||
const stillExists = mapped.some((d) => d.clientId === 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));
|
setSavedSnapshot(serializeDetails(mapped));
|
||||||
} else {
|
} else {
|
||||||
@@ -2004,6 +2106,7 @@ export function TreatmentWorkspace({
|
|||||||
|
|
||||||
setRecentOrganizationIds((prev) => {
|
setRecentOrganizationIds((prev) => {
|
||||||
const orgId = labCase.destinationOrganizationId!;
|
const orgId = labCase.destinationOrganizationId!;
|
||||||
|
rememberLastLab(currentOrganization?.id, orgId);
|
||||||
return [orgId, ...prev.filter((id) => id !== orgId)].slice(0, 10);
|
return [orgId, ...prev.filter((id) => id !== orgId)].slice(0, 10);
|
||||||
});
|
});
|
||||||
showSuccess(t('successCaseSent'));
|
showSuccess(t('successCaseSent'));
|
||||||
@@ -2030,6 +2133,7 @@ export function TreatmentWorkspace({
|
|||||||
showError,
|
showError,
|
||||||
t,
|
t,
|
||||||
tErrors,
|
tErrors,
|
||||||
|
currentOrganization?.id,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2046,9 +2150,9 @@ export function TreatmentWorkspace({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<header className="space-y-1">
|
<header className="space-y-1">
|
||||||
<h1 className="text-xl sm:text-2xl font-semibold text-text-primary">{t('title')}</h1>
|
<h1 className="text-xl sm:text-2xl font-semibold text-text-primary">{t('title')}</h1>
|
||||||
<p className="text-sm text-text-secondary">
|
{!canEdit ? (
|
||||||
{canEdit ? t('subtitleEditPhase4') : t('subtitleReadOnly')}
|
<p className="text-sm text-text-secondary">{t('subtitleReadOnly')}</p>
|
||||||
</p>
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<AppointmentsStrip
|
<AppointmentsStrip
|
||||||
@@ -2132,16 +2236,6 @@ export function TreatmentWorkspace({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{workspaceMode === 'live' && !isBrowsing && hasLiveContext ? (
|
|
||||||
<TreatmentPreviewCard
|
|
||||||
treatment={currentDraftPreview}
|
|
||||||
heading={t('previewCurrentDraft')}
|
|
||||||
labDependentCodes={labDependentCodes}
|
|
||||||
treatmentCatalog={treatmentCatalog}
|
|
||||||
orgs={orgs}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{isBrowsing && previewTreatment ? (
|
{isBrowsing && previewTreatment ? (
|
||||||
<>
|
<>
|
||||||
<div className="rounded-[var(--radius-md)] border border-primary/40 bg-primary/5 px-3 py-2 space-y-2">
|
<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]);
|
setDetails((prev) => [...prev, next]);
|
||||||
setActiveDetailId(next.clientId);
|
setActiveDetailId(next.clientId);
|
||||||
setEntryStep('teeth');
|
setEntryStep('treatment');
|
||||||
}}
|
}}
|
||||||
onRemoveDetail={handleRemoveDetail}
|
onRemoveDetail={handleRemoveDetail}
|
||||||
onUploadFiles={(files) => void uploadForDetail(activeDetailId, files ?? [])}
|
onUploadFiles={(files, onProgress) =>
|
||||||
showChrome
|
uploadForDetail(activeDetailId, files, onProgress)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
onClick={() => {
|
onRemoveAttachment={(attachmentId) => {
|
||||||
if (entryStep === 'teeth') setEntryStep('content');
|
setDetails((prev) =>
|
||||||
else if (entryStep === 'content' && showLabWizardStep) setEntryStep('lab');
|
prev.map((d) =>
|
||||||
|
d.clientId === activeDetailId
|
||||||
|
? {
|
||||||
|
...d,
|
||||||
|
attachmentMetas: d.attachmentMetas.filter((a) => a.id !== attachmentId),
|
||||||
|
}
|
||||||
|
: d,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setLabCaseDrafts((prev) =>
|
||||||
|
prev.map((lc) =>
|
||||||
|
lc.detailClientId === activeDetailId && !lc.sentAt
|
||||||
|
? {
|
||||||
|
...lc,
|
||||||
|
attachmentIds: lc.attachmentIds.filter((id) => id !== attachmentId),
|
||||||
|
}
|
||||||
|
: lc,
|
||||||
|
),
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
>
|
showChrome
|
||||||
{t('entryStepNext')}
|
showFields={entryStep === 'treatment'}
|
||||||
</Button>
|
chartLocked={
|
||||||
</div>
|
entryStep === 'treatment' && !activeTypeSelected && !showWholeTreatmentPlan
|
||||||
</div>
|
}
|
||||||
|
chartLockMessage={t('selectTypeBeforeTeeth')}
|
||||||
{entryStep === 'teeth' ? (
|
chart={
|
||||||
|
entryStep === 'treatment' ? (
|
||||||
<FdiToothChart
|
<FdiToothChart
|
||||||
|
className="w-full"
|
||||||
selected={chartSelectedTeeth}
|
selected={chartSelectedTeeth}
|
||||||
linkedEdges={showWholeTreatmentPlan ? undefined : linkedToothEdges}
|
linkedEdges={chartLinkedEdges}
|
||||||
connectedTeeth={showWholeTreatmentPlan ? undefined : connectedSelectedTeeth}
|
connectedTeeth={showWholeTreatmentPlan ? undefined : connectedSelectedTeeth}
|
||||||
toothColors={chartToothColors}
|
toothColors={chartToothColors}
|
||||||
readOnly={showWholeTreatmentPlan}
|
readOnly={showWholeTreatmentPlan}
|
||||||
headerControl={
|
headerControl={
|
||||||
details.length > 1 ? (
|
<button
|
||||||
<Checkbox
|
type="button"
|
||||||
checked={showWholeTreatmentPlan}
|
onClick={() => setShowWholeTreatmentPlan((open) => !open)}
|
||||||
onChange={setShowWholeTreatmentPlan}
|
aria-pressed={showWholeTreatmentPlan}
|
||||||
label={t('toothChartWholePlan')}
|
className={`
|
||||||
className="text-[11px] [&_span:last-child]:text-[11px] [&_span:last-child]:text-text-muted"
|
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
|
||||||
) : undefined
|
${
|
||||||
|
showWholeTreatmentPlan
|
||||||
|
? 'border-primary bg-primary-soft font-medium text-text-primary'
|
||||||
|
: 'border-border/70 text-text-secondary hover:border-border hover:bg-background-card/50'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{t('toothChartWholePlan')}
|
||||||
|
</button>
|
||||||
}
|
}
|
||||||
onToggle={(fdi, event) => {
|
onToggle={(fdi, event) => {
|
||||||
if (
|
if (
|
||||||
!canEditTreatmentForDay ||
|
!canEditTreatmentForDay ||
|
||||||
isDetailLocked(activeDetail) ||
|
activeLocked ||
|
||||||
showWholeTreatmentPlan ||
|
showWholeTreatmentPlan ||
|
||||||
!activeDetailId
|
!activeDetailId ||
|
||||||
|
!activeTypeSelected
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2347,7 +2434,6 @@ export function TreatmentWorkspace({
|
|||||||
|
|
||||||
if (event.shiftKey) {
|
if (event.shiftKey) {
|
||||||
const anchor = rangeAnchorRef.current;
|
const anchor = rangeAnchorRef.current;
|
||||||
// Need a prior click as range start; shift alone on one tooth does nothing.
|
|
||||||
if (!anchor || anchor === fdi) {
|
if (!anchor || anchor === fdi) {
|
||||||
rangeAnchorRef.current = fdi;
|
rangeAnchorRef.current = fdi;
|
||||||
return;
|
return;
|
||||||
@@ -2389,9 +2475,10 @@ export function TreatmentWorkspace({
|
|||||||
onToggleLink={(a, b) => {
|
onToggleLink={(a, b) => {
|
||||||
if (
|
if (
|
||||||
!canEditTreatmentForDay ||
|
!canEditTreatmentForDay ||
|
||||||
isDetailLocked(activeDetail) ||
|
activeLocked ||
|
||||||
showWholeTreatmentPlan ||
|
showWholeTreatmentPlan ||
|
||||||
!activeDetailId
|
!activeDetailId ||
|
||||||
|
!activeTypeSelected
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2433,42 +2520,73 @@ export function TreatmentWorkspace({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
disabled={!canEditTreatmentForDay || isDetailLocked(activeDetail)}
|
disabled={
|
||||||
|
!canEditTreatmentForDay ||
|
||||||
|
activeLocked ||
|
||||||
|
!activeTypeSelected
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : undefined
|
||||||
|
}
|
||||||
{entryStep === 'content' ? (
|
stepper={
|
||||||
<TreatmentDetailsEditor
|
showLabWizardStep ? (
|
||||||
details={details}
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||||
activeDetailId={activeDetailId}
|
<WizardStepper
|
||||||
onActiveDetailChange={setActiveDetailId}
|
className="min-w-0 flex-1"
|
||||||
onDetailsChange={setDetails}
|
aria-label={t('entryWizardLabel')}
|
||||||
isDetailLocked={isDetailLocked}
|
steps={[
|
||||||
labDependentCodes={labDependentCodes}
|
{ id: 'treatment', label: t('entryStepTreatment') },
|
||||||
treatmentCatalog={treatmentDropdownCatalog}
|
{ id: 'lab', label: t('entryStepLab') },
|
||||||
disabled={!canEditTreatmentForDay}
|
]}
|
||||||
canEdit={canEdit}
|
currentStepId={entryStep}
|
||||||
saveStatus={saveStatus}
|
onStepChange={(stepId) => {
|
||||||
uploadBusy={uploadBusyDetailId === activeDetailId}
|
const next = stepId as EntryStep;
|
||||||
onAddDetail={() => {
|
if (next === 'lab' && canEditTreatmentForDay) {
|
||||||
const next = newDetail(
|
void persistDraft({ force: true })
|
||||||
defaultTreatmentTypeForAppointment(
|
.then(() => setEntryStep('lab'))
|
||||||
selectedAppointment?.purpose,
|
.catch((error: unknown) => {
|
||||||
treatmentCatalog,
|
showError(getUserFacingError(error, tErrors, t('errorSaveDraft')));
|
||||||
),
|
});
|
||||||
);
|
return;
|
||||||
setDetails((prev) => [...prev, next]);
|
}
|
||||||
setActiveDetailId(next.clientId);
|
setEntryStep(next);
|
||||||
setEntryStep('teeth');
|
|
||||||
}}
|
}}
|
||||||
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' ? (
|
{entryStep === 'lab' ? (
|
||||||
<div ref={labPanelRef}>
|
<div ref={labPanelRef} className="space-y-3">
|
||||||
{showLabShipmentBlocked ? <LabShipmentBlockedNotice /> : null}
|
{showLabShipmentBlocked ? <LabShipmentBlockedNotice /> : null}
|
||||||
{showLabDispatchPanel ? (
|
{showLabDispatchPanel ? (
|
||||||
<LabCasesDispatchPanel
|
<LabCasesDispatchPanel
|
||||||
@@ -2477,6 +2595,7 @@ export function TreatmentWorkspace({
|
|||||||
labCases={labCaseDrafts}
|
labCases={labCaseDrafts}
|
||||||
labDependentCodes={labDependentCodes}
|
labDependentCodes={labDependentCodes}
|
||||||
treatmentCatalog={treatmentCatalog}
|
treatmentCatalog={treatmentCatalog}
|
||||||
|
clinicOrganizationId={currentOrganization?.id}
|
||||||
labCaseSummary={activeLabCaseSummary}
|
labCaseSummary={activeLabCaseSummary}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
onLabCaseSummaryChange={handleLabCaseSummaryChange}
|
onLabCaseSummaryChange={handleLabCaseSummaryChange}
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ export const casesApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
deleteDraft: async (id: string): Promise<{ success: boolean }> => {
|
||||||
|
const response = await apiClient.delete(`/cases/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
uploadLineAttachments: async (
|
uploadLineAttachments: async (
|
||||||
caseId: string,
|
caseId: string,
|
||||||
lineClientId: string,
|
lineClientId: string,
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ export const treatmentsApi = {
|
|||||||
treatmentId: string,
|
treatmentId: string,
|
||||||
detailClientId: string,
|
detailClientId: string,
|
||||||
files: File[],
|
files: File[],
|
||||||
|
onUploadProgress?: (percent: number) => void,
|
||||||
): Promise<{ success: boolean; data: import('@/types/treatment').TreatmentAttachmentMeta[] }> => {
|
): Promise<{ success: boolean; data: import('@/types/treatment').TreatmentAttachmentMeta[] }> => {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
@@ -103,7 +104,16 @@ export const treatmentsApi = {
|
|||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/treatments/${treatmentId}/details/${encodeURIComponent(detailClientId)}/attachments`,
|
`/treatments/${treatmentId}/details/${encodeURIComponent(detailClientId)}/attachments`,
|
||||||
form,
|
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;
|
return response.data;
|
||||||
},
|
},
|
||||||
@@ -131,6 +141,7 @@ export const treatmentsApi = {
|
|||||||
appointmentId: string,
|
appointmentId: string,
|
||||||
detailClientId: string,
|
detailClientId: string,
|
||||||
files: File[],
|
files: File[],
|
||||||
|
onUploadProgress?: (percent: number) => void,
|
||||||
): Promise<{ success: boolean; data: import('@/types/treatment').TreatmentAttachmentMeta[] }> => {
|
): Promise<{ success: boolean; data: import('@/types/treatment').TreatmentAttachmentMeta[] }> => {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
@@ -139,7 +150,16 @@ export const treatmentsApi = {
|
|||||||
const response = await apiClient.post(
|
const response = await apiClient.post(
|
||||||
`/treatments/appointments/${appointmentId}/details/${encodeURIComponent(detailClientId)}/attachments`,
|
`/treatments/appointments/${appointmentId}/details/${encodeURIComponent(detailClientId)}/attachments`,
|
||||||
form,
|
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;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user