From 8bfa8c88fe4efa1a5e71078a34ce4cd9383dab78 Mon Sep 17 00:00:00 2001 From: Admin Date: Wed, 19 Aug 2026 15:05:58 +0330 Subject: [PATCH] improvement: v1 standalone treatment/case creation made possible. --- .cursor/skills/lab-cases/SKILL.md | 11 +- .cursor/skills/treatment-workspace/SKILL.md | 22 +- AGENTS.md | 4 +- .../migration.sql | 79 +++ backend/prisma/regenerate-lab-tasks.ts | 1 + backend/prisma/schema.prisma | 84 ++- backend/src/common/errors/error-codes.ts | 9 + backend/src/common/walk-in-patient.ts | 40 ++ .../appointments/appointments.service.ts | 4 +- backend/src/modules/cases/cases.controller.ts | 69 +- backend/src/modules/cases/cases.service.ts | 568 +++++++++++++-- backend/src/modules/cases/dto/cases.dto.ts | 119 +++- .../modules/cases/lab-case-access.service.ts | 17 +- .../cases/lab-case-task.generator.spec.ts | 58 +- .../modules/cases/lab-case-task.generator.ts | 23 +- .../lab-case-comments.controller.ts | 2 +- .../lab-case-comments.service.ts | 21 +- .../lab-case-activity.service.ts | 18 +- .../user-notification.service.ts | 24 +- .../src/modules/patients/patients.service.ts | 20 +- backend/src/modules/tasks/tasks.service.ts | 188 +++-- backend/src/modules/today/today.service.ts | 3 +- .../modules/treatments/dto/treatment.dto.ts | 22 + .../treatments/treatments.controller.ts | 108 +++ .../modules/treatments/treatments.service.ts | 409 +++++++++-- frontend/messages/en.json | 40 +- frontend/messages/fa.json | 40 +- frontend/messages/nl.json | 40 +- frontend/src/components/treatment/dayStrip.ts | 14 + .../src/components/ui/lab/CaseCreatePanel.tsx | 669 ++++++++++++++++++ frontend/src/components/ui/lab/CasesPage.tsx | 125 +++- .../ui/lab/LabCaseCommentsPanel.tsx | 8 +- .../ui/treatment/AppointmentsStrip.tsx | 97 +-- .../components/ui/treatment/DayStripCard.tsx | 97 +++ .../ui/treatment/TreatmentWorkspace.tsx | 468 +++++++++--- frontend/src/lib/api/cases.ts | 68 ++ frontend/src/lib/api/treatments.ts | 62 ++ frontend/src/types/cases.ts | 23 +- frontend/src/types/treatment.ts | 9 +- 39 files changed, 3296 insertions(+), 387 deletions(-) create mode 100644 backend/prisma/migrations/20260819120000_standalone_treatments_lab_internal_cases/migration.sql create mode 100644 backend/src/common/walk-in-patient.ts create mode 100644 frontend/src/components/treatment/dayStrip.ts create mode 100644 frontend/src/components/ui/lab/CaseCreatePanel.tsx create mode 100644 frontend/src/components/ui/treatment/DayStripCard.tsx diff --git a/.cursor/skills/lab-cases/SKILL.md b/.cursor/skills/lab-cases/SKILL.md index 202740a..6e75933 100644 --- a/.cursor/skills/lab-cases/SKILL.md +++ b/.cursor/skills/lab-cases/SKILL.md @@ -11,12 +11,17 @@ description: Lab Cases tab — list, filters, detail panel, assignment, share QR ## List behavior -- **Default sort:** `sentAt` desc (newest first). +- **Default sort:** `startedAt` / `sentAt` desc (newest first). Lab-origin drafts (`origin === LAB_INTERNAL && !startedAt`) show a **Draft** badge. - **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). +- **Add case:** `TAB_CASES_EDIT` — `POST /cases` then replace the **right pane** with `CaseCreatePanel` (inline wizard, not a modal). Prosthesis-only lines, interactive `FdiToothChart` (same connect/Shift rules as Treatment). Header: referring clinic/dentist, patient name/mobile, optional ACTIVE partner clinic, due date (`AppDateInput`). **Start** (`POST /cases/:id/start`) generates tasks; no `LabCaseSend`, no clinic inbox/`CASE_SENT`. +- **Right panel:** Draft lab-origin → wizard; started/received → `CaseDetailPanel`. - **Left rail layout:** section is `flex flex-col` + `lg:min-h-[420px]` / `h-full` with `lg:items-stretch` on the grid; case list wrapper is `flex-1 min-h-0 overflow-y-auto` (do **not** use a fixed `max-h-[55vh]` — that leaves empty space above pagination). +List `where`: received clinic cases (`sends` + `sentAt`) **OR** `origin = LAB_INTERNAL` and `destinationOrganizationId = lab`. Map clinic/patient from `treatment` **or** snapshots / partner org. Search includes snapshot names. + +Share QR remains clinic-sent only (`sentAt` / `shareUrl`). Lab-origin has no share link in this pass. + ## Filters (`GET /cases`) | Param | Behavior | @@ -36,7 +41,7 @@ List item shape: `prosthesisGroups: { prosthesisTypeCode, teeth[] }[]` from task ## Detail panel - Task assignment: `PATCH /cases/:caseId/tasks/:taskId/assign` (`TAB_CASES_EDIT`) -- Comments: shared `LabCaseCommentsPanel` + `tasksApi` comment routes (`viewerSide="LAB"`). Newest-first; sent/received use logical start/end alignment (RTL-safe). Compact `h-9` composer with primary send + visibility controls. +- Comments: shared `LabCaseCommentsPanel` + `tasksApi` comment routes (`viewerSide="LAB"`). Newest-first; sent/received use logical start/end alignment (RTL-safe). Compact `h-9` composer with primary send + visibility controls. Lab-origin cases (`LAB_INTERNAL`, including after Start) are visible to lab comments/mark-read — do not require `sentAt` / `LabCaseSend`. - Mark read: `POST /notifications/mark-case-read` on select (Cases tab badge) - FDI chart (`CaseToothChartPanel`): prosthesis colors + **connected bridge dots** from `selectionGroupId` (`buildCaseConnectedTeeth` / `buildCaseProsthesisRows` in `caseDetailUtils.ts`). - **Important + external code** (`TAB_CASES_EDIT`): row is **Important Case** label then checkbox (`Checkbox` `labelPosition="start"`), then optional external-code input (no title; placeholder only). Save code on blur → `PATCH /cases/:id/external-code`. diff --git a/.cursor/skills/treatment-workspace/SKILL.md b/.cursor/skills/treatment-workspace/SKILL.md index 984b8ef..8b4b81a 100644 --- a/.cursor/skills/treatment-workspace/SKILL.md +++ b/.cursor/skills/treatment-workspace/SKILL.md @@ -22,7 +22,7 @@ Thin route: `app/[locale]/(dashboard)/treatment/page.tsx` (supports `?appointmen -1. **Appointments strip** — `AppointmentsStrip.tsx` + `ScheduleDayPicker.tsx` (Today checkbox) + `pickAutoAppointment()` in `components/shared/treatmentSelection.ts` +1. **Day strip** — `AppointmentsStrip.tsx` renders `DayStripItem[]` (`appointment` | `unscheduled`) via `DayStripCard`. Timed appointments show the slot; standalone treatments show i18n “No appointment” and sort after timed cards. Empty unscheduled cards (`details.length === 0`) show a trash control (`DELETE /treatments/:id`). Workspace fetches `GET /appointments` **and** `GET /treatments/day`. Strip stays dumb (no draft API). New treatment / Walk-in use shared `Button`. 2. **Treatment preview** — `TreatmentPreviewCard.tsx` (read-only summary; no load button for current draft) @@ -180,27 +180,37 @@ Use shared `Checkbox` (not native ``) to avoid focus-driv |----------|---------| -| `GET /appointments?from&to` | Strip | +| `GET /appointments?from&to` | Timed strip cards | + +| `GET /treatments/day?from&to` | Standalone (unscheduled) strip cards | + +| `POST /treatments` | Create standalone `{ patientId?, walkIn?, treatmentAt }` | + +| `DELETE /treatments/:id` | Empty standalone only (`appointmentId` null, no details) | | `GET /treatments/patients/:patientId/history` | History (patient + org; filtered by provider) | | `GET /treatments/appointments/:id/draft` | Load form on appointment select | -| `PUT .../draft`, `PUT .../lab-cases` | Autosave (600ms debounce) | +| `GET/PUT /treatments/:treatmentId/draft` | Load/save when there is no appointment | + +| `PUT .../lab-cases` | Autosave (600ms debounce) — appointment or treatment id | -Draft writes require provider match (`ensureAppointmentProvider`) unless org owner. +Walk-in uses one sentinel `Patient` per clinic (`isWalkIn`, hidden from Patients/search/booking). Display via i18n, never the stored name. Patient search: same workspace patient → no-op; else load latest history into the editor; **no history → auto-create** standalone on the selected day. + +Draft writes for appointments require provider match (`ensureAppointmentProvider`). Standalone requires `treatment.providerUserId === actor`. ## Edit gating ```typescript -canEditTreatmentForDay = canEdit && selectedAppointment && !isViewingPastDay && workspaceMode === 'live' +canEditTreatmentForDay = canEdit && hasLiveContext && !isViewingPastDay && workspaceMode === 'live' ``` -Past day or `historical` workspace mode freezes the treatment form + most lab-dispatch fields. +`hasLiveContext` is a selected live appointment **or** standalone treatment. Past day or `historical` workspace mode freezes the treatment form + most lab-dispatch fields. ### Per-detail sent lock diff --git a/AGENTS.md b/AGENTS.md index 9119124..5b98bbe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ 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 edit / details (quick ref):** -- Day/mode gate: editable only for live draft on today/future (`canEditTreatmentForDay`). Past day / historical load → read-only form. +- Day/mode gate: editable only for live draft on today/future (`canEditTreatmentForDay`). Past day / historical load → read-only form. Empty **no-appointment** strip cards can be deleted (trash; no details). New treatment / Walk-in use shared `Button`. - Sent-to-lab detail locks that line; **Add detail** still OK same day; **Remove detail** = trash icon on each detail chip (not in the wizard Content step) — only unsent and not the last line. Attachment upload blocked when sent (`TREATMENT_DETAIL_SENT`). - **Entry wizard:** `WizardStepper` — Teeth → Content → Lab; Lab step only when active detail type is lab-dependent (prosthesis); entering Lab auto-opens shipment draft (no Add-shipment CTA). Content notes field label is **Notes** (clinical). Detail chips ≠ wizard chrome. Switching detail chips resets wizard to Teeth unless `pendingEntryStepRef` requests Lab (e.g. opening a case from the lab shipments rail). - **Tooth selection:** Neighbor empty/filled circles between selected adjacent teeth connect/disconnect bridges; Shift+range selects only (empty circles; overlap absorbs as singles); midline 11–21 / 41–31 allowed. Plain click selects/deselects (deselect splits bridges). Never a 1-tooth connected. Helpers: `toothSelectionGroups.ts`. Connected label: `ConnectedSelectionBadge`. After send, Cases/Tasks merge teeth by prosthesis type. @@ -62,7 +62,7 @@ frontend/src/ **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) — live via inbox Socket.IO → `notifyTabBadgesChanged()` + soft list refresh — see `.cursor/skills/lab-tasks/SKILL.md`, `.cursor/skills/tab-badges/SKILL.md`, `.cursor/skills/notifications-inbox/SKILL.md`. -**Lab Cases tab:** Filter by **prosthesis type** (not treatment type); auto-select newest case on open; list **10 per page**; left rail list fills column height (`flex-1 overflow-y-auto`); list cards use `LabCaseProsthesisGroupsList` (colored type + teeth, shared with Treatment rail). Deep link: `?caseId=`, `?clinicOrganizationId=`. **Share link:** QR + URL on sent cases (attachment left, QR right); opens `/lab-case/[token]` focus page. **Case Sheet PDF:** client A4 (`jspdf`/`html2canvas`); hex-only print layout; optional `externalCode` replaces order number. **Live:** inbox Socket.IO → `notifyTabBadgesChanged()` soft-refreshes list + selected detail (no remount). See `.cursor/skills/lab-cases/SKILL.md` and `.cursor/skills/lab-case-share-link/SKILL.md`. +**Lab Cases tab:** Filter by **prosthesis type** (not treatment type); auto-select newest case on open; list **10 per page**; left rail list fills column height (`flex-1 overflow-y-auto`); list cards use `LabCaseProsthesisGroupsList` (colored type + teeth, shared with Treatment rail). Deep link: `?caseId=`, `?clinicOrganizationId=`. **Share link:** QR + URL on sent cases (attachment left, QR right); opens `/lab-case/[token]` focus page. **Case Sheet PDF:** client A4 (`jspdf`/`html2canvas`); hex-only print layout; optional `externalCode` replaces order number. Lab-origin Start has no clinic send; comments and mark-read still work. **Live:** inbox Socket.IO → `notifyTabBadgesChanged()` soft-refreshes list + selected detail (no remount). See `.cursor/skills/lab-cases/SKILL.md` and `.cursor/skills/lab-case-share-link/SKILL.md`. **Lab case share link (quick ref):** - Token on first ship → `/{locale}/lab-case/{token}` after login. diff --git a/backend/prisma/migrations/20260819120000_standalone_treatments_lab_internal_cases/migration.sql b/backend/prisma/migrations/20260819120000_standalone_treatments_lab_internal_cases/migration.sql new file mode 100644 index 0000000..df409a4 --- /dev/null +++ b/backend/prisma/migrations/20260819120000_standalone_treatments_lab_internal_cases/migration.sql @@ -0,0 +1,79 @@ +-- AlterTable +ALTER TABLE "patients" ADD COLUMN "isWalkIn" BOOLEAN NOT NULL DEFAULT false; + +CREATE INDEX "patients_createdByOrganizationId_isWalkIn_idx" ON "patients"("createdByOrganizationId", "isWalkIn"); + +CREATE UNIQUE INDEX "patients_one_walkin_per_org" ON "patients"("createdByOrganizationId") WHERE "isWalkIn" = true; + +-- CreateEnum +CREATE TYPE "LabCaseOrigin" AS ENUM ('CLINIC_DISPATCH', 'LAB_INTERNAL'); + +-- AlterTable +ALTER TABLE "lab_cases" ALTER COLUMN "treatmentId" DROP NOT NULL; +ALTER TABLE "lab_cases" ALTER COLUMN "sortOrder" SET DEFAULT 0; +ALTER TABLE "lab_cases" ADD COLUMN "startedAt" TIMESTAMP(3); +ALTER TABLE "lab_cases" ADD COLUMN "origin" "LabCaseOrigin" NOT NULL DEFAULT 'CLINIC_DISPATCH'; +ALTER TABLE "lab_cases" ADD COLUMN "referringClinicName" TEXT; +ALTER TABLE "lab_cases" ADD COLUMN "referringDentistName" TEXT; +ALTER TABLE "lab_cases" ADD COLUMN "patientDisplayName" TEXT; +ALTER TABLE "lab_cases" ADD COLUMN "patientDisplayMobile" TEXT; +ALTER TABLE "lab_cases" ADD COLUMN "partnerClinicOrganizationId" TEXT; + +CREATE INDEX "lab_cases_destinationOrganizationId_origin_idx" ON "lab_cases"("destinationOrganizationId", "origin"); +CREATE INDEX "lab_cases_partnerClinicOrganizationId_idx" ON "lab_cases"("partnerClinicOrganizationId"); + +ALTER TABLE "lab_cases" ADD CONSTRAINT "lab_cases_partnerClinicOrganizationId_fkey" FOREIGN KEY ("partnerClinicOrganizationId") REFERENCES "organizations"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- CreateTable +CREATE TABLE "lab_case_lines" ( + "id" TEXT NOT NULL, + "labCaseId" TEXT NOT NULL, + "clientKey" TEXT, + "sortOrder" INTEGER NOT NULL, + "treatmentType" TEXT NOT NULL DEFAULT 'prosthesis', + "teeth" JSONB NOT NULL, + "toothSelectionGroups" JSONB, + "comment" TEXT, + + CONSTRAINT "lab_case_lines_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "lab_case_lines_labCaseId_sortOrder_idx" ON "lab_case_lines"("labCaseId", "sortOrder"); + +ALTER TABLE "lab_case_lines" ADD CONSTRAINT "lab_case_lines_labCaseId_fkey" FOREIGN KEY ("labCaseId") REFERENCES "lab_cases"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Tooth prosthesis: nullable detail, lineId, sourceKey, treatmentType +DROP INDEX IF EXISTS "lab_case_tooth_prosthesis_labCaseId_treatmentDetailId_tooth_key"; +ALTER TABLE "lab_case_tooth_prosthesis" ALTER COLUMN "treatmentDetailId" DROP NOT NULL; +ALTER TABLE "lab_case_tooth_prosthesis" ADD COLUMN "lineId" TEXT; +ALTER TABLE "lab_case_tooth_prosthesis" ADD COLUMN "sourceKey" TEXT; +ALTER TABLE "lab_case_tooth_prosthesis" ADD COLUMN "treatmentType" TEXT NOT NULL DEFAULT 'prosthesis'; + +UPDATE "lab_case_tooth_prosthesis" SET "sourceKey" = "treatmentDetailId" WHERE "sourceKey" IS NULL AND "treatmentDetailId" IS NOT NULL; + +ALTER TABLE "lab_case_tooth_prosthesis" ALTER COLUMN "sourceKey" SET NOT NULL; + +CREATE UNIQUE INDEX "lab_case_tooth_prosthesis_labCaseId_sourceKey_tooth_key" ON "lab_case_tooth_prosthesis"("labCaseId", "sourceKey", "tooth"); + +ALTER TABLE "lab_case_tooth_prosthesis" ADD CONSTRAINT "lab_case_tooth_prosthesis_lineId_fkey" FOREIGN KEY ("lineId") REFERENCES "lab_case_lines"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Tasks: nullable detail, lineId, sourceKey +ALTER TABLE "lab_case_tasks" DROP CONSTRAINT IF EXISTS "lab_case_tasks_case_detail_prosthesis_step_key"; +DROP INDEX IF EXISTS "lab_case_tasks_case_detail_prosthesis_step_key"; + +ALTER TABLE "lab_case_tasks" ALTER COLUMN "treatmentDetailId" DROP NOT NULL; +ALTER TABLE "lab_case_tasks" ADD COLUMN "lineId" TEXT; +ALTER TABLE "lab_case_tasks" ADD COLUMN "sourceKey" TEXT; + +UPDATE "lab_case_tasks" SET "sourceKey" = "treatmentDetailId" WHERE "sourceKey" IS NULL AND "treatmentDetailId" IS NOT NULL; + +ALTER TABLE "lab_case_tasks" ALTER COLUMN "sourceKey" SET NOT NULL; + +CREATE UNIQUE INDEX "lab_case_tasks_case_source_prosthesis_step_key" ON "lab_case_tasks"("labCaseId", "sourceKey", "prosthesisTypeCode", "stepOrder"); + +ALTER TABLE "lab_case_tasks" ADD CONSTRAINT "lab_case_tasks_lineId_fkey" FOREIGN KEY ("lineId") REFERENCES "lab_case_lines"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE INDEX "treatments_organizationId_providerUserId_treatmentAt_idx" ON "treatments"("organizationId", "providerUserId", "treatmentAt"); + +ALTER TABLE "treatment_detail_attachments" ADD COLUMN "treatmentId" TEXT; +CREATE INDEX "treatment_detail_attachments_treatmentId_detailClientKey_idx" ON "treatment_detail_attachments"("treatmentId", "detailClientKey"); diff --git a/backend/prisma/regenerate-lab-tasks.ts b/backend/prisma/regenerate-lab-tasks.ts index 31cf1ae..cd4229c 100644 --- a/backend/prisma/regenerate-lab-tasks.ts +++ b/backend/prisma/regenerate-lab-tasks.ts @@ -37,6 +37,7 @@ async function main() { let total = 0; for (const labCase of cases) { + if (!labCase.treatment) continue; const locale = labCase.treatment.organization.owner.language ?? 'en'; // Clear any stale tasks first so the generator's "already exists" guard passes. await prisma.labCaseTask.deleteMany({ where: { labCaseId: labCase.id } }); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 66d1992..b1508d2 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -90,6 +90,7 @@ model Organization { treatments Treatment[] labCaseSends LabCaseSend[] labCaseComments LabCaseComment[] + partnerLabCases LabCase[] @relation("LabCasePartnerClinic") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -106,6 +107,7 @@ model Patient { dateOfBirth DateTime? notes String? isActive Boolean @default(true) + isWalkIn Boolean @default(false) createdByOrganizationId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -115,6 +117,7 @@ model Patient { appointments Appointment[] @@index([lastName, firstName]) + @@index([createdByOrganizationId, isWalkIn]) @@map("patients") } @@ -149,6 +152,11 @@ enum LabCaseCommentSide { CLINIC } +enum LabCaseOrigin { + CLINIC_DISPATCH + LAB_INTERNAL +} + model Treatment { id String @id @default(uuid()) organizationId String @@ -168,6 +176,7 @@ model Treatment { updatedAt DateTime @updatedAt @@index([patientId, treatmentAt]) + @@index([organizationId, providerUserId, treatmentAt]) @@map("treatments") } @@ -196,6 +205,7 @@ model TreatmentDetailAttachment { id String @id @default(uuid()) detailId String? appointmentId String? + treatmentId String? detailClientKey String? fileName String mimeType String @@ -208,25 +218,35 @@ model TreatmentDetailAttachment { createdAt DateTime @default(now()) @@index([appointmentId, detailClientKey]) + @@index([treatmentId, detailClientKey]) @@index([detailId]) @@map("treatment_detail_attachments") } model LabCase { - id String @id @default(uuid()) - treatmentId String + id String @id @default(uuid()) + treatmentId String? clientKey String? - sortOrder Int + sortOrder Int @default(0) destinationOrganizationId String? sentAt DateTime? + startedAt DateTime? dueDate DateTime? - isImportant Boolean @default(false) + isImportant Boolean @default(false) + origin LabCaseOrigin @default(CLINIC_DISPATCH) /// Optional code from an external lab app (e.g. exocad) for print/matching. externalCode String? - accessToken String? @unique + accessToken String? @unique + referringClinicName String? + referringDentistName String? + patientDisplayName String? + patientDisplayMobile String? + partnerClinicOrganizationId String? - treatment Treatment @relation(fields: [treatmentId], references: [id], onDelete: Cascade) + treatment Treatment? @relation(fields: [treatmentId], references: [id], onDelete: Cascade) + partnerClinic Organization? @relation("LabCasePartnerClinic", fields: [partnerClinicOrganizationId], references: [id], onDelete: SetNull) details LabCaseDetail[] + lines LabCaseLine[] sends LabCaseSend[] tasks LabCaseTask[] toothProsthesis LabCaseToothProsthesis[] @@ -236,9 +256,30 @@ model LabCase { userReadStates LabCaseUserReadState[] @@index([treatmentId, sortOrder]) + @@index([destinationOrganizationId, origin]) + @@index([partnerClinicOrganizationId]) @@map("lab_cases") } +/// Lab-origin work lines (analog of TreatmentDetail). Clinic-sent cases do not use this table. +model LabCaseLine { + id String @id @default(uuid()) + labCaseId String + clientKey String? + sortOrder Int + treatmentType String @default("prosthesis") + teeth Json + toothSelectionGroups Json? + comment String? + + labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade) + toothProsthesis LabCaseToothProsthesis[] + tasks LabCaseTask[] + + @@index([labCaseId, sortOrder]) + @@map("lab_case_lines") +} + model LabCaseAttachment { labCaseId String attachmentId String @@ -343,23 +384,31 @@ model ProsthesisTypeStep { model LabCaseToothProsthesis { id String @id @default(uuid()) labCaseId String - treatmentDetailId String + treatmentDetailId String? + lineId String? + /// treatmentDetailId (clinic) or lineId (lab-origin) — required unique grouping key. + sourceKey String tooth String prosthesisTypeCode String - /** Links to TreatmentDetail.toothSelectionGroups[].groupId for task grouping. */ - selectionGroupId String @default("") + treatmentType String @default("prosthesis") + /** Links to TreatmentDetail/LabCaseLine toothSelectionGroups[].groupId for task grouping. */ + selectionGroupId String @default("") - labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade) - detail TreatmentDetail @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade) + labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade) + detail TreatmentDetail? @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade) + line LabCaseLine? @relation(fields: [lineId], references: [id], onDelete: Cascade) - @@unique([labCaseId, treatmentDetailId, tooth]) + @@unique([labCaseId, sourceKey, tooth]) @@map("lab_case_tooth_prosthesis") } model LabCaseTask { id String @id @default(uuid()) labCaseId String - treatmentDetailId String + treatmentDetailId String? + lineId String? + /// treatmentDetailId (clinic) or lineId (lab-origin) — required unique grouping key. + sourceKey String teeth Json treatmentType String prosthesisTypeCode String @@ -374,16 +423,17 @@ model LabCaseTask { lastStatusChangedByUserId String? lastStatusChangedAt DateTime? - labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade) - detail TreatmentDetail @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade) - assignee User? @relation("LabCaseTaskAssignee", fields: [assigneeUserId], references: [id], onDelete: SetNull) + labCase LabCase @relation(fields: [labCaseId], references: [id], onDelete: Cascade) + detail TreatmentDetail? @relation(fields: [treatmentDetailId], references: [id], onDelete: Cascade) + line LabCaseLine? @relation(fields: [lineId], references: [id], onDelete: Cascade) + assignee User? @relation("LabCaseTaskAssignee", fields: [assigneeUserId], references: [id], onDelete: SetNull) lastStatusChangedBy User? @relation("LabCaseTaskLastStatusChangedBy", fields: [lastStatusChangedByUserId], references: [id], onDelete: SetNull) statusEvents LabCaseTaskStatusEvent[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - @@unique([labCaseId, treatmentDetailId, prosthesisTypeCode, stepOrder], map: "lab_case_tasks_case_detail_prosthesis_step_key") + @@unique([labCaseId, sourceKey, prosthesisTypeCode, stepOrder], map: "lab_case_tasks_case_source_prosthesis_step_key") @@index([labCaseId, status]) @@index([assigneeUserId]) @@map("lab_case_tasks") diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index 9eb88ad..5c17305 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -149,7 +149,16 @@ export const ErrorCode = { TREATMENT_CASE_INVALID_ATTACHMENTS: 'TREATMENT_CASE_INVALID_ATTACHMENTS', TREATMENT_TOOTH_PROSTHESIS_INCOMPLETE: 'TREATMENT_TOOTH_PROSTHESIS_INCOMPLETE', TREATMENT_DETAIL_SENT: 'TREATMENT_DETAIL_SENT', + TREATMENT_NOT_FOUND: 'TREATMENT_NOT_FOUND', + TREATMENT_PATIENT_OR_WALK_IN: 'TREATMENT_PATIENT_OR_WALK_IN', + TREATMENT_NOT_STANDALONE: 'TREATMENT_NOT_STANDALONE', + TREATMENT_HAS_DETAILS: 'TREATMENT_HAS_DETAILS', LAB_CASE_NOT_FOUND: 'LAB_CASE_NOT_FOUND', + LAB_CASE_NOT_EDITABLE: 'LAB_CASE_NOT_EDITABLE', + LAB_CASE_ALREADY_STARTED: 'LAB_CASE_ALREADY_STARTED', + LAB_CASE_LINES_REQUIRED: 'LAB_CASE_LINES_REQUIRED', + LAB_CASE_START_INCOMPLETE: 'LAB_CASE_START_INCOMPLETE', + LAB_CASE_CLIENT_REQUIRED: 'LAB_CASE_CLIENT_REQUIRED', TASK_ASSIGNEE_INVALID: 'TASK_ASSIGNEE_INVALID', TASK_ASSIGNED_TO_OTHER: 'TASK_ASSIGNED_TO_OTHER', diff --git a/backend/src/common/walk-in-patient.ts b/backend/src/common/walk-in-patient.ts new file mode 100644 index 0000000..cadcdf3 --- /dev/null +++ b/backend/src/common/walk-in-patient.ts @@ -0,0 +1,40 @@ +import type { Prisma } from '@prisma/client'; +import type { PrismaService } from '../../prisma/prisma.service'; + +export const WALK_IN_MOBILE_PREFIX = '__walkin__:'; + +export function walkInMobileForOrganization(organizationId: string): string { + return `${WALK_IN_MOBILE_PREFIX}${organizationId}`; +} + +export function isWalkInMobile(mobile: string): boolean { + return mobile.startsWith(WALK_IN_MOBILE_PREFIX); +} + +type PrismaClientLike = PrismaService | Prisma.TransactionClient; + +/** One sentinel Patient per clinic for treatments without a named patient. */ +export async function ensureWalkInPatient( + prisma: PrismaClientLike, + organizationId: string, +): Promise<{ id: string; isWalkIn: true }> { + const existing = await prisma.patient.findFirst({ + where: { createdByOrganizationId: organizationId, isWalkIn: true }, + select: { id: true }, + }); + if (existing) { + return { id: existing.id, isWalkIn: true }; + } + + const created = await prisma.patient.create({ + data: { + firstName: 'Walk-in', + lastName: 'Walk-in', + mobile: walkInMobileForOrganization(organizationId), + isWalkIn: true, + createdByOrganizationId: organizationId, + }, + select: { id: true }, + }); + return { id: created.id, isWalkIn: true }; +} diff --git a/backend/src/modules/appointments/appointments.service.ts b/backend/src/modules/appointments/appointments.service.ts index 6c6ff56..2b760df 100644 --- a/backend/src/modules/appointments/appointments.service.ts +++ b/backend/src/modules/appointments/appointments.service.ts @@ -344,9 +344,9 @@ export class AppointmentsService { private async ensurePatientInOrg(patientId: string, _organizationId: string) { const patient = await this.prisma.patient.findUnique({ where: { id: patientId }, - select: { id: true }, + select: { id: true, isWalkIn: true }, }); - if (!patient) { + if (!patient || patient.isWalkIn) { throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND); } } diff --git a/backend/src/modules/cases/cases.controller.ts b/backend/src/modules/cases/cases.controller.ts index 3bcc7b8..eff7f4a 100644 --- a/backend/src/modules/cases/cases.controller.ts +++ b/backend/src/modules/cases/cases.controller.ts @@ -4,17 +4,30 @@ import { Get, Param, Patch, + Post, + Put, Query, Req, Res, + UploadedFiles, UseGuards, + UseInterceptors, } from '@nestjs/common'; +import { FilesInterceptor } from '@nestjs/platform-express'; +import { memoryStorage } from 'multer'; import type { Response } from 'express'; -import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger'; import { LabOrgGuard } from '../../common/guards/lab-org.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { CasesService } from './cases.service'; -import { ListLabCasesDto, UpdateLabCaseImportantDto, UpdateLabCaseExternalCodeDto, AssignLabCaseTaskDto } from './dto/cases.dto'; +import { + ListLabCasesDto, + UpdateLabCaseImportantDto, + UpdateLabCaseExternalCodeDto, + AssignLabCaseTaskDto, + CreateLabInternalCaseDto, + UpdateLabInternalCaseDto, +} from './dto/cases.dto'; @ApiTags('cases') @ApiBearerAuth('JWT-auth') @@ -30,6 +43,13 @@ export class CasesController { return this.casesService.list(organizationId, req.user.id, query); } + @Post() + @ApiOperation({ summary: 'Create a lab-origin draft case (TAB_CASES_EDIT)' }) + create(@Body() dto: CreateLabInternalCaseDto, @Req() req) { + const organizationId = this.casesService.getOrganizationIdFromUser(req.user); + return this.casesService.createInternal(dto, organizationId, req.user.id, req.user.language); + } + @Get('assignable-staff') @ApiOperation({ summary: 'Staff who can be assigned lab tasks' }) listAssignableStaff(@Req() req) { @@ -44,6 +64,13 @@ export class CasesController { return this.casesService.listFilterOptions(organizationId, req.user.id); } + @Get('linked-clinics') + @ApiOperation({ summary: 'Active linked clinic organizations for lab-origin cases' }) + listLinkedClinics(@Req() req) { + const organizationId = this.casesService.getOrganizationIdFromUser(req.user); + return this.casesService.listLinkedClinics(organizationId, req.user.id); + } + @Get(':id') @ApiOperation({ summary: 'Get one lab case with tasks grouped by tooth' }) getOne(@Param('id') id: string, @Req() req) { @@ -51,6 +78,44 @@ export class CasesController { return this.casesService.getOne(id, organizationId, req.user.id, req.user.language); } + @Put(':id') + @ApiOperation({ summary: 'Update a lab-origin draft case and its lines' }) + update(@Param('id') id: string, @Body() dto: UpdateLabInternalCaseDto, @Req() req) { + const organizationId = this.casesService.getOrganizationIdFromUser(req.user); + return this.casesService.updateInternal(id, dto, organizationId, req.user.id, req.user.language); + } + + @Post(':id/start') + @ApiOperation({ summary: 'Start a lab-origin draft (generate tasks, no clinic notify)' }) + start(@Param('id') id: string, @Req() req) { + const organizationId = this.casesService.getOrganizationIdFromUser(req.user); + return this.casesService.startInternal(id, organizationId, req.user.id, req.user.language); + } + + @Post(':id/lines/:lineClientKey/attachments') + @ApiOperation({ summary: 'Upload attachments for a lab-origin draft line' }) + @ApiConsumes('multipart/form-data') + @UseInterceptors( + FilesInterceptor('files', 20, { + storage: memoryStorage(), + }), + ) + uploadAttachments( + @Param('id') id: string, + @Param('lineClientKey') lineClientKey: string, + @UploadedFiles() files: Express.Multer.File[], + @Req() req, + ) { + const organizationId = this.casesService.getOrganizationIdFromUser(req.user); + return this.casesService.uploadInternalAttachments( + id, + lineClientKey, + files, + organizationId, + req.user.id, + ); + } + @Get(':id/attachments/:attachmentId/file') @ApiOperation({ summary: 'Download an attachment shared with this lab case' }) async downloadAttachment( diff --git a/backend/src/modules/cases/cases.service.ts b/backend/src/modules/cases/cases.service.ts index 63074dd..0ea2174 100644 --- a/backend/src/modules/cases/cases.service.ts +++ b/backend/src/modules/cases/cases.service.ts @@ -3,8 +3,10 @@ import { Injectable, } from '@nestjs/common'; import { AppException, ErrorCode } from '../../common/errors'; -import { createReadStream, existsSync } from 'fs'; -import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma, UserNotificationType } from '@prisma/client'; +import { randomUUID } from 'crypto'; +import { createReadStream, existsSync, mkdirSync } from 'fs'; +import { join } from 'path'; +import { CatalogEntityKind, LabCaseActivityType, LabCaseOrigin, LabTaskStatus, LinkStatus, Prisma, UserNotificationType } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { normalizeMobile } from '../../common/phone'; import { @@ -13,7 +15,8 @@ import { } from '../catalog/catalog-label.service'; import { ProsthesisCatalogService } from '../prosthesis-catalog/prosthesis-catalog.service'; import { normalizeTeeth } from '../treatments/treatment.utils'; -import { ListLabCasesDto, UpdateLabCaseImportantDto, UpdateLabCaseExternalCodeDto, AssignLabCaseTaskDto } from './dto/cases.dto'; +import { ListLabCasesDto, UpdateLabCaseImportantDto, UpdateLabCaseExternalCodeDto, AssignLabCaseTaskDto, CreateLabInternalCaseDto, UpdateLabInternalCaseDto } from './dto/cases.dto'; +import { generateLabCaseTasks } from './lab-case-task.generator'; import { isLabCaseOverdue, } from '../../common/lab-case-due-date'; @@ -32,6 +35,8 @@ const labCaseListInclude = { appointment: { select: { startAt: true } }, }, }, + partnerClinic: { select: { id: true, name: true } }, + lines: { orderBy: [{ sortOrder: 'asc' as const }] }, details: { include: { detail: { @@ -73,6 +78,7 @@ const labCaseListInclude = { mimeType: true, sizeBytes: true, createdAt: true, + detailClientKey: true, }, }, }, @@ -91,6 +97,8 @@ type LabCaseTaskWithRelations = Prisma.LabCaseTaskGetPayload<{ @Injectable() export class CasesService { + private readonly uploadRoot = join(process.cwd(), 'uploads', 'lab-cases'); + constructor( private readonly prisma: PrismaService, private readonly prosthesisCatalog: ProsthesisCatalogService, @@ -130,6 +138,7 @@ export class CasesService { patient: { select: { id: true, firstName: true, lastName: true, mobile: true } }, }, }, + partnerClinic: { select: { id: true, name: true } }, tasks: { select: { id: true, @@ -140,7 +149,7 @@ export class CasesService { }, }, }, - orderBy: [{ sentAt: 'desc' }], + orderBy: [{ startedAt: 'desc' }, { sentAt: 'desc' }, { id: 'desc' }], skip, take: limit, }), @@ -177,11 +186,9 @@ export class CasesService { const [clinicRows, taskRows] = await Promise.all([ this.prisma.labCase.findMany({ - where: { - sentAt: { not: null }, - sends: { some: { organizationId: labOrganizationId } }, - }, + where: this.visibleToLabWhere(labOrganizationId), select: { + partnerClinic: { select: { id: true, name: true } }, treatment: { select: { organization: { select: { id: true, name: true } }, @@ -191,10 +198,7 @@ export class CasesService { }), this.prisma.labCaseTask.findMany({ where: { - labCase: { - sentAt: { not: null }, - sends: { some: { organizationId: labOrganizationId } }, - }, + labCase: this.visibleStartedToLabWhere(labOrganizationId), }, select: { prosthesisTypeCode: true }, distinct: ['prosthesisTypeCode'], @@ -203,7 +207,12 @@ export class CasesService { const clinicsById = new Map(); for (const row of clinicRows) { - clinicsById.set(row.treatment.organization.id, row.treatment.organization); + if (row.treatment?.organization) { + clinicsById.set(row.treatment.organization.id, row.treatment.organization); + } + if (row.partnerClinic) { + clinicsById.set(row.partnerClinic.id, row.partnerClinic); + } } const typeCodes = new Set(taskRows.map((row) => row.prosthesisTypeCode)); @@ -308,6 +317,322 @@ export class CasesService { }; } + async listLinkedClinics(labOrganizationId: string, actorUserId: string) { + await this.assertCanReadCases(actorUserId, labOrganizationId); + + const [linksA, linksB] = await Promise.all([ + this.prisma.organizationLink.findMany({ + where: { organizationAId: labOrganizationId, status: LinkStatus.ACTIVE }, + include: { + organizationB: { select: { id: true, name: true, type: { select: { name: true } } } }, + }, + }), + this.prisma.organizationLink.findMany({ + where: { organizationBId: labOrganizationId, status: LinkStatus.ACTIVE }, + include: { + organizationA: { select: { id: true, name: true, type: { select: { name: true } } } }, + }, + }), + ]); + + const data = [ + ...linksA.map((l) => ({ + id: l.organizationB.id, + name: l.organizationB.name, + type: l.organizationB.type.name, + })), + ...linksB.map((l) => ({ + id: l.organizationA.id, + name: l.organizationA.name, + type: l.organizationA.type.name, + })), + ] + .filter((o) => o.type === 'CLINIC') + .map(({ id, name }) => ({ id, name, active: true })) + .sort((a, b) => a.name.localeCompare(b.name)); + + return { success: true, data }; + } + + async createInternal( + dto: CreateLabInternalCaseDto, + labOrganizationId: string, + actorUserId: string, + localeInput?: string | null, + ) { + await this.assertCanEditCases(actorUserId, labOrganizationId); + await this.assertPartnerClinic(dto.partnerClinicOrganizationId, labOrganizationId); + + const created = await this.prisma.labCase.create({ + data: { + origin: LabCaseOrigin.LAB_INTERNAL, + destinationOrganizationId: labOrganizationId, + referringClinicName: dto.referringClinicName?.trim() || null, + referringDentistName: dto.referringDentistName?.trim() || null, + patientDisplayName: dto.patientDisplayName?.trim() || null, + patientDisplayMobile: dto.patientDisplayMobile?.trim() || null, + partnerClinicOrganizationId: dto.partnerClinicOrganizationId || null, + dueDate: dto.dueDate ? new Date(dto.dueDate) : null, + sortOrder: 0, + }, + include: labCaseListInclude, + }); + + return { success: true, data: await this.mapLabCaseDetail(created, localeInput) }; + } + + async updateInternal( + labCaseId: string, + dto: UpdateLabInternalCaseDto, + labOrganizationId: string, + actorUserId: string, + localeInput?: string | null, + ) { + await this.assertCanEditCases(actorUserId, labOrganizationId); + const existing = await this.prisma.labCase.findFirst({ + where: { + id: labCaseId, + origin: LabCaseOrigin.LAB_INTERNAL, + destinationOrganizationId: labOrganizationId, + }, + select: { id: true, startedAt: true }, + }); + if (!existing) { + throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND); + } + if (existing.startedAt) { + throw new AppException(ErrorCode.LAB_CASE_NOT_EDITABLE, HttpStatus.CONFLICT); + } + + await this.assertPartnerClinic(dto.partnerClinicOrganizationId, labOrganizationId); + + const saved = await this.prisma.$transaction(async (tx) => { + await tx.labCase.update({ + where: { id: labCaseId }, + data: { + referringClinicName: dto.referringClinicName?.trim() || null, + referringDentistName: dto.referringDentistName?.trim() || null, + patientDisplayName: dto.patientDisplayName?.trim() || null, + patientDisplayMobile: dto.patientDisplayMobile?.trim() || null, + partnerClinicOrganizationId: dto.partnerClinicOrganizationId || null, + dueDate: + dto.dueDate === undefined ? undefined : dto.dueDate ? new Date(dto.dueDate) : null, + }, + }); + + if (dto.lines) { + const keepIds = dto.lines.map((l) => l.id).filter(Boolean) as string[]; + await tx.labCaseLine.deleteMany({ + where: { + labCaseId, + ...(keepIds.length ? { id: { notIn: keepIds } } : {}), + }, + }); + + for (const [index, line] of dto.lines.entries()) { + if (line.id) { + const owned = await tx.labCaseLine.findFirst({ + where: { id: line.id, labCaseId }, + select: { id: true }, + }); + if (!owned) { + throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.BAD_REQUEST); + } + } + + const teeth = normalizeTeeth(line.teeth); + const row = line.id + ? await tx.labCaseLine.update({ + where: { id: line.id }, + data: { + clientKey: line.clientId, + sortOrder: index, + teeth, + toothSelectionGroups: (line.toothSelectionGroups ?? + undefined) as Prisma.InputJsonValue | undefined, + comment: line.comment?.trim() || null, + }, + }) + : await tx.labCaseLine.create({ + data: { + labCaseId, + clientKey: line.clientId, + sortOrder: index, + treatmentType: 'prosthesis', + teeth, + toothSelectionGroups: (line.toothSelectionGroups ?? + undefined) as Prisma.InputJsonValue | undefined, + comment: line.comment?.trim() || null, + }, + }); + + await tx.labCaseToothProsthesis.deleteMany({ where: { lineId: row.id } }); + if (line.toothProsthesis?.length) { + for (const tp of line.toothProsthesis) { + this.prosthesisCatalog.assertKnownProsthesisType(tp.prosthesisTypeCode); + } + await tx.labCaseToothProsthesis.createMany({ + data: line.toothProsthesis.map((tp) => ({ + labCaseId, + lineId: row.id, + sourceKey: row.id, + treatmentType: 'prosthesis', + tooth: tp.tooth, + prosthesisTypeCode: tp.prosthesisTypeCode, + selectionGroupId: tp.selectionGroupId?.trim() || '', + })), + }); + } + + if (line.attachmentIds) { + const lineAttachments = await tx.treatmentDetailAttachment.findMany({ + where: { + detailClientKey: line.clientId, + labCaseLinks: { some: { labCaseId } }, + }, + select: { id: true }, + }); + const keep = new Set(line.attachmentIds); + const removeIds = lineAttachments.map((a) => a.id).filter((id) => !keep.has(id)); + if (removeIds.length) { + await tx.labCaseAttachment.deleteMany({ + where: { labCaseId, attachmentId: { in: removeIds } }, + }); + } + if (line.attachmentIds.length) { + await tx.labCaseAttachment.createMany({ + data: line.attachmentIds.map((attachmentId) => ({ labCaseId, attachmentId })), + skipDuplicates: true, + }); + } + } + } + } + + return tx.labCase.findFirstOrThrow({ + where: { id: labCaseId }, + include: labCaseListInclude, + }); + }); + + return { success: true, data: await this.mapLabCaseDetail(saved, localeInput) }; + } + + async startInternal( + labCaseId: string, + labOrganizationId: string, + actorUserId: string, + localeInput?: string | null, + ) { + await this.assertCanEditCases(actorUserId, labOrganizationId); + const existing = await this.prisma.labCase.findFirst({ + where: { + id: labCaseId, + origin: LabCaseOrigin.LAB_INTERNAL, + destinationOrganizationId: labOrganizationId, + }, + include: { lines: true, toothProsthesis: true }, + }); + if (!existing) { + throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND); + } + if (existing.startedAt) { + throw new AppException(ErrorCode.LAB_CASE_ALREADY_STARTED, HttpStatus.CONFLICT); + } + if (existing.lines.length === 0 || existing.toothProsthesis.length === 0) { + throw new AppException(ErrorCode.LAB_CASE_START_INCOMPLETE, HttpStatus.BAD_REQUEST); + } + const hasClient = + Boolean(existing.referringClinicName?.trim()) || + Boolean(existing.patientDisplayName?.trim()) || + Boolean(existing.partnerClinicOrganizationId); + if (!hasClient) { + throw new AppException(ErrorCode.LAB_CASE_CLIENT_REQUIRED, HttpStatus.BAD_REQUEST); + } + + await this.prisma.$transaction(async (tx) => { + await tx.labCase.update({ + where: { id: labCaseId }, + data: { startedAt: new Date() }, + }); + await generateLabCaseTasks(tx, labCaseId, localeInput); + }); + + const refreshed = await this.prisma.labCase.findFirstOrThrow({ + where: { id: labCaseId }, + include: labCaseListInclude, + }); + return { success: true, data: await this.mapLabCaseDetail(refreshed, localeInput) }; + } + + async uploadInternalAttachments( + labCaseId: string, + lineClientKey: string, + files: Express.Multer.File[], + labOrganizationId: string, + actorUserId: string, + ) { + await this.assertCanEditCases(actorUserId, labOrganizationId); + const existing = await this.prisma.labCase.findFirst({ + where: { + id: labCaseId, + origin: LabCaseOrigin.LAB_INTERNAL, + destinationOrganizationId: labOrganizationId, + }, + select: { id: true, startedAt: true }, + }); + if (!existing) { + throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND); + } + if (existing.startedAt) { + throw new AppException(ErrorCode.LAB_CASE_NOT_EDITABLE, HttpStatus.CONFLICT); + } + if (!lineClientKey?.trim()) { + throw new AppException(ErrorCode.TREATMENT_DETAIL_KEY_REQUIRED, HttpStatus.BAD_REQUEST); + } + if (!files?.length) { + throw new AppException(ErrorCode.TREATMENT_FILE_REQUIRED, HttpStatus.BAD_REQUEST); + } + + const orgDir = join(this.uploadRoot, labOrganizationId); + mkdirSync(orgDir, { recursive: true }); + + const created: { + id: string; + fileName: string; + mimeType: string; + sizeBytes: number; + }[] = []; + + for (const file of files) { + const storageName = `${randomUUID()}-${file.originalname.replace(/[^\w.\-()+]/g, '_')}`; + const storagePath = join(orgDir, storageName); + const { writeFileSync } = await import('fs'); + writeFileSync(storagePath, file.buffer); + + const attachment = await this.prisma.treatmentDetailAttachment.create({ + data: { + detailClientKey: lineClientKey, + fileName: file.originalname, + mimeType: file.mimetype || 'application/octet-stream', + sizeBytes: file.size, + storagePath, + }, + }); + await this.prisma.labCaseAttachment.create({ + data: { labCaseId, attachmentId: attachment.id }, + }); + created.push({ + id: attachment.id, + fileName: attachment.fileName, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + }); + } + + return { success: true, data: created }; + } + async getOne( labCaseId: string, labOrganizationId: string, @@ -319,8 +644,7 @@ export class CasesService { const labCase = await this.prisma.labCase.findFirst({ where: { id: labCaseId, - sentAt: { not: null }, - sends: { some: { organizationId: labOrganizationId } }, + ...this.visibleToLabWhere(labOrganizationId), }, include: labCaseListInclude, }); @@ -344,10 +668,7 @@ export class CasesService { where: { labCaseId, attachmentId, - labCase: { - sentAt: { not: null }, - sends: { some: { organizationId: labOrganizationId } }, - }, + labCase: this.visibleToLabWhere(labOrganizationId), }, include: { attachment: { select: { storagePath: true, fileName: true, mimeType: true } }, @@ -381,8 +702,7 @@ export class CasesService { const existing = await this.prisma.labCase.findFirst({ where: { id: labCaseId, - sentAt: { not: null }, - sends: { some: { organizationId: labOrganizationId } }, + ...this.visibleToLabWhere(labOrganizationId), }, select: { id: true, isImportant: true }, }); @@ -432,8 +752,7 @@ export class CasesService { const existing = await this.prisma.labCase.findFirst({ where: { id: labCaseId, - sentAt: { not: null }, - sends: { some: { organizationId: labOrganizationId } }, + ...this.visibleToLabWhere(labOrganizationId), }, select: { id: true }, }); @@ -500,10 +819,7 @@ export class CasesService { where: { id: taskId, labCaseId, - labCase: { - sentAt: { not: null }, - sends: { some: { organizationId: labOrganizationId } }, - }, + labCase: this.visibleStartedToLabWhere(labOrganizationId), }, select: { id: true }, }); @@ -590,20 +906,44 @@ export class CasesService { sentAtFilter.lte = to; } - return { + const received: Prisma.LabCaseWhereInput = { sentAt: sentAtFilter, sends: { some: { organizationId: labOrganizationId } }, ...(query.clinicOrganizationId ? { treatment: { organizationId: query.clinicOrganizationId } } : {}), - ...(query.prosthesisTypeCode + }; + + const ownedInternal: Prisma.LabCaseWhereInput = { + origin: LabCaseOrigin.LAB_INTERNAL, + destinationOrganizationId: labOrganizationId, + ...(query.clinicOrganizationId ? { - tasks: { - some: { prosthesisTypeCode: query.prosthesisTypeCode }, - }, + OR: [ + { partnerClinicOrganizationId: query.clinicOrganizationId }, + ], } : {}), - ...(query.q?.trim() ? this.buildSearchWhere(query.q.trim()) : {}), + }; + + if (query.sentFrom || query.sentTo) { + ownedInternal.startedAt = { ...sentAtFilter }; + } + + return { + AND: [ + { OR: [received, ownedInternal] }, + ...(query.prosthesisTypeCode + ? [ + { + tasks: { + some: { prosthesisTypeCode: query.prosthesisTypeCode }, + }, + } satisfies Prisma.LabCaseWhereInput, + ] + : []), + ...(query.q?.trim() ? [this.buildSearchWhere(query.q.trim())] : []), + ], }; } @@ -624,6 +964,10 @@ export class CasesService { organization: { name: { contains: q, mode: 'insensitive' } }, }, }, + { referringClinicName: { contains: q, mode: 'insensitive' } }, + { referringDentistName: { contains: q, mode: 'insensitive' } }, + { patientDisplayName: { contains: q, mode: 'insensitive' } }, + { patientDisplayMobile: { contains: q, mode: 'insensitive' } }, ]; const normalized = normalizeMobile(q); @@ -636,15 +980,49 @@ export class CasesService { return { OR: orConditions }; } + private resolveClinicAndPatient(lc: { + origin?: LabCaseOrigin; + referringClinicName?: string | null; + patientDisplayName?: string | null; + patientDisplayMobile?: string | null; + partnerClinic?: { id: string; name: string } | null; + treatment?: { + organization: { id: string; name: string }; + patient: { id: string; firstName: string; lastName: string; mobile: string }; + } | null; + }) { + const clinic = + lc.treatment?.organization ?? + lc.partnerClinic ?? { + id: '', + name: lc.referringClinicName?.trim() || '', + }; + const displayName = lc.patientDisplayName?.trim() || ''; + const [firstName, ...rest] = displayName.split(/\s+/); + const patient = lc.treatment?.patient ?? { + id: '', + firstName: firstName || displayName || clinic.name || '—', + lastName: rest.join(' '), + mobile: lc.patientDisplayMobile?.trim() || '', + }; + return { clinic, patient }; + } + private mapLabCaseListItem(lc: { id: string; sentAt: Date | null; + startedAt?: Date | null; dueDate: Date | null; isImportant: boolean; + origin?: LabCaseOrigin; + referringClinicName?: string | null; + patientDisplayName?: string | null; + patientDisplayMobile?: string | null; + partnerClinic?: { id: string; name: string } | null; treatment: { organization: { id: string; name: string }; patient: { id: string; firstName: string; lastName: string; mobile: string }; - }; + } | null; tasks: Array<{ id: string; status: LabTaskStatus; @@ -654,20 +1032,18 @@ export class CasesService { }) { const prosthesisGroups = this.buildProsthesisGroupsFromTasks(lc.tasks); const completedTasks = lc.tasks.filter((t) => t.status === LabTaskStatus.COMPLETED).length; + const { clinic, patient } = this.resolveClinicAndPatient(lc); return { id: lc.id, sentAt: lc.sentAt?.toISOString() ?? null, + startedAt: lc.startedAt?.toISOString() ?? null, + origin: lc.origin ?? LabCaseOrigin.CLINIC_DISPATCH, dueDate: lc.dueDate?.toISOString() ?? null, isOverdue: isLabCaseOverdue(lc.dueDate, lc.tasks), isImportant: lc.isImportant, - clinic: lc.treatment.organization, - patient: { - id: lc.treatment.patient.id, - firstName: lc.treatment.patient.firstName, - lastName: lc.treatment.patient.lastName, - mobile: lc.treatment.patient.mobile, - }, + clinic, + patient, prosthesisGroups, taskProgress: { completed: completedTasks, @@ -681,8 +1057,10 @@ export class CasesService { localeInput?: string | null, ) { const locale = normalizeCatalogLocale(localeInput); - const treatmentType = lc.details[0]?.detail.treatmentType ?? null; + const treatmentType = + lc.details[0]?.detail?.treatmentType ?? lc.lines[0]?.treatmentType ?? null; const link = lc.details[0]; + const { clinic, patient } = this.resolveClinicAndPatient(lc); const prosthesisCodes = [...new Set(lc.tasks.map((t) => t.prosthesisTypeCode).filter(Boolean))]; const prosthesisLabels = await this.catalogLabels.resolveLabels( CatalogEntityKind.PROSTHESIS_TYPE, @@ -704,21 +1082,44 @@ export class CasesService { isOverdue: isLabCaseOverdue(lc.dueDate, lc.tasks), isImportant: lc.isImportant, externalCode: lc.externalCode ?? null, + origin: lc.origin, + startedAt: lc.startedAt?.toISOString() ?? null, + referringClinicName: lc.referringClinicName, + referringDentistName: lc.referringDentistName, + patientDisplayName: lc.patientDisplayName, + patientDisplayMobile: lc.patientDisplayMobile, + partnerClinicOrganizationId: lc.partnerClinicOrganizationId, shareUrl, - clinic: lc.treatment.organization, - patient: lc.treatment.patient, - appointmentStartAt: lc.treatment.appointment?.startAt.toISOString() ?? null, + clinic, + patient, + appointmentStartAt: lc.treatment?.appointment?.startAt?.toISOString() ?? null, treatmentType, - detail: link + detail: link?.detail ? { id: link.detail.id, treatmentType: link.detail.treatmentType, teeth: normalizeTeeth(link.detail.teeth), comment: link.detail.comment, } - : null, + : lc.lines[0] + ? { + id: lc.lines[0].id, + treatmentType: lc.lines[0].treatmentType, + teeth: normalizeTeeth(lc.lines[0].teeth), + comment: lc.lines[0].comment, + } + : null, + lines: lc.lines.map((line) => ({ + id: line.id, + clientId: line.clientKey ?? line.id, + treatmentType: line.treatmentType, + teeth: normalizeTeeth(line.teeth), + toothSelectionGroups: line.toothSelectionGroups, + comment: line.comment, + })), toothProsthesis: lc.toothProsthesis.map((row) => ({ treatmentDetailId: row.treatmentDetailId, + lineId: row.lineId, tooth: row.tooth, prosthesisTypeCode: row.prosthesisTypeCode, selectionGroupId: row.selectionGroupId?.trim() || undefined, @@ -729,6 +1130,7 @@ export class CasesService { mimeType: row.attachment.mimeType, sizeBytes: row.attachment.sizeBytes, createdAt: row.attachment.createdAt.toISOString(), + detailClientKey: row.attachment.detailClientKey, })), sends: lc.sends.map((s) => ({ organizationId: s.organizationId, @@ -806,9 +1208,9 @@ export class CasesService { for (const task of tasks) { const selectionGroupId = task.selectionGroupId ?? ''; - const key = `${task.treatmentDetailId}:${selectionGroupId}:${task.prosthesisTypeCode}`; + const key = `${task.sourceKey ?? task.treatmentDetailId ?? task.lineId}:${selectionGroupId}:${task.prosthesisTypeCode}`; const entry = groups.get(key) ?? { - treatmentDetailId: task.treatmentDetailId, + treatmentDetailId: task.treatmentDetailId ?? task.sourceKey ?? task.lineId ?? '', teeth: normalizeTaskTeeth(task.teeth), treatmentType: task.treatmentType, prosthesisTypeCode: task.prosthesisTypeCode, @@ -839,7 +1241,7 @@ export class CasesService { ) { return { id: task.id, - treatmentDetailId: task.treatmentDetailId, + treatmentDetailId: task.treatmentDetailId ?? task.sourceKey ?? task.lineId ?? '', teeth: normalizeTaskTeeth(task.teeth), treatmentType: task.treatmentType, prosthesisTypeCode: task.prosthesisTypeCode, @@ -870,6 +1272,72 @@ export class CasesService { }; } + private visibleToLabWhere(labOrganizationId: string): Prisma.LabCaseWhereInput { + return { + OR: [ + { + sentAt: { not: null }, + sends: { some: { organizationId: labOrganizationId } }, + }, + { + origin: LabCaseOrigin.LAB_INTERNAL, + destinationOrganizationId: labOrganizationId, + }, + ], + }; + } + + private visibleStartedToLabWhere(labOrganizationId: string): Prisma.LabCaseWhereInput { + return { + OR: [ + { + sentAt: { not: null }, + sends: { some: { organizationId: labOrganizationId } }, + }, + { + origin: LabCaseOrigin.LAB_INTERNAL, + destinationOrganizationId: labOrganizationId, + startedAt: { not: null }, + }, + ], + }; + } + + private async assertPartnerClinic( + partnerId: string | null | undefined, + labOrganizationId: string, + ) { + if (!partnerId) return; + const org = await this.prisma.organization.findUnique({ + where: { id: partnerId }, + select: { id: true, type: { select: { name: true } } }, + }); + if (!org || org.type.name !== 'CLINIC') { + throw new AppException(ErrorCode.VALIDATION_INVALID_REQUEST, HttpStatus.BAD_REQUEST); + } + const [linksA, linksB] = await Promise.all([ + this.prisma.organizationLink.findFirst({ + where: { + organizationAId: labOrganizationId, + organizationBId: partnerId, + status: LinkStatus.ACTIVE, + }, + select: { id: true }, + }), + this.prisma.organizationLink.findFirst({ + where: { + organizationAId: partnerId, + organizationBId: labOrganizationId, + status: LinkStatus.ACTIVE, + }, + select: { id: true }, + }), + ]); + if (!linksA && !linksB) { + throw new AppException(ErrorCode.VALIDATION_INVALID_REQUEST, HttpStatus.BAD_REQUEST); + } + } + private async assertCanReadCases(userId: string, organizationId: string) { const m = await this.getMembership(userId, organizationId); if (!m) { diff --git a/backend/src/modules/cases/dto/cases.dto.ts b/backend/src/modules/cases/dto/cases.dto.ts index a553caf..380b325 100644 --- a/backend/src/modules/cases/dto/cases.dto.ts +++ b/backend/src/modules/cases/dto/cases.dto.ts @@ -1,5 +1,18 @@ -import { Transform } from 'class-transformer'; -import { IsBoolean, IsDateString, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator'; +import { Transform, Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsBoolean, + IsDateString, + IsInt, + IsOptional, + IsString, + IsUUID, + Max, + MaxLength, + Min, + ValidateNested, +} from 'class-validator'; export class UpdateLabCaseImportantDto { @IsBoolean() @@ -53,3 +66,105 @@ export class ListLabCasesDto { @Max(100) limit = 20; } + +export class ToothSelectionGroupDto { + @IsString() + @MaxLength(64) + groupId: string; + + @IsString() + kind: string; + + @IsArray() + @IsString({ each: true }) + teeth: string[]; +} + +export class LabInternalToothProsthesisDto { + @IsString() + @MaxLength(8) + tooth: string; + + @IsString() + @MaxLength(64) + prosthesisTypeCode: string; + + @IsOptional() + @IsString() + @MaxLength(64) + selectionGroupId?: string; +} + +export class LabInternalCaseLineDto { + @IsString() + @MaxLength(64) + clientId: string; + + @IsOptional() + @IsUUID() + id?: string; + + @IsArray() + @IsString({ each: true }) + teeth: string[]; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ToothSelectionGroupDto) + toothSelectionGroups?: ToothSelectionGroupDto[]; + + @IsOptional() + @IsString() + @MaxLength(5000) + comment?: string; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => LabInternalToothProsthesisDto) + toothProsthesis?: LabInternalToothProsthesisDto[]; + + @IsOptional() + @IsArray() + @IsUUID(undefined, { each: true }) + attachmentIds?: string[]; +} + +export class CreateLabInternalCaseDto { + @IsOptional() + @IsString() + @MaxLength(200) + referringClinicName?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + referringDentistName?: string; + + @IsOptional() + @IsString() + @MaxLength(200) + patientDisplayName?: string; + + @IsOptional() + @IsString() + @MaxLength(32) + patientDisplayMobile?: string; + + @IsOptional() + @IsUUID() + partnerClinicOrganizationId?: string | null; + + @IsOptional() + @IsDateString() + dueDate?: string | null; +} + +export class UpdateLabInternalCaseDto extends CreateLabInternalCaseDto { + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => LabInternalCaseLineDto) + lines?: LabInternalCaseLineDto[]; +} diff --git a/backend/src/modules/cases/lab-case-access.service.ts b/backend/src/modules/cases/lab-case-access.service.ts index 6a8f1aa..2e681fa 100644 --- a/backend/src/modules/cases/lab-case-access.service.ts +++ b/backend/src/modules/cases/lab-case-access.service.ts @@ -128,9 +128,16 @@ export class LabCaseAccessService { dueDate: labCase.dueDate?.toISOString() ?? null, isOverdue: isLabCaseOverdue(labCase.dueDate, labCase.tasks), isImportant: labCase.isImportant, - clinic: labCase.treatment.organization, + clinic: labCase.treatment?.organization ?? { + id: '', + name: '', + }, lab: labCase.sends[0]?.organization ?? null, - patient: labCase.treatment.patient, + patient: labCase.treatment?.patient ?? { + id: '', + firstName: '', + lastName: '', + }, prosthesisGroups, }, }; @@ -240,7 +247,7 @@ export class LabCaseAccessService { actorUserId: string, organizationId: string, ): Promise { - const clinicOrgId = labCase.treatment.organization.id; + const clinicOrgId = labCase.treatment?.organization.id; const labOrgId = labCase.destinationOrganizationId ?? labCase.sends[0]?.organizationId ?? null; @@ -266,13 +273,13 @@ export class LabCaseAccessService { return { kind: 'denied' }; } - if (organizationId === clinicOrgId) { + if (clinicOrgId && organizationId === clinicOrgId) { const membership = await this.getMembership(actorUserId, clinicOrgId); if (!membership) return { kind: 'denied' }; if (!hasEffectivePermission(membership, 'TAB_TREATMENT_EDIT')) { return { kind: 'denied' }; } - if (!isActorTreatmentProvider(labCase.treatment, actorUserId)) { + if (!labCase.treatment || !isActorTreatmentProvider(labCase.treatment, actorUserId)) { return { kind: 'denied' }; } return { 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 935dbcf..99b114f 100644 --- a/backend/src/modules/cases/lab-case-task.generator.spec.ts +++ b/backend/src/modules/cases/lab-case-task.generator.spec.ts @@ -8,7 +8,9 @@ import { generateLabCaseTasks } from './lab-case-task.generator'; function buildMockTx(options: { existingCount?: number; toothProsthesisRows: Array<{ - treatmentDetailId: string; + treatmentDetailId?: string | null; + lineId?: string | null; + sourceKey?: string; tooth: string; prosthesisTypeCode: string; selectionGroupId?: string; @@ -33,15 +35,27 @@ function buildMockTx(options: { labCaseToothProsthesis: { findMany: jest.fn().mockResolvedValue( options.toothProsthesisRows.map((row) => ({ - treatmentDetailId: row.treatmentDetailId, + treatmentDetailId: row.treatmentDetailId ?? null, + lineId: row.lineId ?? null, + sourceKey: row.sourceKey ?? row.treatmentDetailId ?? row.lineId ?? '', + treatmentType: row.treatmentType ?? 'prosthesis', tooth: row.tooth, prosthesisTypeCode: row.prosthesisTypeCode, selectionGroupId: row.selectionGroupId ?? '', - detail: { - id: row.treatmentDetailId, - treatmentType: row.treatmentType ?? 'prosthesis', - toothSelectionGroups: null, - }, + detail: row.treatmentDetailId + ? { + id: row.treatmentDetailId, + treatmentType: row.treatmentType ?? 'prosthesis', + toothSelectionGroups: null, + } + : null, + line: row.lineId + ? { + id: row.lineId, + treatmentType: row.treatmentType ?? 'prosthesis', + toothSelectionGroups: null, + } + : null, })), ), }, @@ -219,4 +233,34 @@ describe('generateLabCaseTasks', () => { expect(count).toBe(0); expect(tx.labCaseTask.createMany).not.toHaveBeenCalled(); }); + + it('creates tasks from lab-origin lines using sourceKey and lineId', async () => { + const pfmSteps = stepsFromSeed('pfm_crown'); + const { tx, created } = buildMockTx({ + toothProsthesisRows: [ + { + lineId: 'line-1', + sourceKey: 'line-1', + tooth: '11', + prosthesisTypeCode: 'pfm_crown', + }, + { + lineId: 'line-1', + sourceKey: 'line-1', + tooth: '21', + prosthesisTypeCode: 'pfm_crown', + }, + ], + prosthesisTypes: [{ code: 'pfm_crown', steps: pfmSteps }], + }); + + const count = await generateLabCaseTasks(tx as never, 'lab-internal-1', 'en'); + expect(count).toBe(pfmSteps.length); + expect(created[0]).toMatchObject({ + lineId: 'line-1', + sourceKey: 'line-1', + treatmentDetailId: null, + teeth: ['11', '21'], + }); + }); }); diff --git a/backend/src/modules/cases/lab-case-task.generator.ts b/backend/src/modules/cases/lab-case-task.generator.ts index 063056f..5caa5c0 100644 --- a/backend/src/modules/cases/lab-case-task.generator.ts +++ b/backend/src/modules/cases/lab-case-task.generator.ts @@ -19,6 +19,7 @@ export async function generateLabCaseTasks( where: { labCaseId }, include: { detail: { select: { id: true, treatmentType: true, toothSelectionGroups: true } }, + line: { select: { id: true, treatmentType: true, toothSelectionGroups: true } }, }, }); @@ -64,7 +65,9 @@ export async function generateLabCaseTasks( const groups = new Map< string, { - treatmentDetailId: string; + sourceKey: string; + treatmentDetailId: string | null; + lineId: string | null; treatmentType: string; prosthesisTypeCode: string; selectionGroupId: string; @@ -73,11 +76,23 @@ export async function generateLabCaseTasks( >(); for (const row of toothProsthesisRows) { - const key = `${row.treatmentDetailId}::${row.prosthesisTypeCode}`; + const sourceKey = + row.sourceKey || + row.treatmentDetailId || + row.lineId || + ''; + if (!sourceKey) continue; + const key = `${sourceKey}::${row.prosthesisTypeCode}`; const selectionGroupId = row.selectionGroupId?.trim() || ''; const group = groups.get(key) ?? { + sourceKey, treatmentDetailId: row.treatmentDetailId, - treatmentType: row.detail.treatmentType, + lineId: row.lineId, + treatmentType: + row.detail?.treatmentType ?? + row.line?.treatmentType ?? + row.treatmentType ?? + 'prosthesis', prosthesisTypeCode: row.prosthesisTypeCode, selectionGroupId, teeth: [], @@ -104,6 +119,8 @@ export async function generateLabCaseTasks( taskRows.push({ labCaseId, treatmentDetailId: group.treatmentDetailId, + lineId: group.lineId, + sourceKey: group.sourceKey, teeth, treatmentType: group.treatmentType, prosthesisTypeCode: group.prosthesisTypeCode, diff --git a/backend/src/modules/lab-case-comments/lab-case-comments.controller.ts b/backend/src/modules/lab-case-comments/lab-case-comments.controller.ts index 0328c03..a0511be 100644 --- a/backend/src/modules/lab-case-comments/lab-case-comments.controller.ts +++ b/backend/src/modules/lab-case-comments/lab-case-comments.controller.ts @@ -31,7 +31,7 @@ export class LabCaseCommentsController { @Get(':caseId') @ApiOperation({ summary: 'List comments for a lab case (lab side)' }) list(@Param('caseId') caseId: string, @Req() req) { - return this.service.listForLab(caseId, this.orgId(req), req.user.id); + return this.service.listForLabViewer(caseId, this.orgId(req), req.user.id); } @Post(':caseId') diff --git a/backend/src/modules/lab-case-comments/lab-case-comments.service.ts b/backend/src/modules/lab-case-comments/lab-case-comments.service.ts index ee22a07..9c994bc 100644 --- a/backend/src/modules/lab-case-comments/lab-case-comments.service.ts +++ b/backend/src/modules/lab-case-comments/lab-case-comments.service.ts @@ -3,7 +3,7 @@ import { Injectable, } from '@nestjs/common'; import { AppException, ErrorCode } from '../../common/errors'; -import { LabCaseCommentSide, LabCaseActivityType, Prisma, UserNotificationType } from '@prisma/client'; +import { LabCaseCommentSide, LabCaseActivityType, LabCaseOrigin, Prisma, UserNotificationType } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { CreateLabCaseCommentDto } from './dto/lab-case-comment.dto'; import { hasEffectivePermission } from '../../common/membership-permissions'; @@ -290,8 +290,16 @@ export class LabCaseCommentsService { const labCase = await this.prisma.labCase.findFirst({ where: { id: caseId, - sentAt: { not: null }, - sends: { some: { organizationId: labOrganizationId } }, + OR: [ + { + sentAt: { not: null }, + sends: { some: { organizationId: labOrganizationId } }, + }, + { + origin: LabCaseOrigin.LAB_INTERNAL, + destinationOrganizationId: labOrganizationId, + }, + ], }, select: { id: true }, }); @@ -398,8 +406,11 @@ export class LabCaseCommentsService { private async clinicOrgIdForCase(caseId: string): Promise { const labCase = await this.prisma.labCase.findUnique({ where: { id: caseId }, - select: { treatment: { select: { organizationId: true } } }, + select: { + partnerClinicOrganizationId: true, + treatment: { select: { organizationId: true } }, + }, }); - return labCase?.treatment.organizationId ?? null; + return labCase?.treatment?.organizationId ?? labCase?.partnerClinicOrganizationId ?? null; } } diff --git a/backend/src/modules/notifications/lab-case-activity.service.ts b/backend/src/modules/notifications/lab-case-activity.service.ts index cdd6411..ca0f060 100644 --- a/backend/src/modules/notifications/lab-case-activity.service.ts +++ b/backend/src/modules/notifications/lab-case-activity.service.ts @@ -3,11 +3,7 @@ import { Injectable, } from '@nestjs/common'; import { AppException, ErrorCode } from '../../common/errors'; -import { - LabCaseActivityType, - LabCaseTabReadTarget, - Prisma, -} from '@prisma/client'; +import { LabCaseActivityType, LabCaseOrigin, LabCaseTabReadTarget, Prisma } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { hasEffectivePermission } from '../../common/membership-permissions'; import { @@ -476,8 +472,16 @@ export class LabCaseActivityService { org.type.name === 'LAB' ? { id: labCaseId, - sentAt: { not: null }, - sends: { some: { organizationId } }, + OR: [ + { + sentAt: { not: null }, + sends: { some: { organizationId } }, + }, + { + origin: LabCaseOrigin.LAB_INTERNAL, + destinationOrganizationId: organizationId, + }, + ], } : { id: labCaseId, diff --git a/backend/src/modules/notifications/user-notification.service.ts b/backend/src/modules/notifications/user-notification.service.ts index a816325..48c7f57 100644 --- a/backend/src/modules/notifications/user-notification.service.ts +++ b/backend/src/modules/notifications/user-notification.service.ts @@ -54,6 +54,10 @@ export class UserNotificationService { select: { id: true, destinationOrganizationId: true, + origin: true, + referringClinicName: true, + patientDisplayName: true, + partnerClinic: { select: { name: true } }, treatment: { select: { organization: { select: { name: true } }, @@ -70,7 +74,7 @@ export class UserNotificationService { }, }); - if (!labCase?.treatment) { + if (!labCase) { return { labCaseId, ...(extra ?? {}) }; } @@ -92,16 +96,21 @@ export class UserNotificationService { ), ]; - const patient = labCase.treatment.patient; - const patientName = `${patient.firstName} ${patient.lastName}`.trim(); + const patient = labCase.treatment?.patient; + const patientName = patient + ? `${patient.firstName} ${patient.lastName}`.trim() + : labCase.patientDisplayName?.trim() || ''; + const clinicName = + labCase.treatment?.organization.name ?? + labCase.partnerClinic?.name ?? + labCase.referringClinicName?.trim() ?? + ''; - // Caller ids (taskId, commentId, …) come from `extra`; denormalized display - // fields must win so they are never overwritten by a thin emit payload. return { ...(extra ?? {}), labCaseId, patientName, - clinicName: labCase.treatment.organization.name, + clinicName, labName, prosthesisTypeCodes, }; @@ -340,8 +349,9 @@ export class UserNotificationService { }, }); if (!labCase?.treatment) return []; + const treatment = labCase.treatment; userIds = userIds.filter((userId) => - isActorTreatmentProvider(labCase.treatment, userId), + isActorTreatmentProvider(treatment, userId), ); } diff --git a/backend/src/modules/patients/patients.service.ts b/backend/src/modules/patients/patients.service.ts index 562e910..050da08 100644 --- a/backend/src/modules/patients/patients.service.ts +++ b/backend/src/modules/patients/patients.service.ts @@ -43,9 +43,10 @@ export class PatientsService { const { page = 1, limit = 10, q } = query; const skip = (page - 1) * limit; - const where = q?.trim() - ? this.buildSearchWhere(q.trim()) - : {}; + const where = { + isWalkIn: false, + ...(q?.trim() ? this.buildSearchWhere(q.trim()) : {}), + }; const [items, total] = await Promise.all([ this.prisma.patient.findMany({ @@ -76,7 +77,7 @@ export class PatientsService { where: { id }, }); - if (!patient) { + if (!patient || patient.isWalkIn) { throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND); } @@ -85,6 +86,13 @@ export class PatientsService { async update(id: string, updatePatientDto: UpdatePatientDto) { await this.ensurePatient(id); + const patient = await this.prisma.patient.findUnique({ + where: { id }, + select: { isWalkIn: true }, + }); + if (patient?.isWalkIn) { + throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND); + } const data: { firstName?: string; @@ -116,12 +124,12 @@ export class PatientsService { : null; } - const patient = await this.prisma.patient.update({ + const updated = await this.prisma.patient.update({ where: { id }, data, }); - return { success: true, data: patient }; + return { success: true, data: updated }; } async listAppointments( diff --git a/backend/src/modules/tasks/tasks.service.ts b/backend/src/modules/tasks/tasks.service.ts index 994cbe7..2ad158a 100644 --- a/backend/src/modules/tasks/tasks.service.ts +++ b/backend/src/modules/tasks/tasks.service.ts @@ -3,7 +3,7 @@ import { Injectable, } from '@nestjs/common'; import { AppException, ErrorCode } from '../../common/errors'; -import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma, UserNotificationType } from '@prisma/client'; +import { CatalogEntityKind, LabCaseActivityType, LabCaseOrigin, LabTaskStatus, Prisma, UserNotificationType } from '@prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { normalizeMobile } from '../../common/phone'; import { @@ -23,6 +23,7 @@ const taskListInclude = { labCase: { include: { tasks: { select: { status: true } }, + partnerClinic: { select: { id: true, name: true } }, treatment: { include: { organization: { select: { id: true, name: true } }, @@ -110,8 +111,7 @@ export class TasksService { const labCase = await this.prisma.labCase.findFirst({ where: { id: labCaseId, - sentAt: { not: null }, - sends: { some: { organizationId } }, + ...this.visibleStartedToLabWhere(organizationId), }, select: { id: true }, }); @@ -129,6 +129,7 @@ export class TasksService { include: taskListInclude, orderBy: [ { treatmentDetailId: 'asc' }, + { sourceKey: 'asc' }, { prosthesisTypeCode: 'asc' }, { stepOrder: 'asc' }, { id: 'asc' }, @@ -164,17 +165,15 @@ export class TasksService { const target = await this.prisma.labCaseTask.findFirst({ where: { id: query.taskId, - labCase: { - sentAt: { not: null }, - sends: { some: { organizationId: labOrganizationId } }, - }, + labCase: this.visibleStartedToLabWhere(labOrganizationId), }, include: { - labCase: { select: { sentAt: true } }, + labCase: { select: { sentAt: true, startedAt: true } }, }, }); - if (!target?.labCase.sentAt) { + const effectiveAt = target?.labCase.sentAt ?? target?.labCase.startedAt; + if (!target || !effectiveAt) { throw new AppException(ErrorCode.CASE_TASK_NOT_FOUND, HttpStatus.NOT_FOUND); } @@ -194,9 +193,9 @@ export class TasksService { where, listQuery, { - sentAt: target.labCase.sentAt, + sentAt: effectiveAt, labCaseId: target.labCaseId, - treatmentDetailId: target.treatmentDetailId, + sourceKey: target.sourceKey, prosthesisTypeCode: target.prosthesisTypeCode, stepOrder: target.stepOrder, id: target.id, @@ -225,10 +224,7 @@ export class TasksService { const task = await this.prisma.labCaseTask.findFirst({ where: { id: taskId, - labCase: { - sentAt: { not: null }, - sends: { some: { organizationId: labOrganizationId } }, - }, + labCase: this.visibleStartedToLabWhere(labOrganizationId), }, include: taskListInclude, }); @@ -366,11 +362,9 @@ export class TasksService { await this.assertCanReadTasks(actorUserId, labOrganizationId); const rows = await this.prisma.labCase.findMany({ - where: { - sentAt: { not: null }, - sends: { some: { organizationId: labOrganizationId } }, - }, + where: this.visibleStartedToLabWhere(labOrganizationId), select: { + partnerClinic: { select: { id: true, name: true } }, treatment: { select: { organization: { select: { id: true, name: true } }, @@ -381,7 +375,12 @@ export class TasksService { const clinicsById = new Map(); for (const row of rows) { - clinicsById.set(row.treatment.organization.id, row.treatment.organization); + if (row.treatment?.organization) { + clinicsById.set(row.treatment.organization.id, row.treatment.organization); + } + if (row.partnerClinic) { + clinicsById.set(row.partnerClinic.id, row.partnerClinic); + } } const locale = normalizeCatalogLocale(localeInput); @@ -442,12 +441,33 @@ export class TasksService { } const labCaseScope: Prisma.LabCaseWhereInput = { - sentAt: sentAtFilter, - sends: { some: { organizationId: labOrganizationId } }, - ...(query.clinicOrganizationId - ? { treatment: { organizationId: query.clinicOrganizationId } } - : {}), - ...(query.q?.trim() ? { treatment: this.buildSearchWhere(query.q.trim()) } : {}), + AND: [ + this.visibleStartedToLabWhere(labOrganizationId), + ...(query.sentFrom || query.sentTo + ? [ + { + OR: [ + { sentAt: sentAtFilter }, + { + origin: LabCaseOrigin.LAB_INTERNAL, + startedAt: sentAtFilter, + }, + ], + } satisfies Prisma.LabCaseWhereInput, + ] + : []), + ...(query.clinicOrganizationId + ? [ + { + OR: [ + { treatment: { organizationId: query.clinicOrganizationId } }, + { partnerClinicOrganizationId: query.clinicOrganizationId }, + ], + } satisfies Prisma.LabCaseWhereInput, + ] + : []), + ...(query.q?.trim() ? [this.buildTaskSearchWhere(query.q.trim())] : []), + ], }; const base: Prisma.LabCaseTaskWhereInput = { @@ -475,7 +495,7 @@ export class TasksService { } const completedGroups = await this.prisma.labCaseTask.groupBy({ - by: ['labCaseId', 'treatmentDetailId', 'prosthesisTypeCode'], + by: ['labCaseId', 'sourceKey', 'prosthesisTypeCode'], where: { workflowStepCode: stepCompleted, status: LabTaskStatus.COMPLETED, @@ -493,7 +513,7 @@ export class TasksService { { OR: completedGroups.map((group) => ({ labCaseId: group.labCaseId, - treatmentDetailId: group.treatmentDetailId, + sourceKey: group.sourceKey, prosthesisTypeCode: group.prosthesisTypeCode, })), }, @@ -529,7 +549,7 @@ export class TasksService { target: { sentAt: Date; labCaseId: string; - treatmentDetailId: string; + sourceKey: string; prosthesisTypeCode: string; stepOrder: number; id: string; @@ -543,7 +563,11 @@ export class TasksService { } const sentAt = target.sentAt; - const sameSentAt = { labCase: { sentAt } }; + const sameSentAt = { + labCase: { + OR: [{ sentAt }, { AND: [{ sentAt: null }, { startedAt: sentAt }] }], + }, + }; const tupleBefore: Prisma.LabCaseTaskWhereInput[] = [ { AND: [sameSentAt, { labCaseId: { lt: target.labCaseId } }], @@ -552,14 +576,14 @@ export class TasksService { AND: [ sameSentAt, { labCaseId: target.labCaseId }, - { treatmentDetailId: { lt: target.treatmentDetailId } }, + { sourceKey: { lt: target.sourceKey } }, ], }, { AND: [ sameSentAt, { labCaseId: target.labCaseId }, - { treatmentDetailId: target.treatmentDetailId }, + { sourceKey: target.sourceKey }, { prosthesisTypeCode: { lt: target.prosthesisTypeCode } }, ], }, @@ -567,7 +591,7 @@ export class TasksService { AND: [ sameSentAt, { labCaseId: target.labCaseId }, - { treatmentDetailId: target.treatmentDetailId }, + { sourceKey: target.sourceKey }, { prosthesisTypeCode: target.prosthesisTypeCode }, { stepOrder: { lt: target.stepOrder } }, ], @@ -576,7 +600,7 @@ export class TasksService { AND: [ sameSentAt, { labCaseId: target.labCaseId }, - { treatmentDetailId: target.treatmentDetailId }, + { sourceKey: target.sourceKey }, { prosthesisTypeCode: target.prosthesisTypeCode }, { stepOrder: target.stepOrder }, { id: { lt: target.id } }, @@ -586,8 +610,16 @@ export class TasksService { const sentAtBefore: Prisma.LabCaseTaskWhereInput = dir === 'desc' - ? { labCase: { sentAt: { gt: sentAt } } } - : { labCase: { sentAt: { lt: sentAt } } }; + ? { + labCase: { + OR: [{ sentAt: { gt: sentAt } }, { startedAt: { gt: sentAt } }], + }, + } + : { + labCase: { + OR: [{ sentAt: { lt: sentAt } }, { startedAt: { lt: sentAt } }], + }, + }; return this.prisma.labCaseTask.count({ where: { @@ -596,21 +628,49 @@ export class TasksService { }); } - private buildSearchWhere(q: string): Prisma.TreatmentWhereInput { - const orConditions: Prisma.PatientWhereInput[] = [ - { firstName: { contains: q, mode: 'insensitive' } }, - { lastName: { contains: q, mode: 'insensitive' } }, + private visibleStartedToLabWhere(labOrganizationId: string): Prisma.LabCaseWhereInput { + return { + OR: [ + { + sentAt: { not: null }, + sends: { some: { organizationId: labOrganizationId } }, + }, + { + origin: LabCaseOrigin.LAB_INTERNAL, + destinationOrganizationId: labOrganizationId, + startedAt: { not: null }, + }, + ], + }; + } + + private buildTaskSearchWhere(q: string): Prisma.LabCaseWhereInput { + const orConditions: Prisma.LabCaseWhereInput[] = [ + { + treatment: { + patient: { + OR: [ + { firstName: { contains: q, mode: 'insensitive' } }, + { lastName: { contains: q, mode: 'insensitive' } }, + ], + }, + }, + }, + { + treatment: { + organization: { name: { contains: q, mode: 'insensitive' } }, + }, + }, + { referringClinicName: { contains: q, mode: 'insensitive' } }, + { referringDentistName: { contains: q, mode: 'insensitive' } }, + { patientDisplayName: { contains: q, mode: 'insensitive' } }, + { patientDisplayMobile: { contains: q, mode: 'insensitive' } }, ]; const normalized = normalizeMobile(q); if (normalized) { - orConditions.push({ mobile: normalized }); + orConditions.push({ treatment: { patient: { mobile: normalized } } }); } - return { - OR: [ - { patient: { OR: orConditions } }, - { organization: { name: { contains: q, mode: 'insensitive' } } }, - ], - }; + return { OR: orConditions }; } private buildOrderBy(query: ListLabTasksDto): Prisma.LabCaseTaskOrderByWithRelationInput[] { @@ -628,6 +688,7 @@ export class TasksService { break; case 'clinic': orderBy = [ + { labCase: { referringClinicName: dir } }, { labCase: { treatment: { organization: { name: dir } } } }, { createdAt: 'desc' }, ...stepTiebreakers, @@ -635,6 +696,7 @@ export class TasksService { break; case 'patient': orderBy = [ + { labCase: { patientDisplayName: dir } }, { labCase: { treatment: { patient: { lastName: dir } } } }, { labCase: { treatment: { patient: { firstName: dir } } } }, ...stepTiebreakers, @@ -661,9 +723,10 @@ export class TasksService { case 'dueDate': orderBy = [ { labCase: { dueDate: dir } }, + { labCase: { startedAt: 'desc' } }, { labCase: { sentAt: 'desc' } }, { labCaseId: 'asc' }, - { treatmentDetailId: 'asc' }, + { sourceKey: 'asc' }, { prosthesisTypeCode: 'asc' }, { stepOrder: 'asc' }, { id: 'asc' }, @@ -672,9 +735,10 @@ export class TasksService { case 'date': default: orderBy = [ + { labCase: { startedAt: dir } }, { labCase: { sentAt: dir } }, { labCaseId: 'asc' }, - { treatmentDetailId: 'asc' }, + { sourceKey: 'asc' }, { prosthesisTypeCode: 'asc' }, { stepOrder: 'asc' }, { id: 'asc' }, @@ -693,10 +757,24 @@ export class TasksService { task: Prisma.LabCaseTaskGetPayload<{ include: typeof taskListInclude }>, prosthesisLabels: Map, ) { + const clinic = + task.labCase.treatment?.organization ?? + task.labCase.partnerClinic ?? { + id: '', + name: task.labCase.referringClinicName?.trim() || '', + }; + const displayName = task.labCase.patientDisplayName?.trim() || ''; + const [firstName, ...rest] = displayName.split(/\s+/); + const patient = task.labCase.treatment?.patient ?? { + id: '', + firstName: firstName || displayName || clinic.name || '—', + lastName: rest.join(' '), + }; + return { id: task.id, labCaseId: task.labCaseId, - treatmentDetailId: task.treatmentDetailId, + treatmentDetailId: task.treatmentDetailId ?? task.sourceKey, teeth: normalizeTaskTeeth(task.teeth), treatmentType: task.treatmentType, prosthesisTypeCode: task.prosthesisTypeCode, @@ -718,13 +796,9 @@ export class TasksService { ? { id: task.lastStatusChangedBy.id, name: task.lastStatusChangedBy.name } : null, createdAt: task.createdAt.toISOString(), - caseSentAt: task.labCase.sentAt?.toISOString() ?? null, - clinic: task.labCase.treatment.organization, - patient: { - id: task.labCase.treatment.patient.id, - firstName: task.labCase.treatment.patient.firstName, - lastName: task.labCase.treatment.patient.lastName, - }, + caseSentAt: (task.labCase.sentAt ?? task.labCase.startedAt)?.toISOString() ?? null, + clinic, + patient, }; } diff --git a/backend/src/modules/today/today.service.ts b/backend/src/modules/today/today.service.ts index 29e5e12..7ed5187 100644 --- a/backend/src/modules/today/today.service.ts +++ b/backend/src/modules/today/today.service.ts @@ -1243,6 +1243,7 @@ export class TodayService { }, select: { destinationOrganizationId: true, + partnerClinicOrganizationId: true, tasks: { select: { status: true } }, treatment: { select: { organizationId: true } }, }, @@ -1254,7 +1255,7 @@ export class TodayService { const partnerId = orgType === 'CLINIC' ? labCase.destinationOrganizationId - : labCase.treatment.organizationId; + : labCase.treatment?.organizationId ?? labCase.partnerClinicOrganizationId; if (!partnerId) continue; const isCompleted = diff --git a/backend/src/modules/treatments/dto/treatment.dto.ts b/backend/src/modules/treatments/dto/treatment.dto.ts index 3bb6a24..1d542d2 100644 --- a/backend/src/modules/treatments/dto/treatment.dto.ts +++ b/backend/src/modules/treatments/dto/treatment.dto.ts @@ -1,6 +1,7 @@ import { ArrayMinSize, IsArray, + IsBoolean, IsDateString, IsIn, IsOptional, @@ -133,3 +134,24 @@ export class ListPatientTreatmentHistoryDto { @IsOptional() limit?: number; } + +export class CreateStandaloneTreatmentDto { + @IsOptional() + @IsUUID() + patientId?: string; + + @IsOptional() + @IsBoolean() + walkIn?: boolean; + + @IsDateString() + treatmentAt: string; +} + +export class ListDayTreatmentsDto { + @IsDateString() + from: string; + + @IsDateString() + to: string; +} diff --git a/backend/src/modules/treatments/treatments.controller.ts b/backend/src/modules/treatments/treatments.controller.ts index 2b6f313..abd6f8c 100644 --- a/backend/src/modules/treatments/treatments.controller.ts +++ b/backend/src/modules/treatments/treatments.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, + Delete, Get, Param, ParseIntPipe, @@ -24,6 +25,8 @@ import { SaveTreatmentDraftDto, SaveTreatmentLabCasesDto, UpdateLabCaseDueDateDto, + CreateStandaloneTreatmentDto, + ListDayTreatmentsDto, } from './dto/treatment.dto'; import { CreateLabCaseCommentDto } from '../lab-case-comments/dto/lab-case-comment.dto'; import { LabCaseCommentsService } from '../lab-case-comments/lab-case-comments.service'; @@ -83,6 +86,31 @@ export class TreatmentsController { ); } + @Get('day') + @ApiOperation({ summary: 'Standalone (no appointment) treatments for a local day range' }) + listDayStandalone( + @Query() query: ListDayTreatmentsDto, + @Req() req: { user: { id: string; organizationId?: string } }, + ) { + const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); + return this.treatmentsService.listDayStandalone( + query.from, + query.to, + organizationId, + req.user.id, + ); + } + + @Post() + @ApiOperation({ summary: 'Create a treatment without an appointment (TAB_TREATMENT_EDIT)' }) + createStandalone( + @Body() dto: CreateStandaloneTreatmentDto, + @Req() req: { user: { id: string; organizationId?: string } }, + ) { + const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); + return this.treatmentsService.createStandalone(dto, organizationId, req.user.id); + } + @Get('appointments/:appointmentId/draft') @ApiOperation({ summary: 'Get draft treatment for an appointment (TAB_TREATMENT_READ)' }) getDraft( @@ -268,4 +296,84 @@ export class TreatmentsController { dto, ); } + + @Get(':treatmentId/draft') + @ApiOperation({ summary: 'Get a treatment draft by treatment id (TAB_TREATMENT_READ)' }) + getDraftByTreatment( + @Param('treatmentId') treatmentId: string, + @Req() req: { user: { id: string; organizationId?: string } }, + ) { + const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); + return this.treatmentsService.getDraftForTreatment( + treatmentId, + organizationId, + req.user.id, + ); + } + + @Put(':treatmentId/draft') + @ApiOperation({ summary: 'Save draft treatment details by treatment id (TAB_TREATMENT_EDIT)' }) + saveDraftByTreatment( + @Param('treatmentId') treatmentId: string, + @Body() dto: SaveTreatmentDraftDto, + @Req() req: { user: { id: string; organizationId?: string } }, + ) { + const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); + return this.treatmentsService.saveDraftForTreatment( + treatmentId, + dto, + organizationId, + req.user.id, + ); + } + + @Put(':treatmentId/lab-cases') + @ApiOperation({ summary: 'Save lab case groupings by treatment id (TAB_TREATMENT_EDIT)' }) + saveLabCasesByTreatment( + @Param('treatmentId') treatmentId: string, + @Body() dto: SaveTreatmentLabCasesDto, + @Req() req: { user: { id: string; organizationId?: string } }, + ) { + const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); + return this.treatmentsService.saveLabCasesForTreatment( + treatmentId, + dto, + organizationId, + req.user.id, + ); + } + + @Post(':treatmentId/details/:detailClientKey/attachments') + @ApiOperation({ summary: 'Upload attachments for a standalone treatment detail' }) + @ApiConsumes('multipart/form-data') + @UseInterceptors( + FilesInterceptor('files', 20, { + storage: memoryStorage(), + }), + ) + uploadDetailAttachmentsByTreatment( + @Param('treatmentId') treatmentId: string, + @Param('detailClientKey') detailClientKey: string, + @UploadedFiles() files: Express.Multer.File[], + @Req() req: { user: { id: string; organizationId?: string } }, + ) { + const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); + return this.treatmentsService.uploadDetailAttachmentsForTreatment( + treatmentId, + detailClientKey, + files, + organizationId, + req.user.id, + ); + } + + @Delete(':treatmentId') + @ApiOperation({ summary: 'Delete an empty standalone treatment (no details)' }) + deleteStandalone( + @Param('treatmentId') treatmentId: string, + @Req() req: { user: { id: string; organizationId?: string } }, + ) { + const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user); + return this.treatmentsService.deleteStandalone(treatmentId, organizationId, req.user.id); + } } diff --git a/backend/src/modules/treatments/treatments.service.ts b/backend/src/modules/treatments/treatments.service.ts index 47b30c8..f370657 100644 --- a/backend/src/modules/treatments/treatments.service.ts +++ b/backend/src/modules/treatments/treatments.service.ts @@ -16,6 +16,7 @@ import { SaveTreatmentDraftDto, SaveTreatmentLabCasesDto, UpdateLabCaseDueDateDto, + CreateStandaloneTreatmentDto, } from './dto/treatment.dto'; import { isLabCaseFullyCompleted, @@ -36,6 +37,7 @@ import { isActorTreatmentProvider, treatmentProviderScopeWhere, } from '../../common/treatment-provider-scope'; +import { ensureWalkInPatient } from '../../common/walk-in-patient'; const sentLabCaseInclude = { treatment: { @@ -67,6 +69,9 @@ const sentLabCaseInclude = { type SentLabCaseRow = Prisma.LabCaseGetPayload<{ include: typeof sentLabCaseInclude }>; const treatmentInclude = { + patient: { + select: { id: true, firstName: true, lastName: true, isWalkIn: true }, + }, details: { orderBy: [{ sortOrder: 'asc' as const }], include: { @@ -192,6 +197,108 @@ export class TreatmentsService { return { success: true, data: items.map((t) => this.mapTreatment(t)) }; } + async createStandalone( + dto: CreateStandaloneTreatmentDto, + organizationId: string, + actorUserId: string, + ) { + await this.assertCanEditTreatment(actorUserId, organizationId); + + const walkIn = Boolean(dto.walkIn); + if (!walkIn && !dto.patientId) { + throw new AppException(ErrorCode.TREATMENT_PATIENT_OR_WALK_IN, HttpStatus.BAD_REQUEST); + } + + const treatmentAt = new Date(dto.treatmentAt); + if (Number.isNaN(treatmentAt.getTime())) { + throw new AppException(ErrorCode.VALIDATION_INVALID_REQUEST, HttpStatus.BAD_REQUEST); + } + + let patientId: string; + if (walkIn) { + const sentinel = await ensureWalkInPatient(this.prisma, organizationId); + patientId = sentinel.id; + } else { + await this.ensurePatientExists(dto.patientId!); + const patient = await this.prisma.patient.findUnique({ + where: { id: dto.patientId! }, + select: { isWalkIn: true }, + }); + if (patient?.isWalkIn) { + throw new AppException(ErrorCode.TREATMENT_PATIENT_OR_WALK_IN, HttpStatus.BAD_REQUEST); + } + patientId = dto.patientId!; + } + + const treatment = await this.prisma.treatment.create({ + data: { + organizationId, + patientId, + appointmentId: null, + providerUserId: actorUserId, + title: generateTreatmentTitle([]), + treatmentAt, + }, + include: treatmentInclude, + }); + + return { success: true, data: this.mapTreatment(treatment) }; + } + + async deleteStandalone( + treatmentId: string, + organizationId: string, + actorUserId: string, + ) { + await this.assertCanEditTreatment(actorUserId, organizationId); + const treatment = await this.ensureTreatmentProvider( + treatmentId, + organizationId, + actorUserId, + ); + + if (treatment.appointmentId) { + throw new AppException(ErrorCode.TREATMENT_NOT_STANDALONE, HttpStatus.CONFLICT); + } + + const detailCount = await this.prisma.treatmentDetail.count({ + where: { treatmentId }, + }); + if (detailCount > 0) { + throw new AppException(ErrorCode.TREATMENT_HAS_DETAILS, HttpStatus.CONFLICT); + } + + await this.prisma.treatment.delete({ where: { id: treatmentId } }); + return { success: true }; + } + + async listDayStandalone( + fromIso: string, + toIso: string, + organizationId: string, + actorUserId: string, + ) { + await this.assertCanReadTreatment(actorUserId, organizationId); + const from = new Date(fromIso); + const to = new Date(toIso); + if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime()) || to <= from) { + throw new AppException(ErrorCode.VALIDATION_INVALID_REQUEST, HttpStatus.BAD_REQUEST); + } + + const items = await this.prisma.treatment.findMany({ + where: { + organizationId, + appointmentId: null, + treatmentAt: { gte: from, lt: to }, + ...treatmentProviderScopeWhere(actorUserId), + }, + include: treatmentInclude, + orderBy: [{ treatmentAt: 'asc' }, { createdAt: 'asc' }], + }); + + return { success: true, data: items.map((t) => this.mapTreatment(t)) }; + } + async listPatientLabCases( patientId: string, organizationId: string, @@ -322,16 +429,20 @@ export class TreatmentsService { })) .sort((a, b) => a.prosthesisTypeCode.localeCompare(b.prosthesisTypeCode)); - const patient = lc.treatment.patient; + const treatment = lc.treatment; + if (!treatment) { + throw new AppException(ErrorCode.LAB_CASE_NOT_FOUND, HttpStatus.NOT_FOUND); + } + const patient = treatment.patient; return { labCaseId: lc.id, patientId: patient.id, patientFirstName: patient.firstName, patientLastName: patient.lastName, - treatmentId: lc.treatment.id, - appointmentId: lc.treatment.appointmentId, - treatmentAt: lc.treatment.treatmentAt.toISOString(), + treatmentId: treatment.id, + appointmentId: treatment.appointmentId, + treatmentAt: treatment.treatmentAt.toISOString(), detailClientId: detail?.clientKey ?? detailLink?.treatmentDetailId ?? '', teeth: detailTeeth, prosthesisGroups, @@ -385,6 +496,15 @@ export class TreatmentsService { return { success: true, data: treatment ? this.mapTreatment(treatment) : null }; } + async getDraftForTreatment( + treatmentId: string, + organizationId: string, + actorUserId: string, + ) { + const treatment = await this.ensureTreatmentProvider(treatmentId, organizationId, actorUserId); + return { success: true, data: this.mapTreatment(treatment) }; + } + async saveDraftForAppointment( appointmentId: string, dto: SaveTreatmentDraftDto, @@ -398,11 +518,78 @@ export class TreatmentsService { actorUserId, ); - for (const d of dto.details) { + const existing = await this.prisma.treatment.findFirst({ + where: { appointmentId: appointment.id, organizationId }, + select: { id: true }, + }); + + const treatmentId = existing + ? existing.id + : ( + await this.prisma.treatment.create({ + data: { + organizationId, + patientId: appointment.patientId, + appointmentId: appointment.id, + providerUserId: actorUserId, + title: generateTreatmentTitle([]), + treatmentAt: appointment.startAt, + }, + select: { id: true }, + }) + ).id; + + const saved = await this.persistDraftDetails({ + treatmentId, + organizationId, + actorUserId, + dto, + pendingAppointmentId: appointment.id, + treatmentAt: appointment.startAt, + patientId: appointment.patientId, + }); + + return { success: true, data: this.mapTreatment(saved) }; + } + + async saveDraftForTreatment( + treatmentId: string, + dto: SaveTreatmentDraftDto, + organizationId: string, + actorUserId: string, + ) { + await this.assertCanEditTreatment(actorUserId, organizationId); + const existing = await this.ensureTreatmentProvider(treatmentId, organizationId, actorUserId); + + const saved = await this.persistDraftDetails({ + treatmentId, + organizationId, + actorUserId, + dto, + pendingAppointmentId: existing.appointmentId, + pendingTreatmentId: existing.appointmentId ? null : existing.id, + treatmentAt: existing.appointmentId ? existing.treatmentAt : existing.treatmentAt, + patientId: existing.patientId, + }); + + return { success: true, data: this.mapTreatment(saved) }; + } + + private async persistDraftDetails(args: { + treatmentId: string; + organizationId: string; + actorUserId: string; + dto: SaveTreatmentDraftDto; + pendingAppointmentId: string | null; + pendingTreatmentId?: string | null; + treatmentAt: Date; + patientId: string; + }) { + for (const d of args.dto.details) { this.treatmentCatalog.assertKnownTreatmentType(d.treatmentType); } - const normalizedDetails = dto.details.map((d, index) => { + const normalizedDetails = args.dto.details.map((d, index) => { const teeth = normalizeTeeth(d.teeth); const toothSelectionGroups = normalizeToothSelectionGroups( d.toothSelectionGroups, @@ -422,41 +609,23 @@ export class TreatmentsService { normalizedDetails.map((d) => ({ treatmentType: d.treatmentType, teeth: d.teeth })), ); - const treatment = await this.prisma.$transaction(async (tx) => { - const existing = await tx.treatment.findFirst({ - where: { appointmentId: appointment.id, organizationId }, - select: { id: true }, + return this.prisma.$transaction(async (tx) => { + const saved = await tx.treatment.update({ + where: { id: args.treatmentId }, + data: { + title, + treatmentAt: args.treatmentAt, + patientId: args.patientId, + providerUserId: args.actorUserId, + }, }); - const saved = existing - ? await tx.treatment.update({ - where: { id: existing.id }, - data: { - title, - treatmentAt: appointment.startAt, - patientId: appointment.patientId, - providerUserId: actorUserId, - }, - }) - : await tx.treatment.create({ - data: { - organizationId, - patientId: appointment.patientId, - appointmentId: appointment.id, - providerUserId: actorUserId, - title, - treatmentAt: appointment.startAt, - }, - }); - const keepDetailIds = normalizedDetails.map((d) => d.id).filter(Boolean) as string[]; - const existingDetails = existing - ? await tx.treatmentDetail.findMany({ - where: { treatmentId: saved.id }, - select: { id: true, labCaseLink: { select: { labCase: { select: { sentAt: true } } } } }, - }) - : []; + const existingDetails = await tx.treatmentDetail.findMany({ + where: { treatmentId: saved.id }, + select: { id: true, labCaseLink: { select: { labCase: { select: { sentAt: true } } } } }, + }); const lockedDetailIds = new Set( existingDetails @@ -504,11 +673,14 @@ export class TreatmentsService { }); const allowedAttachmentIds = new Set(d.attachmentIds); + const pendingWhere: Prisma.TreatmentDetailAttachmentWhereInput = { + detailClientKey: d.clientId, + ...(args.pendingAppointmentId + ? { appointmentId: args.pendingAppointmentId } + : { treatmentId: args.pendingTreatmentId ?? saved.id }), + }; const pendingAttachments = await tx.treatmentDetailAttachment.findMany({ - where: { - appointmentId: appointment.id, - detailClientKey: d.clientId, - }, + where: pendingWhere, }); for (const attachment of pendingAttachments) { @@ -517,7 +689,12 @@ export class TreatmentsService { } else { await tx.treatmentDetailAttachment.update({ where: { id: attachment.id }, - data: { detailId: row.id, appointmentId: null, detailClientKey: null }, + data: { + detailId: row.id, + appointmentId: null, + treatmentId: null, + detailClientKey: null, + }, }); } } @@ -535,8 +712,6 @@ export class TreatmentsService { include: treatmentInclude, }); }); - - return { success: true, data: this.mapTreatment(treatment) }; } async saveLabCasesForAppointment( @@ -561,6 +736,20 @@ export class TreatmentsService { throw new AppException(ErrorCode.TREATMENT_SAVE_DETAILS_BEFORE_LAB, HttpStatus.NOT_FOUND); } + return this.saveLabCasesForTreatment(treatment.id, dto, organizationId, actorUserId); + } + + async saveLabCasesForTreatment( + treatmentId: string, + dto: SaveTreatmentLabCasesDto, + organizationId: string, + actorUserId: string, + ) { + await this.assertCanEditTreatment(actorUserId, organizationId); + await this.ensureTreatmentProvider(treatmentId, organizationId, actorUserId); + + const treatment = { id: treatmentId }; + const detailIds = dto.labCases.map((lc) => lc.treatmentDetailId); const uniqueDetailIds = new Set(detailIds); if (uniqueDetailIds.size !== detailIds.length) { @@ -658,6 +847,7 @@ export class TreatmentsService { : await tx.labCase.create({ data: { treatmentId: treatment.id, + origin: 'CLINIC_DISPATCH', clientKey: lc.clientId, sortOrder: index, destinationOrganizationId: lc.destinationOrganizationId ?? null, @@ -679,6 +869,8 @@ export class TreatmentsService { data: lc.toothProsthesis.map((tp) => ({ labCaseId: row.id, treatmentDetailId: tp.treatmentDetailId, + sourceKey: tp.treatmentDetailId, + treatmentType: detailById.get(tp.treatmentDetailId)?.treatmentType ?? 'prosthesis', tooth: tp.tooth, prosthesisTypeCode: tp.prosthesisTypeCode, selectionGroupId: tp.selectionGroupId?.trim() || '', @@ -763,7 +955,17 @@ export class TreatmentsService { throw new AppException(ErrorCode.TREATMENT_CASE_ONE_DETAIL, HttpStatus.BAD_REQUEST); } - assertCompleteToothProsthesisMap(labCase); + if (!labCase.treatment) { + throw new AppException(ErrorCode.LAB_CASE_NOT_FOUND, HttpStatus.NOT_FOUND); + } + + assertCompleteToothProsthesisMap({ + details: labCase.details, + toothProsthesis: labCase.toothProsthesis.filter( + (row): row is typeof row & { treatmentDetailId: string } => + Boolean(row.treatmentDetailId), + ), + }); if (!isActorTreatmentProvider(labCase.treatment, actorUserId)) { throw new AppException(ErrorCode.TREATMENT_ONLY_PROVIDER_SEND, HttpStatus.FORBIDDEN); @@ -1011,6 +1213,72 @@ export class TreatmentsService { return { success: true, data: created }; } + async uploadDetailAttachmentsForTreatment( + treatmentId: string, + detailClientKey: string, + files: Express.Multer.File[], + organizationId: string, + actorUserId: string, + ) { + await this.assertCanEditTreatment(actorUserId, organizationId); + await this.ensureTreatmentProvider(treatmentId, organizationId, actorUserId); + + if (!detailClientKey?.trim()) { + throw new AppException(ErrorCode.TREATMENT_DETAIL_KEY_REQUIRED, HttpStatus.BAD_REQUEST); + } + + if (!files?.length) { + throw new AppException(ErrorCode.TREATMENT_FILE_REQUIRED, HttpStatus.BAD_REQUEST); + } + + const existingDetail = await this.prisma.treatmentDetail.findFirst({ + where: { + clientKey: detailClientKey, + treatmentId, + }, + select: { + labCaseLink: { + select: { labCase: { select: { sentAt: true } } }, + }, + }, + }); + if (existingDetail?.labCaseLink?.labCase.sentAt) { + throw new AppException(ErrorCode.TREATMENT_DETAIL_SENT, HttpStatus.CONFLICT); + } + + const orgDir = join(this.uploadRoot, organizationId); + mkdirSync(orgDir, { recursive: true }); + + const created: { + id: string; + fileName: string; + mimeType: string; + sizeBytes: number; + }[] = []; + + for (const file of files) { + const storageName = `${randomUUID()}-${file.originalname.replace(/[^\w.\-()+]/g, '_')}`; + const storagePath = join(orgDir, storageName); + const { writeFileSync } = await import('fs'); + writeFileSync(storagePath, file.buffer); + + const attachment = await this.prisma.treatmentDetailAttachment.create({ + data: { + treatmentId, + detailClientKey, + fileName: file.originalname, + mimeType: file.mimetype || 'application/octet-stream', + sizeBytes: file.size, + storagePath, + }, + }); + + created.push(this.mapAttachment(attachment)); + } + + return { success: true, data: created }; + } + async streamAttachmentFile( attachmentId: string, organizationId: string, @@ -1031,6 +1299,7 @@ export class TreatmentsService { }, }, { appointmentId: { not: null } }, + { treatmentId: { not: null } }, ], }, include: { @@ -1060,6 +1329,20 @@ export class TreatmentsService { } } + if (!attachment.detail && attachment.treatmentId) { + const treatment = await this.prisma.treatment.findFirst({ + where: { + id: attachment.treatmentId, + organizationId, + providerUserId: actorUserId, + }, + select: { id: true }, + }); + if (!treatment) { + throw new AppException(ErrorCode.TREATMENT_ATTACHMENT_NOT_FOUND, HttpStatus.NOT_FOUND); + } + } + if (!existsSync(attachment.storagePath)) { throw new AppException(ErrorCode.TREATMENT_FILE_UNAVAILABLE, HttpStatus.NOT_FOUND); } @@ -1075,8 +1358,15 @@ export class TreatmentsService { id: string; patientId: string; appointmentId: string | null; + providerUserId?: string; title: string; treatmentAt: Date; + patient?: { + id: string; + firstName: string; + lastName: string; + isWalkIn: boolean; + }; details: Array<{ id: string; clientKey: string | null; @@ -1127,8 +1417,17 @@ export class TreatmentsService { id: treatment.id, patientId: treatment.patientId, appointmentId: treatment.appointmentId, + providerUserId: treatment.providerUserId ?? null, title: treatment.title, treatmentAt: treatment.treatmentAt.toISOString(), + patient: treatment.patient + ? { + id: treatment.patient.id, + firstName: treatment.patient.firstName, + lastName: treatment.patient.lastName, + isWalkIn: treatment.patient.isWalkIn, + } + : null, details: treatment.details.map((d) => this.mapDetail(d)), labCases: treatment.labCases.map((lc) => this.mapLabCase(lc)), documents, @@ -1209,7 +1508,7 @@ export class TreatmentsService { organization?: { id: string; name: string }; }>; toothProsthesis?: Array<{ - treatmentDetailId: string; + treatmentDetailId: string | null; tooth: string; prosthesisTypeCode: string; selectionGroupId?: string; @@ -1319,6 +1618,24 @@ export class TreatmentsService { } } + private async ensureTreatmentProvider( + treatmentId: string, + organizationId: string, + actorUserId: string, + ) { + const treatment = await this.prisma.treatment.findFirst({ + where: { id: treatmentId, organizationId }, + include: treatmentInclude, + }); + if (!treatment) { + throw new AppException(ErrorCode.TREATMENT_NOT_FOUND, HttpStatus.NOT_FOUND); + } + if (!isActorTreatmentProvider(treatment, actorUserId)) { + throw new AppException(ErrorCode.TREATMENT_ONLY_PROVIDER_SEND, HttpStatus.FORBIDDEN); + } + return treatment; + } + private async ensureAppointmentProvider( appointmentId: string, organizationId: string, diff --git a/frontend/messages/en.json b/frontend/messages/en.json index f1b8e57..bdaa5b0 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -493,7 +493,25 @@ "caseSheetNoComments": "No comments.", "errorDownloadCaseSheet": "Could not download the case sheet PDF.", "externalCodePlaceholder": "external code e.g. exocad code", - "errorUpdateExternalCode": "Could not save the external code." + "errorUpdateExternalCode": "Could not save the external code.", + "addCase": "Add case", + "addCaseTitle": "New case", + "addCaseSubtitle": "Enter the referring clinic or patient, then add prosthesis lines. Start when the case is ready.", + "startCase": "Start", + "draftBadge": "Draft", + "referringClinic": "Referring clinic", + "referringDentist": "Dentist", + "patientName": "Patient name", + "partnerClinic": "Linked clinic", + "partnerClinicNone": "None", + "dueDateField": "Due date", + "caseLinesTitle": "Work lines", + "addLine": "Add line", + "lineChip": "Line {n}", + "removeLineAria": "Remove line {n}", + "errorCreateCase": "Could not create the case.", + "errorSaveCase": "Could not save the case.", + "errorStartCase": "Could not start the case." }, "labCaseAccess": { "pageTitle": "Case tasks", @@ -676,7 +694,16 @@ "showAppointments": "Show appointments", "appointmentsTitle": "My appointments", "hideAppointments": "Hide appointments", - "emptyDay": "No appointments assigned to you on this day.", + "emptyDay": "No appointments or unscheduled treatments assigned to you on this day.", + "unscheduledHeading": "Without appointment", + "noAppointment": "No appointment", + "walkIn": "Walk-in", + "newTreatment": "New treatment", + "newWalkInTreatment": "Walk-in treatment", + "errorCreateTreatment": "Could not create treatment.", + "deleteEmptyTreatment": "Delete empty treatment", + "confirmDeleteEmptyTreatment": "Delete this treatment? It has no details yet.", + "errorDeleteTreatment": "Could not delete treatment.", "casesTitle": "Treatment cases", "casesSubtitle": "Each case has its own teeth, notes, attachments, and destinations for send.", "detailsTitle": "Treatment details", @@ -1181,6 +1208,15 @@ "APPOINTMENT_PATIENT_LOCKED": "Cannot change the patient while a treatment is linked to this appointment.", "APPOINTMENT_HAS_TREATMENT": "This appointment cannot be deleted because a treatment is linked to it.", "TREATMENT_DETAIL_SENT": "This detail was sent to a lab and can no longer be changed.", + "TREATMENT_NOT_FOUND": "Treatment not found.", + "TREATMENT_PATIENT_OR_WALK_IN": "Choose a patient or create a walk-in treatment.", + "TREATMENT_NOT_STANDALONE": "Only treatments without an appointment can be deleted this way.", + "TREATMENT_HAS_DETAILS": "This treatment has details and cannot be deleted.", + "LAB_CASE_NOT_EDITABLE": "This case can no longer be edited.", + "LAB_CASE_ALREADY_STARTED": "This case has already been started.", + "LAB_CASE_LINES_REQUIRED": "Add at least one work line with teeth and a prosthesis type.", + "LAB_CASE_START_INCOMPLETE": "Complete teeth and prosthesis types before starting this case.", + "LAB_CASE_CLIENT_REQUIRED": "Enter a clinic name or a patient name for this case.", "BAD_REQUEST": "The request could not be processed.", "INTERNAL_ERROR": "Something went wrong on our end. Please try again later." } diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 44c32f3..5b15057 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -494,7 +494,25 @@ "caseSheetNoComments": "بدون توضیحات.", "errorDownloadCaseSheet": "دانلود PDF برگه پرونده ممکن نشد.", "externalCodePlaceholder": "کد خارجی مثلاً کد exocad", - "errorUpdateExternalCode": "ذخیره کد خارجی ممکن نشد." + "errorUpdateExternalCode": "ذخیره کد خارجی ممکن نشد.", + "addCase": "افزودن پرونده", + "addCaseTitle": "پرونده جدید", + "addCaseSubtitle": "کلینیک ارجاع‌دهنده یا بیمار را وارد کنید و خطوط پروتز را اضافه کنید. وقتی آماده بود، شروع کنید.", + "startCase": "شروع", + "draftBadge": "پیش‌نویس", + "referringClinic": "کلینیک ارجاع‌دهنده", + "referringDentist": "دندان‌پزشک", + "patientName": "نام بیمار", + "partnerClinic": "کلینیک متصل", + "partnerClinicNone": "هیچ‌کدام", + "dueDateField": "موعد", + "caseLinesTitle": "خطوط کار", + "addLine": "افزودن خط", + "lineChip": "خط {n}", + "removeLineAria": "حذف خط {n}", + "errorCreateCase": "ایجاد پرونده ممکن نشد.", + "errorSaveCase": "ذخیره پرونده ممکن نشد.", + "errorStartCase": "شروع پرونده ممکن نشد." }, "labCaseAccess": { "pageTitle": "وظایف پرونده", @@ -677,7 +695,16 @@ "showAppointments": "نمایش نوبت‌ها", "appointmentsTitle": "نوبت‌های من", "hideAppointments": "پنهان کردن نوبت‌ها", - "emptyDay": "هیچ نوبتی به شما در این روز اختصاص داده نشده است.", + "emptyDay": "در این روز هیچ نوبت یا درمان بدون نوبتی به شما اختصاص داده نشده است.", + "unscheduledHeading": "بدون نوبت", + "noAppointment": "بدون نوبت", + "walkIn": "بدون نوبت (مراجع)", + "newTreatment": "درمان جدید", + "newWalkInTreatment": "درمان بدون نوبت", + "errorCreateTreatment": "ایجاد درمان ممکن نشد.", + "deleteEmptyTreatment": "حذف درمان خالی", + "confirmDeleteEmptyTreatment": "این درمان حذف شود؟ هنوز جزئیاتی ندارد.", + "errorDeleteTreatment": "حذف درمان ممکن نشد.", "casesTitle": "پرونده‌های درمانی", "casesSubtitle": "هر پرونده دارای دندان‌ها، یادداشت‌ها، پیوست‌ها و مقصدهای ارسال خود است.", "detailsTitle": "جزئیات درمان", @@ -1182,6 +1209,15 @@ "APPOINTMENT_PATIENT_LOCKED": "تا وقتی درمانی به این نوبت متصل است، امکان تغییر بیمار وجود ندارد.", "APPOINTMENT_HAS_TREATMENT": "به‌دلیل وجود درمان مرتبط با این نوبت، امکان حذف آن وجود ندارد.", "TREATMENT_DETAIL_SENT": "این جزئیات به لابراتوار ارسال شده و دیگر قابل تغییر نیست.", + "TREATMENT_NOT_FOUND": "درمان پیدا نشد.", + "TREATMENT_PATIENT_OR_WALK_IN": "یک بیمار انتخاب کنید یا درمان بدون نوبت بسازید.", + "TREATMENT_NOT_STANDALONE": "فقط درمان‌های بدون نوبت را می‌توان این‌گونه حذف کرد.", + "TREATMENT_HAS_DETAILS": "این درمان جزئیات دارد و قابل حذف نیست.", + "LAB_CASE_NOT_EDITABLE": "این پرونده دیگر قابل ویرایش نیست.", + "LAB_CASE_ALREADY_STARTED": "این پرونده قبلاً شروع شده است.", + "LAB_CASE_LINES_REQUIRED": "حداقل یک خط کار با دندان و نوع پروتز اضافه کنید.", + "LAB_CASE_START_INCOMPLETE": "قبل از شروع پرونده، دندان‌ها و نوع پروتز را کامل کنید.", + "LAB_CASE_CLIENT_REQUIRED": "نام کلینیک یا نام بیمار را وارد کنید.", "BAD_REQUEST": "درخواست قابل پردازش نبود.", "INTERNAL_ERROR": "مشکلی در سرور رخ داد. لطفاً بعداً تلاش کنید." } diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 46c1ecf..83a246e 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -494,7 +494,25 @@ "caseSheetNoComments": "Geen opmerkingen.", "errorDownloadCaseSheet": "Case Sheet PDF kon niet worden gedownload.", "externalCodePlaceholder": "externe code bijv. exocad-code", - "errorUpdateExternalCode": "Externe code kon niet worden opgeslagen." + "errorUpdateExternalCode": "Externe code kon niet worden opgeslagen.", + "addCase": "Case toevoegen", + "addCaseTitle": "Nieuwe case", + "addCaseSubtitle": "Vul de verwijzende kliniek of patiënt in en voeg protheselijnen toe. Start wanneer de case klaar is.", + "startCase": "Starten", + "draftBadge": "Concept", + "referringClinic": "Verwijzende kliniek", + "referringDentist": "Tandarts", + "patientName": "Patiëntnaam", + "partnerClinic": "Gekoppelde kliniek", + "partnerClinicNone": "Geen", + "dueDateField": "Vervaldatum", + "caseLinesTitle": "Werklijnen", + "addLine": "Lijn toevoegen", + "lineChip": "Lijn {n}", + "removeLineAria": "Lijn {n} verwijderen", + "errorCreateCase": "Case kon niet worden aangemaakt.", + "errorSaveCase": "Case kon niet worden opgeslagen.", + "errorStartCase": "Case kon niet worden gestart." }, "labCaseAccess": { "pageTitle": "Dossiertaken", @@ -676,7 +694,16 @@ "showAppointments": "Afspraken tonen", "appointmentsTitle": "Mijn afspraken", "hideAppointments": "Afspraken verbergen", - "emptyDay": "Geen afspraken aan u toegewezen op deze dag.", + "emptyDay": "Geen afspraken of ongeplande behandelingen voor u op deze dag.", + "unscheduledHeading": "Zonder afspraak", + "noAppointment": "Geen afspraak", + "walkIn": "Inloop", + "newTreatment": "Nieuwe behandeling", + "newWalkInTreatment": "Inloopbehandeling", + "errorCreateTreatment": "Behandeling aanmaken is mislukt.", + "deleteEmptyTreatment": "Lege behandeling verwijderen", + "confirmDeleteEmptyTreatment": "Deze behandeling verwijderen? Er zijn nog geen details.", + "errorDeleteTreatment": "Behandeling kon niet worden verwijderd.", "casesTitle": "Behandelcasussen", "casesSubtitle": "Elke case heeft zijn eigen tanden, notities, bijlagen en verzendbestemmingen.", "detailsTitle": "Behandeldetails", @@ -1181,6 +1208,15 @@ "APPOINTMENT_PATIENT_LOCKED": "De patiënt kan niet worden gewijzigd zolang er een behandeling aan deze afspraak is gekoppeld.", "APPOINTMENT_HAS_TREATMENT": "Deze afspraak kan niet worden verwijderd omdat er een behandeling aan is gekoppeld.", "TREATMENT_DETAIL_SENT": "Dit detail is naar het lab verzonden en kan niet meer worden gewijzigd.", + "TREATMENT_NOT_FOUND": "Behandeling niet gevonden.", + "TREATMENT_PATIENT_OR_WALK_IN": "Kies een patiënt of maak een inloopbehandeling.", + "TREATMENT_NOT_STANDALONE": "Alleen behandelingen zonder afspraak kunnen op deze manier worden verwijderd.", + "TREATMENT_HAS_DETAILS": "Deze behandeling heeft details en kan niet worden verwijderd.", + "LAB_CASE_NOT_EDITABLE": "Deze case kan niet meer worden bewerkt.", + "LAB_CASE_ALREADY_STARTED": "Deze case is al gestart.", + "LAB_CASE_LINES_REQUIRED": "Voeg minstens één werkregel toe met tanden en een prothesetype.", + "LAB_CASE_START_INCOMPLETE": "Vul tanden en prothesetypes in voordat u deze case start.", + "LAB_CASE_CLIENT_REQUIRED": "Voer een klinieknaam of een patiëntnaam in voor deze case.", "BAD_REQUEST": "Het verzoek kon niet worden verwerkt.", "INTERNAL_ERROR": "Er is iets misgegaan aan onze kant. Probeer het later opnieuw." } diff --git a/frontend/src/components/treatment/dayStrip.ts b/frontend/src/components/treatment/dayStrip.ts new file mode 100644 index 0000000..41707a2 --- /dev/null +++ b/frontend/src/components/treatment/dayStrip.ts @@ -0,0 +1,14 @@ +export type DayStripItemKind = 'appointment' | 'unscheduled'; + +export type DayStripItem = { + kind: DayStripItemKind; + id: string; + patientId: string; + patientFirstName: string; + patientLastName: string; + patientIsWalkIn: boolean; + colorCode: string; + timeLabel: string | null; + subtitle: string; + canDelete?: boolean; +}; diff --git a/frontend/src/components/ui/lab/CaseCreatePanel.tsx b/frontend/src/components/ui/lab/CaseCreatePanel.tsx new file mode 100644 index 0000000..bc56b50 --- /dev/null +++ b/frontend/src/components/ui/lab/CaseCreatePanel.tsx @@ -0,0 +1,669 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { Trash2 } from 'lucide-react'; +import { getUserFacingError } from '@/components/shared/formatApiError'; +import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles'; +import { prosthesisTypeColorFromCatalog } from '@/components/treatment/prosthesisTypeDisplay'; +import { + applyShiftRange, + deriveTeethFromGroups, + groupsFromFlatTeeth, + linkedEdgesFromGroups, + linkAdjacentTeeth, + normalizeToothSelectionGroups, + pruneToothProsthesisForGroups, + toggleToothInGroups, + toothEdgeKey, + unlinkAdjacentTeeth, + type ToothSelectionGroup, +} from '@/components/treatment/toothSelectionGroups'; +import { AppDateInput } from '@/components/ui/shared/AppDateInput'; +import { Button } from '@/components/ui/shared/Button'; +import { ConnectedSelectionBadge } from '@/components/ui/treatment/ConnectedSelectionBadge'; +import { FdiToothChart } from '@/components/ui/treatment/FdiToothChart'; +import { toDateInputValue } from '@/components/lab/labCaseDueDateDisplay'; +import { casesApi } from '@/lib/api/cases'; +import { prosthesisCatalogApi } from '@/lib/api/prosthesis-catalog'; +import type { LabCaseAttachmentMeta, LabCaseDetail } from '@/types/cases'; +import type { FdiToothId } from '@/types/treatment'; +import type { LinkedOrganizationOption } from '@/types/treatment'; +import type { ProsthesisCatalogEntry } from '@/types/treatment-catalog'; + +const INPUT_CLASS = + 'mt-1 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'; + +type LineDraft = { + clientId: string; + id?: string; + teeth: FdiToothId[]; + toothSelectionGroups: ToothSelectionGroup[]; + comment: string; + toothProsthesis: Array<{ + tooth: string; + prosthesisTypeCode: string; + selectionGroupId?: string; + detailClientId: string; + }>; + attachments: LabCaseAttachmentMeta[]; +}; + +function newLine(): LineDraft { + const clientId = + typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : `line-${Date.now()}`; + return { + clientId, + teeth: [], + toothSelectionGroups: [], + comment: '', + toothProsthesis: [], + attachments: [], + }; +} + +function linesFromDetail(labCase: LabCaseDetail): LineDraft[] { + const source = labCase.lines ?? []; + if (source.length === 0) return [newLine()]; + return source.map((line) => { + const groups = normalizeToothSelectionGroups(line.toothSelectionGroups); + const teeth = (line.teeth as FdiToothId[]) ?? []; + return { + clientId: line.clientId || line.id, + id: line.id, + teeth, + toothSelectionGroups: groups.length ? groups : groupsFromFlatTeeth(teeth), + comment: line.comment ?? '', + toothProsthesis: labCase.toothProsthesis + .filter((tp) => tp.lineId === line.id) + .map((tp) => ({ + tooth: tp.tooth, + prosthesisTypeCode: tp.prosthesisTypeCode, + selectionGroupId: tp.selectionGroupId, + detailClientId: line.clientId || line.id, + })), + attachments: labCase.attachments.filter( + (a) => a.detailClientKey === (line.clientId || line.id), + ), + }; + }); +} + +interface CaseCreatePanelProps { + labCase: LabCaseDetail; + canEdit: boolean; + onSaved: (detail: LabCaseDetail) => void; + onStarted: (detail: LabCaseDetail) => void; + onError: (message: string) => void; +} + +export function CaseCreatePanel({ + labCase, + canEdit, + onSaved, + onStarted, + onError, +}: CaseCreatePanelProps) { + const t = useTranslations('cases'); + const tTreatment = useTranslations('treatment'); + const tErrors = useTranslations('errors'); + const tCommon = useTranslations('common'); + + const [referringClinicName, setReferringClinicName] = useState( + labCase.referringClinicName ?? '', + ); + const [referringDentistName, setReferringDentistName] = useState( + labCase.referringDentistName ?? '', + ); + const [patientDisplayName, setPatientDisplayName] = useState( + labCase.patientDisplayName ?? '', + ); + const [patientDisplayMobile, setPatientDisplayMobile] = useState( + labCase.patientDisplayMobile ?? '', + ); + const [partnerClinicOrganizationId, setPartnerClinicOrganizationId] = useState( + labCase.partnerClinicOrganizationId ?? '', + ); + const [dueDate, setDueDate] = useState(toDateInputValue(labCase.dueDate)); + const [lines, setLines] = useState(() => linesFromDetail(labCase)); + const [activeLineId, setActiveLineId] = useState( + () => linesFromDetail(labCase)[0]?.clientId ?? '', + ); + const [partners, setPartners] = useState([]); + const [prosthesisOptions, setProsthesisOptions] = useState([]); + const [applyAllProsthesis, setApplyAllProsthesis] = useState(''); + const [saving, setSaving] = useState(false); + const [starting, setStarting] = useState(false); + const [uploadBusy, setUploadBusy] = useState(false); + const rangeAnchorRef = useRef(null); + const hydratedIdRef = useRef(labCase.id); + const skipSaveRef = useRef(true); + const startingRef = useRef(false); + const allowPersistWhileStartingRef = useRef(false); + const attachmentInputRef = useRef(null); + + useEffect(() => { + if (hydratedIdRef.current === labCase.id) return; + hydratedIdRef.current = labCase.id; + skipSaveRef.current = true; + const nextLines = linesFromDetail(labCase); + setReferringClinicName(labCase.referringClinicName ?? ''); + setReferringDentistName(labCase.referringDentistName ?? ''); + setPatientDisplayName(labCase.patientDisplayName ?? ''); + setPatientDisplayMobile(labCase.patientDisplayMobile ?? ''); + setPartnerClinicOrganizationId(labCase.partnerClinicOrganizationId ?? ''); + setDueDate(toDateInputValue(labCase.dueDate)); + setLines(nextLines); + setActiveLineId(nextLines[0]?.clientId ?? ''); + }, [labCase]); + + useEffect(() => { + void casesApi.listLinkedClinics().then((r) => setPartners(r.data)).catch(() => undefined); + void prosthesisCatalogApi.list().then((r) => setProsthesisOptions(r.data)).catch(() => undefined); + }, []); + + const activeLine = lines.find((l) => l.clientId === activeLineId) ?? lines[0]; + const groups = + activeLine?.toothSelectionGroups.length + ? activeLine.toothSelectionGroups + : groupsFromFlatTeeth(activeLine?.teeth ?? []); + const selected = new Set(activeLine?.teeth ?? []); + const linkedEdges = linkedEdgesFromGroups(groups); + + const prosthesisRows = groups.map((g) => ({ + groupId: g.groupId, + kind: g.kind, + teeth: g.teeth, + })); + + const toothColors = useMemo(() => { + const colors: Partial> = {}; + for (const tp of activeLine?.toothProsthesis ?? []) { + const color = prosthesisTypeColorFromCatalog(tp.prosthesisTypeCode, prosthesisOptions); + if (color) colors[tp.tooth as FdiToothId] = color; + } + return colors; + }, [activeLine?.toothProsthesis, prosthesisOptions]); + + const buildPayload = useCallback( + () => ({ + referringClinicName: referringClinicName.trim() || null, + referringDentistName: referringDentistName.trim() || null, + patientDisplayName: patientDisplayName.trim() || null, + patientDisplayMobile: patientDisplayMobile.trim() || null, + partnerClinicOrganizationId: partnerClinicOrganizationId || null, + dueDate: dueDate || null, + lines: lines.map((line) => ({ + clientId: line.clientId, + id: line.id, + teeth: line.teeth, + toothSelectionGroups: line.toothSelectionGroups, + comment: line.comment, + toothProsthesis: line.toothProsthesis.map((tp) => ({ + tooth: tp.tooth, + prosthesisTypeCode: tp.prosthesisTypeCode, + selectionGroupId: tp.selectionGroupId, + })), + attachmentIds: line.attachments.map((a) => a.id), + })), + }), + [ + referringClinicName, + referringDentistName, + patientDisplayName, + patientDisplayMobile, + partnerClinicOrganizationId, + dueDate, + lines, + ], + ); + + const persist = useCallback(async () => { + if (startingRef.current && !allowPersistWhileStartingRef.current) { + return null; + } + setSaving(true); + try { + const response = await casesApi.update(labCase.id, buildPayload()); + setLines((prev) => + prev.map((line) => { + const saved = response.data.lines?.find((l) => l.clientId === line.clientId); + return saved ? { ...line, id: saved.id } : line; + }), + ); + onSaved(response.data); + return response.data; + } catch (error: unknown) { + onError(getUserFacingError(error, tErrors, t('errorSaveCase'))); + return null; + } finally { + setSaving(false); + } + }, [buildPayload, labCase.id, onError, onSaved, t, tErrors]); + + const persistRef = useRef(persist); + persistRef.current = persist; + + useEffect(() => { + if (skipSaveRef.current) { + skipSaveRef.current = false; + return; + } + if (!canEdit || startingRef.current) return; + const timeout = setTimeout(() => { + void persistRef.current(); + }, 500); + return () => clearTimeout(timeout); + }, [buildPayload, canEdit]); + + function updateActiveLine(patch: (line: LineDraft) => LineDraft) { + setLines((prev) => + prev.map((line) => (line.clientId === activeLine?.clientId ? patch(line) : line)), + ); + } + + async function handleStart() { + if (!canEdit) return; + setStarting(true); + startingRef.current = true; + skipSaveRef.current = true; + allowPersistWhileStartingRef.current = true; + try { + const saved = await persist(); + allowPersistWhileStartingRef.current = false; + if (!saved) { + startingRef.current = false; + return; + } + skipSaveRef.current = true; + const response = await casesApi.start(labCase.id); + onStarted(response.data); + } catch (error: unknown) { + startingRef.current = false; + onError(getUserFacingError(error, tErrors, t('errorStartCase'))); + } finally { + allowPersistWhileStartingRef.current = false; + setStarting(false); + } + } + + async function handleUpload(files: FileList | null) { + if (!files?.length || !activeLine || !canEdit) return; + setUploadBusy(true); + try { + await persist(); + const response = await casesApi.uploadLineAttachments( + labCase.id, + activeLine.clientId, + Array.from(files), + ); + updateActiveLine((line) => ({ + ...line, + attachments: [...line.attachments, ...response.data], + })); + } catch (error: unknown) { + onError(getUserFacingError(error, tErrors, t('errorSaveCase'))); + } finally { + setUploadBusy(false); + if (attachmentInputRef.current) attachmentInputRef.current.value = ''; + } + } + + const disabled = !canEdit || starting; + + return ( +
+
+
+

{t('addCaseTitle')}

+

{t('addCaseSubtitle')}

+
+ +
+ +
+ + + + + + +
+ +
+

{t('caseLinesTitle')}

+ +
+ +
+ {lines.map((line, idx) => { + const isActive = line.clientId === activeLine?.clientId; + return ( +
+ + {lines.length > 1 ? ( + + ) : null} +
+ ); + })} +
+ + {activeLine ? ( + <> + { + if (disabled) return; + const currentGroups = + activeLine.toothSelectionGroups.length > 0 + ? activeLine.toothSelectionGroups + : groupsFromFlatTeeth(activeLine.teeth); + let nextGroups: ToothSelectionGroup[] | null = null; + if (event.shiftKey) { + const anchor = rangeAnchorRef.current; + if (!anchor || anchor === fdi) { + rangeAnchorRef.current = fdi; + return; + } + nextGroups = applyShiftRange(currentGroups, anchor, fdi); + rangeAnchorRef.current = fdi; + } else { + nextGroups = toggleToothInGroups(currentGroups, fdi); + rangeAnchorRef.current = fdi; + } + if (!nextGroups) return; + updateActiveLine((line) => ({ + ...line, + toothSelectionGroups: nextGroups!, + teeth: deriveTeethFromGroups(nextGroups!), + toothProsthesis: pruneToothProsthesisForGroups( + line.toothProsthesis, + line.clientId, + nextGroups!, + ), + })); + }} + onToggleLink={(a, b) => { + if (disabled) return; + const currentGroups = + activeLine.toothSelectionGroups.length > 0 + ? activeLine.toothSelectionGroups + : groupsFromFlatTeeth(activeLine.teeth); + const edgeLinked = linkedEdgesFromGroups(currentGroups).has(toothEdgeKey(a, b)); + const nextGroups = edgeLinked + ? unlinkAdjacentTeeth(currentGroups, a, b) + : linkAdjacentTeeth(currentGroups, a, b); + if (!nextGroups) return; + updateActiveLine((line) => ({ + ...line, + toothSelectionGroups: nextGroups, + teeth: deriveTeethFromGroups(nextGroups), + toothProsthesis: pruneToothProsthesisForGroups( + line.toothProsthesis, + line.clientId, + nextGroups, + ), + })); + }} + /> + + {prosthesisRows.length > 0 ? ( +
+

+ {tTreatment('prosthesisTypesTitle')} +

+ {prosthesisRows.every((r) => r.kind === 'single') && + prosthesisRows.reduce((sum, r) => sum + r.teeth.length, 0) > 1 ? ( + + ) : null} +
+ {prosthesisRows.map((row) => { + const current = + activeLine.toothProsthesis.find( + (tp) => + tp.selectionGroupId === row.groupId && + (row.teeth as string[]).includes(tp.tooth), + )?.prosthesisTypeCode ?? + activeLine.toothProsthesis.find((tp) => + (row.teeth as string[]).includes(tp.tooth), + )?.prosthesisTypeCode ?? + ''; + return ( + + ); + })} +
+
+ ) : null} + +