diff --git a/.cursor/rules/treatment-workspace.mdc b/.cursor/rules/treatment-workspace.mdc index 495d033..a22441a 100644 --- a/.cursor/rules/treatment-workspace.mdc +++ b/.cursor/rules/treatment-workspace.mdc @@ -8,8 +8,8 @@ alwaysApply: false - **Browse mode** (`selectedPreviewId` set): preview only; banner + **Load into workspace**; form unchanged until load. - **Current draft** preview: heading “Current draft”; no load button while editing live. -- **Entry wizard:** Teeth → Content → Lab via `WizardStepper` (not detail chips). Lab step only for lab-dependent (prosthesis) active detail; entering Lab auto-opens shipment draft. Add detail ungated. Appointment purpose does not gate type or Next. -- **Tooth groups:** Shift+same-arch range = connected (linked dots); Ctrl/⌘+same-arch range = singles (one lab row each); click in connected span → collapse to that single only. `toothSelectionGroups.ts`; prune lab `toothProsthesis` on change. +- **Entry wizard:** Teeth → Content → Lab via `WizardStepper` (not detail chips). Lab step only for lab-dependent (prosthesis) active detail; entering Lab auto-opens shipment draft. Add detail ungated. Appointment purpose does not gate type or Next. Detail-chip switches reset to Teeth unless `pendingEntryStepRef` requests Lab (shipments rail open). +- **Tooth groups:** Shift+same-arch range = connected bridge (linked dots, one shipment row); overlapping Shift ranges merge by union. Plain click = add/remove singles; click in a bridge cuts that tooth out as a selected single (end → remainder stays connected; middle → two bridges). Never 1-tooth connected. `toothSelectionGroups.ts`; prune lab `toothProsthesis` on change. Cases/Tasks merge by prosthesis type after send. - **Lab dispatch UI:** due date end-aligned beside title (`sm:flex-row` + `justify-between`; stacks on mobile). Prosthesis type: stacked below `md`, 50/50 same-row from `md`. Content clinical field = **Notes** (not case comments). - **Lab comments:** shared `LabCaseCommentsPanel` (newest-first; sent=`justify-start`, received=`justify-end`; `viewerSide`) across Treatment / Cases / Tasks / share. Use logical `text-start`/`text-end`, not left/right. - **Detail chrome:** chips + Add at top; **Remove** = trash on chip (unsent, >1 detail; disabled when day-locked / no edit / uploading). No delete in Content step. diff --git a/.cursor/skills/lab-cases/SKILL.md b/.cursor/skills/lab-cases/SKILL.md index fe06c2d..b646819 100644 --- a/.cursor/skills/lab-cases/SKILL.md +++ b/.cursor/skills/lab-cases/SKILL.md @@ -12,8 +12,10 @@ description: Lab Cases tab — list, filters, detail panel, assignment, share QR ## List behavior - **Default sort:** `sentAt` desc (newest first). +- **Page size:** `PAGE_SIZE = 10` in `CasesPage.tsx`. - **Auto-select:** On tab open / after filter reload, select first list item if none selected; keep selection when still in list; `?caseId=` URL wins. - **Right panel:** Always shows detail for selected case when list non-empty (loading state while fetching). +- **Left rail layout:** section is `flex flex-col` + `lg:min-h-[420px]` / `h-full` with `lg:items-stretch` on the grid; case list wrapper is `flex-1 min-h-0 overflow-y-auto` (do **not** use a fixed `max-h-[55vh]` — that leaves empty space above pagination). ## Filters (`GET /cases`) diff --git a/.cursor/skills/lab-tasks/SKILL.md b/.cursor/skills/lab-tasks/SKILL.md index 3f0b73e..889f729 100644 --- a/.cursor/skills/lab-tasks/SKILL.md +++ b/.cursor/skills/lab-tasks/SKILL.md @@ -66,7 +66,7 @@ Components: `TaskCaseGroupHeader`, `TaskProsthesisGroupHeader`, `TaskRow`. ## APIs - `GET /tasks` — paginated flat task list (grouping is client-side when `sortBy=date`) -- `PATCH /tasks/:taskId` — update status (only assignee or unassigned task) +- `PATCH /tasks/:taskId` — update status (only assignee or unassigned task). Completing **`intraoral_scan`** also completes every other `intraoral_scan` task in the same case (case-scoped; catalog first step for all prosthesis types). - `GET /tasks/filter-options` — clinics + workflow steps (localized) - `GET /tasks/locate-page` — page number for a task in the sorted filtered list - `GET /cases/assignable-staff` — staff eligible for task assignment diff --git a/.cursor/skills/treatment-workspace/SKILL.md b/.cursor/skills/treatment-workspace/SKILL.md index 5080a77..f470599 100644 --- a/.cursor/skills/treatment-workspace/SKILL.md +++ b/.cursor/skills/treatment-workspace/SKILL.md @@ -40,12 +40,13 @@ Right-column entry uses `WizardStepper` (`components/ui/shared/WizardStepper.tsx | Step | UI | Notes | |------|-----|--------| -| **Teeth** | `FdiToothChart` | Shift+click connected range; Ctrl/⌘+click single range; linked dots | +| **Teeth** | `FdiToothChart` | Shift+click connected bridge (union if overlap); click cuts tooth from bridge as selected single | | **Content** | `TreatmentDetailsEditor` fields only (`showChrome={false}`) | Type / **Notes** / attachments — **no delete button** | | **Lab** | `LabCasesDispatchPanel` | Shown in stepper **only** when active detail type is lab-dependent (`labDependentCodes`). Entering Lab **auto-ensures** a shipment draft (no “Add lab shipment” click). Add detail stays ungated. Due date beside title (RTL via logical end). Prosthesis dropdown same-row from `md`. | - Detail type may differ from appointment purpose (purpose only defaults new details). - Next/Back navigate visible steps; leaving prosthesis while on Lab returns to Content. +- Switching `activeDetailId` resets the wizard to **Teeth**, unless `pendingEntryStepRef` is set to `lab` first (lab shipments rail / “Go to dispatch” / load-with-focus). That ref prevents the reset effect from overwriting Lab on the first click. @@ -53,12 +54,11 @@ Right-column entry uses `WizardStepper` (`components/ui/shared/WizardStepper.tsx -Helpers: `frontend/src/components/treatment/toothSelectionGroups.ts`. Persisted as `toothSelectionGroups` on the detail; lab tasks group by `selectionGroupId`. +Helpers: `frontend/src/components/treatment/toothSelectionGroups.ts`. Persisted as `toothSelectionGroups` on the detail. Lab **dispatch** rows are 1:1 with selection groups; after send, Cases/Tasks **merge** teeth by prosthesis type (not by selection group). -- **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 → one **connected** group (linked dots above/below). Shift wins if Ctrl also held. -- **Ctrl/⌘+click:** same range, but each tooth as its own **single** (one lab-dispatch row per tooth). -- Prevent browser selection artifacts (`select-none`, modifier `preventDefault` on mousedown). +- **Plain click:** add/remove single; if tooth is in a **connected** bridge → cut it out as a selected **single** (second click deselects). End of bridge → remainder stays connected (≥2) or demotes to single. Middle → left and right become separate groups. Never a 1-tooth connected (no lone dots). +- **Shift+click:** inclusive same-arch range → one **connected** bridge; if the range overlaps existing bridges, **union** them into one bridge. Non-overlapping bridges stay separate. +- 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. - Lab case comments: shared `LabCaseCommentsPanel` — newest-first; **sent** = `justify-start` / `text-start`, **received** = `justify-end` / `text-end` (RTL-safe); require `viewerSide: 'LAB' | 'CLINIC'`. Used in Cases, Tasks, share focus, and Treatment (`DetailLabCaseCommentsSection`). Composer: `h-9` input + primary send / visibility buttons (white icons; Send mirrored in RTL). diff --git a/AGENTS.md b/AGENTS.md index 493d590..bd53c4f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,23 +45,23 @@ frontend/src/ **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); entering Lab auto-opens shipment draft (no Add-shipment CTA). Content notes field label is **Notes** (clinical). Detail chips ≠ wizard chrome. -- **Tooth selection:** Shift+click same-arch range = connected group (linked dots); Ctrl/⌘+click same range = individual singles (one lab row each); 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). +- **Entry wizard:** `WizardStepper` — Teeth → Content → Lab; Lab step only when active detail type is lab-dependent (prosthesis); entering Lab auto-opens shipment draft (no Add-shipment CTA). Content notes field label is **Notes** (clinical). Detail chips ≠ wizard chrome. Switching detail chips resets wizard to Teeth unless `pendingEntryStepRef` requests Lab (e.g. opening a case from the lab shipments rail). +- **Tooth selection:** Shift+click same-arch range = connected bridge (one lab row); overlapping Shift bridges merge by union. Plain click add/remove singles; click in a bridge cuts that tooth out as a selected single (ends keep one bridge; middle splits into two). Never a 1-tooth connected (no lone dots). Helpers: `toothSelectionGroups.ts`. Connected label: `ConnectedSelectionBadge`. After send, Cases/Tasks merge teeth by prosthesis type. - **Lab dispatch layout:** due date beside title (`justify-between`, logical start/end for RTL); prosthesis type on same row as teeth from `md:` up (stacked on mobile). **Treatment lab rules (quick ref):** - Lab-dependent details (e.g. prosthesis) **without teeth** can save but **cannot ship** — show `LabShipmentBlockedNotice` + inline banner; toast on dispatch add. - Detail treatment type need **not** match appointment purpose — purpose only pre-fills new details. - **History filters** are client-side only (`treatmentHistoryFilters.ts`): “Not shipped to lab” + single date on already-fetched patient history; includes live current draft when filtering. -- **Lab shipments rail**: unified list with scope toggle **This patient** vs **All updates** (unread across org for **this clinician's cases only**, includes patient name). +- **Lab shipments rail**: unified list with scope toggle **This patient** vs **All updates** (unread across org for **this clinician's cases only**, includes patient name). Opening a case from the rail jumps to the entry wizard **Lab** step. - **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. Shared UI: `LabCaseCommentsPanel` — newest first; sent = start / received = end (`text-start`/`justify-start`, RTL-safe); pass `viewerSide`. **Appointments (quick ref):** Do not delete (or change patient) when `hasTreatment`; codes `APPOINTMENT_HAS_TREATMENT` / `APPOINTMENT_PATIENT_LOCKED`. Past days: no new bookings; edit/delete OK without treatment; with treatment → toast. 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; completing **`intraoral_scan`** completes every scan task in that case (case-scoped; catalog first step for all prosthesis types); **mobile:** larger task status controls, sticky case header when grouped; **tab badges:** `LabCaseActivity` + `GET /notifications/tab-counts` (lab Cases/Tasks split, clinic Treatment) — 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 **10 per page**; left rail list fills column height (`flex-1 overflow-y-auto`); list cards use `LabCaseProsthesisGroupsList` (colored type + teeth, shared with Treatment rail). 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 case share link (quick ref):** - Token on first ship → `/{locale}/lab-case/{token}` after login. diff --git a/backend/prisma/migrations/20260717123000_drop_stale_lab_case_task_unique/migration.sql b/backend/prisma/migrations/20260717123000_drop_stale_lab_case_task_unique/migration.sql new file mode 100644 index 0000000..bbab5ea --- /dev/null +++ b/backend/prisma/migrations/20260717123000_drop_stale_lab_case_task_unique/migration.sql @@ -0,0 +1,15 @@ +-- The tooth_selection_groups migration tried to drop +-- "lab_case_tasks_labCaseId_treatmentDetailId_prosthesisTypeCode_stepOrder_key", +-- but PostgreSQL truncated/renamed that index to +-- "lab_case_tasks_labCaseId_treatmentDetailId_prosthesisTypeCo_key" +-- (see 20260710150528). The old unique index (without selectionGroupId) +-- therefore remained and blocks multiple selection groups that share +-- the same prosthesis type + stepOrder. + +DROP INDEX IF EXISTS "lab_case_tasks_labCaseId_treatmentDetailId_prosthesisTypeCo_key"; +DROP INDEX IF EXISTS "lab_case_tasks_labCaseId_treatmentDetailId_prosthesisTypeCode_stepOrder_key"; +DROP INDEX IF EXISTS "lab_case_tasks_labCaseId_treatmentDetailId_prosthesisTypeCode_s"; + +-- Ensure the correct unique index exists (idempotent for DBs that already have it). +CREATE UNIQUE INDEX IF NOT EXISTS "lab_case_tasks_labCaseId_treatmentDetailId_selectionGroupId_prosthesisTypeCode_stepOrder_key" + ON "lab_case_tasks"("labCaseId", "treatmentDetailId", "selectionGroupId", "prosthesisTypeCode", "stepOrder"); diff --git a/backend/prisma/migrations/20260717130000_drop_stale_lab_task_unique_by_def/migration.sql b/backend/prisma/migrations/20260717130000_drop_stale_lab_task_unique_by_def/migration.sql new file mode 100644 index 0000000..cd5edf0 --- /dev/null +++ b/backend/prisma/migrations/20260717130000_drop_stale_lab_task_unique_by_def/migration.sql @@ -0,0 +1,34 @@ +-- Defensive cleanup for stage/prod (and any DB where the stale unique index +-- survived under a truncated/renamed Postgres identifier). +-- +-- History: +-- 20260707130000 created unique (labCaseId, treatmentDetailId, prosthesisTypeCode, stepOrder) +-- → PG truncates the index name to 63 chars (...prosthesisTypeCode_s) +-- 20260710150528 renamed that truncated name to ...prosthesisTypeCo_key +-- 20260716120000 dropped the *untruncated* name (no-op) and added the correct +-- unique that includes selectionGroupId — leaving the stale unique in place +-- 20260717123000 drops known stale names; this migration also drops by definition +-- so any leftover old unique is removed regardless of identifier length/rename. + +DO $$ +DECLARE + r RECORD; +BEGIN + FOR r IN + SELECT indexname + FROM pg_indexes + WHERE schemaname = 'public' + AND tablename = 'lab_case_tasks' + AND indexdef ILIKE '%UNIQUE%' + AND indexdef ILIKE '%prosthesisTypeCode%' + AND indexdef ILIKE '%stepOrder%' + AND indexdef ILIKE '%treatmentDetailId%' + AND indexdef NOT ILIKE '%selectionGroupId%' + LOOP + EXECUTE format('DROP INDEX IF EXISTS %I', r.indexname); + END LOOP; +END $$; + +-- Correct unique (includes selectionGroupId). Idempotent if already present. +CREATE UNIQUE INDEX IF NOT EXISTS "lab_case_tasks_labCaseId_treatmentDetailId_selectionGroupId_prosthesisTypeCode_stepOrder_key" + ON "lab_case_tasks"("labCaseId", "treatmentDetailId", "selectionGroupId", "prosthesisTypeCode", "stepOrder"); diff --git a/backend/prisma/migrations/20260717160000_lab_tasks_merge_by_prosthesis_unique/migration.sql b/backend/prisma/migrations/20260717160000_lab_tasks_merge_by_prosthesis_unique/migration.sql new file mode 100644 index 0000000..9c9d9ad --- /dev/null +++ b/backend/prisma/migrations/20260717160000_lab_tasks_merge_by_prosthesis_unique/migration.sql @@ -0,0 +1,40 @@ +-- Restore merge-by-prosthesis uniqueness for lab case tasks. +-- Product rule: all teeth on a detail that share the same prosthesis type become +-- one task set (Scan/Design/…). selectionGroupId remains on the table for UI +-- but is not part of the unique key. +-- +-- Drop the selectionGroupId-inclusive unique (by known names + by definition). + +DO $$ +DECLARE + r RECORD; +BEGIN + FOR r IN + SELECT indexname + FROM pg_indexes + WHERE schemaname = 'public' + AND tablename = 'lab_case_tasks' + AND indexdef ILIKE '%UNIQUE%' + AND indexdef ILIKE '%selectionGroupId%' + AND indexdef ILIKE '%prosthesisTypeCode%' + AND indexdef ILIKE '%stepOrder%' + LOOP + EXECUTE format('DROP INDEX IF EXISTS %I', r.indexname); + END LOOP; +END $$; + +DROP INDEX IF EXISTS "lab_case_tasks_labCaseId_treatmentDetailId_selectionGroupId_prosthesisTypeCode_stepOrder_key"; + +-- Collapse rows that were split by selectionGroupId but share the merge key +-- (status events cascade via FK). Kept row may need regen for full teeth[]; new sends are correct. +DELETE FROM "lab_case_tasks" AS a + USING "lab_case_tasks" AS b +WHERE a."id" > b."id" + AND a."labCaseId" = b."labCaseId" + AND a."treatmentDetailId" = b."treatmentDetailId" + AND a."prosthesisTypeCode" = b."prosthesisTypeCode" + AND a."stepOrder" = b."stepOrder"; + +-- Short name stays under Postgres 63-char identifier limit. +CREATE UNIQUE INDEX IF NOT EXISTS "lab_case_tasks_case_detail_prosthesis_step_key" + ON "lab_case_tasks"("labCaseId", "treatmentDetailId", "prosthesisTypeCode", "stepOrder"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index c01f942..e943bcb 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -379,7 +379,7 @@ model LabCaseTask { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@unique([labCaseId, treatmentDetailId, selectionGroupId, prosthesisTypeCode, stepOrder]) + @@unique([labCaseId, treatmentDetailId, prosthesisTypeCode, stepOrder], map: "lab_case_tasks_case_detail_prosthesis_step_key") @@index([labCaseId, status]) @@index([assigneeUserId]) @@map("lab_case_tasks") diff --git a/backend/src/modules/cases/lab-case-task.generator.spec.ts b/backend/src/modules/cases/lab-case-task.generator.spec.ts index 10ba133..935dbcf 100644 --- a/backend/src/modules/cases/lab-case-task.generator.spec.ts +++ b/backend/src/modules/cases/lab-case-task.generator.spec.ts @@ -172,7 +172,7 @@ describe('generateLabCaseTasks', () => { expect(stepCodes).toContain('printer_resin'); }); - it('keeps separate selection groups with the same prosthesis type as separate task sets', async () => { + it('merges teeth with the same prosthesis type across selection groups', async () => { const pfmSteps = stepsFromSeed('pfm_crown'); const { tx, created } = buildMockTx({ toothProsthesisRows: [ @@ -198,21 +198,13 @@ describe('generateLabCaseTasks', () => { 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( + const count = await generateLabCaseTasks(tx as never, 'lab-case-merge', 'en'); + expect(count).toBe(pfmSteps.length); + const rows = created as Array<{ teeth: string[]; prosthesisTypeCode: string }>; + expect(rows).toHaveLength(pfmSteps.length); + expect(rows.every((r) => JSON.stringify(r.teeth) === JSON.stringify(['14', '15', '21']))).toBe( true, ); - expect(single.every((r) => JSON.stringify(r.teeth) === JSON.stringify(['21']))).toBe(true); }); it('skips generation when tasks already exist', async () => { diff --git a/backend/src/modules/cases/lab-case-task.generator.ts b/backend/src/modules/cases/lab-case-task.generator.ts index 47b4fbc..063056f 100644 --- a/backend/src/modules/cases/lab-case-task.generator.ts +++ b/backend/src/modules/cases/lab-case-task.generator.ts @@ -58,8 +58,9 @@ export async function generateLabCaseTasks( const stepLabels = await resolveStepLabels(tx, allStepCodes, locale); - // Group by selection group + prosthesis type so connected spans stay one task set, - // and separate singles stay separate even with the same prosthesis type. + // Merge all teeth that share the same prosthesis type on a detail into one + // task set (pre–selection-group behavior). selectionGroupId is stored for + // UI/history only — not part of the grouping key. const groups = new Map< string, { @@ -72,10 +73,8 @@ export async function generateLabCaseTasks( >(); for (const row of toothProsthesisRows) { - const selectionGroupId = - row.selectionGroupId?.trim() || - fallbackGroupIdForTooth(row.detail.toothSelectionGroups, row.tooth, row.prosthesisTypeCode); - const key = `${row.treatmentDetailId}::${selectionGroupId}::${row.prosthesisTypeCode}`; + const key = `${row.treatmentDetailId}::${row.prosthesisTypeCode}`; + const selectionGroupId = row.selectionGroupId?.trim() || ''; const group = groups.get(key) ?? { treatmentDetailId: row.treatmentDetailId, treatmentType: row.detail.treatmentType, @@ -83,6 +82,10 @@ export async function generateLabCaseTasks( selectionGroupId, teeth: [], }; + // Keep first non-empty selectionGroupId for persistence; grouping ignores it. + if (!group.selectionGroupId && selectionGroupId) { + group.selectionGroupId = selectionGroupId; + } group.teeth.push(row.tooth); groups.set(key, group); } @@ -121,24 +124,6 @@ export async function generateLabCaseTasks( 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[] { return [...new Set(teeth)].sort((a, b) => { const na = Number(a); diff --git a/backend/src/modules/tasks/tasks.service.ts b/backend/src/modules/tasks/tasks.service.ts index 67d80a7..be94074 100644 --- a/backend/src/modules/tasks/tasks.service.ts +++ b/backend/src/modules/tasks/tasks.service.ts @@ -33,6 +33,9 @@ const taskListInclude = { }, } satisfies Prisma.LabCaseTaskInclude; +/** Completing this step completes every matching task in the same case (catalog: first step of all prosthesis types). */ +const CASE_SCOPED_SCAN_STEP_CODE = 'intraoral_scan'; + @Injectable() export class TasksService { constructor( @@ -238,12 +241,13 @@ export class TasksService { } const updated = await this.prisma.$transaction(async (tx) => { + const now = new Date(); const result = await tx.labCaseTask.update({ where: { id: taskId }, data: { status: dto.status, lastStatusChangedByUserId: actorUserId, - lastStatusChangedAt: new Date(), + lastStatusChangedAt: now, }, include: taskListInclude, }); @@ -259,13 +263,58 @@ export class TasksService { }); } + const cascadedScanTaskIds: string[] = []; + if ( + dto.status === LabTaskStatus.COMPLETED && + task.status !== LabTaskStatus.COMPLETED && + task.workflowStepCode === CASE_SCOPED_SCAN_STEP_CODE + ) { + const siblingScans = await tx.labCaseTask.findMany({ + where: { + labCaseId: task.labCaseId, + workflowStepCode: CASE_SCOPED_SCAN_STEP_CODE, + status: { not: LabTaskStatus.COMPLETED }, + id: { not: taskId }, + }, + select: { id: true, status: true }, + }); + + for (const sibling of siblingScans) { + await tx.labCaseTask.update({ + where: { id: sibling.id }, + data: { + status: LabTaskStatus.COMPLETED, + lastStatusChangedByUserId: actorUserId, + lastStatusChangedAt: now, + }, + }); + await tx.labCaseTaskStatusEvent.create({ + data: { + taskId: sibling.id, + fromStatus: sibling.status, + toStatus: LabTaskStatus.COMPLETED, + changedByUserId: actorUserId, + }, + }); + cascadedScanTaskIds.push(sibling.id); + } + } + if (dto.status === LabTaskStatus.COMPLETED && task.status !== LabTaskStatus.COMPLETED) { await this.labCaseActivity.record( { labCaseId: task.labCaseId, type: LabCaseActivityType.TASK_COMPLETED, actorUserId, - payload: { taskId }, + payload: { + taskId, + ...(cascadedScanTaskIds.length > 0 + ? { + cascadedTaskIds: cascadedScanTaskIds, + caseScopedStep: CASE_SCOPED_SCAN_STEP_CODE, + } + : {}), + }, }, tx, ); diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 9aa533e..000816c 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -809,7 +809,7 @@ "entryStepNext": "Next", "entryStepLabUnavailable": "Lab dispatch is available after you save a lab-dependent detail with teeth selected.", "toothChartTitleCompact": "Tooth chart", - "toothChartHint": "Tap teeth to select. Shift-click another tooth in the same arch for a connected span. Ctrl-click (⌘ on Mac) the same way to select each tooth separately.", + "toothChartHint": "Tap teeth to select or deselect. Shift-click another tooth in the same arch for a connected bridge. Click a bridge tooth to cut it out (still selected as a single); ends keep one bridge, middle splits into two.", "toothConnectedHint": "Connected selection", "toothConnectedSuffix": " (connected)", "connectedBadge": "Connected", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 6245a2d..45ca4a7 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -803,7 +803,7 @@ "confirmSend": "تأیید ارسال", "toothChartTitle": "نمودار دندانها FDI", "toothChartTitleCompact": "نمودار دندان", - "toothChartHint": "برای انتخاب روی دندان ضربه بزنید. Shift+کلیک روی دندان دیگر در همان قوس، یک بازه متصل میسازد. Ctrl+کلیک (⌘ در مک) به همان شکل هر دندان را جداگانه انتخاب میکند.", + "toothChartHint": "برای انتخاب یا لغو انتخاب روی دندان ضربه بزنید. Shift+کلیک روی دندان دیگر در همان قوس، یک پل متصل میسازد. کلیک روی دندان داخل پل آن را جدا میکند (همچنان انتخابشده بهصورت تک)؛ انتها یک پل میماند، وسط به دو پل تقسیم میشود.", "toothConnectedHint": "انتخاب متصل", "toothConnectedSuffix": " (متصل)", "connectedBadge": "متصل", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index c2b58fe..ca0c221 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -802,7 +802,7 @@ "confirmSend": "Bevestig verzending", "toothChartTitle": "FDI-tanddiagram", "toothChartTitleCompact": "Tanddiagram", - "toothChartHint": "Tik op tanden om te selecteren. Shift-klik op een andere tand in dezelfde boog voor een verbonden span. Ctrl-klik (⌘ op Mac) op dezelfde manier om elke tand apart te selecteren.", + "toothChartHint": "Tik op tanden om te selecteren of deselecteren. Shift-klik op een andere tand in dezelfde boog voor een verbonden brug. Klik op een brugtand om die eruit te snijden (blijft geselecteerd als single); uiteinden houden één brug, midden splitst in twee.", "toothConnectedHint": "Verbonden selectie", "toothConnectedSuffix": " (verbonden)", "connectedBadge": "Verbonden", diff --git a/frontend/src/app/[locale]/(dashboard)/layout.tsx b/frontend/src/app/[locale]/(dashboard)/layout.tsx index c3dc289..7947c0b 100644 --- a/frontend/src/app/[locale]/(dashboard)/layout.tsx +++ b/frontend/src/app/[locale]/(dashboard)/layout.tsx @@ -76,6 +76,15 @@ export default function DashboardLayout({ children }: { children: React.ReactNod ); } + // Do not mount clinic-only pages for lab orgs (avoids PERMISSION_CLINIC_ONLY toasts during redirect). + if (!canAccessDashboardRoute(currentOrganization, pathname)) { + return ( +