improvement/v1-demo-improvements. #62
12
.cursor/rules/appointments.mdc
Normal file
12
.cursor/rules/appointments.mdc
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
description: Clinic appointments — delete/patient locks when treatment linked
|
||||||
|
globs: frontend/src/components/ui/appointments/**,frontend/src/components/appointments/**,backend/src/modules/appointments/**
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Appointments
|
||||||
|
|
||||||
|
- List includes `hasTreatment` when a `Treatment` row is linked (`appointmentId`).
|
||||||
|
- **Patient change** blocked while linked → `APPOINTMENT_PATIENT_LOCKED` (UI: `patientLockedHint`).
|
||||||
|
- **Delete** blocked while linked → `APPOINTMENT_HAS_TREATMENT` (hide delete + `deleteBlockedHint`). Empty appointments (no treatment yet) remain deletable.
|
||||||
|
- Prisma: `Treatment.appointmentId` is `onDelete: SetNull` — deleting an appointment does **not** cascade-delete treatments/cases/tasks; do not rely on cascade for cleanup.
|
||||||
@@ -1,40 +1,22 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
description: Treatment workspace — preview vs form, history, load flow, lab dispatch
|
description: Treatment workspace — preview vs form, history, load flow, lab dispatch
|
||||||
|
|
||||||
globs: frontend/src/components/ui/treatment/**,frontend/src/components/treatment/**,frontend/src/components/shared/treatmentSelection.ts,frontend/src/components/shared/scrollWithinMain.ts,backend/src/modules/treatments/**
|
globs: frontend/src/components/ui/treatment/**,frontend/src/components/treatment/**,frontend/src/components/shared/treatmentSelection.ts,frontend/src/components/shared/scrollWithinMain.ts,backend/src/modules/treatments/**
|
||||||
|
|
||||||
alwaysApply: false
|
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: 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. Appointment purpose does not gate type or Next.
|
||||||
|
- **Tooth groups:** Shift+same-arch range = connected; click in connected span → collapse to that single only. `toothSelectionGroups.ts`; prune lab `toothProsthesis` on change.
|
||||||
|
- **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.
|
||||||
- **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`. No new fetch params.
|
- **Lab shipment block:** lab-dependent detail with no teeth saves but cannot ship — `LabShipmentBlockedNotice`, inline banner, toast on add shipment.
|
||||||
|
- **Edit gating:** `canEditTreatmentForDay` = permission + appointment + not past day + `live` mode. Sent detail locks that line; Add still OK same day. Upload rejects sent (`TREATMENT_DETAIL_SENT`).
|
||||||
- **Lab shipment block:** prosthesis/lab-dependent detail with no teeth saves but cannot ship — `LabShipmentBlockedNotice`, inline banner, toast on add shipment.
|
- **Lab search:** `LinkedOrganizationSearchCombobox`; invite lab via `/organizations?action=invite-lab` when permitted.
|
||||||
|
- **Scroll:** `scrollWithinMainScrollContainer`; shared `Checkbox` only.
|
||||||
- **Lab case comments:** `DetailLabCaseCommentsSection` under detail when sent and lab tasks not all complete (`taskProgress` from API).
|
|
||||||
|
|
||||||
- **Today checkbox:** unchecked when viewing a non-today day; checking Today unlocks selection and auto-picks nearest appointment.
|
|
||||||
|
|
||||||
- **Lab search:** `LinkedOrganizationSearchCombobox` — select from results; invite lab via `/organizations?action=invite-lab` when permitted.
|
|
||||||
|
|
||||||
- **Scroll:** use `scrollWithinMainScrollContainer`; shared `Checkbox` only.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Full map: `.cursor/skills/treatment-workspace/SKILL.md`
|
Full map: `.cursor/skills/treatment-workspace/SKILL.md`
|
||||||
|
|
||||||
|
|||||||
@@ -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, 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, entry wizard, connected teeth, lab dispatch. Use when changing treatment UX, preview/history, or lab dispatch in TreatmentWorkspace.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -28,7 +28,37 @@ Thin route: `app/[locale]/(dashboard)/treatment/page.tsx` (supports `?appointmen
|
|||||||
|
|
||||||
3. **Treatment history** — `PastTreatmentsPanel.tsx` (past saved plans for patient; **client-side** filters in `treatmentHistoryFilters.ts`)
|
3. **Treatment history** — `PastTreatmentsPanel.tsx` (past saved plans for patient; **client-side** filters in `treatmentHistoryFilters.ts`)
|
||||||
|
|
||||||
4. **Editor** — `TreatmentDetailsEditor.tsx`, `FdiToothChart.tsx`, `LabCasesDispatchPanel.tsx`
|
4. **Editor** — detail chips + Add (`TreatmentDetailsEditor` chrome); entry wizard (`WizardStepper`); step panels: `FdiToothChart`, Content fields, `LabCasesDispatchPanel`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Entry wizard (Teeth / Content / Lab)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Right-column entry uses `WizardStepper` (`components/ui/shared/WizardStepper.tsx`) — numbered nodes + connector rail. **Do not** reuse detail-chip tab styling for steps.
|
||||||
|
|
||||||
|
| Step | UI | Notes |
|
||||||
|
|------|-----|--------|
|
||||||
|
| **Teeth** | `FdiToothChart` | Shift+click range; connected dots |
|
||||||
|
| **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`) |
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Tooth selection groups
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Helpers: `frontend/src/components/treatment/toothSelectionGroups.ts`. Persisted as `toothSelectionGroups` on the detail; lab tasks group by `selectionGroupId`.
|
||||||
|
|
||||||
|
- **Plain click:** add/remove single; if tooth is in a **connected** group → drop the whole span and keep **only** that tooth as a single (no peel / no connected+single pair for the same span).
|
||||||
|
- **Shift+click:** inclusive same-arch range between prior anchor and target; replaces overlapping groups. Prevent browser selection artifacts (`select-none`, Shift `preventDefault` on mousedown).
|
||||||
|
- On group change, prune/remap `labCase.toothProsthesis` via `pruneToothProsthesisForGroups`.
|
||||||
|
- Connected UI label: `ConnectedSelectionBadge` (shared `Badge` + primary tint) in dispatch + lab case lists.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -161,17 +191,27 @@ Draft writes require provider match (`ensureAppointmentProvider`) unless org own
|
|||||||
|
|
||||||
## Edit gating
|
## Edit gating
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|
||||||
canEditTreatmentForDay = canEdit && selectedAppointment && !isViewingPastDay && workspaceMode === 'live'
|
canEditTreatmentForDay = canEdit && selectedAppointment && !isViewingPastDay && workspaceMode === 'live'
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Past day or `historical` workspace mode freezes the treatment form + most lab-dispatch fields.
|
||||||
|
|
||||||
|
### Per-detail sent lock
|
||||||
|
|
||||||
|
`isDetailLocked` = linked lab case has `sentAt`. Locked details: type, comment, teeth, attachments are read-only (UI + draft save skips updates/deletes; attachment upload returns `TREATMENT_DETAIL_SENT`).
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### Sent lab shipment fields (vs detail)
|
||||||
|
|
||||||
|
After send, destination / prosthesis / shipment attachments are frozen. **Due date** stays editable until all lab tasks complete (UI still respects day/mode `disabled`). **Comments** stay editable until tasks complete and intentionally ignore the day/mode gate.
|
||||||
|
|
||||||
|
Full map helpers: `treatmentDetailRules.ts`, `LabCasesDispatchPanel.tsx`.
|
||||||
|
|
||||||
## Optional fields
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
11
AGENTS.md
11
AGENTS.md
@@ -42,13 +42,22 @@ frontend/src/
|
|||||||
|
|
||||||
**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):**
|
||||||
|
- Day/mode gate: editable only for live draft on today/future (`canEditTreatmentForDay`). Past day / historical load → read-only form.
|
||||||
|
- 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). Detail chips ≠ wizard chrome (chips stay bordered tabs; stepper is numbered rail).
|
||||||
|
- **Tooth selection:** Shift+click same-arch range = connected group; plain click on a connected tooth collapses the whole span to that single tooth (no peel). Helpers: `toothSelectionGroups.ts`. Connected label: `ConnectedSelectionBadge` (shared `Badge` + primary tint).
|
||||||
|
|
||||||
**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.
|
||||||
- **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).
|
- **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).
|
||||||
- **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).
|
||||||
- **Lab shipment progress + comments**: shown in **Lab dispatch panel** for the active shipment; expanding activity / opening comments marks that case read.
|
- **Lab shipment progress + comments**: shown in **Lab dispatch panel** for the active shipment; expanding activity / opening comments marks that case read.
|
||||||
|
|
||||||
|
**Appointments (quick ref):** Do not delete (or change patient) when `hasTreatment`; codes `APPOINTMENT_HAS_TREATMENT` / `APPOINTMENT_PATIENT_LOCKED`. Appointment delete does not cascade-delete treatments. See `.cursor/rules/appointments.mdc`.
|
||||||
|
|
||||||
**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; 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; **mobile:** larger task status controls, sticky case header when grouped; **tab badges:** `LabCaseActivity` + `GET /notifications/tab-counts` (lab Cases/Tasks split, clinic Treatment) — see `.cursor/skills/lab-tasks/SKILL.md` and `.cursor/skills/lab-notifications/SKILL.md`.
|
**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; 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; **mobile:** larger task status controls, sticky case header when grouped; **tab badges:** `LabCaseActivity` + `GET /notifications/tab-counts` (lab Cases/Tasks split, clinic Treatment) — see `.cursor/skills/lab-tasks/SKILL.md` and `.cursor/skills/lab-notifications/SKILL.md`.
|
||||||
|
|
||||||
**Lab Cases tab:** Filter by **prosthesis type** (not treatment type); auto-select newest case on open; list cards use `LabCaseProsthesisGroupsList` (colored type + teeth, shared with Treatment rail). Deep link: `?caseId=`, `?clinicOrganizationId=`. **Share link:** QR + URL on sent cases (attachment left, QR right); opens `/lab-case/[token]` focus page. 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 cards use `LabCaseProsthesisGroupsList` (colored type + teeth, shared with Treatment rail). Deep link: `?caseId=`, `?clinicOrganizationId=`. **Share link:** QR + URL on sent cases (attachment left, QR right); opens `/lab-case/[token]` focus page. See `.cursor/skills/lab-cases/SKILL.md` and `.cursor/skills/lab-case-share-link/SKILL.md`.
|
||||||
@@ -82,7 +91,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 |
|
| `.cursor/skills/treatment-workspace/` | Treatment tab: preview vs form, history, load flow, drafts, entry wizard, 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, list cards, assignment |
|
| `.cursor/skills/lab-cases/` | Lab Cases tab: prosthesis filter, auto-select, list cards, assignment |
|
||||||
| `.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 |
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "treatment_details" ADD COLUMN "toothSelectionGroups" JSONB;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "lab_case_tooth_prosthesis" ADD COLUMN "selectionGroupId" TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "lab_case_tasks" ADD COLUMN "selectionGroupId" TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX IF EXISTS "lab_case_tasks_labCaseId_treatmentDetailId_prosthesisTypeCode_stepOrder_key";
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "lab_case_tasks_labCaseId_treatmentDetailId_selectionGroupId_prosthesisTypeCode_stepOrder_key" ON "lab_case_tasks"("labCaseId", "treatmentDetailId", "selectionGroupId", "prosthesisTypeCode", "stepOrder");
|
||||||
@@ -176,6 +176,8 @@ model TreatmentDetail {
|
|||||||
sortOrder Int
|
sortOrder Int
|
||||||
treatmentType String
|
treatmentType String
|
||||||
teeth Json
|
teeth Json
|
||||||
|
/** Selection groups: connected spans + singles. Shape: { groupId, kind, teeth[] }[] */
|
||||||
|
toothSelectionGroups Json?
|
||||||
comment String?
|
comment String?
|
||||||
|
|
||||||
treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade)
|
treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade)
|
||||||
@@ -340,6 +342,8 @@ model LabCaseToothProsthesis {
|
|||||||
treatmentDetailId String
|
treatmentDetailId String
|
||||||
tooth String
|
tooth String
|
||||||
prosthesisTypeCode String
|
prosthesisTypeCode String
|
||||||
|
/** Links to TreatmentDetail.toothSelectionGroups[].groupId for task grouping. */
|
||||||
|
selectionGroupId String @default("")
|
||||||
|
|
||||||
labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade)
|
labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade)
|
||||||
detail TreatmentDetail @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade)
|
detail TreatmentDetail @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade)
|
||||||
@@ -355,6 +359,8 @@ model LabCaseTask {
|
|||||||
teeth Json
|
teeth Json
|
||||||
treatmentType String
|
treatmentType String
|
||||||
prosthesisTypeCode String
|
prosthesisTypeCode String
|
||||||
|
/** Empty string for legacy tasks; otherwise matches selection group id. */
|
||||||
|
selectionGroupId String @default("")
|
||||||
workflowStepCode String
|
workflowStepCode String
|
||||||
stepOrder Int
|
stepOrder Int
|
||||||
stepLabel String
|
stepLabel String
|
||||||
@@ -373,7 +379,7 @@ model LabCaseTask {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@unique([labCaseId, treatmentDetailId, prosthesisTypeCode, stepOrder])
|
@@unique([labCaseId, treatmentDetailId, selectionGroupId, prosthesisTypeCode, stepOrder])
|
||||||
@@index([labCaseId, status])
|
@@index([labCaseId, status])
|
||||||
@@index([assigneeUserId])
|
@@index([assigneeUserId])
|
||||||
@@map("lab_case_tasks")
|
@@map("lab_case_tasks")
|
||||||
|
|||||||
@@ -660,24 +660,44 @@ export class CasesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildProsthesisGroupsFromTasks(
|
private buildProsthesisGroupsFromTasks(
|
||||||
tasks: Array<{ prosthesisTypeCode: string; teeth: Prisma.JsonValue }>,
|
tasks: Array<{
|
||||||
): Array<{ prosthesisTypeCode: string; teeth: string[] }> {
|
prosthesisTypeCode: string;
|
||||||
const prosthesisByCode = new Map<string, string[]>();
|
teeth: Prisma.JsonValue;
|
||||||
|
selectionGroupId?: string;
|
||||||
|
}>,
|
||||||
|
): Array<{
|
||||||
|
prosthesisTypeCode: string;
|
||||||
|
teeth: string[];
|
||||||
|
connected?: boolean;
|
||||||
|
selectionGroupId?: string;
|
||||||
|
}> {
|
||||||
|
const prosthesisByGroup = new Map<
|
||||||
|
string,
|
||||||
|
{ prosthesisTypeCode: string; teeth: string[]; selectionGroupId: string }
|
||||||
|
>();
|
||||||
|
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
if (!task.prosthesisTypeCode) continue;
|
if (!task.prosthesisTypeCode) continue;
|
||||||
|
const selectionGroupId = task.selectionGroupId ?? '';
|
||||||
|
const key = `${selectionGroupId}::${task.prosthesisTypeCode}`;
|
||||||
const teeth = normalizeTaskTeeth(task.teeth);
|
const teeth = normalizeTaskTeeth(task.teeth);
|
||||||
const list = prosthesisByCode.get(task.prosthesisTypeCode) ?? [];
|
const entry = prosthesisByGroup.get(key) ?? {
|
||||||
list.push(...teeth);
|
prosthesisTypeCode: task.prosthesisTypeCode,
|
||||||
prosthesisByCode.set(task.prosthesisTypeCode, list);
|
teeth: [],
|
||||||
|
selectionGroupId,
|
||||||
|
};
|
||||||
|
entry.teeth.push(...teeth);
|
||||||
|
prosthesisByGroup.set(key, entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...prosthesisByCode.entries()]
|
return [...prosthesisByGroup.values()]
|
||||||
.map(([prosthesisTypeCode, teeth]) => ({
|
.map((group) => ({
|
||||||
prosthesisTypeCode,
|
prosthesisTypeCode: group.prosthesisTypeCode,
|
||||||
teeth: [...new Set(teeth)].sort((a, b) =>
|
teeth: [...new Set(group.teeth)].sort((a, b) =>
|
||||||
a.localeCompare(b, undefined, { numeric: true }),
|
a.localeCompare(b, undefined, { numeric: true }),
|
||||||
),
|
),
|
||||||
|
selectionGroupId: group.selectionGroupId || undefined,
|
||||||
|
connected: Boolean(group.selectionGroupId) && [...new Set(group.teeth)].length > 1,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => a.prosthesisTypeCode.localeCompare(b.prosthesisTypeCode));
|
.sort((a, b) => a.prosthesisTypeCode.localeCompare(b.prosthesisTypeCode));
|
||||||
}
|
}
|
||||||
@@ -694,12 +714,14 @@ export class CasesService {
|
|||||||
treatmentType: string;
|
treatmentType: string;
|
||||||
prosthesisTypeCode: string;
|
prosthesisTypeCode: string;
|
||||||
prosthesisTypeLabel: string;
|
prosthesisTypeLabel: string;
|
||||||
|
selectionGroupId: string;
|
||||||
tasks: ReturnType<CasesService['mapTask']>[];
|
tasks: ReturnType<CasesService['mapTask']>[];
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
|
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
const key = `${task.treatmentDetailId}:${task.prosthesisTypeCode}`;
|
const selectionGroupId = task.selectionGroupId ?? '';
|
||||||
|
const key = `${task.treatmentDetailId}:${selectionGroupId}:${task.prosthesisTypeCode}`;
|
||||||
const entry = groups.get(key) ?? {
|
const entry = groups.get(key) ?? {
|
||||||
treatmentDetailId: task.treatmentDetailId,
|
treatmentDetailId: task.treatmentDetailId,
|
||||||
teeth: normalizeTaskTeeth(task.teeth),
|
teeth: normalizeTaskTeeth(task.teeth),
|
||||||
@@ -707,6 +729,7 @@ export class CasesService {
|
|||||||
prosthesisTypeCode: task.prosthesisTypeCode,
|
prosthesisTypeCode: task.prosthesisTypeCode,
|
||||||
prosthesisTypeLabel:
|
prosthesisTypeLabel:
|
||||||
prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode,
|
prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode,
|
||||||
|
selectionGroupId,
|
||||||
tasks: [],
|
tasks: [],
|
||||||
};
|
};
|
||||||
entry.tasks.push(this.mapTask(task, prosthesisLabels));
|
entry.tasks.push(this.mapTask(task, prosthesisLabels));
|
||||||
|
|||||||
@@ -293,24 +293,41 @@ export class LabCaseAccessService {
|
|||||||
) {
|
) {
|
||||||
const tasks = await this.prisma.labCaseTask.findMany({
|
const tasks = await this.prisma.labCaseTask.findMany({
|
||||||
where: { labCaseId },
|
where: { labCaseId },
|
||||||
select: { prosthesisTypeCode: true, teeth: true },
|
select: { prosthesisTypeCode: true, teeth: true, selectionGroupId: true },
|
||||||
orderBy: [{ prosthesisTypeCode: 'asc' }],
|
orderBy: [{ prosthesisTypeCode: 'asc' }],
|
||||||
});
|
});
|
||||||
|
|
||||||
const byCode = new Map<string, string[]>();
|
const byGroup = new Map<
|
||||||
|
string,
|
||||||
|
{ prosthesisTypeCode: string; teeth: string[]; selectionGroupId: string }
|
||||||
|
>();
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
if (!task.prosthesisTypeCode) continue;
|
if (!task.prosthesisTypeCode) continue;
|
||||||
|
const selectionGroupId = task.selectionGroupId ?? '';
|
||||||
|
const key = `${selectionGroupId}::${task.prosthesisTypeCode}`;
|
||||||
const teeth = normalizeTaskTeeth(task.teeth);
|
const teeth = normalizeTaskTeeth(task.teeth);
|
||||||
const list = byCode.get(task.prosthesisTypeCode) ?? [];
|
const entry = byGroup.get(key) ?? {
|
||||||
list.push(...teeth);
|
prosthesisTypeCode: task.prosthesisTypeCode,
|
||||||
byCode.set(task.prosthesisTypeCode, list);
|
teeth: [],
|
||||||
|
selectionGroupId,
|
||||||
|
};
|
||||||
|
entry.teeth.push(...teeth);
|
||||||
|
byGroup.set(key, entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...byCode.entries()].map(([prosthesisTypeCode, teeth]) => ({
|
return [...byGroup.values()].map((group) => {
|
||||||
prosthesisTypeCode,
|
const teeth = [...new Set(group.teeth)].sort((a, b) =>
|
||||||
prosthesisTypeLabel: prosthesisLabels.get(prosthesisTypeCode) ?? prosthesisTypeCode,
|
a.localeCompare(b, undefined, { numeric: true }),
|
||||||
teeth: [...new Set(teeth)].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })),
|
);
|
||||||
}));
|
return {
|
||||||
|
prosthesisTypeCode: group.prosthesisTypeCode,
|
||||||
|
prosthesisTypeLabel:
|
||||||
|
prosthesisLabels.get(group.prosthesisTypeCode) ?? group.prosthesisTypeCode,
|
||||||
|
teeth,
|
||||||
|
selectionGroupId: group.selectionGroupId || undefined,
|
||||||
|
connected: Boolean(group.selectionGroupId) && teeth.length > 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getMembership(userId: string, organizationId: string) {
|
private async getMembership(userId: string, organizationId: string) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ function buildMockTx(options: {
|
|||||||
treatmentDetailId: string;
|
treatmentDetailId: string;
|
||||||
tooth: string;
|
tooth: string;
|
||||||
prosthesisTypeCode: string;
|
prosthesisTypeCode: string;
|
||||||
|
selectionGroupId?: string;
|
||||||
treatmentType?: string;
|
treatmentType?: string;
|
||||||
}>;
|
}>;
|
||||||
prosthesisTypes: Array<{
|
prosthesisTypes: Array<{
|
||||||
@@ -35,9 +36,11 @@ function buildMockTx(options: {
|
|||||||
treatmentDetailId: row.treatmentDetailId,
|
treatmentDetailId: row.treatmentDetailId,
|
||||||
tooth: row.tooth,
|
tooth: row.tooth,
|
||||||
prosthesisTypeCode: row.prosthesisTypeCode,
|
prosthesisTypeCode: row.prosthesisTypeCode,
|
||||||
|
selectionGroupId: row.selectionGroupId ?? '',
|
||||||
detail: {
|
detail: {
|
||||||
id: row.treatmentDetailId,
|
id: row.treatmentDetailId,
|
||||||
treatmentType: row.treatmentType ?? 'prosthesis',
|
treatmentType: row.treatmentType ?? 'prosthesis',
|
||||||
|
toothSelectionGroups: null,
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
@@ -169,6 +172,49 @@ describe('generateLabCaseTasks', () => {
|
|||||||
expect(stepCodes).toContain('printer_resin');
|
expect(stepCodes).toContain('printer_resin');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps separate selection groups with the same prosthesis type as separate task sets', async () => {
|
||||||
|
const pfmSteps = stepsFromSeed('pfm_crown');
|
||||||
|
const { tx, created } = buildMockTx({
|
||||||
|
toothProsthesisRows: [
|
||||||
|
{
|
||||||
|
treatmentDetailId: 'detail-1',
|
||||||
|
tooth: '14',
|
||||||
|
prosthesisTypeCode: 'pfm_crown',
|
||||||
|
selectionGroupId: 'connected-1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
treatmentDetailId: 'detail-1',
|
||||||
|
tooth: '15',
|
||||||
|
prosthesisTypeCode: 'pfm_crown',
|
||||||
|
selectionGroupId: 'connected-1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
treatmentDetailId: 'detail-1',
|
||||||
|
tooth: '21',
|
||||||
|
prosthesisTypeCode: 'pfm_crown',
|
||||||
|
selectionGroupId: 'single-21',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
prosthesisTypes: [{ code: 'pfm_crown', steps: pfmSteps }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const count = await generateLabCaseTasks(tx as never, 'lab-case-split', 'en');
|
||||||
|
expect(count).toBe(pfmSteps.length * 2);
|
||||||
|
const rows = created as Array<{
|
||||||
|
teeth: string[];
|
||||||
|
selectionGroupId: string;
|
||||||
|
prosthesisTypeCode: string;
|
||||||
|
}>;
|
||||||
|
const connected = rows.filter((r) => r.selectionGroupId === 'connected-1');
|
||||||
|
const single = rows.filter((r) => r.selectionGroupId === 'single-21');
|
||||||
|
expect(connected).toHaveLength(pfmSteps.length);
|
||||||
|
expect(single).toHaveLength(pfmSteps.length);
|
||||||
|
expect(connected.every((r) => JSON.stringify(r.teeth) === JSON.stringify(['14', '15']))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(single.every((r) => JSON.stringify(r.teeth) === JSON.stringify(['21']))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('skips generation when tasks already exist', async () => {
|
it('skips generation when tasks already exist', async () => {
|
||||||
const { tx } = buildMockTx({
|
const { tx } = buildMockTx({
|
||||||
existingCount: 3,
|
existingCount: 3,
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export async function generateLabCaseTasks(
|
|||||||
const toothProsthesisRows = await tx.labCaseToothProsthesis.findMany({
|
const toothProsthesisRows = await tx.labCaseToothProsthesis.findMany({
|
||||||
where: { labCaseId },
|
where: { labCaseId },
|
||||||
include: {
|
include: {
|
||||||
detail: { select: { id: true, treatmentType: true } },
|
detail: { select: { id: true, treatmentType: true, toothSelectionGroups: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -58,19 +58,29 @@ export async function generateLabCaseTasks(
|
|||||||
|
|
||||||
const stepLabels = await resolveStepLabels(tx, allStepCodes, locale);
|
const stepLabels = await resolveStepLabels(tx, allStepCodes, locale);
|
||||||
|
|
||||||
// Group teeth that share the same (treatment detail + prosthesis type): one task set
|
// Group by selection group + prosthesis type so connected spans stay one task set,
|
||||||
// per group, with each step covering every tooth in that group.
|
// and separate singles stay separate even with the same prosthesis type.
|
||||||
const groups = new Map<
|
const groups = new Map<
|
||||||
string,
|
string,
|
||||||
{ treatmentDetailId: string; treatmentType: string; prosthesisTypeCode: string; teeth: string[] }
|
{
|
||||||
|
treatmentDetailId: string;
|
||||||
|
treatmentType: string;
|
||||||
|
prosthesisTypeCode: string;
|
||||||
|
selectionGroupId: string;
|
||||||
|
teeth: string[];
|
||||||
|
}
|
||||||
>();
|
>();
|
||||||
|
|
||||||
for (const row of toothProsthesisRows) {
|
for (const row of toothProsthesisRows) {
|
||||||
const key = `${row.treatmentDetailId}::${row.prosthesisTypeCode}`;
|
const selectionGroupId =
|
||||||
|
row.selectionGroupId?.trim() ||
|
||||||
|
fallbackGroupIdForTooth(row.detail.toothSelectionGroups, row.tooth, row.prosthesisTypeCode);
|
||||||
|
const key = `${row.treatmentDetailId}::${selectionGroupId}::${row.prosthesisTypeCode}`;
|
||||||
const group = groups.get(key) ?? {
|
const group = groups.get(key) ?? {
|
||||||
treatmentDetailId: row.treatmentDetailId,
|
treatmentDetailId: row.treatmentDetailId,
|
||||||
treatmentType: row.detail.treatmentType,
|
treatmentType: row.detail.treatmentType,
|
||||||
prosthesisTypeCode: row.prosthesisTypeCode,
|
prosthesisTypeCode: row.prosthesisTypeCode,
|
||||||
|
selectionGroupId,
|
||||||
teeth: [],
|
teeth: [],
|
||||||
};
|
};
|
||||||
group.teeth.push(row.tooth);
|
group.teeth.push(row.tooth);
|
||||||
@@ -94,6 +104,7 @@ export async function generateLabCaseTasks(
|
|||||||
teeth,
|
teeth,
|
||||||
treatmentType: group.treatmentType,
|
treatmentType: group.treatmentType,
|
||||||
prosthesisTypeCode: group.prosthesisTypeCode,
|
prosthesisTypeCode: group.prosthesisTypeCode,
|
||||||
|
selectionGroupId: group.selectionGroupId,
|
||||||
workflowStepCode: step.workflowStepCode,
|
workflowStepCode: step.workflowStepCode,
|
||||||
stepOrder: step.stepOrder,
|
stepOrder: step.stepOrder,
|
||||||
stepLabel: stepLabels.get(step.workflowStepCode) ?? step.workflowStepCode,
|
stepLabel: stepLabels.get(step.workflowStepCode) ?? step.workflowStepCode,
|
||||||
@@ -110,6 +121,24 @@ export async function generateLabCaseTasks(
|
|||||||
return taskRows.length;
|
return taskRows.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fallbackGroupIdForTooth(
|
||||||
|
toothSelectionGroups: unknown,
|
||||||
|
tooth: string,
|
||||||
|
prosthesisTypeCode: string,
|
||||||
|
): string {
|
||||||
|
if (Array.isArray(toothSelectionGroups)) {
|
||||||
|
for (const row of toothSelectionGroups) {
|
||||||
|
if (!row || typeof row !== 'object') continue;
|
||||||
|
const rec = row as { groupId?: unknown; teeth?: unknown };
|
||||||
|
if (typeof rec.groupId !== 'string') continue;
|
||||||
|
if (Array.isArray(rec.teeth) && rec.teeth.includes(tooth)) {
|
||||||
|
return rec.groupId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `legacy-${prosthesisTypeCode}`;
|
||||||
|
}
|
||||||
|
|
||||||
function sortTeeth(teeth: string[]): string[] {
|
function sortTeeth(teeth: string[]): string[] {
|
||||||
return [...new Set(teeth)].sort((a, b) => {
|
return [...new Set(teeth)].sort((a, b) => {
|
||||||
const na = Number(a);
|
const na = Number(a);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
ArrayMinSize,
|
ArrayMinSize,
|
||||||
IsArray,
|
IsArray,
|
||||||
IsDateString,
|
IsDateString,
|
||||||
|
IsIn,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
IsUUID,
|
IsUUID,
|
||||||
@@ -10,6 +11,19 @@ import {
|
|||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class ToothSelectionGroupDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
|
groupId: string;
|
||||||
|
|
||||||
|
@IsIn(['connected', 'single'])
|
||||||
|
kind: 'connected' | 'single';
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
teeth: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export class SaveTreatmentDetailDto {
|
export class SaveTreatmentDetailDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(64)
|
@MaxLength(64)
|
||||||
@@ -27,6 +41,12 @@ export class SaveTreatmentDetailDto {
|
|||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
teeth: string[];
|
teeth: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => ToothSelectionGroupDto)
|
||||||
|
toothSelectionGroups?: ToothSelectionGroupDto[];
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(5000)
|
@MaxLength(5000)
|
||||||
@@ -57,6 +77,11 @@ export class LabCaseToothProsthesisDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(64)
|
@MaxLength(64)
|
||||||
prosthesisTypeCode: string;
|
prosthesisTypeCode: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(64)
|
||||||
|
selectionGroupId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SaveLabCaseDto {
|
export class SaveLabCaseDto {
|
||||||
|
|||||||
@@ -20,6 +20,42 @@ export function normalizeTeeth(teeth: unknown): string[] {
|
|||||||
return [...unique].sort();
|
return [...unique].sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ToothSelectionGroupNormalized = {
|
||||||
|
groupId: string;
|
||||||
|
kind: 'connected' | 'single';
|
||||||
|
teeth: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeToothSelectionGroups(
|
||||||
|
value: unknown,
|
||||||
|
fallbackTeeth: string[] = [],
|
||||||
|
): ToothSelectionGroupNormalized[] {
|
||||||
|
if (Array.isArray(value) && value.length > 0) {
|
||||||
|
const out: ToothSelectionGroupNormalized[] = [];
|
||||||
|
for (const row of value) {
|
||||||
|
if (!row || typeof row !== 'object') continue;
|
||||||
|
const rec = row as Record<string, unknown>;
|
||||||
|
const groupId = typeof rec.groupId === 'string' && rec.groupId.trim() ? rec.groupId.trim() : '';
|
||||||
|
if (!groupId) continue;
|
||||||
|
const kind = rec.kind === 'connected' ? 'connected' : 'single';
|
||||||
|
const teeth = normalizeTeeth(rec.teeth);
|
||||||
|
if (teeth.length === 0) continue;
|
||||||
|
out.push({
|
||||||
|
groupId,
|
||||||
|
kind: kind === 'connected' && teeth.length < 2 ? 'single' : kind,
|
||||||
|
teeth,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (out.length > 0) return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackTeeth.map((tooth, index) => ({
|
||||||
|
groupId: `legacy-${tooth}-${index}`,
|
||||||
|
kind: 'single' as const,
|
||||||
|
teeth: [tooth],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export function generateTreatmentTitle(
|
export function generateTreatmentTitle(
|
||||||
cases: { treatmentType: string; teeth: string[] }[],
|
cases: { treatmentType: string; teeth: string[] }[],
|
||||||
): string {
|
): string {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { LabCaseActivityService } from '../notifications/lab-case-activity.servi
|
|||||||
import {
|
import {
|
||||||
generateTreatmentTitle,
|
generateTreatmentTitle,
|
||||||
normalizeTeeth,
|
normalizeTeeth,
|
||||||
|
normalizeToothSelectionGroups,
|
||||||
} from './treatment.utils';
|
} from './treatment.utils';
|
||||||
import { assertCompleteToothProsthesisMap } from './lab-case-send.validation';
|
import { assertCompleteToothProsthesisMap } from './lab-case-send.validation';
|
||||||
import { hasEffectivePermission } from '../../common/membership-permissions';
|
import { hasEffectivePermission } from '../../common/membership-permissions';
|
||||||
@@ -51,7 +52,7 @@ const sentLabCaseInclude = {
|
|||||||
details: {
|
details: {
|
||||||
include: {
|
include: {
|
||||||
detail: {
|
detail: {
|
||||||
select: { clientKey: true, treatmentType: true, teeth: true },
|
select: { clientKey: true, treatmentType: true, teeth: true, toothSelectionGroups: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -59,9 +60,9 @@ const sentLabCaseInclude = {
|
|||||||
orderBy: [{ sentAt: 'asc' as const }],
|
orderBy: [{ sentAt: 'asc' as const }],
|
||||||
include: { organization: { select: { id: true, name: true } } },
|
include: { organization: { select: { id: true, name: true } } },
|
||||||
},
|
},
|
||||||
tasks: { select: { status: true } },
|
tasks: { select: { status: true, selectionGroupId: true, prosthesisTypeCode: true, teeth: true } },
|
||||||
toothProsthesis: {
|
toothProsthesis: {
|
||||||
select: { tooth: true, prosthesisTypeCode: true },
|
select: { tooth: true, prosthesisTypeCode: true, selectionGroupId: true },
|
||||||
},
|
},
|
||||||
} satisfies Prisma.LabCaseInclude;
|
} satisfies Prisma.LabCaseInclude;
|
||||||
|
|
||||||
@@ -93,7 +94,13 @@ const treatmentInclude = {
|
|||||||
details: {
|
details: {
|
||||||
include: {
|
include: {
|
||||||
detail: {
|
detail: {
|
||||||
select: { id: true, clientKey: true, treatmentType: true, teeth: true },
|
select: {
|
||||||
|
id: true,
|
||||||
|
clientKey: true,
|
||||||
|
treatmentType: true,
|
||||||
|
teeth: true,
|
||||||
|
toothSelectionGroups: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -283,17 +290,36 @@ export class TreatmentsService {
|
|||||||
const taskProgress = this.mapTaskProgress(lc.tasks);
|
const taskProgress = this.mapTaskProgress(lc.tasks);
|
||||||
const labOrg = lc.sends[lc.sends.length - 1]?.organization ?? null;
|
const labOrg = lc.sends[lc.sends.length - 1]?.organization ?? null;
|
||||||
const detailTeeth = detail ? normalizeTeeth(detail.teeth) : [];
|
const detailTeeth = detail ? normalizeTeeth(detail.teeth) : [];
|
||||||
|
const selectionGroups = normalizeToothSelectionGroups(
|
||||||
|
detail?.toothSelectionGroups,
|
||||||
|
detailTeeth,
|
||||||
|
);
|
||||||
|
const connectedGroupIds = new Set(
|
||||||
|
selectionGroups.filter((g) => g.kind === 'connected').map((g) => g.groupId),
|
||||||
|
);
|
||||||
|
|
||||||
const prosthesisByCode = new Map<string, string[]>();
|
const prosthesisByGroup = new Map<
|
||||||
|
string,
|
||||||
|
{ prosthesisTypeCode: string; teeth: string[]; selectionGroupId: string; connected: boolean }
|
||||||
|
>();
|
||||||
for (const row of lc.toothProsthesis ?? []) {
|
for (const row of lc.toothProsthesis ?? []) {
|
||||||
const list = prosthesisByCode.get(row.prosthesisTypeCode) ?? [];
|
const selectionGroupId = row.selectionGroupId || `legacy-${row.prosthesisTypeCode}`;
|
||||||
list.push(row.tooth);
|
const key = `${selectionGroupId}::${row.prosthesisTypeCode}`;
|
||||||
prosthesisByCode.set(row.prosthesisTypeCode, list);
|
const entry = prosthesisByGroup.get(key) ?? {
|
||||||
|
prosthesisTypeCode: row.prosthesisTypeCode,
|
||||||
|
teeth: [],
|
||||||
|
selectionGroupId,
|
||||||
|
connected: connectedGroupIds.has(row.selectionGroupId),
|
||||||
|
};
|
||||||
|
entry.teeth.push(row.tooth);
|
||||||
|
prosthesisByGroup.set(key, entry);
|
||||||
}
|
}
|
||||||
const prosthesisGroups = [...prosthesisByCode.entries()]
|
const prosthesisGroups = [...prosthesisByGroup.values()]
|
||||||
.map(([prosthesisTypeCode, teeth]) => ({
|
.map((group) => ({
|
||||||
prosthesisTypeCode,
|
prosthesisTypeCode: group.prosthesisTypeCode,
|
||||||
teeth: [...new Set(teeth)].sort(),
|
teeth: [...new Set(group.teeth)].sort(),
|
||||||
|
connected: group.connected,
|
||||||
|
selectionGroupId: group.selectionGroupId,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => a.prosthesisTypeCode.localeCompare(b.prosthesisTypeCode));
|
.sort((a, b) => a.prosthesisTypeCode.localeCompare(b.prosthesisTypeCode));
|
||||||
|
|
||||||
@@ -377,13 +403,21 @@ export class TreatmentsService {
|
|||||||
this.treatmentCatalog.assertKnownTreatmentType(d.treatmentType);
|
this.treatmentCatalog.assertKnownTreatmentType(d.treatmentType);
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedDetails = dto.details.map((d, index) => ({
|
const normalizedDetails = dto.details.map((d, index) => {
|
||||||
|
const teeth = normalizeTeeth(d.teeth);
|
||||||
|
const toothSelectionGroups = normalizeToothSelectionGroups(
|
||||||
|
d.toothSelectionGroups,
|
||||||
|
teeth,
|
||||||
|
);
|
||||||
|
return {
|
||||||
...d,
|
...d,
|
||||||
sortOrder: index,
|
sortOrder: index,
|
||||||
teeth: normalizeTeeth(d.teeth),
|
teeth,
|
||||||
|
toothSelectionGroups,
|
||||||
comment: d.comment?.trim() || null,
|
comment: d.comment?.trim() || null,
|
||||||
attachmentIds: d.attachmentIds ?? [],
|
attachmentIds: d.attachmentIds ?? [],
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const title = generateTreatmentTitle(
|
const title = generateTreatmentTitle(
|
||||||
normalizedDetails.map((d) => ({ treatmentType: d.treatmentType, teeth: d.teeth })),
|
normalizedDetails.map((d) => ({ treatmentType: d.treatmentType, teeth: d.teeth })),
|
||||||
@@ -454,6 +488,7 @@ export class TreatmentsService {
|
|||||||
sortOrder: d.sortOrder,
|
sortOrder: d.sortOrder,
|
||||||
treatmentType: d.treatmentType,
|
treatmentType: d.treatmentType,
|
||||||
teeth: d.teeth,
|
teeth: d.teeth,
|
||||||
|
toothSelectionGroups: d.toothSelectionGroups,
|
||||||
comment: d.comment,
|
comment: d.comment,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -464,6 +499,7 @@ export class TreatmentsService {
|
|||||||
sortOrder: d.sortOrder,
|
sortOrder: d.sortOrder,
|
||||||
treatmentType: d.treatmentType,
|
treatmentType: d.treatmentType,
|
||||||
teeth: d.teeth,
|
teeth: d.teeth,
|
||||||
|
toothSelectionGroups: d.toothSelectionGroups,
|
||||||
comment: d.comment,
|
comment: d.comment,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -642,6 +678,7 @@ export class TreatmentsService {
|
|||||||
treatmentDetailId: tp.treatmentDetailId,
|
treatmentDetailId: tp.treatmentDetailId,
|
||||||
tooth: tp.tooth,
|
tooth: tp.tooth,
|
||||||
prosthesisTypeCode: tp.prosthesisTypeCode,
|
prosthesisTypeCode: tp.prosthesisTypeCode,
|
||||||
|
selectionGroupId: tp.selectionGroupId?.trim() || '',
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1091,6 +1128,7 @@ export class TreatmentsService {
|
|||||||
clientKey?: string | null;
|
clientKey?: string | null;
|
||||||
treatmentType: string;
|
treatmentType: string;
|
||||||
teeth: unknown;
|
teeth: unknown;
|
||||||
|
toothSelectionGroups?: unknown;
|
||||||
comment?: string | null;
|
comment?: string | null;
|
||||||
attachments?: Array<{
|
attachments?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1114,11 +1152,13 @@ export class TreatmentsService {
|
|||||||
}) {
|
}) {
|
||||||
const labCase = d.labCaseLink?.labCase;
|
const labCase = d.labCaseLink?.labCase;
|
||||||
const taskProgress = this.mapTaskProgress(labCase?.tasks ?? []);
|
const taskProgress = this.mapTaskProgress(labCase?.tasks ?? []);
|
||||||
|
const teeth = normalizeTeeth(d.teeth);
|
||||||
return {
|
return {
|
||||||
id: d.id,
|
id: d.id,
|
||||||
clientId: d.clientKey ?? d.id,
|
clientId: d.clientKey ?? d.id,
|
||||||
treatmentType: d.treatmentType,
|
treatmentType: d.treatmentType,
|
||||||
teeth: normalizeTeeth(d.teeth),
|
teeth,
|
||||||
|
toothSelectionGroups: normalizeToothSelectionGroups(d.toothSelectionGroups, teeth),
|
||||||
notes: d.comment ?? null,
|
notes: d.comment ?? null,
|
||||||
attachmentMetas: (d.attachments ?? []).map((a) => this.mapAttachment(a)),
|
attachmentMetas: (d.attachments ?? []).map((a) => this.mapAttachment(a)),
|
||||||
labCaseId: labCase?.id ?? null,
|
labCaseId: labCase?.id ?? null,
|
||||||
@@ -1143,7 +1183,13 @@ export class TreatmentsService {
|
|||||||
dueDate?: Date | null;
|
dueDate?: Date | null;
|
||||||
details?: Array<{
|
details?: Array<{
|
||||||
treatmentDetailId: string;
|
treatmentDetailId: string;
|
||||||
detail?: { id: string; clientKey: string | null; treatmentType: string; teeth: unknown };
|
detail?: {
|
||||||
|
id: string;
|
||||||
|
clientKey: string | null;
|
||||||
|
treatmentType: string;
|
||||||
|
teeth: unknown;
|
||||||
|
toothSelectionGroups?: unknown;
|
||||||
|
};
|
||||||
}>;
|
}>;
|
||||||
sends?: Array<{
|
sends?: Array<{
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
@@ -1154,6 +1200,7 @@ export class TreatmentsService {
|
|||||||
treatmentDetailId: string;
|
treatmentDetailId: string;
|
||||||
tooth: string;
|
tooth: string;
|
||||||
prosthesisTypeCode: string;
|
prosthesisTypeCode: string;
|
||||||
|
selectionGroupId?: string;
|
||||||
}>;
|
}>;
|
||||||
attachments?: Array<{
|
attachments?: Array<{
|
||||||
attachment: {
|
attachment: {
|
||||||
@@ -1167,6 +1214,9 @@ export class TreatmentsService {
|
|||||||
tasks?: Array<{ id: string; status: LabTaskStatus }>;
|
tasks?: Array<{ id: string; status: LabTaskStatus }>;
|
||||||
}) {
|
}) {
|
||||||
const taskProgress = this.mapTaskProgress(lc.tasks ?? []);
|
const taskProgress = this.mapTaskProgress(lc.tasks ?? []);
|
||||||
|
const detailTeeth = lc.details?.[0]?.detail
|
||||||
|
? normalizeTeeth(lc.details[0].detail.teeth)
|
||||||
|
: [];
|
||||||
return {
|
return {
|
||||||
id: lc.id,
|
id: lc.id,
|
||||||
clientId: lc.clientKey ?? lc.id,
|
clientId: lc.clientKey ?? lc.id,
|
||||||
@@ -1180,13 +1230,18 @@ export class TreatmentsService {
|
|||||||
id: lc.details[0].detail?.id ?? lc.details[0].treatmentDetailId,
|
id: lc.details[0].detail?.id ?? lc.details[0].treatmentDetailId,
|
||||||
clientId: lc.details[0].detail?.clientKey ?? lc.details[0].treatmentDetailId,
|
clientId: lc.details[0].detail?.clientKey ?? lc.details[0].treatmentDetailId,
|
||||||
treatmentType: lc.details[0].detail?.treatmentType ?? '',
|
treatmentType: lc.details[0].detail?.treatmentType ?? '',
|
||||||
teeth: lc.details[0].detail ? normalizeTeeth(lc.details[0].detail.teeth) : [],
|
teeth: detailTeeth,
|
||||||
|
toothSelectionGroups: normalizeToothSelectionGroups(
|
||||||
|
lc.details[0].detail?.toothSelectionGroups,
|
||||||
|
detailTeeth,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
toothProsthesis: (lc.toothProsthesis ?? []).map((tp) => ({
|
toothProsthesis: (lc.toothProsthesis ?? []).map((tp) => ({
|
||||||
treatmentDetailId: tp.treatmentDetailId,
|
treatmentDetailId: tp.treatmentDetailId,
|
||||||
tooth: tp.tooth,
|
tooth: tp.tooth,
|
||||||
prosthesisTypeCode: tp.prosthesisTypeCode,
|
prosthesisTypeCode: tp.prosthesisTypeCode,
|
||||||
|
selectionGroupId: tp.selectionGroupId ?? '',
|
||||||
})),
|
})),
|
||||||
attachments: (lc.attachments ?? []).map((row) => ({
|
attachments: (lc.attachments ?? []).map((row) => ({
|
||||||
id: row.attachment.id,
|
id: row.attachment.id,
|
||||||
|
|||||||
@@ -661,6 +661,7 @@
|
|||||||
"detailsSubtitle": "Plan teeth, type, notes, and attachments for each detail line.",
|
"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}",
|
||||||
"detailLabel": "Detail {n}",
|
"detailLabel": "Detail {n}",
|
||||||
"detailSentBadge": "sent",
|
"detailSentBadge": "sent",
|
||||||
"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.",
|
||||||
@@ -799,8 +800,19 @@
|
|||||||
"noActiveOrgs": "No active linked organizations.",
|
"noActiveOrgs": "No active linked organizations.",
|
||||||
"confirmSend": "Confirm send",
|
"confirmSend": "Confirm send",
|
||||||
"toothChartTitle": "FDI tooth chart",
|
"toothChartTitle": "FDI tooth chart",
|
||||||
|
"entryWizardLabel": "Treatment entry steps",
|
||||||
|
"entryStepTeeth": "Teeth",
|
||||||
|
"entryStepContent": "Content",
|
||||||
|
"entryStepLab": "Lab",
|
||||||
|
"entryStepBack": "Back",
|
||||||
|
"entryStepNext": "Next",
|
||||||
|
"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 multi-select. Applies to the active detail.",
|
"toothChartHint": "Tap teeth to select. Shift-click another tooth in the same arch to select a connected span.",
|
||||||
|
"toothConnectedHint": "Connected selection",
|
||||||
|
"toothConnectedSuffix": " (connected)",
|
||||||
|
"connectedBadge": "Connected",
|
||||||
|
"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": "Select which attachments from this detail are included in this shipment. None are sent by default.",
|
||||||
|
|||||||
@@ -662,6 +662,7 @@
|
|||||||
"detailsSubtitle": "دندانها، نوع، یادداشت و پیوستها را برای هر خط جزئیات برنامهریزی کنید.",
|
"detailsSubtitle": "دندانها، نوع، یادداشت و پیوستها را برای هر خط جزئیات برنامهریزی کنید.",
|
||||||
"addDetail": "افزودن جزئیات",
|
"addDetail": "افزودن جزئیات",
|
||||||
"confirmRemoveDetail": "این جزئیات درمان حذف شود؟",
|
"confirmRemoveDetail": "این جزئیات درمان حذف شود؟",
|
||||||
|
"removeDetailAria": "حذف جزئیات {n}",
|
||||||
"detailLabel": "جزئیات {n}",
|
"detailLabel": "جزئیات {n}",
|
||||||
"detailSentBadge": "ارسالشده",
|
"detailSentBadge": "ارسالشده",
|
||||||
"detailLockedInShipment": "این جزئیات به لابراتوار ارسال شده و دیگر قابل ویرایش نیست.",
|
"detailLockedInShipment": "این جزئیات به لابراتوار ارسال شده و دیگر قابل ویرایش نیست.",
|
||||||
@@ -801,7 +802,18 @@
|
|||||||
"confirmSend": "تأیید ارسال",
|
"confirmSend": "تأیید ارسال",
|
||||||
"toothChartTitle": "نمودار دندانها FDI",
|
"toothChartTitle": "نمودار دندانها FDI",
|
||||||
"toothChartTitleCompact": "نمودار دندان",
|
"toothChartTitleCompact": "نمودار دندان",
|
||||||
"toothChartHint": "برای انتخاب چندگانه روی دندانها ضربه بزنید. برای جزئیات فعال اعمال میشود.",
|
"toothChartHint": "برای انتخاب روی دندان ضربه بزنید. Shift+کلیک روی دندان دیگر در همان قوس، یک بازه متصل میسازد.",
|
||||||
|
"toothConnectedHint": "انتخاب متصل",
|
||||||
|
"toothConnectedSuffix": " (متصل)",
|
||||||
|
"connectedBadge": "متصل",
|
||||||
|
"prosthesisConnectedLabel": "نوع پروتز دندانهای متصل",
|
||||||
|
"entryWizardLabel": "مراحل ثبت درمان",
|
||||||
|
"entryStepTeeth": "دندانها",
|
||||||
|
"entryStepContent": "محتوا",
|
||||||
|
"entryStepLab": "لابراتوار",
|
||||||
|
"entryStepBack": "قبلی",
|
||||||
|
"entryStepNext": "بعدی",
|
||||||
|
"entryStepLabUnavailable": "ارسال به لابراتوار پس از ذخیره جزئیات وابسته به لاب با انتخاب دندان در دسترس است.",
|
||||||
"toothChartWholePlan": "نمایش کامل",
|
"toothChartWholePlan": "نمایش کامل",
|
||||||
"labShipmentAttachments": "فایلها برای لابراتوار",
|
"labShipmentAttachments": "فایلها برای لابراتوار",
|
||||||
"labShipmentAttachmentsHint": "انتخاب کنید کدام پیوستهای این جزئیات در این محموله ارسال شوند. پیشفرض هیچکدام نیست.",
|
"labShipmentAttachmentsHint": "انتخاب کنید کدام پیوستهای این جزئیات در این محموله ارسال شوند. پیشفرض هیچکدام نیست.",
|
||||||
|
|||||||
@@ -661,6 +661,7 @@
|
|||||||
"detailsSubtitle": "Plan tanden, type, notities en bijlagen per detailregel.",
|
"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",
|
||||||
"detailLabel": "Detail {n}",
|
"detailLabel": "Detail {n}",
|
||||||
"detailSentBadge": "verzonden",
|
"detailSentBadge": "verzonden",
|
||||||
"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.",
|
||||||
@@ -800,7 +801,18 @@
|
|||||||
"confirmSend": "Bevestig verzending",
|
"confirmSend": "Bevestig verzending",
|
||||||
"toothChartTitle": "FDI-tanddiagram",
|
"toothChartTitle": "FDI-tanddiagram",
|
||||||
"toothChartTitleCompact": "Tanddiagram",
|
"toothChartTitleCompact": "Tanddiagram",
|
||||||
"toothChartHint": "Tik op tanden om meerdere te selecteren. Geldt voor het actieve detail.",
|
"toothChartHint": "Tik op tanden om te selecteren. Shift-klik op een andere tand in dezelfde boog voor een verbonden span.",
|
||||||
|
"toothConnectedHint": "Verbonden selectie",
|
||||||
|
"toothConnectedSuffix": " (verbonden)",
|
||||||
|
"connectedBadge": "Verbonden",
|
||||||
|
"prosthesisConnectedLabel": "Prothesetype voor verbonden tanden",
|
||||||
|
"entryWizardLabel": "Behandeling invoerstappen",
|
||||||
|
"entryStepTeeth": "Tanden",
|
||||||
|
"entryStepContent": "Inhoud",
|
||||||
|
"entryStepLab": "Lab",
|
||||||
|
"entryStepBack": "Terug",
|
||||||
|
"entryStepNext": "Volgende",
|
||||||
|
"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": "Kies welke bijlagen van dit detail bij deze zending horen. Standaard worden er geen meegestuurd.",
|
||||||
|
|||||||
189
frontend/src/components/treatment/toothSelectionGroups.ts
Normal file
189
frontend/src/components/treatment/toothSelectionGroups.ts
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import type { FdiToothId } from '@/types/treatment';
|
||||||
|
import { FDI_LOWER_LEFT_TO_RIGHT, FDI_UPPER_LEFT_TO_RIGHT } from '@/components/treatment/fdiToothMeta';
|
||||||
|
|
||||||
|
export type ToothSelectionKind = 'connected' | 'single';
|
||||||
|
|
||||||
|
export type ToothSelectionGroup = {
|
||||||
|
groupId: string;
|
||||||
|
kind: ToothSelectionKind;
|
||||||
|
teeth: FdiToothId[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function newGroupId(): string {
|
||||||
|
return typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `tg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveTeethFromGroups(groups: ToothSelectionGroup[]): FdiToothId[] {
|
||||||
|
const set = new Set<FdiToothId>();
|
||||||
|
for (const group of groups) {
|
||||||
|
for (const tooth of group.teeth) set.add(tooth);
|
||||||
|
}
|
||||||
|
return [...set].sort() as FdiToothId[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legacy / empty groups: each selected tooth becomes its own single group. */
|
||||||
|
export function groupsFromFlatTeeth(
|
||||||
|
teeth: readonly FdiToothId[],
|
||||||
|
existing?: ToothSelectionGroup[] | null,
|
||||||
|
): ToothSelectionGroup[] {
|
||||||
|
if (existing && existing.length > 0) {
|
||||||
|
const flat = new Set(teeth);
|
||||||
|
const next = existing
|
||||||
|
.map((g) => ({
|
||||||
|
...g,
|
||||||
|
teeth: g.teeth.filter((t) => flat.has(t)) as FdiToothId[],
|
||||||
|
}))
|
||||||
|
.filter((g) => g.teeth.length > 0)
|
||||||
|
.map((g) =>
|
||||||
|
g.kind === 'connected' && g.teeth.length < 2
|
||||||
|
? { ...g, kind: 'single' as const }
|
||||||
|
: g,
|
||||||
|
);
|
||||||
|
const covered = new Set(next.flatMap((g) => g.teeth));
|
||||||
|
for (const tooth of teeth) {
|
||||||
|
if (!covered.has(tooth)) {
|
||||||
|
next.push({ groupId: newGroupId(), kind: 'single', teeth: [tooth] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
return teeth.map((tooth) => ({
|
||||||
|
groupId: newGroupId(),
|
||||||
|
kind: 'single' as const,
|
||||||
|
teeth: [tooth],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function connectedTeethSet(groups: ToothSelectionGroup[]): Set<FdiToothId> {
|
||||||
|
const set = new Set<FdiToothId>();
|
||||||
|
for (const group of groups) {
|
||||||
|
if (group.kind !== 'connected') continue;
|
||||||
|
for (const tooth of group.teeth) set.add(tooth);
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
function archOrder(tooth: FdiToothId): FdiToothId[] | null {
|
||||||
|
if ((FDI_UPPER_LEFT_TO_RIGHT as readonly string[]).includes(tooth)) {
|
||||||
|
return FDI_UPPER_LEFT_TO_RIGHT;
|
||||||
|
}
|
||||||
|
if ((FDI_LOWER_LEFT_TO_RIGHT as readonly string[]).includes(tooth)) {
|
||||||
|
return FDI_LOWER_LEFT_TO_RIGHT;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sameArch(a: FdiToothId, b: FdiToothId): boolean {
|
||||||
|
const archA = archOrder(a);
|
||||||
|
const archB = archOrder(b);
|
||||||
|
return Boolean(archA && archB && archA === archB);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function teethBetweenInclusive(a: FdiToothId, b: FdiToothId): FdiToothId[] | null {
|
||||||
|
const arch = archOrder(a);
|
||||||
|
if (!arch || !sameArch(a, b)) return null;
|
||||||
|
const i = arch.indexOf(a);
|
||||||
|
const j = arch.indexOf(b);
|
||||||
|
if (i < 0 || j < 0) return null;
|
||||||
|
const [from, to] = i <= j ? [i, j] : [j, i];
|
||||||
|
return arch.slice(from, to + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain click:
|
||||||
|
* - unselected → add as single
|
||||||
|
* - selected single → remove
|
||||||
|
* - selected in connected → drop the whole connected span and keep only this tooth as a single
|
||||||
|
*/
|
||||||
|
export function toggleToothInGroups(
|
||||||
|
groups: ToothSelectionGroup[],
|
||||||
|
tooth: FdiToothId,
|
||||||
|
): ToothSelectionGroup[] {
|
||||||
|
const owning = groups.find((g) => g.teeth.includes(tooth));
|
||||||
|
if (!owning) {
|
||||||
|
return [...groups, { groupId: newGroupId(), kind: 'single', teeth: [tooth] }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (owning.kind === 'single') {
|
||||||
|
return groups.filter((g) => g.groupId !== owning.groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapse connected span: only the clicked tooth remains, as a separate single.
|
||||||
|
const next = groups.filter((g) => g.groupId !== owning.groupId);
|
||||||
|
next.push({ groupId: newGroupId(), kind: 'single', teeth: [tooth] });
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shift-click creates a connected span between anchor and target on the same arch.
|
||||||
|
* Any existing groups that intersect the span are replaced by the new connected group.
|
||||||
|
*/
|
||||||
|
export function applyShiftRange(
|
||||||
|
groups: ToothSelectionGroup[],
|
||||||
|
anchor: FdiToothId,
|
||||||
|
target: FdiToothId,
|
||||||
|
): ToothSelectionGroup[] | null {
|
||||||
|
if (anchor === target) return null;
|
||||||
|
const span = teethBetweenInclusive(anchor, target);
|
||||||
|
if (!span || span.length < 2) return null;
|
||||||
|
|
||||||
|
const spanSet = new Set(span);
|
||||||
|
const next = groups
|
||||||
|
.map((g) => ({
|
||||||
|
...g,
|
||||||
|
teeth: g.teeth.filter((t) => !spanSet.has(t)) as FdiToothId[],
|
||||||
|
}))
|
||||||
|
.filter((g) => g.teeth.length > 0)
|
||||||
|
.map((g) =>
|
||||||
|
g.kind === 'connected' && g.teeth.length < 2
|
||||||
|
? { ...g, kind: 'single' as const }
|
||||||
|
: g,
|
||||||
|
);
|
||||||
|
|
||||||
|
next.push({ groupId: newGroupId(), kind: 'connected', teeth: span });
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop prosthesis rows for teeth no longer selected on a detail, and refresh
|
||||||
|
* selectionGroupId so connected→single collapses do not leave stale group ids.
|
||||||
|
*/
|
||||||
|
export function pruneToothProsthesisForGroups<
|
||||||
|
T extends { detailClientId: string; tooth: string; selectionGroupId?: string },
|
||||||
|
>(toothProsthesis: T[], detailClientId: string, groups: ToothSelectionGroup[]): T[] {
|
||||||
|
const toothToGroup = new Map<string, ToothSelectionGroup>();
|
||||||
|
for (const group of groups) {
|
||||||
|
for (const tooth of group.teeth) toothToGroup.set(tooth, group);
|
||||||
|
}
|
||||||
|
return toothProsthesis
|
||||||
|
.filter((tp) => tp.detailClientId !== detailClientId || toothToGroup.has(tp.tooth))
|
||||||
|
.map((tp) => {
|
||||||
|
if (tp.detailClientId !== detailClientId) return tp;
|
||||||
|
const group = toothToGroup.get(tp.tooth);
|
||||||
|
if (!group) return tp;
|
||||||
|
return { ...tp, selectionGroupId: group.groupId };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeToothSelectionGroups(value: unknown): ToothSelectionGroup[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
const out: ToothSelectionGroup[] = [];
|
||||||
|
for (const row of value) {
|
||||||
|
if (!row || typeof row !== 'object') continue;
|
||||||
|
const rec = row as Record<string, unknown>;
|
||||||
|
const groupId = typeof rec.groupId === 'string' ? rec.groupId : newGroupId();
|
||||||
|
const kind = rec.kind === 'connected' ? 'connected' : 'single';
|
||||||
|
const teeth = Array.isArray(rec.teeth)
|
||||||
|
? (rec.teeth.filter((t) => typeof t === 'string') as FdiToothId[])
|
||||||
|
: [];
|
||||||
|
if (teeth.length === 0) continue;
|
||||||
|
out.push({
|
||||||
|
groupId,
|
||||||
|
kind: kind === 'connected' && teeth.length < 2 ? 'single' : kind,
|
||||||
|
teeth,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { ConnectedSelectionBadge } from '@/components/ui/treatment/ConnectedSelectionBadge';
|
||||||
import {
|
import {
|
||||||
formatToothList,
|
formatToothList,
|
||||||
prosthesisTypeColorFromCatalog,
|
prosthesisTypeColorFromCatalog,
|
||||||
@@ -9,6 +10,8 @@ import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog';
|
|||||||
export type LabCaseProsthesisGroup = {
|
export type LabCaseProsthesisGroup = {
|
||||||
prosthesisTypeCode: string;
|
prosthesisTypeCode: string;
|
||||||
teeth: string[];
|
teeth: string[];
|
||||||
|
connected?: boolean;
|
||||||
|
selectionGroupId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface LabCaseProsthesisGroupsListProps {
|
interface LabCaseProsthesisGroupsListProps {
|
||||||
@@ -31,12 +34,15 @@ export function LabCaseProsthesisGroupsList({
|
|||||||
<ul className="space-y-0.5">
|
<ul className="space-y-0.5">
|
||||||
{groups.map((group) => (
|
{groups.map((group) => (
|
||||||
<li
|
<li
|
||||||
key={group.prosthesisTypeCode}
|
key={`${group.selectionGroupId ?? ''}-${group.prosthesisTypeCode}-${group.teeth.join(',')}`}
|
||||||
className="text-[11px] leading-snug"
|
className="text-[11px] leading-snug"
|
||||||
style={{
|
style={{
|
||||||
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>
|
||||||
|
|||||||
100
frontend/src/components/ui/shared/WizardStepper.tsx
Normal file
100
frontend/src/components/ui/shared/WizardStepper.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
export type WizardStepItem = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WizardStepperProps = {
|
||||||
|
steps: WizardStepItem[];
|
||||||
|
currentStepId: string;
|
||||||
|
onStepChange: (stepId: string) => void;
|
||||||
|
/** Accessible name for the stepper. */
|
||||||
|
'aria-label': string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact horizontal progress stepper — numbered nodes + connector rail.
|
||||||
|
* Distinct from chip/tab row patterns used elsewhere (e.g. treatment detail chips).
|
||||||
|
*/
|
||||||
|
export function WizardStepper({
|
||||||
|
steps,
|
||||||
|
currentStepId,
|
||||||
|
onStepChange,
|
||||||
|
'aria-label': ariaLabel,
|
||||||
|
className,
|
||||||
|
}: WizardStepperProps) {
|
||||||
|
const currentIndex = Math.max(
|
||||||
|
0,
|
||||||
|
steps.findIndex((step) => step.id === currentStepId),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav aria-label={ariaLabel} className={className}>
|
||||||
|
<ol className="flex items-center gap-0 min-w-0">
|
||||||
|
{steps.map((step, index) => {
|
||||||
|
const isCurrent = index === currentIndex;
|
||||||
|
const isComplete = index < currentIndex;
|
||||||
|
const isUpcoming = index > currentIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={step.id} className="flex items-center min-w-0 flex-1 last:flex-none">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-current={isCurrent ? 'step' : undefined}
|
||||||
|
onClick={() => onStepChange(step.id)}
|
||||||
|
className={`
|
||||||
|
group inline-flex items-center gap-1.5 shrink-0 rounded-[var(--radius-sm)]
|
||||||
|
py-0.5 pe-1 ps-0.5 transition-colors
|
||||||
|
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
|
||||||
|
${isUpcoming ? 'opacity-70 hover:opacity-100' : ''}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`
|
||||||
|
inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full
|
||||||
|
text-[10px] font-semibold leading-none tabular-nums transition-colors
|
||||||
|
${
|
||||||
|
isCurrent
|
||||||
|
? 'bg-primary text-primary-contrast shadow-[0_0_0_2px_var(--color-primary-soft)]'
|
||||||
|
: isComplete
|
||||||
|
? 'bg-primary-soft text-primary'
|
||||||
|
: 'bg-background-secondary text-text-muted ring-1 ring-inset ring-border'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`
|
||||||
|
text-[11px] leading-none whitespace-nowrap transition-colors
|
||||||
|
${
|
||||||
|
isCurrent
|
||||||
|
? 'font-semibold text-text-primary'
|
||||||
|
: isComplete
|
||||||
|
? 'font-medium text-text-secondary'
|
||||||
|
: 'font-medium text-text-muted'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{step.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{index < steps.length - 1 ? (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={`
|
||||||
|
mx-1.5 h-px min-w-[0.75rem] flex-1 rounded-full
|
||||||
|
${isComplete ? 'bg-primary/55' : 'bg-border'}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { Badge } from '@/components/ui/shared/Badge';
|
||||||
|
|
||||||
|
/** Primary-tint styling matching the previous custom connected pill. */
|
||||||
|
const CONNECTED_BADGE_STYLE = {
|
||||||
|
backgroundColor: 'color-mix(in srgb, var(--color-primary) 10%, transparent)',
|
||||||
|
color: 'var(--color-primary)',
|
||||||
|
borderColor: 'color-mix(in srgb, var(--color-primary) 40%, transparent)',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type ConnectedSelectionBadgeProps = {
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Compact “Connected” label — shared Badge with primary tint (not default variant colors). */
|
||||||
|
export function ConnectedSelectionBadge({ className }: ConnectedSelectionBadgeProps) {
|
||||||
|
const t = useTranslations('treatment');
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
fixedWidth={false}
|
||||||
|
className={`text-[10px] min-h-0 py-0.5 px-1.5 leading-none ${className ?? ''}`}
|
||||||
|
style={CONNECTED_BADGE_STYLE}
|
||||||
|
>
|
||||||
|
{t('connectedBadge')}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,7 +21,9 @@ function quadrantMirrored(fdi: FdiToothId): boolean {
|
|||||||
|
|
||||||
interface FdiToothChartProps {
|
interface FdiToothChartProps {
|
||||||
selected: ReadonlySet<FdiToothId>;
|
selected: ReadonlySet<FdiToothId>;
|
||||||
onToggle?: (fdi: FdiToothId) => void;
|
/** Teeth that belong to a connected selection span (dots above/below). */
|
||||||
|
connectedTeeth?: ReadonlySet<FdiToothId>;
|
||||||
|
onToggle?: (fdi: FdiToothId, event: { shiftKey: boolean }) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
/** Non-interactive display (Cases / connection history). */
|
/** Non-interactive display (Cases / connection history). */
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
@@ -38,6 +40,7 @@ interface FdiToothChartProps {
|
|||||||
|
|
||||||
export function FdiToothChart({
|
export function FdiToothChart({
|
||||||
selected,
|
selected,
|
||||||
|
connectedTeeth,
|
||||||
onToggle,
|
onToggle,
|
||||||
disabled,
|
disabled,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
@@ -147,6 +150,7 @@ export function FdiToothChart({
|
|||||||
const kind = getToothShapeKind(fdi);
|
const kind = getToothShapeKind(fdi);
|
||||||
const gid = `${uid}-g-${fdi}-${i}`;
|
const gid = `${uid}-g-${fdi}-${i}`;
|
||||||
const isSel = selected.has(fdi);
|
const isSel = selected.has(fdi);
|
||||||
|
const isConnected = Boolean(connectedTeeth?.has(fdi));
|
||||||
const size = toothSizeClass(fdi);
|
const size = toothSizeClass(fdi);
|
||||||
const tweak = TOOTH_TWEAKS[fdi];
|
const tweak = TOOTH_TWEAKS[fdi];
|
||||||
const offsetY = tweak ? tweak.offset : archOffset(i, teeth.length, upper);
|
const offsetY = tweak ? tweak.offset : archOffset(i, teeth.length, upper);
|
||||||
@@ -168,24 +172,44 @@ export function FdiToothChart({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const connectedDot = isConnected ? (
|
||||||
|
<span
|
||||||
|
className={`block h-1.5 w-1.5 rounded-full bg-primary ${upper ? 'mb-0.5' : 'mt-0.5'}`}
|
||||||
|
aria-hidden
|
||||||
|
title={t('toothConnectedHint')}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className={`block h-1.5 w-1.5 ${upper ? 'mb-0.5' : 'mt-0.5'}`} aria-hidden />
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={fdi} className={`flex flex-col items-center ${size.wrapper}`}>
|
<div key={fdi} className={`flex flex-col items-center ${size.wrapper}`}>
|
||||||
|
{upper ? connectedDot : null}
|
||||||
<div className={`h-[5.9rem] flex ${alignItems} justify-center`}>
|
<div className={`h-[5.9rem] flex ${alignItems} justify-center`}>
|
||||||
{interactive ? (
|
{interactive ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
onClick={() => onToggle?.(fdi)}
|
onMouseDown={(e) => {
|
||||||
|
// Shift+click otherwise triggers browser text-selection / sticky focus boxes.
|
||||||
|
if (e.shiftKey) e.preventDefault();
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onToggle?.(fdi, { shiftKey: e.shiftKey });
|
||||||
|
}}
|
||||||
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
|
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
|
||||||
className={`
|
className={`
|
||||||
rounded-[var(--radius-sm)] p-0.5 transition-transform
|
rounded-[var(--radius-sm)] p-0.5 transition-transform select-none
|
||||||
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 ? 'opacity-50 cursor-not-allowed' : 'hover:scale-105 active:scale-95'}
|
||||||
`}
|
`}
|
||||||
aria-pressed={isSel}
|
aria-pressed={isSel}
|
||||||
aria-label={
|
aria-label={
|
||||||
isSel
|
isSel
|
||||||
? `${t('toothAria', { fdi })}${t('toothSelectedSuffix')}`
|
? `${t('toothAria', { fdi })}${t('toothSelectedSuffix')}${
|
||||||
|
isConnected ? t('toothConnectedSuffix') : ''
|
||||||
|
}`
|
||||||
: t('toothAria', { fdi })
|
: t('toothAria', { fdi })
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -201,6 +225,7 @@ export function FdiToothChart({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{!upper ? connectedDot : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -215,8 +240,8 @@ export function FdiToothChart({
|
|||||||
<>
|
<>
|
||||||
<p className="text-[11px] uppercase tracking-wide text-text-muted mb-1 text-center">{t('upperArch')}</p>
|
<p className="text-[11px] uppercase tracking-wide text-text-muted mb-1 text-center">{t('upperArch')}</p>
|
||||||
|
|
||||||
<div className="overflow-x-auto py-1 -mx-1 px-1">
|
<div className="overflow-x-auto py-1 -mx-1 px-1 select-none">
|
||||||
<div className="relative isolate min-w-max mx-auto w-fit">
|
<div className="relative isolate min-w-max mx-auto w-fit select-none">
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none absolute left-1/2 top-3 bottom-3 w-px -translate-x-1/2 bg-border/70"
|
className="pointer-events-none absolute left-1/2 top-3 bottom-3 w-px -translate-x-1/2 bg-border/70"
|
||||||
aria-hidden
|
aria-hidden
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useEffect, 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';
|
||||||
|
import { ConnectedSelectionBadge } from '@/components/ui/treatment/ConnectedSelectionBadge';
|
||||||
import { isDetailReadyForLabDispatch, isLabCaseCompleted } from '@/components/treatment/treatmentDetailRules';
|
import { isDetailReadyForLabDispatch, isLabCaseCompleted } from '@/components/treatment/treatmentDetailRules';
|
||||||
import { AppDateInput } from '@/components/ui/shared/AppDateInput';
|
import { AppDateInput } from '@/components/ui/shared/AppDateInput';
|
||||||
import { toDateInputValue } from '@/components/lab/labCaseDueDateDisplay';
|
import { toDateInputValue } from '@/components/lab/labCaseDueDateDisplay';
|
||||||
@@ -19,6 +20,7 @@ import { prosthesisTypeColorFromCatalog } from '@/components/treatment/prosthesi
|
|||||||
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';
|
||||||
|
import { groupsFromFlatTeeth } from '@/components/treatment/toothSelectionGroups';
|
||||||
|
|
||||||
interface LabCasesDispatchPanelProps {
|
interface LabCasesDispatchPanelProps {
|
||||||
details: TreatmentDetailDraft[];
|
details: TreatmentDetailDraft[];
|
||||||
@@ -56,33 +58,51 @@ function sentDetailClientIds(labCases: LabCaseDraft[]): Set<string> {
|
|||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
function prosthesisTeethRows(
|
type ProsthesisGroupRow = {
|
||||||
|
groupId: string;
|
||||||
|
kind: 'connected' | 'single';
|
||||||
|
teeth: string[];
|
||||||
|
detailClientId: string;
|
||||||
|
detailNumber: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function prosthesisGroupRows(
|
||||||
labCase: LabCaseDraft,
|
labCase: LabCaseDraft,
|
||||||
activeDetail: TreatmentDetailDraft,
|
activeDetail: TreatmentDetailDraft,
|
||||||
detailNumber: number,
|
detailNumber: number,
|
||||||
): Array<{ detailClientId: string; tooth: string; detailNumber: number }> {
|
): ProsthesisGroupRow[] {
|
||||||
if (labCase.detailClientId !== activeDetail.clientId) return [];
|
if (labCase.detailClientId !== activeDetail.clientId) return [];
|
||||||
if (activeDetail.treatmentType !== 'prosthesis') return [];
|
if (activeDetail.treatmentType !== 'prosthesis') return [];
|
||||||
|
|
||||||
return activeDetail.teeth.map((tooth) => ({
|
const groups =
|
||||||
|
activeDetail.toothSelectionGroups.length > 0
|
||||||
|
? activeDetail.toothSelectionGroups
|
||||||
|
: groupsFromFlatTeeth(activeDetail.teeth);
|
||||||
|
|
||||||
|
return groups.map((g) => ({
|
||||||
|
groupId: g.groupId,
|
||||||
|
kind: g.kind,
|
||||||
|
teeth: g.teeth,
|
||||||
detailClientId: activeDetail.clientId,
|
detailClientId: activeDetail.clientId,
|
||||||
tooth,
|
|
||||||
detailNumber,
|
detailNumber,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function isProsthesisMapComplete(
|
function isProsthesisMapComplete(
|
||||||
labCase: LabCaseDraft,
|
labCase: LabCaseDraft,
|
||||||
rows: Array<{ detailClientId: string; tooth: string }>,
|
rows: ProsthesisGroupRow[],
|
||||||
): boolean {
|
): boolean {
|
||||||
if (rows.length === 0) return true;
|
if (rows.length === 0) return true;
|
||||||
return rows.every((row) =>
|
return rows.every((row) =>
|
||||||
|
row.teeth.every((tooth) =>
|
||||||
labCase.toothProsthesis.some(
|
labCase.toothProsthesis.some(
|
||||||
(tp) =>
|
(tp) =>
|
||||||
tp.detailClientId === row.detailClientId &&
|
tp.detailClientId === row.detailClientId &&
|
||||||
tp.tooth === row.tooth &&
|
tp.tooth === tooth &&
|
||||||
|
tp.selectionGroupId === row.groupId &&
|
||||||
Boolean(tp.prosthesisTypeCode),
|
Boolean(tp.prosthesisTypeCode),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,11 +168,12 @@ export function LabCasesDispatchPanel({
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const prosthesisRows = activeLabCase && activeDetail
|
const prosthesisRows = activeLabCase && activeDetail
|
||||||
? prosthesisTeethRows(activeLabCase, activeDetail, activeDetailNumber)
|
? prosthesisGroupRows(activeLabCase, activeDetail, activeDetailNumber)
|
||||||
: [];
|
: [];
|
||||||
const prosthesisComplete = activeLabCase
|
const prosthesisComplete = activeLabCase
|
||||||
? isProsthesisMapComplete(activeLabCase, prosthesisRows)
|
? isProsthesisMapComplete(activeLabCase, prosthesisRows)
|
||||||
: true;
|
: true;
|
||||||
|
const flatToothCount = prosthesisRows.reduce((sum, row) => sum + row.teeth.length, 0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeLabCase?.destinationOrganizationId) {
|
if (!activeLabCase?.destinationOrganizationId) {
|
||||||
@@ -196,28 +217,36 @@ export function LabCasesDispatchPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setToothProsthesis(
|
function setGroupProsthesis(row: ProsthesisGroupRow, prosthesisTypeCode: string) {
|
||||||
detailClientId: string,
|
|
||||||
tooth: string,
|
|
||||||
prosthesisTypeCode: string,
|
|
||||||
) {
|
|
||||||
if (!activeLabCase) return;
|
if (!activeLabCase) return;
|
||||||
|
const toothSet = new Set(row.teeth);
|
||||||
const rest = activeLabCase.toothProsthesis.filter(
|
const rest = activeLabCase.toothProsthesis.filter(
|
||||||
(tp) => !(tp.detailClientId === detailClientId && tp.tooth === tooth),
|
(tp) => !(tp.detailClientId === row.detailClientId && toothSet.has(tp.tooth)),
|
||||||
);
|
);
|
||||||
const next = prosthesisTypeCode
|
const next = prosthesisTypeCode
|
||||||
? [...rest, { detailClientId, tooth, prosthesisTypeCode }]
|
? [
|
||||||
|
...rest,
|
||||||
|
...row.teeth.map((tooth) => ({
|
||||||
|
detailClientId: row.detailClientId,
|
||||||
|
tooth,
|
||||||
|
prosthesisTypeCode,
|
||||||
|
selectionGroupId: row.groupId,
|
||||||
|
})),
|
||||||
|
]
|
||||||
: rest;
|
: rest;
|
||||||
updateActiveLabCase({ toothProsthesis: next });
|
updateActiveLabCase({ toothProsthesis: next });
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyProsthesisToAll(code: string) {
|
function applyProsthesisToAll(code: string) {
|
||||||
if (!activeLabCase || !code) return;
|
if (!activeLabCase || !code) return;
|
||||||
const next = prosthesisRows.map((row) => ({
|
const next = prosthesisRows.flatMap((row) =>
|
||||||
|
row.teeth.map((tooth) => ({
|
||||||
detailClientId: row.detailClientId,
|
detailClientId: row.detailClientId,
|
||||||
tooth: row.tooth,
|
tooth,
|
||||||
prosthesisTypeCode: code,
|
prosthesisTypeCode: code,
|
||||||
}));
|
selectionGroupId: row.groupId,
|
||||||
|
})),
|
||||||
|
);
|
||||||
updateActiveLabCase({ toothProsthesis: next });
|
updateActiveLabCase({ toothProsthesis: next });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,49 +345,55 @@ export function LabCasesDispatchPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const byType = new Map<string, string[]>();
|
const selectionGroups =
|
||||||
for (const tp of activeLabCase.toothProsthesis) {
|
activeDetail.toothSelectionGroups.length > 0
|
||||||
if (tp.detailClientId !== activeDetail.clientId) continue;
|
? activeDetail.toothSelectionGroups
|
||||||
if (!tp.prosthesisTypeCode) continue;
|
: groupsFromFlatTeeth(activeDetail.teeth);
|
||||||
const list = byType.get(tp.prosthesisTypeCode) ?? [];
|
|
||||||
list.push(tp.tooth);
|
|
||||||
byType.set(tp.prosthesisTypeCode, list);
|
|
||||||
}
|
|
||||||
|
|
||||||
const uniqueSelected = [...new Set(activeDetail.teeth)];
|
const rows = selectionGroups.map((group) => {
|
||||||
const mappedTeeth = new Set<string>();
|
const codes = new Set(
|
||||||
for (const teeth of byType.values()) {
|
activeLabCase.toothProsthesis
|
||||||
for (const tooth of teeth) mappedTeeth.add(tooth);
|
.filter(
|
||||||
}
|
(tp) =>
|
||||||
const unmapped = uniqueSelected.filter((t) => !mappedTeeth.has(t));
|
tp.detailClientId === activeDetail.clientId &&
|
||||||
|
group.teeth.includes(tp.tooth as never) &&
|
||||||
const groups = [...byType.entries()]
|
tp.prosthesisTypeCode,
|
||||||
.map(([code, teeth]) => ({
|
)
|
||||||
|
.map((tp) => tp.prosthesisTypeCode),
|
||||||
|
);
|
||||||
|
const code = codes.size === 1 ? [...codes][0] : '';
|
||||||
|
return {
|
||||||
|
groupId: group.groupId,
|
||||||
|
kind: group.kind,
|
||||||
|
teeth: group.teeth,
|
||||||
code,
|
code,
|
||||||
teeth: [...new Set(teeth)].sort((a, b) => a.localeCompare(b)),
|
label: code
|
||||||
label: prosthesisOptions.find((p) => p.code === code)?.label ?? code,
|
? prosthesisOptions.find((p) => p.code === code)?.label ?? code
|
||||||
}))
|
: t('prosthesisUnassigned'),
|
||||||
.sort((a, b) => a.code.localeCompare(b.code));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
||||||
<p className="text-text-primary">
|
<p className="text-text-primary">
|
||||||
{t('detailLabel', { n: activeDetailNumber })} · {typeLabel}
|
{t('detailLabel', { n: activeDetailNumber })} · {typeLabel}
|
||||||
</p>
|
</p>
|
||||||
{groups.map((g) => (
|
{rows.map((g) => (
|
||||||
<p
|
<p
|
||||||
key={g.code}
|
key={g.groupId}
|
||||||
className="text-[13px]"
|
className="text-[13px]"
|
||||||
style={{ color: prosthesisTypeColorFromCatalog(g.code, prosthesisOptions) }}
|
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>
|
{g.label}: <span className="text-text-primary">{g.teeth.join(', ')}</span>
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
{unmapped.length > 0 ? (
|
|
||||||
<p className="text-[13px] text-text-muted">
|
|
||||||
{t('prosthesisUnassigned')}: <span className="text-text-primary">{unmapped.join(', ')}</span>
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -514,6 +549,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') ? (
|
||||||
<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
|
||||||
@@ -534,41 +570,50 @@ export function LabCasesDispatchPanel({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<div className="overflow-x-auto overscroll-x-contain">
|
) : null}
|
||||||
<table className="w-full text-sm">
|
<div className="space-y-3">
|
||||||
<thead>
|
|
||||||
<tr className="text-left text-xs text-text-muted">
|
|
||||||
<th className="pb-2 pr-3 font-medium">{t('prosthesisColTooth')}</th>
|
|
||||||
<th className="pb-2 pr-3 font-medium">{t('prosthesisColDetail')}</th>
|
|
||||||
<th className="pb-2 font-medium">{t('prosthesisColType')}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{prosthesisRows.map((row) => {
|
{prosthesisRows.map((row) => {
|
||||||
const current =
|
const current =
|
||||||
activeLabCase.toothProsthesis.find(
|
activeLabCase.toothProsthesis.find(
|
||||||
(tp) =>
|
(tp) =>
|
||||||
tp.detailClientId === row.detailClientId &&
|
tp.detailClientId === row.detailClientId &&
|
||||||
tp.tooth === row.tooth,
|
tp.selectionGroupId === row.groupId &&
|
||||||
)?.prosthesisTypeCode ?? '';
|
row.teeth.includes(tp.tooth),
|
||||||
|
)?.prosthesisTypeCode ??
|
||||||
|
activeLabCase.toothProsthesis.find(
|
||||||
|
(tp) =>
|
||||||
|
tp.detailClientId === row.detailClientId &&
|
||||||
|
row.teeth.includes(tp.tooth),
|
||||||
|
)?.prosthesisTypeCode ??
|
||||||
|
'';
|
||||||
return (
|
return (
|
||||||
<tr key={`${row.detailClientId}-${row.tooth}`} className="border-t border-border/40">
|
<label
|
||||||
<td className="py-2 pr-3 text-text-primary">{row.tooth}</td>
|
key={row.groupId}
|
||||||
<td className="py-2 pr-3 text-text-secondary">
|
className="block text-xs text-text-muted space-y-1 rounded-[var(--radius-sm)] border border-border/50 bg-background-secondary/40 px-3 py-2"
|
||||||
{t('detailLabel', { n: row.detailNumber })}
|
>
|
||||||
</td>
|
<span className="flex flex-wrap items-center gap-2 text-text-secondary">
|
||||||
<td className="py-2">
|
{row.kind === 'connected' ? <ConnectedSelectionBadge /> : null}
|
||||||
|
<span>
|
||||||
|
{row.kind === 'connected'
|
||||||
|
? t('prosthesisConnectedLabel')
|
||||||
|
: t('prosthesisColTooth')}
|
||||||
|
{': '}
|
||||||
|
<span className="text-text-primary">{row.teeth.join(', ')}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-text-muted">
|
||||||
|
· {t('detailLabel', { n: row.detailNumber })}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
<select
|
<select
|
||||||
value={current}
|
value={current}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(e) =>
|
onChange={(e) => setGroupProsthesis(row, e.target.value)}
|
||||||
setToothProsthesis(
|
className={`${FORM_SELECT_CLASS} w-full mt-1`}
|
||||||
row.detailClientId,
|
aria-label={
|
||||||
row.tooth,
|
row.kind === 'connected'
|
||||||
e.target.value,
|
? t('prosthesisConnectedLabel')
|
||||||
)
|
: t('prosthesisColType')
|
||||||
}
|
}
|
||||||
className={`${FORM_SELECT_CLASS} w-full min-w-0 sm:min-w-[160px]`}
|
|
||||||
>
|
>
|
||||||
<option value="">{t('prosthesisSelectPlaceholder')}</option>
|
<option value="">{t('prosthesisSelectPlaceholder')}</option>
|
||||||
{prosthesisOptions.map((opt) => (
|
{prosthesisOptions.map((opt) => (
|
||||||
@@ -577,12 +622,9 @@ export function LabCasesDispatchPanel({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</label>
|
||||||
</tr>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
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 {
|
||||||
autosaveStatusClass,
|
autosaveStatusClass,
|
||||||
labPendingBannerClass,
|
labPendingBannerClass,
|
||||||
labSentBannerClass,
|
labSentBannerClass,
|
||||||
|
labBlockedBannerClass,
|
||||||
} from '@/components/treatment/treatmentStatusStyles';
|
} 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';
|
||||||
@@ -17,7 +19,6 @@ import {
|
|||||||
isDetailTypeSelected,
|
isDetailTypeSelected,
|
||||||
isLabDependentDetailMissingTeeth,
|
isLabDependentDetailMissingTeeth,
|
||||||
} from '@/components/treatment/treatmentDetailRules';
|
} from '@/components/treatment/treatmentDetailRules';
|
||||||
import { labBlockedBannerClass } from '@/components/treatment/treatmentStatusStyles';
|
|
||||||
|
|
||||||
interface TreatmentDetailsEditorProps {
|
interface TreatmentDetailsEditorProps {
|
||||||
details: TreatmentDetailDraft[];
|
details: TreatmentDetailDraft[];
|
||||||
@@ -32,8 +33,12 @@ interface TreatmentDetailsEditorProps {
|
|||||||
saveStatus: 'idle' | 'dirty' | 'saving' | 'saved' | 'error';
|
saveStatus: 'idle' | 'dirty' | 'saving' | 'saved' | 'error';
|
||||||
uploadBusy: boolean;
|
uploadBusy: boolean;
|
||||||
onAddDetail: () => void;
|
onAddDetail: () => void;
|
||||||
onRemoveDetail: () => void;
|
onRemoveDetail?: (detailClientId: string) => void;
|
||||||
onUploadFiles: (files: FileList | null) => void;
|
onUploadFiles: (files: FileList | null) => void;
|
||||||
|
/** Detail chips + Add detail (default true). */
|
||||||
|
showChrome?: boolean;
|
||||||
|
/** Type / notes / attachments fields (default true). */
|
||||||
|
showFields?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TreatmentDetailsEditor({
|
export function TreatmentDetailsEditor({
|
||||||
@@ -51,6 +56,8 @@ export function TreatmentDetailsEditor({
|
|||||||
onAddDetail,
|
onAddDetail,
|
||||||
onRemoveDetail,
|
onRemoveDetail,
|
||||||
onUploadFiles,
|
onUploadFiles,
|
||||||
|
showChrome = true,
|
||||||
|
showFields = true,
|
||||||
}: TreatmentDetailsEditorProps) {
|
}: TreatmentDetailsEditorProps) {
|
||||||
const t = useTranslations('treatment');
|
const t = useTranslations('treatment');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
@@ -61,7 +68,6 @@ export function TreatmentDetailsEditor({
|
|||||||
|
|
||||||
const locked = isDetailLocked(activeDetail);
|
const locked = isDetailLocked(activeDetail);
|
||||||
const readOnly = disabled || locked;
|
const readOnly = disabled || locked;
|
||||||
const canRemoveDetail = canEdit && !disabled && !locked && details.length > 1;
|
|
||||||
const treatmentTypeTextColor = isDetailTypeSelected(activeDetail)
|
const treatmentTypeTextColor = isDetailTypeSelected(activeDetail)
|
||||||
? treatmentTypeColor(
|
? treatmentTypeColor(
|
||||||
activeDetail.treatmentType,
|
activeDetail.treatmentType,
|
||||||
@@ -75,8 +81,12 @@ export function TreatmentDetailsEditor({
|
|||||||
labDependentCodes,
|
labDependentCodes,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!showChrome && !showFields) return null;
|
||||||
|
|
||||||
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">
|
||||||
|
{showChrome ? (
|
||||||
|
<>
|
||||||
<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>
|
||||||
@@ -95,31 +105,73 @@ export function TreatmentDetailsEditor({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{details.map((d, idx) => (
|
{details.map((d, idx) => {
|
||||||
<button
|
const detailLocked = isDetailLocked(d);
|
||||||
|
const isActive = d.clientId === activeDetailId;
|
||||||
|
// Same rules as the former Content-step delete button:
|
||||||
|
// only when more than one detail remains; disabled if no edit, day-locked, sent, or uploading.
|
||||||
|
const showRemoveAction = details.length > 1;
|
||||||
|
const removeDisabled =
|
||||||
|
!canEdit || disabled || detailLocked || uploadBusy;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
key={d.clientId}
|
key={d.clientId}
|
||||||
type="button"
|
|
||||||
onClick={() => onActiveDetailChange(d.clientId)}
|
|
||||||
className={`
|
className={`
|
||||||
rounded-[var(--radius-md)] border px-3 py-1.5 text-sm transition-colors
|
inline-flex items-stretch overflow-hidden rounded-[var(--radius-md)] border
|
||||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/45
|
|
||||||
${
|
${
|
||||||
d.clientId === activeDetailId
|
isActive
|
||||||
? 'border-primary bg-primary-soft font-medium text-text-primary'
|
? 'border-primary bg-primary-soft'
|
||||||
: 'border-border/70 text-text-secondary hover:border-border hover:bg-background-card/50'
|
: 'border-border/70 hover:border-border hover:bg-background-card/50'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onActiveDetailChange(d.clientId)}
|
||||||
|
className={`
|
||||||
|
px-3 py-1.5 text-sm transition-colors
|
||||||
|
focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary/45
|
||||||
|
${isActive ? 'font-medium text-text-primary' : 'text-text-secondary'}
|
||||||
|
`}
|
||||||
>
|
>
|
||||||
{t('detailLabel', { n: idx + 1 })}
|
{t('detailLabel', { n: idx + 1 })}
|
||||||
{isDetailLocked(d) ? ` · ${t('detailSentBadge')}` : ''}
|
{detailLocked ? ` · ${t('detailSentBadge')}` : ''}
|
||||||
</button>
|
</button>
|
||||||
))}
|
{showRemoveAction ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (removeDisabled) return;
|
||||||
|
onRemoveDetail?.(d.clientId);
|
||||||
|
}}
|
||||||
|
disabled={removeDisabled}
|
||||||
|
title={tCommon('delete')}
|
||||||
|
aria-label={t('removeDetailAria', { n: idx + 1 })}
|
||||||
|
className={`
|
||||||
|
inline-flex items-center justify-center border-s px-1.5 transition-colors
|
||||||
|
focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-red-500/40
|
||||||
|
disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-inherit
|
||||||
|
${
|
||||||
|
isActive
|
||||||
|
? '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'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" aria-hidden />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showFields ? (
|
||||||
<div className="space-y-4 border border-border/60 rounded-[var(--radius-md)] p-4 bg-background-secondary/30">
|
<div className="space-y-4 border border-border/60 rounded-[var(--radius-md)] p-4 bg-background-secondary/30">
|
||||||
{locked && (
|
{locked && <p className={labSentBannerClass}>{t('detailLockedInShipment')}</p>}
|
||||||
<p className={labSentBannerClass}>{t('detailLockedInShipment')}</p>
|
|
||||||
)}
|
|
||||||
{showPendingLabHint && (
|
{showPendingLabHint && (
|
||||||
<p className={labPendingBannerClass}>{t('detailPendingLabSend')}</p>
|
<p className={labPendingBannerClass}>{t('detailPendingLabSend')}</p>
|
||||||
)}
|
)}
|
||||||
@@ -127,23 +179,6 @@ export function TreatmentDetailsEditor({
|
|||||||
<p className={labBlockedBannerClass}>{t('labShipmentBlockedBody')}</p>
|
<p className={labBlockedBannerClass}>{t('labShipmentBlockedBody')}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<label className="block text-xs font-medium text-text-secondary">
|
|
||||||
{t('comments')}
|
|
||||||
<textarea
|
|
||||||
value={activeDetail.comment}
|
|
||||||
onChange={(e) => {
|
|
||||||
const v = e.target.value;
|
|
||||||
onDetailsChange(
|
|
||||||
details.map((d) => (d.clientId === activeDetailId ? { ...d, comment: v } : d)),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
placeholder={t('commentsPlaceholder')}
|
|
||||||
rows={5}
|
|
||||||
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-[120px]"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
label={t('treatmentType')}
|
label={t('treatmentType')}
|
||||||
@@ -175,6 +210,23 @@ export function TreatmentDetailsEditor({
|
|||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label className="block text-xs font-medium text-text-secondary">
|
||||||
|
{t('comments')}
|
||||||
|
<textarea
|
||||||
|
value={activeDetail.comment}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
onDetailsChange(
|
||||||
|
details.map((d) => (d.clientId === activeDetailId ? { ...d, comment: v } : d)),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
placeholder={t('commentsPlaceholder')}
|
||||||
|
rows={5}
|
||||||
|
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-[120px]"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-text-secondary mb-2">{t('attachments')}</p>
|
<p className="text-xs font-medium text-text-secondary mb-2">{t('attachments')}</p>
|
||||||
<input
|
<input
|
||||||
@@ -210,24 +262,10 @@ export function TreatmentDetailsEditor({
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canRemoveDetail ? (
|
|
||||||
<div className="pt-1">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="danger"
|
|
||||||
onClick={onRemoveDetail}
|
|
||||||
disabled={uploadBusy}
|
|
||||||
fullWidth
|
|
||||||
className="sm:w-auto"
|
|
||||||
>
|
|
||||||
{tCommon('delete')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
|
||||||
|
|
||||||
{canEdit && saveStatus !== 'idle' && (
|
{showFields && canEdit && saveStatus !== 'idle' && (
|
||||||
<p
|
<p
|
||||||
className={`text-xs pt-2 border-t border-border/60 ${autosaveStatusClass(saveStatus)}`}
|
className={`text-xs pt-2 border-t border-border/60 ${autosaveStatusClass(saveStatus)}`}
|
||||||
role="status"
|
role="status"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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 { Checkbox } from '@/components/ui/shared/Checkbox';
|
||||||
|
import { WizardStepper } from '@/components/ui/shared/WizardStepper';
|
||||||
import { PatientSearchCombobox } from '@/components/ui/patient/PatientSearchCombobox';
|
import { PatientSearchCombobox } from '@/components/ui/patient/PatientSearchCombobox';
|
||||||
import {
|
import {
|
||||||
TreatmentLabCasesPanel,
|
TreatmentLabCasesPanel,
|
||||||
@@ -39,6 +40,14 @@ import {
|
|||||||
isEmptyDraftDetail,
|
isEmptyDraftDetail,
|
||||||
isLabDependentDetailMissingTeeth,
|
isLabDependentDetailMissingTeeth,
|
||||||
} from '@/components/treatment/treatmentDetailRules';
|
} from '@/components/treatment/treatmentDetailRules';
|
||||||
|
import {
|
||||||
|
applyShiftRange,
|
||||||
|
connectedTeethSet,
|
||||||
|
deriveTeethFromGroups,
|
||||||
|
groupsFromFlatTeeth,
|
||||||
|
pruneToothProsthesisForGroups,
|
||||||
|
toggleToothInGroups,
|
||||||
|
} 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 { canEditTreatment, canViewTreatment, canAccessDashboardRoute } from '@/components/shared/permissions';
|
import { canEditTreatment, canViewTreatment, canAccessDashboardRoute } from '@/components/shared/permissions';
|
||||||
@@ -68,6 +77,9 @@ import type {
|
|||||||
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';
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -98,6 +110,7 @@ function labCaseDraftsToPast(
|
|||||||
clientId: linkedDetail.clientId,
|
clientId: linkedDetail.clientId,
|
||||||
treatmentType: linkedDetail.treatmentType,
|
treatmentType: linkedDetail.treatmentType,
|
||||||
teeth: linkedDetail.teeth,
|
teeth: linkedDetail.teeth,
|
||||||
|
toothSelectionGroups: linkedDetail.toothSelectionGroups,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
sends: lc.sends ?? [],
|
sends: lc.sends ?? [],
|
||||||
@@ -154,6 +167,7 @@ function newDetail(defaultTreatmentType?: string): TreatmentDetailDraft {
|
|||||||
: `detail-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
: `detail-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||||
treatmentType: defaultTreatmentType ?? '',
|
treatmentType: defaultTreatmentType ?? '',
|
||||||
teeth: [],
|
teeth: [],
|
||||||
|
toothSelectionGroups: [],
|
||||||
comment: '',
|
comment: '',
|
||||||
attachmentMetas: [],
|
attachmentMetas: [],
|
||||||
sendToOrganizationIds: [],
|
sendToOrganizationIds: [],
|
||||||
@@ -190,11 +204,14 @@ function mapAppointment(record: AppointmentRecord): TreatmentAppointment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mapDetailFromApi(d: PastTreatmentCase): TreatmentDetailDraft {
|
function mapDetailFromApi(d: PastTreatmentCase): TreatmentDetailDraft {
|
||||||
|
const teeth = d.teeth;
|
||||||
|
const toothSelectionGroups = groupsFromFlatTeeth(teeth, d.toothSelectionGroups ?? null);
|
||||||
return {
|
return {
|
||||||
clientId: d.clientId,
|
clientId: d.clientId,
|
||||||
id: d.id,
|
id: d.id,
|
||||||
treatmentType: d.treatmentType,
|
treatmentType: d.treatmentType,
|
||||||
teeth: d.teeth,
|
teeth,
|
||||||
|
toothSelectionGroups,
|
||||||
comment: d.notes ?? '',
|
comment: d.notes ?? '',
|
||||||
attachmentMetas: d.attachmentMetas ?? [],
|
attachmentMetas: d.attachmentMetas ?? [],
|
||||||
labCaseId: d.labCaseId ?? null,
|
labCaseId: d.labCaseId ?? null,
|
||||||
@@ -215,6 +232,7 @@ function mapLabCaseDraftFromApi(lc: PastLabCase): LabCaseDraft {
|
|||||||
detailClientId: lc.detail?.clientId ?? tp.treatmentDetailId,
|
detailClientId: lc.detail?.clientId ?? tp.treatmentDetailId,
|
||||||
tooth: tp.tooth,
|
tooth: tp.tooth,
|
||||||
prosthesisTypeCode: tp.prosthesisTypeCode,
|
prosthesisTypeCode: tp.prosthesisTypeCode,
|
||||||
|
selectionGroupId: tp.selectionGroupId ?? '',
|
||||||
})),
|
})),
|
||||||
attachmentIds: (lc.attachments ?? []).map((a) => a.id),
|
attachmentIds: (lc.attachments ?? []).map((a) => a.id),
|
||||||
sentAt: lc.sentAt ?? null,
|
sentAt: lc.sentAt ?? null,
|
||||||
@@ -231,6 +249,7 @@ function serializeDetails(details: TreatmentDetailDraft[]) {
|
|||||||
id: d.id,
|
id: d.id,
|
||||||
treatmentType: d.treatmentType,
|
treatmentType: d.treatmentType,
|
||||||
teeth: d.teeth,
|
teeth: d.teeth,
|
||||||
|
toothSelectionGroups: d.toothSelectionGroups,
|
||||||
comment: d.comment,
|
comment: d.comment,
|
||||||
attachmentMetas: d.attachmentMetas,
|
attachmentMetas: d.attachmentMetas,
|
||||||
})),
|
})),
|
||||||
@@ -261,6 +280,7 @@ function detailsToPreviewTreatment(
|
|||||||
clientId: d.clientId,
|
clientId: d.clientId,
|
||||||
treatmentType: d.treatmentType,
|
treatmentType: d.treatmentType,
|
||||||
teeth: d.teeth,
|
teeth: d.teeth,
|
||||||
|
toothSelectionGroups: d.toothSelectionGroups,
|
||||||
notes: d.comment || null,
|
notes: d.comment || null,
|
||||||
attachmentMetas: d.attachmentMetas,
|
attachmentMetas: d.attachmentMetas,
|
||||||
labCaseId: d.labCaseId ?? null,
|
labCaseId: d.labCaseId ?? null,
|
||||||
@@ -380,6 +400,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 isDetailLocked = useCallback(
|
const isDetailLocked = useCallback(
|
||||||
(detail: TreatmentDetailDraft) =>
|
(detail: TreatmentDetailDraft) =>
|
||||||
@@ -478,6 +499,20 @@ export function TreatmentWorkspace({
|
|||||||
[details, labDependentCodes],
|
[details, labDependentCodes],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Lab wizard step only for prosthesis (lab-dependent) treatment types on the active detail. */
|
||||||
|
const showLabWizardStep = useMemo(
|
||||||
|
() => Boolean(activeDetail && labDependentCodes.has(activeDetail.treatmentType)),
|
||||||
|
[activeDetail, labDependentCodes],
|
||||||
|
);
|
||||||
|
|
||||||
|
const visibleEntrySteps = useMemo(
|
||||||
|
() =>
|
||||||
|
showLabWizardStep
|
||||||
|
? ENTRY_STEPS
|
||||||
|
: (ENTRY_STEPS.filter((step) => step !== 'lab') as EntryStep[]),
|
||||||
|
[showLabWizardStep],
|
||||||
|
);
|
||||||
|
|
||||||
const showLabShipmentBlocked = useMemo(
|
const showLabShipmentBlocked = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Boolean(
|
Boolean(
|
||||||
@@ -546,6 +581,11 @@ export function TreatmentWorkspace({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]);
|
const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]);
|
||||||
|
const connectedSelectedTeeth = useMemo(
|
||||||
|
() => connectedTeethSet(activeDetail?.toothSelectionGroups ?? []),
|
||||||
|
[activeDetail?.toothSelectionGroups],
|
||||||
|
);
|
||||||
|
const rangeAnchorRef = useRef<FdiToothId | null>(null);
|
||||||
|
|
||||||
const wholePlanTeethSet = useMemo(() => {
|
const wholePlanTeethSet = useMemo(() => {
|
||||||
const set = new Set<FdiToothId>();
|
const set = new Set<FdiToothId>();
|
||||||
@@ -574,8 +614,17 @@ export function TreatmentWorkspace({
|
|||||||
// Reset whole-plan overview when switching details.
|
// Reset whole-plan overview when switching details.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShowWholeTreatmentPlan(false);
|
setShowWholeTreatmentPlan(false);
|
||||||
|
rangeAnchorRef.current = null;
|
||||||
|
setEntryStep('teeth');
|
||||||
}, [activeDetailId]);
|
}, [activeDetailId]);
|
||||||
|
|
||||||
|
// Leave Lab step if the active detail is no longer prosthesis / lab-dependent.
|
||||||
|
useEffect(() => {
|
||||||
|
if (entryStep === 'lab' && !showLabWizardStep) {
|
||||||
|
setEntryStep('content');
|
||||||
|
}
|
||||||
|
}, [entryStep, showLabWizardStep]);
|
||||||
|
|
||||||
// Sync active lab shipment when the selected treatment detail changes.
|
// Sync active lab shipment when the selected treatment detail changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const match = labCaseDrafts.find((lc) => lc.detailClientId === activeDetailId);
|
const match = labCaseDrafts.find((lc) => lc.detailClientId === activeDetailId);
|
||||||
@@ -864,14 +913,17 @@ export function TreatmentWorkspace({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = await treatmentsApi.saveDraft(selectedAppointment.id, {
|
const response = await treatmentsApi.saveDraft(selectedAppointment.id, {
|
||||||
details: currentDetails.map(({ clientId, id, treatmentType, teeth, comment, attachmentMetas }) => ({
|
details: currentDetails.map(
|
||||||
|
({ clientId, id, treatmentType, teeth, toothSelectionGroups, comment, attachmentMetas }) => ({
|
||||||
clientId,
|
clientId,
|
||||||
id,
|
id,
|
||||||
treatmentType,
|
treatmentType,
|
||||||
teeth,
|
teeth,
|
||||||
|
toothSelectionGroups,
|
||||||
comment,
|
comment,
|
||||||
attachmentIds: attachmentMetas.map((a) => a.id),
|
attachmentIds: attachmentMetas.map((a) => a.id),
|
||||||
})),
|
}),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
const mapped = response.data.details.map(mapDetailFromApi);
|
const mapped = response.data.details.map(mapDetailFromApi);
|
||||||
setDetails(mapped);
|
setDetails(mapped);
|
||||||
@@ -1081,6 +1133,7 @@ export function TreatmentWorkspace({
|
|||||||
setActiveLabCaseId(linked.clientId);
|
setActiveLabCaseId(linked.clientId);
|
||||||
}
|
}
|
||||||
if (options?.scrollToLabPanel !== false) {
|
if (options?.scrollToLabPanel !== false) {
|
||||||
|
setEntryStep('lab');
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
scrollWithinMainScrollContainer(labPanelRef.current);
|
scrollWithinMainScrollContainer(labPanelRef.current);
|
||||||
});
|
});
|
||||||
@@ -1140,6 +1193,7 @@ export function TreatmentWorkspace({
|
|||||||
if (linked) {
|
if (linked) {
|
||||||
setActiveLabCaseId(linked.clientId);
|
setActiveLabCaseId(linked.clientId);
|
||||||
}
|
}
|
||||||
|
setEntryStep('lab');
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
scrollWithinMainScrollContainer(labPanelRef.current);
|
scrollWithinMainScrollContainer(labPanelRef.current);
|
||||||
});
|
});
|
||||||
@@ -1306,11 +1360,18 @@ export function TreatmentWorkspace({
|
|||||||
treatmentDetailId: detailId,
|
treatmentDetailId: detailId,
|
||||||
tooth: tp.tooth,
|
tooth: tp.tooth,
|
||||||
prosthesisTypeCode: tp.prosthesisTypeCode,
|
prosthesisTypeCode: tp.prosthesisTypeCode,
|
||||||
|
selectionGroupId: tp.selectionGroupId ?? '',
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter(
|
.filter(
|
||||||
(row): row is { treatmentDetailId: string; tooth: string; prosthesisTypeCode: string } =>
|
(
|
||||||
row !== null,
|
row,
|
||||||
|
): row is {
|
||||||
|
treatmentDetailId: string;
|
||||||
|
tooth: string;
|
||||||
|
prosthesisTypeCode: string;
|
||||||
|
selectionGroupId: string;
|
||||||
|
} => row !== null,
|
||||||
),
|
),
|
||||||
attachmentIds: lc.attachmentIds,
|
attachmentIds: lc.attachmentIds,
|
||||||
dueDate: lc.dueDate ?? null,
|
dueDate: lc.dueDate ?? null,
|
||||||
@@ -1379,27 +1440,33 @@ export function TreatmentWorkspace({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRemoveActiveDetail = useCallback(() => {
|
const handleRemoveDetail = useCallback(
|
||||||
|
(detailClientId: string) => {
|
||||||
if (!canEditTreatmentForDay) return;
|
if (!canEditTreatmentForDay) return;
|
||||||
const idx = details.findIndex((d) => d.clientId === activeDetailId);
|
const idx = details.findIndex((d) => d.clientId === detailClientId);
|
||||||
if (idx < 0) return;
|
if (idx < 0) return;
|
||||||
const active = details[idx];
|
const target = details[idx];
|
||||||
if (!active || isDetailLocked(active) || details.length <= 1) return;
|
if (!target || isDetailLocked(target) || details.length <= 1) return;
|
||||||
if (!window.confirm(t('confirmRemoveDetail'))) return;
|
if (!window.confirm(t('confirmRemoveDetail'))) return;
|
||||||
|
|
||||||
const removedId = active.clientId;
|
const nextDetails = details.filter((d) => d.clientId !== detailClientId);
|
||||||
const nextDetails = details.filter((d) => d.clientId !== removedId);
|
|
||||||
const nextActive =
|
const nextActive =
|
||||||
nextDetails[Math.min(idx, nextDetails.length - 1)]?.clientId ?? nextDetails[0]?.clientId;
|
activeDetailId === detailClientId
|
||||||
|
? (nextDetails[Math.min(idx, nextDetails.length - 1)]?.clientId ??
|
||||||
|
nextDetails[0]?.clientId)
|
||||||
|
: activeDetailId;
|
||||||
setDetails(nextDetails);
|
setDetails(nextDetails);
|
||||||
if (nextActive) setActiveDetailId(nextActive);
|
if (nextActive) setActiveDetailId(nextActive);
|
||||||
|
|
||||||
if (labCaseDrafts.some((lc) => lc.detailClientId === removedId)) {
|
if (labCaseDrafts.some((lc) => lc.detailClientId === detailClientId)) {
|
||||||
handleLabCasesChange(
|
handleLabCasesChange(
|
||||||
withoutEmptyLabCaseDrafts(labCaseDrafts.filter((lc) => lc.detailClientId !== removedId)),
|
withoutEmptyLabCaseDrafts(
|
||||||
|
labCaseDrafts.filter((lc) => lc.detailClientId !== detailClientId),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [
|
},
|
||||||
|
[
|
||||||
activeDetailId,
|
activeDetailId,
|
||||||
canEditTreatmentForDay,
|
canEditTreatmentForDay,
|
||||||
details,
|
details,
|
||||||
@@ -1407,7 +1474,8 @@ export function TreatmentWorkspace({
|
|||||||
isDetailLocked,
|
isDetailLocked,
|
||||||
labCaseDrafts,
|
labCaseDrafts,
|
||||||
t,
|
t,
|
||||||
]);
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const handleAddLabCase = useCallback(async () => {
|
const handleAddLabCase = useCallback(async () => {
|
||||||
if (!canEditTreatmentForDay || !selectedAppointment) return;
|
if (!canEditTreatmentForDay || !selectedAppointment) return;
|
||||||
@@ -1764,35 +1832,6 @@ export function TreatmentWorkspace({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3 min-w-0 w-full">
|
<div className="space-y-3 min-w-0 w-full">
|
||||||
<FdiToothChart
|
|
||||||
selected={chartSelectedTeeth}
|
|
||||||
toothColors={chartToothColors}
|
|
||||||
readOnly={showWholeTreatmentPlan}
|
|
||||||
headerControl={
|
|
||||||
details.length > 1 ? (
|
|
||||||
<Checkbox
|
|
||||||
checked={showWholeTreatmentPlan}
|
|
||||||
onChange={setShowWholeTreatmentPlan}
|
|
||||||
label={t('toothChartWholePlan')}
|
|
||||||
className="text-[11px] [&_span:last-child]:text-[11px] [&_span:last-child]:text-text-muted"
|
|
||||||
/>
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
onToggle={(fdi) => {
|
|
||||||
if (!canEditTreatmentForDay || isDetailLocked(activeDetail) || showWholeTreatmentPlan) return;
|
|
||||||
setDetails((prev) =>
|
|
||||||
prev.map((d) => {
|
|
||||||
if (d.clientId !== activeDetailId) return d;
|
|
||||||
const set = new Set(d.teeth);
|
|
||||||
if (set.has(fdi)) set.delete(fdi);
|
|
||||||
else set.add(fdi);
|
|
||||||
return { ...d, teeth: [...set].sort() as FdiToothId[] };
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
disabled={!canEditTreatmentForDay || isDetailLocked(activeDetail)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TreatmentDetailsEditor
|
<TreatmentDetailsEditor
|
||||||
details={details}
|
details={details}
|
||||||
activeDetailId={activeDetailId}
|
activeDetailId={activeDetailId}
|
||||||
@@ -1811,11 +1850,169 @@ export function TreatmentWorkspace({
|
|||||||
);
|
);
|
||||||
setDetails((prev) => [...prev, next]);
|
setDetails((prev) => [...prev, next]);
|
||||||
setActiveDetailId(next.clientId);
|
setActiveDetailId(next.clientId);
|
||||||
|
setEntryStep('teeth');
|
||||||
}}
|
}}
|
||||||
onRemoveDetail={handleRemoveActiveDetail}
|
onRemoveDetail={handleRemoveDetail}
|
||||||
onUploadFiles={(files) => void uploadForDetail(activeDetailId, files ?? [])}
|
onUploadFiles={(files) => void uploadForDetail(activeDetailId, files ?? [])}
|
||||||
|
showChrome
|
||||||
|
showFields={false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="surface-card px-3 py-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||||
|
<WizardStepper
|
||||||
|
className="min-w-0 flex-1"
|
||||||
|
aria-label={t('entryWizardLabel')}
|
||||||
|
steps={visibleEntrySteps.map((step) => ({
|
||||||
|
id: step,
|
||||||
|
label:
|
||||||
|
step === 'teeth'
|
||||||
|
? t('entryStepTeeth')
|
||||||
|
: step === 'content'
|
||||||
|
? t('entryStepContent')
|
||||||
|
: t('entryStepLab'),
|
||||||
|
}))}
|
||||||
|
currentStepId={entryStep}
|
||||||
|
onStepChange={(stepId) => setEntryStep(stepId as EntryStep)}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap gap-2 sm:justify-end shrink-0">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={entryStep === 'teeth'}
|
||||||
|
onClick={() => {
|
||||||
|
const idx = visibleEntrySteps.indexOf(entryStep);
|
||||||
|
if (idx > 0) setEntryStep(visibleEntrySteps[idx - 1]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('entryStepBack')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
disabled={
|
||||||
|
entryStep === 'lab' || (entryStep === 'content' && !showLabWizardStep)
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
if (entryStep === 'teeth') setEntryStep('content');
|
||||||
|
else if (entryStep === 'content' && showLabWizardStep) setEntryStep('lab');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('entryStepNext')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{entryStep === 'teeth' ? (
|
||||||
|
<FdiToothChart
|
||||||
|
selected={chartSelectedTeeth}
|
||||||
|
connectedTeeth={showWholeTreatmentPlan ? undefined : connectedSelectedTeeth}
|
||||||
|
toothColors={chartToothColors}
|
||||||
|
readOnly={showWholeTreatmentPlan}
|
||||||
|
headerControl={
|
||||||
|
details.length > 1 ? (
|
||||||
|
<Checkbox
|
||||||
|
checked={showWholeTreatmentPlan}
|
||||||
|
onChange={setShowWholeTreatmentPlan}
|
||||||
|
label={t('toothChartWholePlan')}
|
||||||
|
className="text-[11px] [&_span:last-child]:text-[11px] [&_span:last-child]:text-text-muted"
|
||||||
|
/>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
onToggle={(fdi, event) => {
|
||||||
|
if (
|
||||||
|
!canEditTreatmentForDay ||
|
||||||
|
isDetailLocked(activeDetail) ||
|
||||||
|
showWholeTreatmentPlan ||
|
||||||
|
!activeDetailId
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = details.find((d) => d.clientId === activeDetailId);
|
||||||
|
if (!detail) return;
|
||||||
|
const currentGroups =
|
||||||
|
detail.toothSelectionGroups.length > 0
|
||||||
|
? detail.toothSelectionGroups
|
||||||
|
: groupsFromFlatTeeth(detail.teeth);
|
||||||
|
|
||||||
|
let nextGroups: ReturnType<typeof toggleToothInGroups> | null = null;
|
||||||
|
|
||||||
|
if (event.shiftKey) {
|
||||||
|
const anchor = rangeAnchorRef.current;
|
||||||
|
// Need a prior click as range start; shift alone on one tooth does nothing.
|
||||||
|
if (!anchor || anchor === fdi) {
|
||||||
|
rangeAnchorRef.current = fdi;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
nextGroups = applyShiftRange(currentGroups, anchor, fdi);
|
||||||
|
rangeAnchorRef.current = fdi;
|
||||||
|
if (!nextGroups) return;
|
||||||
|
} else {
|
||||||
|
nextGroups = toggleToothInGroups(currentGroups, fdi);
|
||||||
|
rangeAnchorRef.current = fdi;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDetails((prev) =>
|
||||||
|
prev.map((d) =>
|
||||||
|
d.clientId !== activeDetailId
|
||||||
|
? d
|
||||||
|
: {
|
||||||
|
...d,
|
||||||
|
toothSelectionGroups: nextGroups!,
|
||||||
|
teeth: deriveTeethFromGroups(nextGroups!),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
setLabCaseDrafts((prev) =>
|
||||||
|
prev.map((lc) => {
|
||||||
|
if (lc.detailClientId !== activeDetailId) return lc;
|
||||||
|
return {
|
||||||
|
...lc,
|
||||||
|
toothProsthesis: pruneToothProsthesisForGroups(
|
||||||
|
lc.toothProsthesis,
|
||||||
|
activeDetailId,
|
||||||
|
nextGroups!,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
disabled={!canEditTreatmentForDay || isDetailLocked(activeDetail)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{entryStep === 'content' ? (
|
||||||
|
<TreatmentDetailsEditor
|
||||||
|
details={details}
|
||||||
|
activeDetailId={activeDetailId}
|
||||||
|
onActiveDetailChange={setActiveDetailId}
|
||||||
|
onDetailsChange={setDetails}
|
||||||
|
isDetailLocked={isDetailLocked}
|
||||||
|
labDependentCodes={labDependentCodes}
|
||||||
|
treatmentCatalog={treatmentDropdownCatalog}
|
||||||
|
disabled={!canEditTreatmentForDay}
|
||||||
|
canEdit={canEdit}
|
||||||
|
saveStatus={saveStatus}
|
||||||
|
uploadBusy={uploadBusyDetailId === activeDetailId}
|
||||||
|
onAddDetail={() => {
|
||||||
|
const next = newDetail(
|
||||||
|
defaultTreatmentTypeForAppointment(
|
||||||
|
selectedAppointment?.purpose,
|
||||||
|
treatmentCatalog,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setDetails((prev) => [...prev, next]);
|
||||||
|
setActiveDetailId(next.clientId);
|
||||||
|
setEntryStep('teeth');
|
||||||
|
}}
|
||||||
|
onUploadFiles={(files) => void uploadForDetail(activeDetailId, files ?? [])}
|
||||||
|
showChrome={false}
|
||||||
|
showFields
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{entryStep === 'lab' ? (
|
||||||
<div ref={labPanelRef}>
|
<div ref={labPanelRef}>
|
||||||
{showLabShipmentBlocked ? <LabShipmentBlockedNotice /> : null}
|
{showLabShipmentBlocked ? <LabShipmentBlockedNotice /> : null}
|
||||||
{showLabDispatchPanel ? (
|
{showLabDispatchPanel ? (
|
||||||
@@ -1863,8 +2060,13 @@ export function TreatmentWorkspace({
|
|||||||
canInviteLab={canAccessOrganizations}
|
canInviteLab={canAccessOrganizations}
|
||||||
onInviteLab={() => router.push('/organizations?action=invite-lab')}
|
onInviteLab={() => router.push('/organizations?action=invite-lab')}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : (
|
||||||
|
<p className="text-sm text-text-muted surface-card p-4">
|
||||||
|
{t('entryStepLabUnavailable')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export interface LabCaseAccessProsthesisGroup {
|
|||||||
prosthesisTypeCode: string;
|
prosthesisTypeCode: string;
|
||||||
prosthesisTypeLabel: string;
|
prosthesisTypeLabel: string;
|
||||||
teeth: string[];
|
teeth: string[];
|
||||||
|
connected?: boolean;
|
||||||
|
selectionGroupId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LabCaseAccessSession {
|
export interface LabCaseAccessSession {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ export type LabTaskStatus = 'IN_PROGRESS' | 'COMPLETED';
|
|||||||
export interface LabCaseProsthesisGroup {
|
export interface LabCaseProsthesisGroup {
|
||||||
prosthesisTypeCode: string;
|
prosthesisTypeCode: string;
|
||||||
teeth: string[];
|
teeth: string[];
|
||||||
|
connected?: boolean;
|
||||||
|
selectionGroupId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LabCaseListItem {
|
export interface LabCaseListItem {
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export interface LabCaseActivityItem {
|
|||||||
export interface PatientLabCaseProsthesisGroup {
|
export interface PatientLabCaseProsthesisGroup {
|
||||||
prosthesisTypeCode: string;
|
prosthesisTypeCode: string;
|
||||||
teeth: string[];
|
teeth: string[];
|
||||||
|
connected?: boolean;
|
||||||
|
selectionGroupId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PatientLabCaseSummary {
|
export interface PatientLabCaseSummary {
|
||||||
|
|||||||
@@ -58,6 +58,16 @@ export interface LabCaseToothProsthesisDraft {
|
|||||||
detailClientId: string;
|
detailClientId: string;
|
||||||
tooth: string;
|
tooth: string;
|
||||||
prosthesisTypeCode: string;
|
prosthesisTypeCode: string;
|
||||||
|
/** Links connected/single selection group for shared prosthesis + lab tasks. */
|
||||||
|
selectionGroupId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ToothSelectionKind = 'connected' | 'single';
|
||||||
|
|
||||||
|
export interface ToothSelectionGroup {
|
||||||
|
groupId: string;
|
||||||
|
kind: ToothSelectionKind;
|
||||||
|
teeth: FdiToothId[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LabCaseSendInfo {
|
export interface LabCaseSendInfo {
|
||||||
@@ -79,6 +89,7 @@ export interface PastTreatmentDetail {
|
|||||||
clientId: string;
|
clientId: string;
|
||||||
treatmentType: TreatmentType;
|
treatmentType: TreatmentType;
|
||||||
teeth: FdiToothId[];
|
teeth: FdiToothId[];
|
||||||
|
toothSelectionGroups?: ToothSelectionGroup[];
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
attachmentMetas?: TreatmentAttachmentMeta[];
|
attachmentMetas?: TreatmentAttachmentMeta[];
|
||||||
labCaseId?: string | null;
|
labCaseId?: string | null;
|
||||||
@@ -102,11 +113,13 @@ export interface PastLabCase {
|
|||||||
clientId: string;
|
clientId: string;
|
||||||
treatmentType: string;
|
treatmentType: string;
|
||||||
teeth: FdiToothId[];
|
teeth: FdiToothId[];
|
||||||
|
toothSelectionGroups?: ToothSelectionGroup[];
|
||||||
} | null;
|
} | null;
|
||||||
toothProsthesis?: Array<{
|
toothProsthesis?: Array<{
|
||||||
treatmentDetailId: string;
|
treatmentDetailId: string;
|
||||||
tooth: string;
|
tooth: string;
|
||||||
prosthesisTypeCode: string;
|
prosthesisTypeCode: string;
|
||||||
|
selectionGroupId?: string;
|
||||||
}>;
|
}>;
|
||||||
sends?: LabCaseSendInfo[];
|
sends?: LabCaseSendInfo[];
|
||||||
dueDate?: string | null;
|
dueDate?: string | null;
|
||||||
@@ -136,6 +149,7 @@ export interface TreatmentDetailDraft {
|
|||||||
id?: string;
|
id?: string;
|
||||||
treatmentType: TreatmentType;
|
treatmentType: TreatmentType;
|
||||||
teeth: FdiToothId[];
|
teeth: FdiToothId[];
|
||||||
|
toothSelectionGroups: ToothSelectionGroup[];
|
||||||
comment: string;
|
comment: string;
|
||||||
attachmentMetas: TreatmentAttachmentMeta[];
|
attachmentMetas: TreatmentAttachmentMeta[];
|
||||||
labCaseId?: string | null;
|
labCaseId?: string | null;
|
||||||
@@ -166,6 +180,7 @@ export type SavedTreatmentDetailPayload = {
|
|||||||
id?: string;
|
id?: string;
|
||||||
treatmentType: TreatmentType;
|
treatmentType: TreatmentType;
|
||||||
teeth: FdiToothId[];
|
teeth: FdiToothId[];
|
||||||
|
toothSelectionGroups?: ToothSelectionGroup[];
|
||||||
comment: string;
|
comment: string;
|
||||||
attachmentIds: string[];
|
attachmentIds: string[];
|
||||||
};
|
};
|
||||||
@@ -182,6 +197,7 @@ export interface SaveLabCasePayload {
|
|||||||
treatmentDetailId: string;
|
treatmentDetailId: string;
|
||||||
tooth: string;
|
tooth: string;
|
||||||
prosthesisTypeCode: string;
|
prosthesisTypeCode: string;
|
||||||
|
selectionGroupId?: string;
|
||||||
}>;
|
}>;
|
||||||
attachmentIds?: string[];
|
attachmentIds?: string[];
|
||||||
dueDate?: string | null;
|
dueDate?: string | null;
|
||||||
@@ -204,6 +220,7 @@ export interface LabCaseResponse {
|
|||||||
clientId: string;
|
clientId: string;
|
||||||
treatmentType: string;
|
treatmentType: string;
|
||||||
teeth: string[];
|
teeth: string[];
|
||||||
|
toothSelectionGroups?: ToothSelectionGroup[];
|
||||||
} | null;
|
} | null;
|
||||||
sends: LabCaseSendInfo[];
|
sends: LabCaseSendInfo[];
|
||||||
dueDate: string | null;
|
dueDate: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user