diff --git a/.cursor/rules/appointments.mdc b/.cursor/rules/appointments.mdc index 34ed6d0..96fecf1 100644 --- a/.cursor/rules/appointments.mdc +++ b/.cursor/rules/appointments.mdc @@ -12,4 +12,5 @@ alwaysApply: false - **Past days:** new bookings stay blocked. Existing appointments **without** treatment can be edited/deleted; with treatment → toast `infoEditBlockedHasTreatment` (no modal). Banner clicks are not gated by `canBook` (slots still are). - **Working hours / create:** interpret wall-clock in the **client IANA time zone** (`timeZone` on create/update, e.g. `Asia/Tehran`). Do not use Node `Date#getHours()` / `getDay()` on the server (UTC Docker vs clinic local). Helper: `zoned-civil-time.ts`. - **Disabled feedback:** use `title` + click `toast.showInfo` with `common.readOnlyAccess` (same key as Staff/Patients) or the domain reason (`infoPastViewOnly`, `infoEditBlockedHasTreatment`). +- **Header date:** `ScheduleDayPicker` `compact` centered in a 3-col page header (same pattern as Treatment `AppointmentsStrip`). - Prisma: `Treatment.appointmentId` is `onDelete: SetNull` — deleting an appointment does **not** cascade-delete treatments/cases/tasks; do not rely on cascade for cleanup. diff --git a/.cursor/rules/dyolink-overview.mdc b/.cursor/rules/dyolink-overview.mdc index bb11234..9f32ca5 100644 --- a/.cursor/rules/dyolink-overview.mdc +++ b/.cursor/rules/dyolink-overview.mdc @@ -24,7 +24,7 @@ Monorepo: `backend/` (NestJS + Prisma), `frontend/` (Next.js + next-intl), `infr All user-visible strings: `frontend/messages/en.json`, `fa.json`, `nl.json` — add keys to **all three**. -Dates/times/numbers: `frontend/src/lib/i18n/format.ts` + `useLocale()`. **Form date fields:** `AppDateInput` only (no native ``); 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 ``); 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 diff --git a/.cursor/rules/lab-cases.mdc b/.cursor/rules/lab-cases.mdc index bee2fe2..ad0ba78 100644 --- a/.cursor/rules/lab-cases.mdc +++ b/.cursor/rules/lab-cases.mdc @@ -13,5 +13,6 @@ alwaysApply: false - **Share link:** QR + URL when case is sent; token API + focus page — see `.cursor/skills/lab-case-share-link/SKILL.md`. - **Case Sheet PDF:** client A4 via `jspdf` + `html2canvas`; print layout must stay **hex-only** (Tailwind `lab()`/`oklch` breaks capture). Optional `externalCode` replaces PDF order number when set. - **Detail chrome:** Important Case checkbox (`labelPosition="start"`) then external-code input (no field title; placeholder only). +- **Add case / Start / Delete draft:** `TAB_CASES_EDIT`. Delete only unstarted lab-origin drafts (`origin === LAB_INTERNAL && !startedAt`). Skill: `.cursor/skills/lab-cases/SKILL.md` diff --git a/.cursor/rules/treatment-workspace.mdc b/.cursor/rules/treatment-workspace.mdc index c8fb388..20b55b2 100644 --- a/.cursor/rules/treatment-workspace.mdc +++ b/.cursor/rules/treatment-workspace.mdc @@ -7,12 +7,13 @@ alwaysApply: false # Treatment workspace - **Browse mode** (`selectedPreviewId` set): preview only; banner + **Load into workspace**; form unchanged until load. -- **Current draft** preview: heading “Current draft”; no load button while editing live. -- **Entry wizard:** Teeth → Content → Lab via `WizardStepper` (not detail chips). Lab step only for lab-dependent (prosthesis) active detail; entering Lab auto-opens shipment draft. Add detail ungated. Appointment purpose does not gate type or Next. Detail-chip switches reset to Teeth unless `pendingEntryStepRef` requests Lab (shipments rail open). -- **Tooth groups:** Adjacent selected teeth show an empty circle between them — click to connect / filled to disconnect; Shift+range selects only (empty circles; overlap absorbs as singles; midline OK). Plain click select/deselect (deselect splits bridges). Never 1-tooth connected. `toothSelectionGroups.ts`; prune lab `toothProsthesis` on change. Cases/Tasks merge by prosthesis type after send. +- **Current draft** preview: omitted in live editing (form is the source). History browse still uses preview + **Load into workspace**. +- **Entry:** Type dropdown + `TreatmentDetailAttachmentsStrip` on one row, bordered chart (Cases chrome), then full-width auto-growing Notes. No stepper unless prosthesis — then `WizardStepper` Treatment → Lab with Back/Next. Chip switches reset to the treatment form unless `pendingEntryStepRef` requests Lab (shipments rail open). Lab-dependent chips use colored sent/unsent text; sent date is on Lab dispatch. Lab dispatch keeps comments. +- **Tooth hits:** unrotated full cell, `pointerdown` only (not `click` too — double-toggles). Glyph `pointer-events-none`; nest scale/hover inside the rotate wrapper. Groups: `toothSelectionGroups.ts` (never 1-tooth connected); prune lab `toothProsthesis` on change. +- **Day strip:** `ScheduleDayPicker` `compact` centered in the header. Timed cards use purpose banners. Unscheduled cards use the same banner once typed (sync live draft onto the open card); untyped keep chip theming. Trash inherits banner ink. Strip-delete only when `areUnscheduledDetailsStripDeletable` (blank lines or `[]`); persist `[]` then `DELETE /treatments/:id`. - **Lab dispatch UI:** due date end-aligned beside title (`sm:flex-row` + `justify-between`; stacks on mobile). Prosthesis type: stacked below `md`, 50/50 same-row from `md`. Content clinical field = **Notes** (not case comments). - **Lab comments:** shared `LabCaseCommentsPanel` (newest-first; sent=`justify-start`, received=`justify-end`; `viewerSide`) across Treatment / Cases / Tasks / share. Use logical `text-start`/`text-end`, not left/right. -- **Detail chrome:** chips + Add at top; **Remove** = trash on chip (unsent, >1 detail; disabled when day-locked / no edit / uploading). No delete in Content step. +- **Detail chrome:** chips (type + teeth) + Add at top; **Remove** = trash on chip (unsent, including last line; disabled when day-locked / no edit / uploading). Empty details persist as `[]`. New treatment seeds one blank detail; load of empty stays `[]`. No delete in type/notes fields. - **Lab dispatch attention:** `LabDispatchAttentionPanel` — unsent lab-dependent details; quick jump to dispatch. - **History API:** patient-scoped; non-owners filtered by provider on treatment or appointment; org owners see all. - **History filters (client-side):** `PastTreatmentsPanel` — “Not shipped to lab” + single date; helpers in `treatmentHistoryFilters.ts`. diff --git a/.cursor/skills/i18n-formatting/SKILL.md b/.cursor/skills/i18n-formatting/SKILL.md index 59d169a..221a19e 100644 --- a/.cursor/skills/i18n-formatting/SKILL.md +++ b/.cursor/skills/i18n-formatting/SKILL.md @@ -49,8 +49,8 @@ Use when adding or changing user-visible dates/times/numbers, RTL layout, or loc | Component | Use for | |-----------|---------| -| `ScheduleDayPicker` | Appointments strip — nav arrows + today toggle + expandable panel | -| `CalendarDaySelect` | Navigator wrapper (arrows + panel) | +| `ScheduleDayPicker` | Appointments / Treatment day headers — nav arrows + today toggle + expandable panel. **`compact`**: no field label; date centered; Today on the navigator row | +| `CalendarDaySelect` | Navigator wrapper (arrows + panel). Empty `label` + `showHeader={false}` puts Today beside the date (used by `compact`) | | `CalendarDayPartsPanel` | Year / month / day row — used by schedule picker and `AppDateInput` | Persian (`fa`): Jalali calendar + `arabext` digits via Intl (`usesPersianCalendar`). Internal model stays **`Date` at local midnight** (Gregorian) — APIs unchanged. diff --git a/.cursor/skills/lab-cases/SKILL.md b/.cursor/skills/lab-cases/SKILL.md index 933ae67..397e030 100644 --- a/.cursor/skills/lab-cases/SKILL.md +++ b/.cursor/skills/lab-cases/SKILL.md @@ -14,7 +14,7 @@ description: Lab Cases tab — list, filters, detail panel, assignment, share QR - **Default sort:** `startedAt` / `sentAt` desc (newest first). Lab-origin drafts (`origin === LAB_INTERNAL && !startedAt`) show a **Draft** badge. All list/detail cards also show **Received** (`CLINIC_DISPATCH`) or **Generated** (`LAB_INTERNAL`) via `LabCaseOriginBadge`. - **Page size:** `PAGE_SIZE = 10` in `CasesPage.tsx`. - **Auto-select:** On tab open / after filter reload, select first list item if none selected; keep selection when still in list; `?caseId=` URL wins. -- **Add case:** `TAB_CASES_EDIT` — `POST /cases` then replace the **right pane** with `CaseCreatePanel` (inline wizard, not a modal). Prosthesis-only lines, interactive `FdiToothChart` (same connect/Shift rules as Treatment). Header: referring clinic/dentist, patient name/mobile, optional ACTIVE partner clinic, due date (`AppDateInput`). **Start** (`POST /cases/:id/start`) generates tasks; no `LabCaseSend`, no clinic inbox/`CASE_SENT`. +- **Add case:** `TAB_CASES_EDIT` — `POST /cases` then replace the **right pane** with `CaseCreatePanel` (inline wizard, not a modal). Prosthesis-only lines, interactive `FdiToothChart` (same connect/Shift rules as Treatment). Header: referring clinic/dentist, patient name/mobile, optional ACTIVE partner clinic, due date (`AppDateInput`). **Start** (`POST /cases/:id/start`) generates tasks; no `LabCaseSend`, no clinic inbox/`CASE_SENT`. **Delete** (`DELETE /cases/:id`) only for unstarted lab-origin drafts (`LAB_INTERNAL` && `!startedAt`); confirm in the create-panel header next to Start. Received clinic cases and started generated cases cannot be deleted. - **Right panel:** Draft lab-origin → wizard; started/received → `CaseDetailPanel`. - **Left rail layout:** section is `flex flex-col` + `lg:min-h-[420px]` / `h-full` with `lg:items-stretch` on the grid; case list wrapper is `flex-1 min-h-0 overflow-y-auto` (do **not** use a fixed `max-h-[55vh]` — that leaves empty space above pagination). @@ -34,7 +34,7 @@ Filter options: `GET /cases/filter-options` → `clinics`, `prosthesisTypes` (di ## List card UI -Match Treatment shipment cards: patient name, clinic, **colored prosthesis groups + teeth** (`LabCaseProsthesisGroupsList`), sent date, progress bar, due-date badge, unread dot. **No patient mobile** on list cards. +Match Treatment shipment cards: patient name, clinic, **colored prosthesis groups + teeth** (`LabCaseProsthesisGroupsList`), sent date, progress bar, due-date badge, unread dot. **No patient mobile** on list cards. Prosthesis row order: **type · teeth**, then Connected badge when that group is a bridge. List item shape: `prosthesisGroups: { prosthesisTypeCode, teeth[] }[]` from task teeth aggregation. diff --git a/.cursor/skills/treatment-workspace/SKILL.md b/.cursor/skills/treatment-workspace/SKILL.md index c507c7a..4c7eb56 100644 --- a/.cursor/skills/treatment-workspace/SKILL.md +++ b/.cursor/skills/treatment-workspace/SKILL.md @@ -2,7 +2,7 @@ name: dyolink-treatment-workspace -description: Treatment tab workspace — appointments strip, preview vs form, history, load flow, draft autosave, entry wizard, connected teeth, lab dispatch. Use when changing treatment UX, preview/history, or lab dispatch in TreatmentWorkspace. +description: Treatment tab workspace — appointments strip, preview vs form, history, load flow, draft autosave, type-first entry, connected teeth, lab dispatch. Use when changing treatment UX, preview/history, or lab dispatch in TreatmentWorkspace. --- @@ -22,31 +22,32 @@ Thin route: `app/[locale]/(dashboard)/treatment/page.tsx` (supports `?appointmen -1. **Day strip** — `AppointmentsStrip.tsx` renders `DayStripItem[]` (`appointment` | `unscheduled`) via `DayStripCard`. Timed appointments keep treatment-type pastel banners. Standalone cards keep the same size/content but use **detail-chip theming** (neutral border, selected `border-primary bg-primary-soft`, trash with chip delete hover). Empty unscheduled cards (`details.length === 0`) can be deleted (`DELETE /treatments/:id`). Workspace fetches `GET /appointments` **and** `GET /treatments/day`. **New treatment** is one shared `Button`: it opens `NewTreatmentPatientPicker` (Walk-in always first, then search). Creating happens only after an explicit patient choice — never from the selected appointment card. +1. **Day strip** — `AppointmentsStrip.tsx` renders `DayStripItem[]` (`appointment` | `unscheduled`) via `DayStripCard`. Header uses **`ScheduleDayPicker` `compact`**: date is centered in a 3-col grid; no “Schedule date” label; **Today** sits on the navigator (`CalendarDaySelect` when the label row is hidden). Timed appointments keep treatment-type pastel banners. Unscheduled cards use the same banner once a treatment type is selected (live draft for the open card; `draftHydratingRef` must be set **before** strip/appointment pick so overlay does not paint the previous card’s type). Until typed they keep chip theming. Trash inherits banner ink on typed cards. Strip trash only when `areUnscheduledDetailsStripDeletable` (no type/teeth/notes/attachments, including `[]`). Workspace fetches `GET /appointments` **and** `GET /treatments/day`. **New treatment** is one shared `Button`: it opens `NewTreatmentPatientPicker` (Walk-in always first, then search). Creating happens only after an explicit patient choice — never from the selected appointment card. New treatment seeds one blank detail so the type field is ready; a persisted empty plan hydrates as `[]` until Add. -2. **Treatment preview** — `TreatmentPreviewCard.tsx` (read-only summary; no load button for current draft) +2. **Treatment preview** — `TreatmentPreviewCard.tsx` (history browse only; omitted for the live draft) 3. **Treatment history** — `PastTreatmentsPanel.tsx` (past saved plans for patient; **client-side** filters in `treatmentHistoryFilters.ts`) -4. **Editor** — detail chips + Add (`TreatmentDetailsEditor` chrome); entry wizard (`WizardStepper`); step panels: `FdiToothChart`, Content fields, `LabCasesDispatchPanel` +4. **Editor** — detail chips + Add (`TreatmentDetailsEditor`); type-first form with chart + notes; lab send sheet only for prosthesis (`LabCasesDispatchPanel`) -## Entry wizard (Teeth / Content / Lab) +## Entry (type-first form / optional lab sheet) -Right-column entry uses `WizardStepper` (`components/ui/shared/WizardStepper.tsx`) — numbered nodes + connector rail. **Do not** reuse detail-chip tab styling for steps. +Right-column entry is **not** a three-step wizard. Type dropdown + `TreatmentDetailAttachmentsStrip` on one row (same height as `Dropdown`; paperclip | divider | thumbs grouped image → pdf → other; upload progress in one square; click thumb → preview/remove dialog). Compact FDI chart (same scale as Cases), then full-width auto-growing **Notes** (`rows={1}`). Chart is dimmed until a type is chosen. Lab-dependent chips show colored sent/unsent **text** (not badge pills); sent date stays on the Lab dispatch tab. -| Step | UI | Notes | -|------|-----|--------| -| **Teeth** | `FdiToothChart` | Neighbor circles connect/disconnect; Shift+range selects (empty circles); plain click select/deselect | -| **Content** | `TreatmentDetailsEditor` fields only (`showChrome={false}`) | Type / **Notes** / attachments — **no delete button** | -| **Lab** | `LabCasesDispatchPanel` | Shown in stepper **only** when active detail type is lab-dependent (`labDependentCodes`). Entering Lab **auto-ensures** a shipment draft (no “Add lab shipment” click). Add detail stays ungated. Due date beside title (RTL via logical end). Prosthesis dropdown same-row from `md`. | +| Stage | UI | When | +|-------|-----|------| +| **Treatment** | Type dropdown + `TreatmentDetailAttachmentsStrip`, `FdiToothChart` (Cases scale), full-width Notes | Always | +| **Lab** | `LabCasesDispatchPanel` | Only when active detail type is lab-dependent. Entering Lab auto-ensures a shipment draft. Last-used lab and prosthesis type are remembered. Comments stay on the dispatch panel. | +- Prosthesis uses `WizardStepper` (Treatment → Lab) with Back/Next. Lab dispatch keeps comments. +- Detail chips show **type + teeth**, not “Detail N”. Lab-dependent chips use colored sent/unsent text (same size as the label); sent date stays on Lab dispatch. - Detail type may differ from appointment purpose (purpose only defaults new details). -- Next/Back navigate visible steps; leaving prosthesis while on Lab returns to Content. -- Switching `activeDetailId` resets the wizard to **Teeth**, unless `pendingEntryStepRef` is set to `lab` first (lab shipments rail / “Go to dispatch” / load-with-focus). That ref prevents the reset effect from overwriting Lab on the first click. +- Switching `activeDetailId` resets to the treatment form, unless `pendingEntryStepRef` is set to `lab` first (lab shipments rail / “Go to dispatch” / load-with-focus). +- Live draft is **not** duplicated in the left rail preview; preview is for history browse only. @@ -56,6 +57,8 @@ Right-column entry uses `WizardStepper` (`components/ui/shared/WizardStepper.tsx Helpers: `frontend/src/components/treatment/toothSelectionGroups.ts`. Persisted as `toothSelectionGroups` on the detail. Lab **dispatch** rows are 1:1 with selection groups; after send, Cases/Tasks **merge** teeth by prosthesis type (not by selection group). +Chart hit-testing (`FdiToothChart.tsx`): the hit target is the **unrotated full cell** (`pointerdown` + keyboard). Do **not** bind both `pointerdown` and `click` (double-toggle looks like a miss). Visual glyph is `pointer-events-none`; tilt lives on the outer wrapper; **scale/hover is nested inside** so inline `transform` does not kill scale. Horizontal inset (~22%) leaves a dead zone between teeth; FDI numbers are also clickable (`ToothNumber`). + - **Plain click:** add/remove single; deselecting a tooth in a bridge removes it and splits/shrinks remaining sides (never a 1-tooth connected group). - **Neighbor circles:** when two arch-adjacent teeth are both selected, an empty circle appears **between** them (not per-tooth). Click empty → connect; click filled → disconnect (teeth stay selected). Transitive A–B + B–C = one bridge. - **Shift+click:** inclusive same-arch range → all selected as **singles** (empty circles); overlapping existing bridges are absorbed as singles too. Midline neighbors (11–21, 41–31) allowed. @@ -105,7 +108,7 @@ Helpers: `frontend/src/components/treatment/toothSelectionGroups.ts`. Persisted - **Not shipped to lab** — show treatments that have at least one lab-dependent detail (prosthesis via `labDependentCodes`) with `!sentAt`. - **Date** — filter on `treatmentAt` matching that local calendar day. - When **not shipped** is on, filters **already-fetched** history only (excludes the active appointment row). -- **Previous treatments rail** excludes the active appointment; live draft stays in **Current draft** preview only. Refresh history after lab send. +- **Previous treatments rail** excludes the active appointment; live draft stays in the editor only. Refresh history after lab send. Helpers: `frontend/src/components/treatment/treatmentHistoryFilters.ts`. @@ -144,7 +147,7 @@ On today: in-progress slot first, else nearest start time to `now`. Other days: -**Today checkbox** (`ScheduleDayPicker`): unchecked when `selectedDay` is not today (e.g. after loading a historical treatment). Checking Today calls `onSelectDay(today)` which unlocks selection (`selectionLocked = false`) and resets browse/historical context; appointments reload and auto-select nearest to now. +**Today checkbox** (`ScheduleDayPicker`, including `compact` on the Treatment strip and Appointments page headers): unchecked when `selectedDay` is not today (e.g. after loading a historical treatment). Checking Today calls `onSelectDay(today)` which unlocks selection (`selectionLocked = false`) and resets browse/historical context; appointments reload and auto-select nearest to now. @@ -186,7 +189,7 @@ Use shared `Checkbox` (not native ``) to avoid focus-driv | `POST /treatments` | Create standalone `{ patientId?, walkIn?, treatmentAt }` | -| `DELETE /treatments/:id` | Empty standalone only (`appointmentId` null, no details) | +| `DELETE /treatments/:id` | Empty standalone only (`appointmentId` null, no detail rows). UI may `PUT` `{ details: [] }` first when the strip looks blank but autosave has not finished. | | `GET /treatments/patients/:patientId/history` | History (patient + org; filtered by provider) | @@ -219,7 +222,7 @@ canEditTreatmentForDay = canEdit && hasLiveContext && !isViewingPastDay && works ### Add / remove details - **Add detail** stays enabled whenever `canEditTreatmentForDay` — even if sibling details are already sent. -- **Remove detail:** trash icon on each **detail chip** in chrome (`TreatmentDetailsEditor` `showChrome`). Same gates as before: only when `details.length > 1`; disabled when `!canEdit`, day/workspace `disabled`, detail sent (`isDetailLocked`), or `uploadBusy`. Confirm via `confirmRemoveDetail`. Drop linked unsent lab drafts with the detail. **Do not** put a delete control in the Content wizard step. +- **Remove detail:** trash icon on each **detail chip** in chrome (`TreatmentDetailsEditor` `showChrome`), including the last remaining line. Disabled when `!canEdit`, day/workspace `disabled`, detail sent (`isDetailLocked`), or `uploadBusy`. Confirm via `confirmRemoveDetail`. Drop linked unsent lab drafts with the detail. Empty details persist as `[]` (`SaveTreatmentDraftDto` has no `@ArrayMinSize`; `areDetailsPersistable` is `details.every(isDetailTypeSelected)` so `[]` saves). Unscheduled strip-card trash uses `areUnscheduledDetailsStripDeletable` (blank lines), then `PUT` `{ details: [] }` and `DELETE /treatments/:id` (backend still refuses when any detail row remains — `TREATMENT_HAS_DETAILS`). **Do not** put a delete control in the type/notes fields. ### Sent lab shipment fields (vs detail) diff --git a/AGENTS.md b/AGENTS.md index d934dd8..8416eaa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,22 +39,24 @@ frontend/src/ **Example thin page:** `app/.../treatment/page.tsx` → imports `TreatmentWorkspace` from `components/ui/treatment/`. -**i18n formatting:** Display dates/times/numbers via `lib/i18n/format.ts` + `useLocale()`. Form dates: **`AppDateInput`** (all locales — same component, masked typing + calendar popup). Appointments strip: **`ScheduleDayPicker`**. Filter ``s: **`FORM_SELECT_CLASS`**; data tables: **`Table`** with logical `text-start`/`text-end` (not `text-left`/`text-right`). Skill: `.cursor/skills/i18n-formatting/SKILL.md`. **Treatment tab:** Preview and editable form are **separate** until the user clicks **Load into workspace** on a history item. See `.cursor/skills/treatment-workspace/SKILL.md` before changing that flow. **Treatment edit / details (quick ref):** -- Day/mode gate: editable only for live draft on today/future (`canEditTreatmentForDay`). Past day / historical load → read-only form. Empty **no-appointment** strip cards can be deleted (trash; no details). **New treatment** opens a patient picker (Walk-in always visible); it does not copy the selected appointment’s patient. -- Sent-to-lab detail locks that line; **Add detail** still OK same day; **Remove detail** = trash icon on each detail chip (not in the wizard Content step) — only unsent and not the last line. Attachment upload blocked when sent (`TREATMENT_DETAIL_SENT`). -- **Entry wizard:** `WizardStepper` — Teeth → Content → Lab; Lab step only when active detail type is lab-dependent (prosthesis); entering Lab auto-opens shipment draft (no Add-shipment CTA). Content notes field label is **Notes** (clinical). Detail chips ≠ wizard chrome. Switching detail chips resets wizard to Teeth unless `pendingEntryStepRef` requests Lab (e.g. opening a case from the lab shipments rail). -- **Tooth selection:** Neighbor empty/filled circles between selected adjacent teeth connect/disconnect bridges; Shift+range selects only (empty circles; overlap absorbs as singles); midline 11–21 / 41–31 allowed. Plain click selects/deselects (deselect splits bridges). Never a 1-tooth connected. Helpers: `toothSelectionGroups.ts`. Connected label: `ConnectedSelectionBadge`. After send, Cases/Tasks merge teeth by prosthesis type. +- Day/mode gate: editable only for live draft on today/future (`canEditTreatmentForDay`). Past day / historical load → read-only form. **New treatment** opens a patient picker (Walk-in always visible); it does not copy the selected appointment’s patient. New treatment seeds one blank detail; a persisted empty plan loads as `[]` until Add. +- **Unscheduled strip cards** use the same treatment-type banner as appointments once a type is selected (live draft for the open card). Strip trash only when every line is blank (no type/teeth/notes/attachments, including `[]`); typed cards need chip-delete first. Backend `DELETE /treatments/:id` is empty-only (`TREATMENT_HAS_DETAILS`). +- Sent-to-lab detail locks that line; **Add detail** still OK same day; **Remove detail** = trash on chip (unsent, including last line; day/edit gates apply). Empty details persist as `[]`. Attachment upload blocked when sent (`TREATMENT_DETAIL_SENT`). +- **Entry:** Type dropdown + compact attachments strip (`TreatmentDetailAttachmentsStrip`) on one row, then bordered FDI chart (Cases chrome/scale), then full-width auto-growing **Notes**. No wizard for non-lab types. Prosthesis: `WizardStepper` Treatment → Lab with Back/Next. Detail chips show type + teeth; lab-dependent chips use colored sent/unsent text (sent date on Lab tab). Switching chips resets to the treatment form unless `pendingEntryStepRef` requests Lab (shipments rail). Lab dispatch keeps comments. +- **Tooth selection:** Hit target is the unrotated cell (`pointerdown` only — do not also bind `click`). Glyph is `pointer-events-none`; nest hover/selected scale inside the rotate wrapper. Neighbor empty/filled circles between selected adjacent teeth connect/disconnect bridges; Shift+range selects only (empty circles; overlap absorbs as singles); midline 11–21 / 41–31 allowed. Plain click selects/deselects (deselect splits bridges). Never a 1-tooth connected. Helpers: `toothSelectionGroups.ts`. Connected label: `ConnectedSelectionBadge`. After send, Cases/Tasks merge teeth by prosthesis type. - **Lab dispatch layout:** due date beside title (`justify-between`, logical start/end for RTL); prosthesis type on same row as teeth from `md:` up (stacked on mobile). +- **Schedule date:** Treatment strip and Appointments page headers use `ScheduleDayPicker` `compact` (date centered in a 3-col header; no “Schedule date” label; Today on the navigator). **Treatment lab rules (quick ref):** - Lab-dependent details (e.g. prosthesis) **without teeth** can save but **cannot ship** — show `LabShipmentBlockedNotice` + inline banner; toast on dispatch add. - Detail treatment type need **not** match appointment purpose — purpose only pre-fills new details. - **History filters** are client-side only (`treatmentHistoryFilters.ts`): “Not shipped to lab” + single date on already-fetched patient history; includes live current draft when filtering. -- **Lab shipments rail**: unified list with scope toggle **This patient** vs **All updates** (unread across org for **this clinician's cases only**, includes patient name). Opening a case from the rail jumps to the entry wizard **Lab** step. +- **Lab shipments rail**: unified list with scope toggle **This patient** vs **All updates** (unread across org for **this clinician's cases only**, includes patient name). Opening a case from the rail jumps to the **Lab** send sheet. - **Unread semantics**: Treatment tab badge = count of unread cases **for the user's own treatment plans** (per-case read cursor) and clears when a case is opened/marked read (not on tab visit). - **Live lab rail**: `notification.created` → `notifyTabBadgesChanged()` silently refreshes patient lab cases + unread rail (does **not** clear draft/form state). - **Lab shipment progress + comments**: shown in **Lab dispatch panel** for the active shipment; expanding activity / opening comments marks that case read. Shared UI: `LabCaseCommentsPanel` — newest first; sent = start / received = end (`text-start`/`justify-start`, RTL-safe); pass `viewerSide`. @@ -63,7 +65,7 @@ frontend/src/ **Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; filter by case source (`origin`: received vs generated); prosthesis colors from catalog; task assignment in **Cases** (compact row: status + assignee + last update); on **Tasks**, all staff see every task but only assignee (or unassigned pool) can change status — others see “Assigned to {name}” instead of the status dropdown; **case due dates** set/edited in clinic Treatment lab dispatch, shown on lab Cases/Tasks with overdue filter + sort; completing **`intraoral_scan`** completes every scan task in that case (case-scoped; catalog first step for all prosthesis types); **mobile:** larger task status controls, sticky case header when grouped; **tab badges:** `LabCaseActivity` + `GET /notifications/tab-counts` (lab Cases/Tasks split, clinic Treatment) — live via inbox Socket.IO → `notifyTabBadgesChanged()` + soft list refresh — see `.cursor/skills/lab-tasks/SKILL.md`, `.cursor/skills/tab-badges/SKILL.md`, `.cursor/skills/notifications-inbox/SKILL.md`. -**Lab Cases tab:** Filter by **prosthesis type** (not treatment type); auto-select newest case on open; list **10 per page**; left rail list fills column height (`flex-1 overflow-y-auto`); list cards use `LabCaseProsthesisGroupsList` (colored type + teeth, shared with Treatment rail) plus **Received** / **Generated** origin badges. Deep link: `?caseId=`, `?clinicOrganizationId=`. **Share link:** QR + URL on sent cases (attachment left, QR right); opens `/lab-case/[token]` focus page. **Case Sheet PDF:** client A4 (`jspdf`/`html2canvas`); hex-only print layout; optional `externalCode` replaces order number. Lab-origin Start has no clinic send; comments and mark-read still work (no clinic-visibility toggle). **Live:** inbox Socket.IO → `notifyTabBadgesChanged()` soft-refreshes list + selected detail (no remount). See `.cursor/skills/lab-cases/SKILL.md` and `.cursor/skills/lab-case-share-link/SKILL.md`. +**Lab Cases tab:** Filter by **prosthesis type** (not treatment type); auto-select newest case on open; list **10 per page**; left rail list fills column height (`flex-1 overflow-y-auto`); list cards use `LabCaseProsthesisGroupsList` (colored type + teeth, shared with Treatment rail) plus **Received** / **Generated** origin badges. Deep link: `?caseId=`, `?clinicOrganizationId=`. **Share link:** QR + URL on sent cases (attachment left, QR right); opens `/lab-case/[token]` focus page. **Case Sheet PDF:** client A4 (`jspdf`/`html2canvas`); hex-only print layout; optional `externalCode` replaces order number. Lab-origin Start has no clinic send; comments and mark-read still work (no clinic-visibility toggle). Unstarted generated drafts can be deleted (`DELETE /cases/:id`). **Live:** inbox Socket.IO → `notifyTabBadgesChanged()` soft-refreshes list + selected detail (no remount). See `.cursor/skills/lab-cases/SKILL.md` and `.cursor/skills/lab-case-share-link/SKILL.md`. **Lab case share link (quick ref):** - Token on first ship → `/{locale}/lab-case/{token}` after login. @@ -94,7 +96,7 @@ Errors: `AppException` + `ErrorCode` → frontend `getUserFacingError()`. Never | Skill | When to use | |-------|-------------| | `.cursor/skills/add-feature/` | New tab, API module, or end-to-end feature | -| `.cursor/skills/treatment-workspace/` | Treatment tab: preview vs form, history, load flow, drafts, entry wizard, connected teeth | +| `.cursor/skills/treatment-workspace/` | Treatment tab: preview vs form, history, load flow, drafts, type-first entry, connected teeth | | `.cursor/skills/lab-tasks/` | Lab Tasks tab: sort, case grouping, step-completed filter, prosthesis colors | | `.cursor/skills/lab-cases/` | Lab Cases tab: prosthesis filter, auto-select, Case Sheet PDF, external code | | `.cursor/skills/lab-case-share-link/` | Case QR share link: access token, focus page, auth redirect, access rules | diff --git a/backend/src/modules/cases/cases.controller.ts b/backend/src/modules/cases/cases.controller.ts index eff7f4a..00c02e0 100644 --- a/backend/src/modules/cases/cases.controller.ts +++ b/backend/src/modules/cases/cases.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, + Delete, Get, Param, Patch, @@ -92,6 +93,13 @@ export class CasesController { return this.casesService.startInternal(id, organizationId, req.user.id, req.user.language); } + @Delete(':id') + @ApiOperation({ summary: 'Delete an unstarted lab-origin draft (TAB_CASES_EDIT)' }) + deleteDraft(@Param('id') id: string, @Req() req) { + const organizationId = this.casesService.getOrganizationIdFromUser(req.user); + return this.casesService.deleteInternalDraft(id, organizationId, req.user.id); + } + @Post(':id/lines/:lineClientKey/attachments') @ApiOperation({ summary: 'Upload attachments for a lab-origin draft line' }) @ApiConsumes('multipart/form-data') diff --git a/backend/src/modules/cases/cases.service.ts b/backend/src/modules/cases/cases.service.ts index 0ea2174..93028e7 100644 --- a/backend/src/modules/cases/cases.service.ts +++ b/backend/src/modules/cases/cases.service.ts @@ -4,7 +4,7 @@ import { } from '@nestjs/common'; import { AppException, ErrorCode } from '../../common/errors'; import { randomUUID } from 'crypto'; -import { createReadStream, existsSync, mkdirSync } from 'fs'; +import { createReadStream, existsSync, mkdirSync, unlinkSync } from 'fs'; import { join } from 'path'; import { CatalogEntityKind, LabCaseActivityType, LabCaseOrigin, LabTaskStatus, LinkStatus, Prisma, UserNotificationType } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; @@ -565,6 +565,56 @@ export class CasesService { return { success: true, data: await this.mapLabCaseDetail(refreshed, localeInput) }; } + async deleteInternalDraft( + labCaseId: string, + labOrganizationId: string, + actorUserId: string, + ) { + await this.assertCanEditCases(actorUserId, labOrganizationId); + const existing = await this.prisma.labCase.findFirst({ + where: { + id: labCaseId, + origin: LabCaseOrigin.LAB_INTERNAL, + destinationOrganizationId: labOrganizationId, + }, + select: { + id: true, + startedAt: true, + attachments: { include: { attachment: { select: { id: true, storagePath: true } } } }, + }, + }); + if (!existing) { + throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND); + } + if (existing.startedAt) { + throw new AppException(ErrorCode.LAB_CASE_ALREADY_STARTED, HttpStatus.CONFLICT); + } + + const orphanAttachments = existing.attachments.map((row) => row.attachment); + const orphanIds = orphanAttachments.map((row) => row.id); + + await this.prisma.$transaction(async (tx) => { + await tx.labCase.delete({ where: { id: labCaseId } }); + if (orphanIds.length > 0) { + await tx.treatmentDetailAttachment.deleteMany({ + where: { id: { in: orphanIds }, detailId: null }, + }); + } + }); + + for (const attachment of orphanAttachments) { + try { + if (attachment.storagePath && existsSync(attachment.storagePath)) { + unlinkSync(attachment.storagePath); + } + } catch { + // Best-effort disk cleanup; the draft row is already gone. + } + } + + return { success: true }; + } + async uploadInternalAttachments( labCaseId: string, lineClientKey: string, diff --git a/backend/src/modules/treatments/dto/treatment.dto.ts b/backend/src/modules/treatments/dto/treatment.dto.ts index 1d542d2..c67f1b6 100644 --- a/backend/src/modules/treatments/dto/treatment.dto.ts +++ b/backend/src/modules/treatments/dto/treatment.dto.ts @@ -1,5 +1,4 @@ import { - ArrayMinSize, IsArray, IsBoolean, IsDateString, @@ -61,7 +60,6 @@ export class SaveTreatmentDetailDto { export class SaveTreatmentDraftDto { @IsArray() - @ArrayMinSize(1) @ValidateNested({ each: true }) @Type(() => SaveTreatmentDetailDto) details: SaveTreatmentDetailDto[]; diff --git a/frontend/messages/en.json b/frontend/messages/en.json index bc88e6d..ba37dc5 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -513,7 +513,9 @@ "removeLineAria": "Remove line {n}", "errorCreateCase": "Could not create the case.", "errorSaveCase": "Could not save the case.", - "errorStartCase": "Could not start the case." + "errorStartCase": "Could not start the case.", + "confirmDeleteDraftCase": "Delete this draft case? This cannot be undone.", + "errorDeleteCase": "Could not delete the case." }, "labCaseAccess": { "pageTitle": "Case tasks", @@ -676,7 +678,6 @@ "noPermissionBody": "You do not have permission to view the Treatment tab for this organization.", "title": "Treatment", "subtitleEdit": "Document cases for your appointments, save drafts, and send work to linked organizations.", - "subtitleEditPhase4": "Plan treatment details first, then group lab-dependent work into shipments in the lab dispatch panel.", "subtitleReadOnly": "View-only access — you can review appointments and treatment history but cannot edit.", "pastDayNotice": "Past days are view-only. You can review appointments and history, but treatment cases cannot be added or changed.", "selectedPatient": "Selected patient", @@ -714,12 +715,13 @@ "casesTitle": "Treatment cases", "casesSubtitle": "Each case has its own teeth, notes, attachments, and destinations for send.", "detailsTitle": "Treatment details", - "detailsSubtitle": "Plan teeth, type, notes, and attachments for each detail line.", "addDetail": "Add detail", "confirmRemoveDetail": "Remove this treatment detail?", "removeDetailAria": "Remove detail {n}", "detailLabel": "Detail {n}", "detailSentBadge": "sent", + "detailUnsentBadge": "unsent", + "attachmentPreviewUnavailable": "Preview unavailable", "detailLockedInShipment": "This detail was sent to a lab and can no longer be edited.", "detailsSaveHint": "Lab dispatch is configured separately below.", "addCase": "Add case", @@ -745,7 +747,7 @@ "noOrgInvitePermission": "You do not have permission to invite labs. Contact your organization owner.", "sendThisCase": "Send this case", "labDispatchTitle": "Lab dispatch", - "labDispatchSubtitle": "Group lab-dependent details into shipments and send them to linked labs.", + "labDispatchSubtitle": "Choose a lab, set prosthesis types, and send.", "labDispatchSendHint": "Shipment grouping is saved when you send to the lab.", "addLabShipment": "Add lab shipment", "labShipmentLabel": "Shipment {n}", @@ -861,11 +863,19 @@ "entryStepTeeth": "Teeth", "entryStepContent": "Content", "entryStepLab": "Lab", + "entryStepTreatment": "Treatment", "entryStepBack": "Back", "entryStepNext": "Next", + "continueToLab": "Continue to lab", + "backToTreatment": "Back to treatment", + "selectTypeBeforeTeeth": "Choose a treatment type before selecting teeth.", + "labDispatchNotSent": "Not sent", + "labDispatchLastLab": "Last lab: {name}", + "labCommentsMovedHint": "Progress and comments are in Lab shipments.", "entryStepLabUnavailable": "Lab dispatch is available after you save a lab-dependent detail with teeth selected.", "toothChartTitleCompact": "Tooth chart", "toothChartHint": "Tap teeth to select or deselect. When two neighbors are selected, an empty circle appears between them — tap it to connect (or tap a filled circle to disconnect). Shift-click a range to select all in between (not connected until you fill the circles).", + "toothChartHelpAria": "How to select teeth", "toothConnectedHint": "Connected selection", "toothLinkHint": "Connect these teeth", "toothUnlinkHint": "Disconnect these teeth", @@ -876,7 +886,7 @@ "prosthesisConnectedLabel": "Connected teeth prosthesis type", "toothChartWholePlan": "Full View", "labShipmentAttachments": "Files for the lab", - "labShipmentAttachmentsHint": "Select which attachments from this detail are included in this shipment. None are sent by default.", + "labShipmentAttachmentsHint": "All files are included. Uncheck any you do not want to send.", "selectedLabel": "Selected:", "selectedEmpty": "—", "upperArch": "Upper arch", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index ea3e9bb..755adda 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -514,7 +514,9 @@ "removeLineAria": "حذف خط {n}", "errorCreateCase": "ایجاد پرونده ممکن نشد.", "errorSaveCase": "ذخیره پرونده ممکن نشد.", - "errorStartCase": "شروع پرونده ممکن نشد." + "errorStartCase": "شروع پرونده ممکن نشد.", + "confirmDeleteDraftCase": "این پیش‌نویس پرونده حذف شود؟ این کار قابل بازگشت نیست.", + "errorDeleteCase": "حذف پرونده ممکن نشد." }, "labCaseAccess": { "pageTitle": "وظایف پرونده", @@ -677,7 +679,6 @@ "noPermissionBody": "شما مجوز مشاهده برگه درمان برای این سازمان را ندارید.", "title": "درمان", "subtitleEdit": "پرونده‌های نوبت‌های خود را مستند کنید، پیش‌نویس‌ها را ذخیره کنید و کار را به سازمان‌های مرتبط ارسال کنید.", - "subtitleEditPhase4": "ابتدا جزئیات درمان را برنامه‌ریزی کنید، سپس کار وابسته به لابراتوار را در بخش ارسال لاب گروه‌بندی کنید.", "subtitleReadOnly": "دسترسی فقط خواندنی — می‌توانید نوبت‌ها و تاریخچه درمان را بررسی کنید اما نمی‌توانید ویرایش کنید.", "pastDayNotice": "روزهای گذشته فقط قابل مشاهده هستند. می‌توانید نوبت‌ها و تاریخچه را بررسی کنید، اما پرونده‌های درمانی قابل اضافه یا تغییر نیستند.", "selectedPatient": "بیمار انتخاب شده", @@ -715,12 +716,13 @@ "casesTitle": "پرونده‌های درمانی", "casesSubtitle": "هر پرونده دارای دندان‌ها، یادداشت‌ها، پیوست‌ها و مقصدهای ارسال خود است.", "detailsTitle": "جزئیات درمان", - "detailsSubtitle": "دندان‌ها، نوع، یادداشت و پیوست‌ها را برای هر خط جزئیات برنامه‌ریزی کنید.", "addDetail": "افزودن جزئیات", "confirmRemoveDetail": "این جزئیات درمان حذف شود؟", "removeDetailAria": "حذف جزئیات {n}", "detailLabel": "جزئیات {n}", "detailSentBadge": "ارسال‌شده", + "detailUnsentBadge": "ارسال‌نشده", + "attachmentPreviewUnavailable": "پیش‌نمایش در دسترس نیست", "detailLockedInShipment": "این جزئیات به لابراتوار ارسال شده و دیگر قابل ویرایش نیست.", "detailsSaveHint": "ارسال لاب در بخش جداگانه زیر پیکربندی می‌شود.", "addCase": "افزودن پرونده", @@ -746,7 +748,7 @@ "noOrgInvitePermission": "شما مجوز دعوت از آزمایشگاه را ندارید. با مالک سازمان تماس بگیرید.", "sendThisCase": "ارسال این پرونده", "labDispatchTitle": "ارسال به لابراتوار", - "labDispatchSubtitle": "جزئیات وابسته به لاب را در محموله‌ها گروه‌بندی کرده و به لابراتوارهای متصل ارسال کنید.", + "labDispatchSubtitle": "لابراتوار را انتخاب کنید، نوع پروتز را مشخص کنید و ارسال کنید.", "labDispatchSendHint": "گروه‌بندی محموله هنگام ارسال به لاب ذخیره می‌شود.", "addLabShipment": "افزودن محموله لاب", "labShipmentLabel": "محموله {n}", @@ -860,6 +862,7 @@ "toothChartTitle": "نمودار دندان‌ها FDI", "toothChartTitleCompact": "نمودار دندان", "toothChartHint": "برای انتخاب یا لغو انتخاب روی دندان ضربه بزنید. وقتی دو دندان همسایه انتخاب شوند، دایرهٔ خالی بینشان ظاهر می‌شود — برای اتصال روی آن بزنید (یا روی دایرهٔ پر برای قطع اتصال). Shift+کلیک روی یک بازه، همه را انتخاب می‌کند (تا وقتی دایره‌ها را پر نکنید متصل نیستند).", + "toothChartHelpAria": "راهنمای انتخاب دندان", "toothConnectedHint": "انتخاب متصل", "toothLinkHint": "اتصال این دندان‌ها", "toothUnlinkHint": "قطع اتصال این دندان‌ها", @@ -872,12 +875,19 @@ "entryStepTeeth": "دندان‌ها", "entryStepContent": "محتوا", "entryStepLab": "لابراتوار", + "entryStepTreatment": "درمان", "entryStepBack": "قبلی", "entryStepNext": "بعدی", + "continueToLab": "ادامه به لابراتوار", + "backToTreatment": "بازگشت به درمان", + "selectTypeBeforeTeeth": "قبل از انتخاب دندان، نوع درمان را انتخاب کنید.", + "labDispatchNotSent": "ارسال نشده", + "labDispatchLastLab": "آخرین لاب: {name}", + "labCommentsMovedHint": "پیشرفت و پیام‌ها در بخش محموله‌های لاب هستند.", "entryStepLabUnavailable": "ارسال به لابراتوار پس از ذخیره جزئیات وابسته به لاب با انتخاب دندان در دسترس است.", "toothChartWholePlan": "نمایش کامل", "labShipmentAttachments": "فایل‌ها برای لابراتوار", - "labShipmentAttachmentsHint": "انتخاب کنید کدام پیوست‌های این جزئیات در این محموله ارسال شوند. پیش‌فرض هیچ‌کدام نیست.", + "labShipmentAttachmentsHint": "همه فایل‌ها پیوست می‌شوند. برای حذف از ارسال، تیک را بردارید.", "selectedLabel": "انتخاب شده:", "selectedEmpty": "—", "upperArch": "قوس بالا", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 70f44e4..6aae598 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -514,7 +514,9 @@ "removeLineAria": "Lijn {n} verwijderen", "errorCreateCase": "Case kon niet worden aangemaakt.", "errorSaveCase": "Case kon niet worden opgeslagen.", - "errorStartCase": "Case kon niet worden gestart." + "errorStartCase": "Case kon niet worden gestart.", + "confirmDeleteDraftCase": "Dit concept verwijderen? Dit kan niet ongedaan worden gemaakt.", + "errorDeleteCase": "Case kon niet worden verwijderd." }, "labCaseAccess": { "pageTitle": "Dossiertaken", @@ -676,7 +678,6 @@ "noPermissionBody": "U heeft geen toestemming om het tabblad Behandeling voor deze organisatie te bekijken.", "title": "Behandeling", "subtitleEdit": "Documenteer casussen voor uw afspraken, sla concepten op en stuur werk naar gekoppelde organisaties.", - "subtitleEditPhase4": "Plan eerst behandeldetails, groepeer daarna lab-afhankelijk werk in het lab-dispatchpaneel.", "subtitleReadOnly": "Alleen-lezen toegang — u kunt afspraken en behandelgeschiedenis bekijken, maar niet bewerken.", "pastDayNotice": "Dagen in het verleden zijn alleen-lezen. U kunt afspraken en geschiedenis bekijken, maar behandelcasussen kunnen niet worden toegevoegd of gewijzigd.", "selectedPatient": "Geselecteerde patiënt", @@ -714,12 +715,13 @@ "casesTitle": "Behandelcasussen", "casesSubtitle": "Elke case heeft zijn eigen tanden, notities, bijlagen en verzendbestemmingen.", "detailsTitle": "Behandeldetails", - "detailsSubtitle": "Plan tanden, type, notities en bijlagen per detailregel.", "addDetail": "Detail toevoegen", "confirmRemoveDetail": "Dit behandelingsdetail verwijderen?", "removeDetailAria": "Detail {n} verwijderen", "detailLabel": "Detail {n}", "detailSentBadge": "verzonden", + "detailUnsentBadge": "niet verzonden", + "attachmentPreviewUnavailable": "Voorbeeld niet beschikbaar", "detailLockedInShipment": "Dit detail is naar het lab verzonden en kan niet meer worden bewerkt.", "detailsSaveHint": "Lab-dispatch wordt hieronder apart geconfigureerd.", "addCase": "Case toevoegen", @@ -745,7 +747,7 @@ "noOrgInvitePermission": "U heeft geen toestemming om labs uit te nodigen. Neem contact op met de organisatie-eigenaar.", "sendThisCase": "Verzend deze case", "labDispatchTitle": "Lab-dispatch", - "labDispatchSubtitle": "Groepeer lab-afhankelijke details in zendingen en stuur ze naar gekoppelde labs.", + "labDispatchSubtitle": "Kies een lab, stel prothesetypes in en verstuur.", "labDispatchSendHint": "Groepering wordt opgeslagen wanneer u naar het lab verzendt.", "addLabShipment": "Labzending toevoegen", "labShipmentLabel": "Zending {n}", @@ -859,6 +861,7 @@ "toothChartTitle": "FDI-tanddiagram", "toothChartTitleCompact": "Tanddiagram", "toothChartHint": "Tik op tanden om te selecteren of deselecteren. Bij twee geselecteerde buren verschijnt een lege cirkel ertussen — tik om te verbinden (of tik op een gevulde cirkel om te ontkoppelen). Shift-klik een bereik om alles ertussen te selecteren (nog niet verbonden tot je de cirkels vult).", + "toothChartHelpAria": "Tanden selecteren", "toothConnectedHint": "Verbonden selectie", "toothLinkHint": "Deze tanden verbinden", "toothUnlinkHint": "Deze tanden ontkoppelen", @@ -871,12 +874,19 @@ "entryStepTeeth": "Tanden", "entryStepContent": "Inhoud", "entryStepLab": "Lab", + "entryStepTreatment": "Behandeling", "entryStepBack": "Terug", "entryStepNext": "Volgende", + "continueToLab": "Verder naar lab", + "backToTreatment": "Terug naar behandeling", + "selectTypeBeforeTeeth": "Kies een behandeltype voordat u tanden selecteert.", + "labDispatchNotSent": "Niet verzonden", + "labDispatchLastLab": "Laatste lab: {name}", + "labCommentsMovedHint": "Voortgang en berichten staan bij Labzendingen.", "entryStepLabUnavailable": "Labverzending is beschikbaar nadat u een lab-afhankelijk detail met geselecteerde tanden hebt opgeslagen.", "toothChartWholePlan": "Volledig overzicht", "labShipmentAttachments": "Bestanden voor het lab", - "labShipmentAttachmentsHint": "Kies welke bijlagen van dit detail bij deze zending horen. Standaard worden er geen meegestuurd.", + "labShipmentAttachmentsHint": "Alle bestanden gaan mee. Vink uit wat u niet wilt meesturen.", "selectedLabel": "Geselecteerd:", "selectedEmpty": "—", "upperArch": "Bovenboog", diff --git a/frontend/src/components/treatment/detailChipLabel.ts b/frontend/src/components/treatment/detailChipLabel.ts new file mode 100644 index 0000000..37ce2ad --- /dev/null +++ b/frontend/src/components/treatment/detailChipLabel.ts @@ -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}`; +} diff --git a/frontend/src/components/treatment/labDispatchDefaults.ts b/frontend/src/components/treatment/labDispatchDefaults.ts new file mode 100644 index 0000000..1dc40b0 --- /dev/null +++ b/frontend/src/components/treatment/labDispatchDefaults.ts @@ -0,0 +1,73 @@ +const STORAGE_PREFIX = 'dyolink.labDispatchDefaults.'; + +export type LabDispatchDefaults = { + lastLabId: string | null; + lastProsthesisByLab: Record; +}; + +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; + 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; +} diff --git a/frontend/src/components/treatment/treatmentDetailRules.ts b/frontend/src/components/treatment/treatmentDetailRules.ts index db8ff79..594c574 100644 --- a/frontend/src/components/treatment/treatmentDetailRules.ts +++ b/frontend/src/components/treatment/treatmentDetailRules.ts @@ -44,15 +44,39 @@ export function isDetailTypeSelected(detail: Pick): } export function isEmptyDraftDetail(detail: TreatmentDetailDraft): boolean { + return isBlankTreatmentDetail(detail); +} + +/** Strip-card delete: no type, teeth, notes, or attachments — including an empty list. */ +export function isBlankTreatmentDetail(detail: { + treatmentType?: string | null; + teeth?: readonly string[] | null; + comment?: string | null; + notes?: string | null; + attachmentMetas?: readonly unknown[] | null; +}): boolean { return ( - !isDetailTypeSelected(detail) && - detail.teeth.length === 0 && - !detail.comment.trim() + !detail.treatmentType?.trim() && + (detail.teeth?.length ?? 0) === 0 && + !`${detail.comment ?? ''}${detail.notes ?? ''}`.trim() && + (detail.attachmentMetas?.length ?? 0) === 0 ); } +export function areUnscheduledDetailsStripDeletable( + details: readonly { + treatmentType?: string | null; + teeth?: readonly string[] | null; + comment?: string | null; + notes?: string | null; + attachmentMetas?: readonly unknown[] | null; + }[], +): boolean { + return details.every(isBlankTreatmentDetail); +} + export function areDetailsPersistable(details: TreatmentDetailDraft[]): boolean { - return details.length > 0 && details.every((d) => isDetailTypeSelected(d)); + return details.every((d) => isDetailTypeSelected(d)); } /** Lab dispatch UI applies only to persisted prosthesis (or lab-dependent) lines with teeth. */ diff --git a/frontend/src/components/ui/appointments/AppointmentsPage.tsx b/frontend/src/components/ui/appointments/AppointmentsPage.tsx index f5c69cd..ccbcab3 100644 --- a/frontend/src/components/ui/appointments/AppointmentsPage.tsx +++ b/frontend/src/components/ui/appointments/AppointmentsPage.tsx @@ -219,9 +219,24 @@ export function AppointmentsPage() { return (
-
-

{t('title')}

-

{t('subtitle')}

+
+
+

{t('title')}

+

{t('subtitle')}

+
+
+ setScheduleDate(startOfLocalDay(d))} + /> +
+
+ {loadingSchedule ? ( +

{t('loadingSchedule')}

+ ) : null} +
@@ -248,16 +263,6 @@ export function AppointmentsPage() {
-
- setScheduleDate(startOfLocalDay(d))} - /> - {loadingSchedule && ( -

{t('loadingSchedule')}

- )} -
- void; onStarted: (detail: LabCaseDetail) => void; + onDeleted: () => void; onError: (message: string) => void; } @@ -104,6 +105,7 @@ export function CaseCreatePanel({ canEdit, onSaved, onStarted, + onDeleted, onError, }: CaseCreatePanelProps) { const t = useTranslations('cases'); @@ -136,11 +138,13 @@ export function CaseCreatePanel({ const [applyAllProsthesis, setApplyAllProsthesis] = useState(''); const [saving, setSaving] = useState(false); const [starting, setStarting] = useState(false); + const [deleting, setDeleting] = useState(false); const [uploadBusy, setUploadBusy] = useState(false); const rangeAnchorRef = useRef(null); const hydratedIdRef = useRef(labCase.id); const skipSaveRef = useRef(true); const startingRef = useRef(false); + const deletingRef = useRef(false); const allowPersistWhileStartingRef = useRef(false); const attachmentInputRef = useRef(null); @@ -221,6 +225,9 @@ export function CaseCreatePanel({ ); const persist = useCallback(async () => { + if (deletingRef.current) { + return null; + } if (startingRef.current && !allowPersistWhileStartingRef.current) { return null; } @@ -251,7 +258,7 @@ export function CaseCreatePanel({ skipSaveRef.current = false; return; } - if (!canEdit || startingRef.current) return; + if (!canEdit || startingRef.current || deletingRef.current) return; const timeout = setTimeout(() => { void persistRef.current(); }, 500); @@ -264,6 +271,22 @@ export function CaseCreatePanel({ ); } + async function handleDelete() { + if (!canEdit) return; + if (!window.confirm(t('confirmDeleteDraftCase'))) return; + deletingRef.current = true; + skipSaveRef.current = true; + setDeleting(true); + try { + await casesApi.deleteDraft(labCase.id); + onDeleted(); + } catch (error: unknown) { + deletingRef.current = false; + setDeleting(false); + onError(getUserFacingError(error, tErrors, t('errorDeleteCase'))); + } + } + async function handleStart() { if (!canEdit) return; setStarting(true); @@ -311,7 +334,7 @@ export function CaseCreatePanel({ } } - const disabled = !canEdit || starting; + const disabled = !canEdit || starting || deleting; return (
@@ -320,14 +343,24 @@ export function CaseCreatePanel({

{t('addCaseTitle')}

{t('addCaseSubtitle')}

- +
+ + +
diff --git a/frontend/src/components/ui/lab/CasesPage.tsx b/frontend/src/components/ui/lab/CasesPage.tsx index ae281e2..1839e82 100644 --- a/frontend/src/components/ui/lab/CasesPage.tsx +++ b/frontend/src/components/ui/lab/CasesPage.tsx @@ -397,8 +397,17 @@ export function CasesPage() { mobileDetailOpen && selectedCaseId ? 'hidden lg:flex' : 'flex' }`} > -
-
+ {canEdit ? ( + + ) : null} -
- {canEdit ? ( - - ) : null} -