From f28cd06615061b51cb5fbdf215d666945bb400a6 Mon Sep 17 00:00:00 2001 From: Admin Date: Mon, 13 Jul 2026 01:03:26 +0330 Subject: [PATCH] improvement: treatment UX fully overhauled. --- .cursor/rules/treatment-workspace.mdc | 40 ++ .cursor/skills/treatment-workspace/SKILL.md | 190 +++++++++ AGENTS.md | 8 + .../modules/treatments/treatments.service.ts | 31 +- frontend/messages/en.json | 26 +- frontend/messages/fa.json | 26 +- frontend/messages/nl.json | 26 +- .../src/app/[locale]/(dashboard)/layout.tsx | 2 +- .../src/components/shared/scrollWithinMain.ts | 28 ++ .../treatment/labDispatchAttention.ts | 67 +++ .../treatment/treatmentDetailRules.ts | 106 +++++ .../treatment/treatmentHistoryFilters.ts | 49 +++ .../treatment/treatmentStatusStyles.ts | 3 + .../ui/organizations/OrganizationsPage.tsx | 12 + .../DetailLabCaseCommentsSection.tsx | 46 +++ .../ui/treatment/DetailLabSendBadge.tsx | 3 +- .../ui/treatment/LabCasesDispatchPanel.tsx | 62 ++- .../treatment/LabDispatchAttentionPanel.tsx | 106 +++++ .../ui/treatment/LabShipmentBlockedNotice.tsx | 20 + .../LinkedOrganizationSearchCombobox.tsx | 109 +++++ .../ui/treatment/PastTreatmentsPanel.tsx | 131 +++++- .../treatment/TreatmentDetailSummaryRow.tsx | 15 +- .../ui/treatment/TreatmentDetailsEditor.tsx | 39 +- .../treatment/TreatmentHistoryDetailLine.tsx | 13 +- .../ui/treatment/TreatmentPreviewCard.tsx | 32 +- .../ui/treatment/TreatmentWorkspace.tsx | 385 ++++++++++++------ frontend/src/types/treatment.ts | 7 + 27 files changed, 1370 insertions(+), 212 deletions(-) create mode 100644 .cursor/rules/treatment-workspace.mdc create mode 100644 .cursor/skills/treatment-workspace/SKILL.md create mode 100644 frontend/src/components/shared/scrollWithinMain.ts create mode 100644 frontend/src/components/treatment/labDispatchAttention.ts create mode 100644 frontend/src/components/treatment/treatmentDetailRules.ts create mode 100644 frontend/src/components/treatment/treatmentHistoryFilters.ts create mode 100644 frontend/src/components/ui/treatment/DetailLabCaseCommentsSection.tsx create mode 100644 frontend/src/components/ui/treatment/LabDispatchAttentionPanel.tsx create mode 100644 frontend/src/components/ui/treatment/LabShipmentBlockedNotice.tsx create mode 100644 frontend/src/components/ui/treatment/LinkedOrganizationSearchCombobox.tsx diff --git a/.cursor/rules/treatment-workspace.mdc b/.cursor/rules/treatment-workspace.mdc new file mode 100644 index 0000000..fbe4e17 --- /dev/null +++ b/.cursor/rules/treatment-workspace.mdc @@ -0,0 +1,40 @@ +--- + +description: Treatment workspace — preview vs form, history, load flow, lab dispatch + +globs: frontend/src/components/ui/treatment/**,frontend/src/components/treatment/**,frontend/src/components/shared/treatmentSelection.ts,frontend/src/components/shared/scrollWithinMain.ts,backend/src/modules/treatments/** + +alwaysApply: false + +--- + + + +# Treatment workspace + + + +- **Browse mode** (`selectedPreviewId` set): preview only; banner + **Load into workspace**; form unchanged until load. + +- **Current draft** preview: heading “Current draft”; no load button while editing live. + +- **Lab dispatch attention:** `LabDispatchAttentionPanel` — unsent lab-dependent details; quick jump to dispatch. + +- **History API:** patient-scoped; non-owners filtered by provider on treatment or appointment; org owners see all. + +- **History filters (client-side):** `PastTreatmentsPanel` — “Not shipped to lab” + single date; helpers in `treatmentHistoryFilters.ts`. No new fetch params. + +- **Lab shipment block:** prosthesis/lab-dependent detail with no teeth saves but cannot ship — `LabShipmentBlockedNotice`, inline banner, toast on add shipment. + +- **Lab case comments:** `DetailLabCaseCommentsSection` under detail when sent and lab tasks not all complete (`taskProgress` from API). + +- **Today checkbox:** unchecked when viewing a non-today day; checking Today unlocks selection and auto-picks nearest appointment. + +- **Lab search:** `LinkedOrganizationSearchCombobox` — select from results; invite lab via `/organizations?action=invite-lab` when permitted. + +- **Scroll:** use `scrollWithinMainScrollContainer`; shared `Checkbox` only. + + + +Full map: `.cursor/skills/treatment-workspace/SKILL.md` + diff --git a/.cursor/skills/treatment-workspace/SKILL.md b/.cursor/skills/treatment-workspace/SKILL.md new file mode 100644 index 0000000..9b5ad29 --- /dev/null +++ b/.cursor/skills/treatment-workspace/SKILL.md @@ -0,0 +1,190 @@ +--- + +name: dyolink-treatment-workspace + +description: Treatment tab workspace — appointments strip, preview vs form, history, load flow, draft autosave, lab dispatch. Use when changing treatment UX, preview/history, or lab dispatch in TreatmentWorkspace. + +--- + + + +# Treatment workspace + + + +Main orchestrator: `frontend/src/components/ui/treatment/TreatmentWorkspace.tsx` + +Thin route: `app/[locale]/(dashboard)/treatment/page.tsx` (supports `?appointmentId=`). + + + +## Layout (top → bottom) + + + +1. **Appointments strip** — `AppointmentsStrip.tsx` + `ScheduleDayPicker.tsx` (Today checkbox) + `pickAutoAppointment()` in `components/shared/treatmentSelection.ts` + +2. **Treatment preview** — `TreatmentPreviewCard.tsx` (read-only summary; no load button for current draft) + +3. **Treatment history** — `PastTreatmentsPanel.tsx` (past saved plans for patient; **client-side** filters in `treatmentHistoryFilters.ts`) + +4. **Editor** — `TreatmentDetailsEditor.tsx`, `FdiToothChart.tsx`, `LabCasesDispatchPanel.tsx` + + + +## Two layers of state (critical) + + + +| Layer | State | Updated when | + +|-------|--------|--------------| + +| **Preview** | `previewTreatment`; `selectedPreviewId !== null` = **browse mode** | History click updates preview only | + +| **Form** | `details[]`, `labCaseDrafts[]` | Appointment change → draft API; **Load into workspace** → hydrate | + + + +**Browse mode:** banner + “Load into workspace” / “Back to current draft”. No Open button on preview card. + + + +**Lab dispatch attention:** `LabDispatchAttentionPanel` lists lab-dependent unsent details (current draft + user history). “Go to dispatch” / “Load & dispatch”. + + + +## History API + + + +`GET /treatments/patients/:id/history` returns saved treatments for **that patient** (not the whole day’s schedule). Non-owners see plans where `Treatment.providerUserId` or linked `Appointment.providerUserId` matches the logged-in user; org owners see all saved plans for the patient. New saves set `Treatment.providerUserId` to the logged-in clinician. + + + +## History filters (client-side only) + + + +`PastTreatmentsPanel` filters **already-fetched** history — no extra API params. + +- **Not shipped to lab** — show treatments that have at least one lab-dependent detail (prosthesis via `labDependentCodes`) with `!sentAt`. +- **Date** — filter on `treatmentAt` matching that local calendar day. +- When **not shipped** is on and workspace is live (not browsing), prepend a synthetic **current draft** row (`id: 'current-draft'`) if it has pending lab-dependent details. + +Helpers: `frontend/src/components/treatment/treatmentHistoryFilters.ts`. + + + +## Lab shipment without teeth + + + +Saved lab-dependent detail with **no teeth** can autosave but **cannot** create a lab shipment. + +- Inline banner in `TreatmentDetailsEditor` + `LabShipmentBlockedNotice` above dispatch when active detail qualifies (`isLabDependentDetailMissingTeeth`). +- `handleAddLabCase` shows toast with `labShipmentBlockedBody`. +- Dispatch panel only appears when a detail passes `isDetailReadyForLabDispatch` (persisted + lab-dependent + teeth). + + + +## Lab case comments on details + + + +Below each detail in the editor when the linked lab case is **in progress** (not all tasks completed): + +- `canCommentOnDetailLabCase(detail)` — requires `sentAt`, `labCaseId`, and `!isLabCaseCompleted(taskProgress)`. +- UI: `DetailLabCaseCommentsSection` → existing `LabCaseCommentsPanel` + `treatmentsApi` comment endpoints. +- Backend includes `tasks: { select: { id, status } }` on lab cases; `mapDetail` exposes `taskProgress: { completed, total }`. + + + +## Appointments default selection + + + +On today: in-progress slot first, else nearest start time to `now`. Other days: first appointment. Re-runs every 60s on today unless `selectionLocked`. Frontend filters appointments to `providerUserId === userId`. + + + +**Today checkbox** (`ScheduleDayPicker`): unchecked when `selectedDay` is not today (e.g. after loading a historical treatment). Checking Today calls `onSelectDay(today)` which unlocks selection (`selectionLocked = false`) and resets browse/historical context; appointments reload and auto-select nearest to now. + + + +## Lab org search (dispatch) + + + +`LinkedOrganizationSearchCombobox` in `LabCasesDispatchPanel` — search-only results (no dropdown). No match + org tab access → **Invite a lab** navigates to `/organizations?action=invite-lab`. No org access → show permission message; dispatch stops. + + + +Pattern mirrors `PatientSearchCombobox` in appointments. + + + +## Scroll + + + +Use `scrollWithinMainScrollContainer()` (not raw `scrollIntoView`) when jumping to lab dispatch panel — dashboard `
` is the scroll container; document scroll conflicts with `.app-web-bg { overflow: hidden }`. + + + +Use shared `Checkbox` (not native ``) to avoid focus-driven scroll jumps. + + + +## Backend APIs + + + +| Endpoint | Purpose | + +|----------|---------| + +| `GET /appointments?from&to` | Strip | + +| `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) | + + + +Draft writes require provider match (`ensureAppointmentProvider`) unless org owner. + + + +## Edit gating + + + +```typescript + +canEditTreatmentForDay = canEdit && selectedAppointment && !isViewingPastDay && workspaceMode === 'live' + +``` + + + +## Optional fields + + + +- `@IsOptional()` email: use `@Transform` empty string → `undefined` before `@IsEmail` (see patients DTO). + +- Form validation → inline errors; transient feedback → global `useToast()` via `ToastProvider`. + + + +## When changing history scope + + + +Filter in **backend** `listPatientHistory` on patient + org; provider scoping for non-owners. History is **per selected patient**, not per day or all appointments on the strip. + +**UI filters** (not shipped, date) are client-side only — do not add API params unless product explicitly requires server-side filtering. + diff --git a/AGENTS.md b/AGENTS.md index a229352..62e2675 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,13 @@ frontend/src/ **Example thin page:** `app/.../treatment/page.tsx` → imports `TreatmentWorkspace` from `components/ui/treatment/`. +**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 lab rules (quick ref):** +- Lab-dependent details (e.g. prosthesis) **without teeth** can save but **cannot ship** — show `LabShipmentBlockedNotice` + inline banner; toast on dispatch add. +- **History filters** are client-side only (`treatmentHistoryFilters.ts`): “Not shipped to lab” + single date on already-fetched patient history; includes live current draft when filtering. +- **Lab case comments** on a detail when sent and lab case tasks are not all `COMPLETED` (`taskProgress` from API). + ## Backend layout ``` @@ -59,6 +66,7 @@ Errors: `AppException` + `ErrorCode` → frontend `getUserFacingError()`. Never | Skill | When to use | |-------|-------------| | `.cursor/skills/add-feature/` | New tab, API module, or end-to-end feature | +| `.cursor/skills/treatment-workspace/` | Treatment tab: preview vs form, history, load flow, drafts | | `.cursor/skills/frontend-structure/` | Moving components, auditing folder layout | | `.cursor/skills/api-errors/` | New backend errors + frontend translations | diff --git a/backend/src/modules/treatments/treatments.service.ts b/backend/src/modules/treatments/treatments.service.ts index c749b9b..8f11505 100644 --- a/backend/src/modules/treatments/treatments.service.ts +++ b/backend/src/modules/treatments/treatments.service.ts @@ -4,7 +4,7 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { LinkStatus } from '@prisma/client'; +import { LabTaskStatus, LinkStatus } from '@prisma/client'; import { createReadStream, existsSync, mkdirSync } from 'fs'; import { join } from 'path'; import { randomUUID } from 'crypto'; @@ -36,6 +36,7 @@ const treatmentInclude = { orderBy: [{ sentAt: 'asc' as const }], include: { organization: { select: { id: true, name: true } } }, }, + tasks: { select: { id: true, status: true } }, }, }, }, @@ -64,6 +65,7 @@ const treatmentInclude = { }, }, }, + tasks: { select: { id: true, status: true } }, }, }, }; @@ -124,14 +126,25 @@ export class TreatmentsService { await this.assertCanReadTreatment(actorUserId, organizationId); await this.ensurePatientExists(patientId); + const membership = await this.getMembership(actorUserId, organizationId); + const isOwner = membership?.isOwner ?? false; + const items = await this.prisma.treatment.findMany({ where: { patientId, organizationId, details: { some: {} }, + ...(isOwner + ? {} + : { + OR: [ + { providerUserId: actorUserId }, + { appointment: { is: { providerUserId: actorUserId } } }, + ], + }), }, include: treatmentInclude, - orderBy: [{ treatmentAt: 'desc' }], + orderBy: [{ treatmentAt: 'desc' }, { createdAt: 'desc' }], take: Math.min(Math.max(limit, 1), 100), }); @@ -205,7 +218,7 @@ export class TreatmentsService { title, treatmentAt: appointment.startAt, patientId: appointment.patientId, - providerUserId: appointment.providerUserId, + providerUserId: actorUserId, }, }) : await tx.treatment.create({ @@ -213,7 +226,7 @@ export class TreatmentsService { organizationId, patientId: appointment.patientId, appointmentId: appointment.id, - providerUserId: appointment.providerUserId, + providerUserId: actorUserId, title, treatmentAt: appointment.startAt, }, @@ -566,6 +579,7 @@ export class TreatmentsService { include: { organization: { select: { id: true, name: true } } }, }, toothProsthesis: true, + tasks: { select: { id: true, status: true } }, }, }); @@ -758,10 +772,12 @@ export class TreatmentsService { sentAt: Date; organization?: { id: string; name: string }; }>; + tasks?: Array<{ id: string; status: LabTaskStatus }>; }; } | null; }) { const labCase = d.labCaseLink?.labCase; + const taskProgress = this.mapTaskProgress(labCase?.tasks ?? []); return { id: d.id, clientId: d.clientKey ?? d.id, @@ -772,6 +788,7 @@ export class TreatmentsService { labCaseId: labCase?.id ?? null, sentAt: labCase?.sentAt?.toISOString() ?? null, destinationOrganizationId: labCase?.destinationOrganizationId ?? null, + taskProgress, sends: labCase?.sends.map((s) => ({ organizationId: s.organizationId, @@ -846,6 +863,12 @@ export class TreatmentsService { }; } + private mapTaskProgress(tasks: Array<{ status: LabTaskStatus }>) { + const total = tasks.length; + const completed = tasks.filter((task) => task.status === LabTaskStatus.COMPLETED).length; + return { completed, total }; + } + private mapAttachment(a: { id: string; fileName: string; diff --git a/frontend/messages/en.json b/frontend/messages/en.json index c9bd7c3..fc13a73 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -618,6 +618,8 @@ "comments": "Comments", "commentsPlaceholder": "Write clinical notes for this case…", "treatmentType": "Treatment type", + "treatmentTypePlaceholder": "Select treatment type…", + "treatmentTypeNotSelected": "Type not selected", "typeConsultation": "consultation", "typeFilling": "filling", "typeEndo": "endo", @@ -630,6 +632,8 @@ "searchOrgsPlaceholder": "Search active organizations...", "recent": "Recent:", "noOrgMatch": "No active organization matches your search.", + "inviteLab": "Invite a lab", + "noOrgInvitePermission": "You do not have permission to invite labs. Contact your organization owner.", "sendThisCase": "Send this case", "labDispatchTitle": "Lab dispatch", "labDispatchSubtitle": "Group lab-dependent details into shipments and send them to linked labs.", @@ -663,10 +667,30 @@ "saveStatusError": "Could not save — check your connection", "sendSavesFirst": "Sending is per case and saves first automatically.", "historyTitle": "Previous treatments", - "historySubtitle": "Click a treatment to preview it. Use Open in the preview card to load it in the workspace.", + "historyPatientScope": "Previous treatments for {patientName}", + "historyCurrentAppointment": "This appointment", + "historySubtitle": "Click a past plan to preview it, then load it into the workspace if needed.", + "historyFilterNotShipped": "Not shipped to lab", + "historyFilterDate": "Date", + "historyClearFilters": "Remove filters", + "historyFilterEmpty": "No treatments match these filters.", + "labShipmentBlockedTitle": "Lab shipment not available yet", + "labShipmentBlockedBody": "Select at least one tooth on this prosthesis detail before you can create a lab shipment.", + "labCaseCommentsTitle": "Lab case comments", + "labCaseCommentsHint": "Message the lab while this case is still in progress. Comments close when all lab tasks are completed.", "loadingHistory": "Loading history…", "historyEmpty": "No other treatments recorded for this patient yet.", "historyDetailLabel": "Detail {n} · {type}", + "previewCurrentDraft": "Current draft", + "previewBrowsingTitle": "Previewing saved plan", + "browseBanner": "Viewing {date} — this plan is not loaded in the editor yet.", + "loadIntoWorkspace": "Load into workspace", + "backToCurrentDraft": "Back to current draft", + "labAttentionTitle": "Lab dispatch needed", + "labAttentionSubtitle": "These lab-dependent details have not been sent to a lab yet.", + "labAttentionCurrentDraft": "Current appointment", + "labAttentionGoDispatch": "Go to dispatch", + "labAttentionLoadDispatch": "Load & dispatch", "previewTitle": "Treatment preview", "openTreatment": "Open", "selectAppointment": "Select an appointment to preview its treatment.", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 2d81f21..c9ed74f 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -619,6 +619,8 @@ "comments": "نظرات", "commentsPlaceholder": "یادداشت‌های بالینی این پرونده را بنویسید...", "treatmentType": "نوع درمان", + "treatmentTypePlaceholder": "نوع درمان را انتخاب کنید…", + "treatmentTypeNotSelected": "نوع انتخاب نشده", "typeConsultation": "مشاوره", "typeFilling": "پر کردن", "typeEndo": "درمان ریشه", @@ -631,6 +633,8 @@ "searchOrgsPlaceholder": "جستجوی سازمان‌های فعال...", "recent": "اخیر:", "noOrgMatch": "هیچ سازمان فعالی با جستجوی شما مطابقت ندارد.", + "inviteLab": "دعوت از آزمایشگاه", + "noOrgInvitePermission": "شما مجوز دعوت از آزمایشگاه را ندارید. با مالک سازمان تماس بگیرید.", "sendThisCase": "ارسال این پرونده", "labDispatchTitle": "ارسال به لابراتوار", "labDispatchSubtitle": "جزئیات وابسته به لاب را در محموله‌ها گروه‌بندی کرده و به لابراتوارهای متصل ارسال کنید.", @@ -664,10 +668,30 @@ "saveStatusError": "ذخیره ناموفق بود — اتصال را بررسی کنید", "sendSavesFirst": "ارسال برای هر پرونده به صورت جداگانه است و ابتدا به طور خودکار ذخیره می‌کند.", "historyTitle": "درمان‌های قبلی", - "historySubtitle": "برای پیش‌نمایش روی یک درمان کلیک کنید. از دکمه باز کردن در کارت پیش‌نمایش برای بارگذاری در فضای کاری استفاده کنید.", + "historyPatientScope": "درمان‌های قبلی برای {patientName}", + "historyCurrentAppointment": "این نوبت", + "historySubtitle": "برای پیش‌نمایش روی یک طرح قبلی کلیک کنید، سپس در صورت نیاز آن را در فضای کار بارگذاری کنید.", + "historyFilterNotShipped": "ارسال‌نشده به لابراتوار", + "historyFilterDate": "تاریخ", + "historyClearFilters": "حذف فیلترها", + "historyFilterEmpty": "هیچ درمانی با این فیلترها یافت نشد.", + "labShipmentBlockedTitle": "ارسال به لابراتوار هنوز ممکن نیست", + "labShipmentBlockedBody": "قبل از ایجاد ارسال لابراتوار، حداقل یک دندان برای این جزئیات پروتز انتخاب کنید.", + "labCaseCommentsTitle": "نظرات پرونده لابراتوار", + "labCaseCommentsHint": "تا زمانی که پرونده در لابراتوار در حال انجام است با لابراتوار پیام بگذارید. پس از تکمیل همه کارها، نظردهی بسته می‌شود.", "loadingHistory": "در حال بارگذاری تاریخچه...", "historyEmpty": "هیچ درمان دیگری برای این بیمار ثبت نشده است.", "historyDetailLabel": "جزئیات {n} · {type}", + "previewCurrentDraft": "پیش‌نویس فعلی", + "previewBrowsingTitle": "پیش‌نمایش طرح ذخیره‌شده", + "browseBanner": "در حال مشاهده {date} — این طرح هنوز در ویرایشگر بارگذاری نشده است.", + "loadIntoWorkspace": "بارگذاری در فضای کاری", + "backToCurrentDraft": "بازگشت به پیش‌نویس فعلی", + "labAttentionTitle": "نیاز به ارسال به لابراتوار", + "labAttentionSubtitle": "این جزئیات وابسته به لاب هنوز به لابراتوار ارسال نشده‌اند.", + "labAttentionCurrentDraft": "نوبت فعلی", + "labAttentionGoDispatch": "رفتن به ارسال", + "labAttentionLoadDispatch": "بارگذاری و ارسال", "previewTitle": "پیش‌نمایش درمان", "openTreatment": "باز کردن", "selectAppointment": "یک نوبت را برای پیش‌نمایش درمان انتخاب کنید.", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index a4d98ed..f9bf5fd 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -619,6 +619,8 @@ "comments": "Opmerkingen", "commentsPlaceholder": "Schrijf klinische notities voor deze case...", "treatmentType": "Behandeltype", + "treatmentTypePlaceholder": "Selecteer behandeltype…", + "treatmentTypeNotSelected": "Type niet geselecteerd", "typeConsultation": "consult", "typeFilling": "vulling", "typeEndo": "endo", @@ -631,6 +633,8 @@ "searchOrgsPlaceholder": "Zoek actieve organisaties...", "recent": "Recent:", "noOrgMatch": "Geen actieve organisatie komt overeen met uw zoekopdracht.", + "inviteLab": "Lab uitnodigen", + "noOrgInvitePermission": "U heeft geen toestemming om labs uit te nodigen. Neem contact op met de organisatie-eigenaar.", "sendThisCase": "Verzend deze case", "labDispatchTitle": "Lab-dispatch", "labDispatchSubtitle": "Groepeer lab-afhankelijke details in zendingen en stuur ze naar gekoppelde labs.", @@ -664,10 +668,30 @@ "saveStatusError": "Opslaan mislukt — controleer uw verbinding", "sendSavesFirst": "Verzenden is per case en slaat eerst automatisch op.", "historyTitle": "Eerdere behandelingen", - "historySubtitle": "Klik op een behandeling om te bekijken. Gebruik Open in de voorbeeldkkaart om deze in de werkruimte te laden.", + "historyPatientScope": "Eerdere behandelingen voor {patientName}", + "historyCurrentAppointment": "Deze afspraak", + "historySubtitle": "Klik op een eerdere planning voor een voorbeeld; laad deze indien nodig in de werkruimte.", + "historyFilterNotShipped": "Niet naar lab verzonden", + "historyFilterDate": "Datum", + "historyClearFilters": "Filters verwijderen", + "historyFilterEmpty": "Geen behandelingen komen overeen met deze filters.", + "labShipmentBlockedTitle": "Labverzending nog niet beschikbaar", + "labShipmentBlockedBody": "Selecteer minstens één tand voor dit prothesedetail voordat u een labverzending kunt aanmaken.", + "labCaseCommentsTitle": "Opmerkingen labcase", + "labCaseCommentsHint": "Stuur berichten naar het lab terwijl deze case nog in behandeling is. Opmerkingen sluiten wanneer alle labtaken zijn afgerond.", "loadingHistory": "Geschiedenis laden...", "historyEmpty": "Geen andere behandelingen voor deze patiënt geregistreerd.", "historyDetailLabel": "Detail {n} · {type}", + "previewCurrentDraft": "Huidig concept", + "previewBrowsingTitle": "Opgeslagen planning bekijken", + "browseBanner": "U bekijkt {date} — deze planning is nog niet in de editor geladen.", + "loadIntoWorkspace": "In werkruimte laden", + "backToCurrentDraft": "Terug naar huidig concept", + "labAttentionTitle": "Labverzending nodig", + "labAttentionSubtitle": "Deze lab-afhankelijke details zijn nog niet naar een lab verzonden.", + "labAttentionCurrentDraft": "Huidige afspraak", + "labAttentionGoDispatch": "Naar verzending", + "labAttentionLoadDispatch": "Laden & verzenden", "previewTitle": "Behandelvoorbeeld", "openTreatment": "Openen", "selectAppointment": "Selecteer een afspraak om de behandeling te bekijken.", diff --git a/frontend/src/app/[locale]/(dashboard)/layout.tsx b/frontend/src/app/[locale]/(dashboard)/layout.tsx index b6d836b..d16b2c1 100644 --- a/frontend/src/app/[locale]/(dashboard)/layout.tsx +++ b/frontend/src/app/[locale]/(dashboard)/layout.tsx @@ -90,7 +90,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod onOpenSidebar={() => setSidebarOpen(true)} /> -
+
{children}
diff --git a/frontend/src/components/shared/scrollWithinMain.ts b/frontend/src/components/shared/scrollWithinMain.ts new file mode 100644 index 0000000..012d80c --- /dev/null +++ b/frontend/src/components/shared/scrollWithinMain.ts @@ -0,0 +1,28 @@ +/** + * Scroll an element into view using the dashboard `
` scroll container only. + * Avoids document-level scrollIntoView, which conflicts with `.app-web-bg { overflow: hidden }`. + */ +export function scrollWithinMainScrollContainer( + element: HTMLElement | null, + options?: { behavior?: ScrollBehavior; padding?: number }, +): void { + if (!element) return; + + const behavior = options?.behavior ?? 'smooth'; + const padding = options?.padding ?? 16; + const main = element.closest('main'); + + if (!main) { + element.scrollIntoView({ behavior, block: 'nearest' }); + return; + } + + const mainRect = main.getBoundingClientRect(); + const elRect = element.getBoundingClientRect(); + + if (elRect.top < mainRect.top + padding) { + main.scrollBy({ top: elRect.top - mainRect.top - padding, behavior }); + } else if (elRect.bottom > mainRect.bottom - padding) { + main.scrollBy({ top: elRect.bottom - mainRect.bottom + padding, behavior }); + } +} diff --git a/frontend/src/components/treatment/labDispatchAttention.ts b/frontend/src/components/treatment/labDispatchAttention.ts new file mode 100644 index 0000000..45afe3d --- /dev/null +++ b/frontend/src/components/treatment/labDispatchAttention.ts @@ -0,0 +1,67 @@ +import type { PastTreatment, PastTreatmentDetail } from '@/types/treatment'; +import { isDetailEligibleForLabAttention } from '@/components/treatment/treatmentDetailRules'; + +export type LabDispatchAttentionItem = { + key: string; + treatmentId: string; + appointmentId: string; + treatmentAt: string; + detailClientId: string; + detailNumber: number; + detail: PastTreatmentDetail; + isCurrentDraft: boolean; +}; + +function pushPendingDetails( + items: LabDispatchAttentionItem[], + treatment: PastTreatment, + labDependentCodes: Set, + isCurrentDraft: boolean, +) { + if (!treatment.appointmentId) { + return; + } + + treatment.details.forEach((detail, index) => { + if ( + !isDetailEligibleForLabAttention(detail, labDependentCodes, isCurrentDraft) + ) { + return; + } + items.push({ + key: `${treatment.id}-${detail.clientId ?? detail.id}`, + treatmentId: treatment.id, + appointmentId: treatment.appointmentId!, + treatmentAt: treatment.treatmentAt, + detailClientId: detail.clientId ?? detail.id, + detailNumber: index + 1, + detail, + isCurrentDraft, + }); + }); +} + +/** Lab-dependent details that still need a lab send (current draft + saved history). */ +export function collectLabDispatchAttention( + labDependentCodes: Set, + currentDraft: PastTreatment | null, + history: PastTreatment[], + currentAppointmentId: string | null, +): LabDispatchAttentionItem[] { + const items: LabDispatchAttentionItem[] = []; + + if (currentDraft) { + pushPendingDetails(items, currentDraft, labDependentCodes, true); + } + + for (const treatment of history) { + if (currentAppointmentId && treatment.appointmentId === currentAppointmentId) { + continue; + } + pushPendingDetails(items, treatment, labDependentCodes, false); + } + + return items.sort( + (a, b) => new Date(b.treatmentAt).getTime() - new Date(a.treatmentAt).getTime(), + ); +} diff --git a/frontend/src/components/treatment/treatmentDetailRules.ts b/frontend/src/components/treatment/treatmentDetailRules.ts new file mode 100644 index 0000000..db8ff79 --- /dev/null +++ b/frontend/src/components/treatment/treatmentDetailRules.ts @@ -0,0 +1,106 @@ +import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; +import type { TreatmentDetailDraft } from '@/types/treatment'; + +export type LabCaseTaskProgress = { + completed: number; + total: number; +}; + +type DetailLike = { + id?: string; + treatmentType: string; + teeth: readonly string[]; + comment?: string; + sentAt?: string | null; + labCaseId?: string | null; + taskProgress?: LabCaseTaskProgress | null; +}; +export function isAppointmentOnlyPurpose( + purpose: string | undefined, + catalog: TreatmentCatalogEntry[], +): boolean { + if (!purpose) return false; + const entry = catalog.find((e) => e.code === purpose); + return entry ? !entry.availableInTreatment : false; +} + +/** Default detail type from appointment purpose — only when purpose is a real treatment type. */ +export function defaultTreatmentTypeForAppointment( + purpose: string | undefined, + catalog: TreatmentCatalogEntry[], +): string | undefined { + if (!purpose || isAppointmentOnlyPurpose(purpose, catalog)) { + return undefined; + } + const treatmentOptions = catalog.filter((entry) => entry.availableInTreatment); + if (treatmentOptions.some((entry) => entry.code === purpose)) { + return purpose; + } + return undefined; +} + +export function isDetailTypeSelected(detail: Pick): boolean { + return Boolean(detail.treatmentType?.trim()); +} + +export function isEmptyDraftDetail(detail: TreatmentDetailDraft): boolean { + return ( + !isDetailTypeSelected(detail) && + detail.teeth.length === 0 && + !detail.comment.trim() + ); +} + +export function areDetailsPersistable(details: TreatmentDetailDraft[]): boolean { + return details.length > 0 && details.every((d) => isDetailTypeSelected(d)); +} + +/** Lab dispatch UI applies only to persisted prosthesis (or lab-dependent) lines with teeth. */ +export function isDetailReadyForLabDispatch( + detail: DetailLike, + labDependentCodes: Set, +): boolean { + return ( + Boolean(detail.id) && + isDetailTypeSelected(detail) && + labDependentCodes.has(detail.treatmentType) && + detail.teeth.length > 0 + ); +} + +export function isDetailEligibleForLabAttention( + detail: DetailLike, + labDependentCodes: Set, + isCurrentDraft: boolean, +): boolean { + if (detail.sentAt || !isDetailTypeSelected(detail)) return false; + if (!labDependentCodes.has(detail.treatmentType)) return false; + if (detail.teeth.length === 0) return false; + if (isCurrentDraft && !detail.id) return false; + return true; +} + +/** Saved lab-dependent line with prosthesis type but no teeth — can save, cannot ship. */ +export function isLabDependentDetailMissingTeeth( + detail: DetailLike, + labDependentCodes: Set, +): boolean { + return ( + Boolean(detail.id) && + isDetailTypeSelected(detail) && + labDependentCodes.has(detail.treatmentType) && + detail.teeth.length === 0 && + !detail.sentAt + ); +} + +export function isLabCaseCompleted(progress: LabCaseTaskProgress | null | undefined): boolean { + if (!progress || progress.total <= 0) return false; + return progress.completed >= progress.total; +} + +/** Detail was sent to lab and the lab case still has open tasks. */ +export function canCommentOnDetailLabCase(detail: DetailLike): boolean { + if (!detail.sentAt || !detail.labCaseId) return false; + return !isLabCaseCompleted(detail.taskProgress); +} diff --git a/frontend/src/components/treatment/treatmentHistoryFilters.ts b/frontend/src/components/treatment/treatmentHistoryFilters.ts new file mode 100644 index 0000000..d4871dc --- /dev/null +++ b/frontend/src/components/treatment/treatmentHistoryFilters.ts @@ -0,0 +1,49 @@ +import { startOfLocalDay } from '@/components/appointments/appointmentTime'; +import type { PastTreatment } from '@/types/treatment'; + +export type TreatmentHistoryFilters = { + notShippedOnly: boolean; + date: string; +}; + +export function treatmentHasUnshippedProsthesis( + treatment: PastTreatment, + labDependentCodes: Set, +): boolean { + return treatment.details.some( + (detail) => labDependentCodes.has(detail.treatmentType) && !detail.sentAt, + ); +} + +export function treatmentMatchesHistoryDate(treatmentAt: string, date: string): boolean { + if (!date) return true; + + const treatmentDay = startOfLocalDay(new Date(treatmentAt)).getTime(); + const filterDay = startOfLocalDay(new Date(`${date}T00:00:00`)).getTime(); + return treatmentDay === filterDay; +} + +export function filterTreatmentHistoryItems( + history: PastTreatment[], + currentDraft: PastTreatment | null, + labDependentCodes: Set, + filters: TreatmentHistoryFilters, +): PastTreatment[] { + const source = (() => { + if (!currentDraft) return history; + const withoutCurrentAppointment = currentDraft.appointmentId + ? history.filter((item) => item.appointmentId !== currentDraft.appointmentId) + : history; + return [currentDraft, ...withoutCurrentAppointment]; + })(); + + return source.filter((treatment) => { + if (!treatmentMatchesHistoryDate(treatment.treatmentAt, filters.date)) { + return false; + } + if (filters.notShippedOnly && !treatmentHasUnshippedProsthesis(treatment, labDependentCodes)) { + return false; + } + return true; + }); +} diff --git a/frontend/src/components/treatment/treatmentStatusStyles.ts b/frontend/src/components/treatment/treatmentStatusStyles.ts index e426ef0..8e71ff0 100644 --- a/frontend/src/components/treatment/treatmentStatusStyles.ts +++ b/frontend/src/components/treatment/treatmentStatusStyles.ts @@ -10,6 +10,9 @@ export const labSentBannerClass = export const labPendingBannerClass = 'text-xs rounded-[var(--radius-sm)] border border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-400 px-2 py-1.5'; +export const labBlockedBannerClass = + 'text-xs rounded-[var(--radius-sm)] border border-amber-500/35 bg-amber-500/5 text-amber-800 dark:text-amber-300 px-2 py-1.5'; + export function autosaveStatusClass(status: 'dirty' | 'saving' | 'saved' | 'error'): string { switch (status) { case 'dirty': diff --git a/frontend/src/components/ui/organizations/OrganizationsPage.tsx b/frontend/src/components/ui/organizations/OrganizationsPage.tsx index 3ddbfb2..a4254a2 100644 --- a/frontend/src/components/ui/organizations/OrganizationsPage.tsx +++ b/frontend/src/components/ui/organizations/OrganizationsPage.tsx @@ -1,6 +1,7 @@ 'use client'; import { useCallback, useEffect, useState } from 'react'; +import { useSearchParams } from 'next/navigation'; import { useTranslations } from 'next-intl'; import { useToast } from '@/lib/hooks/useToast'; import { Check, History, Trash2, UserPlus, X } from 'lucide-react'; @@ -25,6 +26,7 @@ import { Input } from '@/components/ui/shared/Input'; import { SearchBar } from '@/components/ui/shared/SearchBar'; import { Table } from '@/components/ui/shared/Table'; import { getUserFacingError } from '@/components/shared/formatApiError'; +import { useRouter } from '@/i18n/navigation'; function formatOrganizationStatusLabel(status: string): string { if (!status) return status; @@ -45,6 +47,8 @@ export function OrganizationsPage() { const tErrors = useTranslations('errors'); const tNav = useTranslations('nav'); const tCommon = useTranslations('common'); + const searchParams = useSearchParams(); + const router = useRouter(); const { currentOrganization } = useAuth(); const [loading, setLoading] = useState(true); const toast = useToast(); @@ -123,6 +127,14 @@ export function OrganizationsPage() { void loadList(); }, []); + useEffect(() => { + if (searchParams.get('action') !== 'invite-lab') { + return; + } + setShowInviteForm(true); + router.replace('/organizations'); + }, [searchParams, router]); + useEffect(() => { let cancelled = false; const q = query.trim(); diff --git a/frontend/src/components/ui/treatment/DetailLabCaseCommentsSection.tsx b/frontend/src/components/ui/treatment/DetailLabCaseCommentsSection.tsx new file mode 100644 index 0000000..ad61213 --- /dev/null +++ b/frontend/src/components/ui/treatment/DetailLabCaseCommentsSection.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel'; +import { treatmentsApi } from '@/lib/api/treatments'; +import type { TreatmentDetailDraft } from '@/types/treatment'; + +interface DetailLabCaseCommentsSectionProps { + detail: TreatmentDetailDraft; + canPost: boolean; + onError?: (message: string) => void; +} + +export function DetailLabCaseCommentsSection({ + detail, + canPost, + onError, +}: DetailLabCaseCommentsSectionProps) { + const t = useTranslations('treatment'); + const caseId = detail.labCaseId; + + if (!caseId) return null; + + return ( +
+
+

{t('labCaseCommentsTitle')}

+

{t('labCaseCommentsHint')}

+
+ { + const response = await treatmentsApi.listLabCaseComments(caseId); + return response.data; + }} + onPost={async (body) => { + const response = await treatmentsApi.addLabCaseComment(caseId, { body }); + return response.data; + }} + onError={onError} + /> +
+ ); +} diff --git a/frontend/src/components/ui/treatment/DetailLabSendBadge.tsx b/frontend/src/components/ui/treatment/DetailLabSendBadge.tsx index 4d4e8c2..8f067fa 100644 --- a/frontend/src/components/ui/treatment/DetailLabSendBadge.tsx +++ b/frontend/src/components/ui/treatment/DetailLabSendBadge.tsx @@ -1,6 +1,7 @@ 'use client'; import { useTranslations } from 'next-intl'; +import { isDetailTypeSelected } from '@/components/treatment/treatmentDetailRules'; import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel'; import { labNotSentBadgeClass, labSentBadgeClass } from '@/components/treatment/treatmentStatusStyles'; import type { LinkedOrganizationOption, PastTreatmentDetail } from '@/types/treatment'; @@ -23,7 +24,7 @@ export function DetailLabSendBadge({ }: DetailLabSendBadgeProps) { const t = useTranslations('treatment'); - if (!labDependentCodes.has(detail.treatmentType)) { + if (!isDetailTypeSelected(detail) || !labDependentCodes.has(detail.treatmentType)) { return null; } diff --git a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx index 2595104..959134a 100644 --- a/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx +++ b/frontend/src/components/ui/treatment/LabCasesDispatchPanel.tsx @@ -4,9 +4,9 @@ import { useEffect, useState } from 'react'; import { useTranslations } from 'next-intl'; import { Button } from '@/components/ui/shared/Button'; import { Checkbox } from '@/components/ui/shared/Checkbox'; -import { Dropdown } from '@/components/ui/shared/Dropdown'; +import { isDetailReadyForLabDispatch } from '@/components/treatment/treatmentDetailRules'; import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles'; -import { SearchBar } from '@/components/ui/shared/SearchBar'; +import { LinkedOrganizationSearchCombobox } from '@/components/ui/treatment/LinkedOrganizationSearchCombobox'; import { CaseSentLabel } from '@/components/ui/treatment/CaseSentLabel'; import { LabCaseCommentsPanel } from '@/components/ui/lab/LabCaseCommentsPanel'; import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay'; @@ -30,6 +30,8 @@ interface LabCasesDispatchPanelProps { onOrganizationSearchChange: (value: string) => void; recentOrganizationIds: string[]; onRecentOrganizationPick: (orgId: string) => void; + canInviteLab?: boolean; + onInviteLab?: () => void; sendBusyId: string | null; onAddLabCase: () => void; onSendLabCase: (labCase: LabCaseDraft, comment?: string) => void; @@ -89,6 +91,8 @@ export function LabCasesDispatchPanel({ onOrganizationSearchChange, recentOrganizationIds, onRecentOrganizationPick, + canInviteLab = false, + onInviteLab, sendBusyId, onAddLabCase, onSendLabCase, @@ -100,18 +104,13 @@ export function LabCasesDispatchPanel({ const [pendingComment, setPendingComment] = useState(''); const activeLinkedOrganizations = orgs.filter((o) => o.active); - const filteredOrganizations = (() => { - const q = organizationSearch.trim().toLowerCase(); - if (!q) return activeLinkedOrganizations; - return activeLinkedOrganizations.filter((o) => o.name.toLowerCase().includes(q)); - })(); const recentOrganizations = recentOrganizationIds .map((id) => activeLinkedOrganizations.find((o) => o.id === id)) .filter(Boolean) as LinkedOrganizationOption[]; const activeDetail = details.find((d) => d.clientId === activeDetailId) ?? null; const isLabDependentDetail = Boolean( - activeDetail && labDependentCodes.has(activeDetail.treatmentType), + activeDetail && isDetailReadyForLabDispatch(activeDetail, labDependentCodes), ); const labCaseForActiveDetail = @@ -214,6 +213,14 @@ export function LabCasesDispatchPanel({ updateActiveLabCase({ attachmentIds: [...set] }); } + function handleSelectOrganization(org: LinkedOrganizationOption) { + updateActiveLabCase({ + destinationOrganizationId: org.id, + toothProsthesis: [], + }); + setApplyAllProsthesis(''); + } + const activeDetailAttachments = activeDetail.attachmentMetas ?? []; return ( @@ -340,11 +347,16 @@ export function LabCasesDispatchPanel({

{t('selectLab')}

- {recentOrganizations.length > 0 && (
@@ -362,28 +374,6 @@ export function LabCasesDispatchPanel({ ))}
)} - { - const nextOrgId = e.target.value || null; - updateActiveLabCase({ - destinationOrganizationId: nextOrgId, - toothProsthesis: [], - }); - setApplyAllProsthesis(''); - }} - disabled={disabled || filteredOrganizations.length === 0} - > - - {filteredOrganizations.map((o) => ( - - ))} - - {filteredOrganizations.length === 0 && ( -

{t('noOrgMatch')}

- )}
{prosthesisRows.length > 0 && activeLabCase.destinationOrganizationId ? ( @@ -411,7 +401,7 @@ export function LabCasesDispatchPanel({ ))} -
+
diff --git a/frontend/src/components/ui/treatment/LabDispatchAttentionPanel.tsx b/frontend/src/components/ui/treatment/LabDispatchAttentionPanel.tsx new file mode 100644 index 0000000..0ed8432 --- /dev/null +++ b/frontend/src/components/ui/treatment/LabDispatchAttentionPanel.tsx @@ -0,0 +1,106 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { AlertTriangle } from 'lucide-react'; +import { Button } from '@/components/ui/shared/Button'; +import { DetailLabSendBadge } from '@/components/ui/treatment/DetailLabSendBadge'; +import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge'; +import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay'; +import type { LabDispatchAttentionItem } from '@/components/treatment/labDispatchAttention'; +import type { LinkedOrganizationOption } from '@/types/treatment'; +import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; + +interface LabDispatchAttentionPanelProps { + items: LabDispatchAttentionItem[]; + treatmentCatalog: TreatmentCatalogEntry[]; + labDependentCodes: Set; + orgs?: LinkedOrganizationOption[]; + onGoToDispatch: (item: LabDispatchAttentionItem) => void; +} + +export function LabDispatchAttentionPanel({ + items, + treatmentCatalog, + labDependentCodes, + orgs, + onGoToDispatch, +}: LabDispatchAttentionPanelProps) { + const t = useTranslations('treatment'); + + if (items.length === 0) { + return null; + } + + return ( +
+
+ +
+

{t('labAttentionTitle')}

+

{t('labAttentionSubtitle')}

+
+
+ +
    + {items.map((item) => { + const teeth = item.detail.teeth.length + ? [...item.detail.teeth].sort().join(', ') + : t('teethNone'); + const dateLabel = new Date(item.treatmentAt).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + + return ( +
  • +
    +
    + + {item.isCurrentDraft ? ( + + {t('labAttentionCurrentDraft')} + + ) : null} +
    +
    + + {t('detailLabel', { n: item.detailNumber })} + + + +
    +

    + {t('teethLabel')} {teeth} +

    +
    + +
  • + ); + })} +
+
+ ); +} diff --git a/frontend/src/components/ui/treatment/LabShipmentBlockedNotice.tsx b/frontend/src/components/ui/treatment/LabShipmentBlockedNotice.tsx new file mode 100644 index 0000000..71eddee --- /dev/null +++ b/frontend/src/components/ui/treatment/LabShipmentBlockedNotice.tsx @@ -0,0 +1,20 @@ +'use client'; + +import { AlertCircle } from 'lucide-react'; +import { useTranslations } from 'next-intl'; + +export function LabShipmentBlockedNotice() { + const t = useTranslations('treatment'); + + return ( +
+
+ +
+

{t('labShipmentBlockedTitle')}

+

{t('labShipmentBlockedBody')}

+
+
+
+ ); +} diff --git a/frontend/src/components/ui/treatment/LinkedOrganizationSearchCombobox.tsx b/frontend/src/components/ui/treatment/LinkedOrganizationSearchCombobox.tsx new file mode 100644 index 0000000..fae2a35 --- /dev/null +++ b/frontend/src/components/ui/treatment/LinkedOrganizationSearchCombobox.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { Search } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { Button } from '@/components/ui/shared/Button'; +import { Input } from '@/components/ui/shared/Input'; +import type { LinkedOrganizationOption } from '@/types/treatment'; + +interface LinkedOrganizationSearchComboboxProps { + search: string; + onSearchChange: (value: string) => void; + organizations: LinkedOrganizationOption[]; + selectedOrganizationId?: string | null; + onSelectOrganization: (org: LinkedOrganizationOption) => void; + disabled?: boolean; + canInviteLab?: boolean; + onInviteLab?: () => void; + placeholder?: string; + emptyResultsMessage?: string; + noPermissionMessage?: string; +} + +export function LinkedOrganizationSearchCombobox({ + search, + onSearchChange, + organizations, + selectedOrganizationId, + onSelectOrganization, + disabled = false, + canInviteLab = false, + onInviteLab, + placeholder, + emptyResultsMessage, + noPermissionMessage, +}: LinkedOrganizationSearchComboboxProps) { + const t = useTranslations('treatment'); + const trimmed = search.trim(); + const showResults = !disabled && trimmed.length > 0; + + const filtered = trimmed + ? organizations.filter((o) => o.name.toLowerCase().includes(trimmed.toLowerCase())) + : []; + + const selectedOrg = selectedOrganizationId + ? organizations.find((o) => o.id === selectedOrganizationId) + : null; + + function handleSelect(org: LinkedOrganizationOption) { + onSelectOrganization(org); + onSearchChange(''); + } + + return ( +
+ onSearchChange(e.target.value)} + disabled={disabled} + icon={} + /> + + {selectedOrg && !trimmed ? ( +

+ {selectedOrg.name} +

+ ) : null} + + {showResults ? ( +
+ {filtered.length === 0 ? ( +
+

+ {emptyResultsMessage ?? t('noOrgMatch')} +

+ {canInviteLab && onInviteLab ? ( + + ) : noPermissionMessage ? ( +

{noPermissionMessage}

+ ) : null} +
+ ) : ( +
+ {filtered.map((org) => { + const isSelected = selectedOrganizationId === org.id; + return ( + + ); + })} +
+ )} +
+ ) : null} +
+ ); +} diff --git a/frontend/src/components/ui/treatment/PastTreatmentsPanel.tsx b/frontend/src/components/ui/treatment/PastTreatmentsPanel.tsx index a97a225..e7884a7 100644 --- a/frontend/src/components/ui/treatment/PastTreatmentsPanel.tsx +++ b/frontend/src/components/ui/treatment/PastTreatmentsPanel.tsx @@ -1,43 +1,127 @@ 'use client'; +import { useMemo, useState } from 'react'; import { useTranslations } from 'next-intl'; +import { Button } from '@/components/ui/shared/Button'; +import { Checkbox } from '@/components/ui/shared/Checkbox'; +import { FORM_SELECT_CLASS } from '@/components/shared/formSelectStyles'; import { TreatmentHistoryDetailLine } from '@/components/ui/treatment/TreatmentHistoryDetailLine'; -import type { PastTreatment } from '@/types/treatment'; +import { DetailLabSendBadge } from '@/components/ui/treatment/DetailLabSendBadge'; +import { filterTreatmentHistoryItems } from '@/components/treatment/treatmentHistoryFilters'; +import type { LinkedOrganizationOption, PastTreatment } from '@/types/treatment'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; interface PastTreatmentsPanelProps { items: PastTreatment[]; + currentDraft?: PastTreatment | null; + patientName?: string; + currentAppointmentId?: string | null; treatmentCatalog: TreatmentCatalogEntry[]; + labDependentCodes: Set; + orgs?: LinkedOrganizationOption[]; loading?: boolean; selectedPreviewId?: string | null; onSelectTreatment?: (treatment: PastTreatment) => void; } +function formatHistoryTimestamp(iso: string): string { + const date = new Date(iso); + return date.toLocaleString(undefined, { + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + }); +} + export function PastTreatmentsPanel({ items, + currentDraft = null, + patientName, + currentAppointmentId, treatmentCatalog, + labDependentCodes, + orgs, loading, selectedPreviewId, onSelectTreatment, }: PastTreatmentsPanelProps) { const t = useTranslations('treatment'); + const [notShippedOnly, setNotShippedOnly] = useState(false); + const [filterDate, setFilterDate] = useState(''); + + const hasActiveFilters = notShippedOnly || Boolean(filterDate); + + const displayedItems = useMemo( + () => + filterTreatmentHistoryItems(items, currentDraft, labDependentCodes, { + notShippedOnly, + date: filterDate, + }), + [items, currentDraft, labDependentCodes, notShippedOnly, filterDate], + ); + + function clearFilters() { + setNotShippedOnly(false); + setFilterDate(''); + } + + const filterInputClass = `${FORM_SELECT_CLASS} rounded-md px-2 py-1.5 text-xs min-w-[9.5rem]`; return (
-

{t('historyTitle')}

+

+ {patientName ? t('historyPatientScope', { patientName }) : t('historyTitle')} +

{t('historySubtitle')}

+
+ + + +
+ {loading &&

{t('loadingHistory')}

} - {!loading && items.length === 0 && ( -

{t('historyEmpty')}

+ {!loading && displayedItems.length === 0 && ( +

+ {hasActiveFilters ? t('historyFilterEmpty') : t('historyEmpty')} +

)} -
- {items.map((treatment) => { +
+ {displayedItems.map((treatment) => { const isSelected = selectedPreviewId === treatment.id; + const isCurrentAppointment = + Boolean(currentAppointmentId) && treatment.appointmentId === currentAppointmentId; + const isLiveDraft = treatment.id === 'current-draft'; return (
- +
+ + {isLiveDraft ? ( + + {t('labAttentionCurrentDraft')} + + ) : isCurrentAppointment ? ( + + {t('historyCurrentAppointment')} + + ) : null} +
{treatment.details.length === 0 ? (

{t('noDetails')}

) : (
{treatment.details.map((detail, idx) => ( -
+
+
))}
diff --git a/frontend/src/components/ui/treatment/TreatmentDetailSummaryRow.tsx b/frontend/src/components/ui/treatment/TreatmentDetailSummaryRow.tsx index e016801..683f757 100644 --- a/frontend/src/components/ui/treatment/TreatmentDetailSummaryRow.tsx +++ b/frontend/src/components/ui/treatment/TreatmentDetailSummaryRow.tsx @@ -3,6 +3,7 @@ import { useTranslations } from 'next-intl'; import { DetailLabSendBadge } from '@/components/ui/treatment/DetailLabSendBadge'; import { TreatmentTypeBadge } from '@/components/ui/treatment/TreatmentTypeBadge'; +import { isDetailTypeSelected } from '@/components/treatment/treatmentDetailRules'; import { treatmentTypeLabelFromCatalog } from '@/components/shared/treatmentTypeDisplay'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; import type { LinkedOrganizationOption, PastTreatmentDetail } from '@/types/treatment'; @@ -39,10 +40,16 @@ export function TreatmentDetailSummaryRow({ {t('detailLabel', { n: detailNumber })} - + {isDetailTypeSelected(detail) ? ( + + ) : ( + + {t('treatmentTypeNotSelected')} + + )}
diff --git a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx index f7a33bb..3a72f39 100644 --- a/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx +++ b/frontend/src/components/ui/treatment/TreatmentDetailsEditor.tsx @@ -12,6 +12,14 @@ import { import type { TreatmentDetailDraft } from '@/types/treatment'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; import { treatmentTypeColor } from '@/components/shared/treatmentTypeDisplay'; +import { + canCommentOnDetailLabCase, + isDetailReadyForLabDispatch, + isDetailTypeSelected, + isLabDependentDetailMissingTeeth, +} from '@/components/treatment/treatmentDetailRules'; +import { DetailLabCaseCommentsSection } from '@/components/ui/treatment/DetailLabCaseCommentsSection'; +import { labBlockedBannerClass } from '@/components/treatment/treatmentStatusStyles'; interface TreatmentDetailsEditorProps { details: TreatmentDetailDraft[]; @@ -27,6 +35,7 @@ interface TreatmentDetailsEditorProps { uploadBusy: boolean; onAddDetail: () => void; onUploadFiles: (files: FileList | null) => void; + onCommentError?: (message: string) => void; } export function TreatmentDetailsEditor({ @@ -43,6 +52,7 @@ export function TreatmentDetailsEditor({ uploadBusy, onAddDetail, onUploadFiles, + onCommentError, }: TreatmentDetailsEditorProps) { const t = useTranslations('treatment'); const attachmentInputRef = useRef(null); @@ -52,12 +62,19 @@ export function TreatmentDetailsEditor({ const locked = isDetailLocked(activeDetail); const readOnly = disabled || locked; - const treatmentTypeTextColor = treatmentTypeColor( - activeDetail.treatmentType, - treatmentCatalog.findIndex((e) => e.code === activeDetail.treatmentType), + const treatmentTypeTextColor = isDetailTypeSelected(activeDetail) + ? treatmentTypeColor( + activeDetail.treatmentType, + treatmentCatalog.findIndex((e) => e.code === activeDetail.treatmentType), + ) + : undefined; + const showPendingLabHint = + isDetailReadyForLabDispatch(activeDetail, labDependentCodes) && !locked && !readOnly; + const showMissingTeethLabBlock = isLabDependentDetailMissingTeeth( + activeDetail, + labDependentCodes, ); - const isLabDependent = labDependentCodes.has(activeDetail.treatmentType); - const showPendingLabHint = isLabDependent && !locked && !readOnly; + const showLabCaseComments = canCommentOnDetailLabCase(activeDetail); return (
@@ -107,6 +124,9 @@ export function TreatmentDetailsEditor({ {showPendingLabHint && (

{t('detailPendingLabSend')}

)} + {showMissingTeethLabBlock && ( +

{t('labShipmentBlockedBody')}

+ )}
+ {showLabCaseComments ? ( + + ) : null} + {canEdit && saveStatus !== 'idle' && (

{detailNumber}. - + {isDetailTypeSelected(detail) ? ( + + ) : ( + {t('treatmentTypeNotSelected')} + )} {teeth} {attachmentCount > 0 && ( diff --git a/frontend/src/components/ui/treatment/TreatmentPreviewCard.tsx b/frontend/src/components/ui/treatment/TreatmentPreviewCard.tsx index 67385c0..44595dc 100644 --- a/frontend/src/components/ui/treatment/TreatmentPreviewCard.tsx +++ b/frontend/src/components/ui/treatment/TreatmentPreviewCard.tsx @@ -1,58 +1,56 @@ 'use client'; import { useTranslations } from 'next-intl'; -import { Button } from '@/components/ui/shared/Button'; import { TreatmentDetailSummaryRow } from '@/components/ui/treatment/TreatmentDetailSummaryRow'; import type { LinkedOrganizationOption, PastTreatment } from '@/types/treatment'; import type { TreatmentCatalogEntry } from '@/types/treatment-catalog'; interface TreatmentPreviewCardProps { treatment: PastTreatment | null; + /** e.g. "Current draft" or a formatted date label while browsing. */ + heading: string; labDependentCodes: Set; treatmentCatalog: TreatmentCatalogEntry[]; orgs?: LinkedOrganizationOption[]; - openDisabled?: boolean; - onOpen: () => void; } export function TreatmentPreviewCard({ treatment, + heading, labDependentCodes, treatmentCatalog, orgs, - openDisabled = false, - onOpen, }: TreatmentPreviewCardProps) { const t = useTranslations('treatment'); return (

-
-

{t('previewTitle')}

- -
+

{heading}

+ {!treatment ? (

{t('selectAppointment')}

) : (
-
-

{treatment.title}

+ {treatment.id !== 'current-draft' ? ( -
+ ) : null}
{treatment.details.length === 0 ? (

{t('noDetails')}

) : ( treatment.details.map((detail, idx) => ( entry.availableInTreatment); - if (purpose && treatmentOptions.some((entry) => entry.code === purpose)) { - return purpose as TreatmentDetailDraft['treatmentType']; - } - return (treatmentOptions[0]?.code ?? 'restoration') as TreatmentDetailDraft['treatmentType']; -} - -function newDetail(defaultTreatmentType?: TreatmentDetailDraft['treatmentType']): TreatmentDetailDraft { +function newDetail(defaultTreatmentType?: string): TreatmentDetailDraft { return { clientId: typeof crypto !== 'undefined' && 'randomUUID' in crypto ? crypto.randomUUID() : `detail-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, - treatmentType: defaultTreatmentType ?? 'restoration', + treatmentType: defaultTreatmentType ?? '', teeth: [], comment: '', attachmentMetas: [], @@ -179,6 +182,7 @@ function mapDetailFromApi(d: PastTreatmentCase): TreatmentDetailDraft { comment: d.notes ?? '', attachmentMetas: d.attachmentMetas ?? [], labCaseId: d.labCaseId ?? null, + taskProgress: d.taskProgress ?? null, sendToOrganizationIds: d.destinationOrganizationId ? [d.destinationOrganizationId] : [], sends: d.sends ?? [], sentAt: d.sentAt ?? null, @@ -220,7 +224,7 @@ function isDetailsDirty( savedSnapshot: string | null, ): boolean { if (savedSnapshot === null) { - return details.length !== 1 || details[0].comment !== '' || details[0].teeth.length > 0; + return details.some((d) => !isEmptyDraftDetail(d)) || details.length > 1; } return serializeDetails(details) !== savedSnapshot; } @@ -242,6 +246,7 @@ function detailsToPreviewTreatment( notes: d.comment || null, attachmentMetas: d.attachmentMetas, labCaseId: d.labCaseId ?? null, + taskProgress: d.taskProgress ?? null, destinationOrganizationId: d.sendToOrganizationIds[0] ?? null, sends: d.sends ?? [], sentAt: d.sentAt ?? null, @@ -316,6 +321,8 @@ export function TreatmentWorkspace({ labCaseDraftsRef.current = labCaseDrafts; const skipNextGetDraftRef = useRef(false); const pendingAppointmentIdRef = useRef(initialAppointmentId); + const labPanelRef = useRef(null); + const historyRequestRef = useRef(0); useEffect(() => { pendingAppointmentIdRef.current = initialAppointmentId; @@ -360,18 +367,25 @@ export function TreatmentWorkspace({ !isViewingPastDay && workspaceMode === 'live'; - const historyPanelItems = useMemo(() => { - return history.filter((item) => { - if ( - workspaceMode === 'live' && - selectedAppointmentId && - item.appointmentId === selectedAppointmentId - ) { - return false; - } - return true; - }); - }, [history, selectedAppointmentId, workspaceMode]); + const historyPanelItems = history; + + const activeDetail = useMemo( + () => details.find((d) => d.clientId === activeDetailId) ?? details[0] ?? null, + [details, activeDetailId], + ); + + const showLabDispatchPanel = useMemo( + () => details.some((d) => isDetailReadyForLabDispatch(d, labDependentCodes)), + [details, labDependentCodes], + ); + + const showLabShipmentBlocked = useMemo( + () => + Boolean( + activeDetail && isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes), + ), + [activeDetail, labDependentCodes], + ); const currentDraftPreview = useMemo(() => { if (!selectedAppointment) return null; @@ -388,17 +402,29 @@ export function TreatmentWorkspace({ const previewTreatment = useMemo(() => { if (!selectedPreviewId) return currentDraftPreview; - return historyPanelItems.find((item) => item.id === selectedPreviewId) ?? currentDraftPreview; - }, [selectedPreviewId, historyPanelItems, currentDraftPreview]); + return ( + history.find((item) => item.id === selectedPreviewId) ?? + historyPanelItems.find((item) => item.id === selectedPreviewId) ?? + currentDraftPreview + ); + }, [selectedPreviewId, history, historyPanelItems, currentDraftPreview]); - const isPreviewAlreadyOpen = useMemo(() => { - if (!previewTreatment?.appointmentId || !selectedAppointmentId) return false; - if (selectedAppointmentId !== previewTreatment.appointmentId) return false; - if (workspaceMode === 'historical') return true; - if (workspaceMode === 'live' && selectedPreviewId === null) return true; - if (workspaceMode === 'live' && selectedPreviewId === previewTreatment.id) return true; - return false; - }, [previewTreatment, selectedAppointmentId, workspaceMode, selectedPreviewId]); + const isBrowsing = selectedPreviewId !== null; + + const previewHeading = isBrowsing + ? t('previewBrowsingTitle') + : t('previewCurrentDraft'); + + const labAttentionItems = useMemo( + () => + collectLabDispatchAttention( + labDependentCodes, + currentDraftPreview, + history, + selectedAppointmentId, + ), + [labDependentCodes, currentDraftPreview, history, selectedAppointmentId], + ); const hydrateFromTreatment = useCallback((treatment: PastTreatment) => { const mapped = treatment.details.map(mapDetailFromApi); @@ -417,11 +443,6 @@ export function TreatmentWorkspace({ setSaveStatus('idle'); }, []); - const activeDetail = useMemo( - () => details.find((d) => d.clientId === activeDetailId) ?? details[0], - [details, activeDetailId], - ); - const selectedTeethSet = useMemo(() => new Set(activeDetail?.teeth ?? []), [activeDetail?.teeth]); const wholePlanTeethSet = useMemo(() => { @@ -459,10 +480,6 @@ export function TreatmentWorkspace({ setActiveLabCaseId(match?.clientId ?? null); }, [activeDetailId, labCaseDrafts]); - useEffect(() => { - setSelectionLocked(false); - }, [selectedDay]); - useEffect(() => { let cancelled = false; setApptsLoading(true); @@ -550,28 +567,34 @@ export function TreatmentWorkspace({ setHistoryLoading(false); return; } - setHistoryPatientId(selectedAppointment.patientId); + const nextPatientId = selectedAppointment.patientId; + setHistoryPatientId((prev) => { + if (prev !== nextPatientId) { + setHistory([]); + setHistoryLoading(true); + } + return nextPatientId; + }); }, [selectedAppointment?.patientId]); useEffect(() => { if (!historyPatientId) return; - let cancelled = false; + const requestId = ++historyRequestRef.current; setHistoryLoading(true); void (async () => { try { - const response = await treatmentsApi.listPatientHistory(historyPatientId); - if (!cancelled) setHistory(response.data); + const response = await treatmentsApi.listPatientHistory(historyPatientId, 50); + if (requestId !== historyRequestRef.current) return; + setHistory(response.data); } catch (error: unknown) { - if (!cancelled) { - showError(getUserFacingError(error, tErrors, t('errorLoadHistory'))); - } + if (requestId !== historyRequestRef.current) return; + showError(getUserFacingError(error, tErrors, t('errorLoadHistory'))); } finally { - if (!cancelled) setHistoryLoading(false); + if (requestId === historyRequestRef.current) { + setHistoryLoading(false); + } } })(); - return () => { - cancelled = true; - }; }, [historyPatientId, showError, t]); useEffect(() => { @@ -652,6 +675,16 @@ export function TreatmentWorkspace({ }); } + if (!areDetailsPersistable(currentDetails)) { + return detailsToPreviewTreatment(currentDetails, { + title: t('treatmentPlanTitle', { + patientName: `${selectedAppointment.patientFirstName} ${selectedAppointment.patientLastName}`, + }), + patientId: selectedAppointment.patientId, + treatmentAt: selectedAppointment.startAt, + }); + } + const response = await treatmentsApi.saveDraft(selectedAppointment.id, { details: currentDetails.map(({ clientId, id, treatmentType, teeth, comment, attachmentMetas }) => ({ clientId, @@ -674,6 +707,18 @@ export function TreatmentWorkspace({ [selectedAppointment, t], ); + const refreshHistory = useCallback(async (patientId: string) => { + const requestId = ++historyRequestRef.current; + try { + const response = await treatmentsApi.listPatientHistory(patientId, 50); + if (requestId !== historyRequestRef.current) return; + setHistory(response.data); + } catch (error: unknown) { + if (requestId !== historyRequestRef.current) return; + showError(getUserFacingError(error, tErrors, t('errorLoadHistory'))); + } + }, [showError, t]); + const runDraftSave = useCallback(async () => { if (!selectedAppointment || saveInFlightRef.current) { if (saveInFlightRef.current) saveQueuedRef.current = true; @@ -681,7 +726,8 @@ export function TreatmentWorkspace({ } if ( - !isDetailsDirty(detailsRef.current, savedSnapshotRef.current) + !isDetailsDirty(detailsRef.current, savedSnapshotRef.current) || + !areDetailsPersistable(detailsRef.current) ) { return; } @@ -691,6 +737,9 @@ export function TreatmentWorkspace({ try { await persistDraft(); setSaveStatus('saved'); + if (historyPatientId) { + await refreshHistory(historyPatientId); + } } catch (error: unknown) { setSaveStatus('error'); showError(getUserFacingError(error, tErrors, t('errorSaveDraft'))); @@ -704,16 +753,7 @@ export function TreatmentWorkspace({ } } } - }, [selectedAppointment, persistDraft, showError, t]); - - const refreshHistory = useCallback(async (patientId: string) => { - try { - const response = await treatmentsApi.listPatientHistory(patientId); - setHistory(response.data); - } catch (error: unknown) { - showError(getUserFacingError(error, tErrors, t('errorLoadHistory'))); - } - }, [showError, t]); + }, [selectedAppointment, persistDraft, showError, t, historyPatientId, refreshHistory]); const flushDraftSave = useCallback(async (): Promise => { if (autosaveTimerRef.current) { @@ -735,14 +775,11 @@ export function TreatmentWorkspace({ try { await runDraftSave(); - if (historyPatientId) { - await refreshHistory(historyPatientId); - } return true; } catch { return window.confirm(t('confirmDiscard')); } - }, [selectedAppointment, canEditTreatmentForDay, runDraftSave, historyPatientId, refreshHistory, t]); + }, [selectedAppointment, canEditTreatmentForDay, runDraftSave, t]); useEffect(() => { if (draftHydratingRef.current || !canEditTreatmentForDay || !selectedAppointment?.id) { @@ -787,6 +824,8 @@ export function TreatmentWorkspace({ [flushDraftSave, resetToLiveContext], ); + const canAccessOrganizations = canAccessDashboardRoute(currentOrganization, '/organizations'); + const onSelectDay = useCallback( (day: Date) => { void (async () => { @@ -794,7 +833,8 @@ export function TreatmentWorkspace({ if (!ok) return; const patientIdToRefresh = historyPatientId; resetToLiveContext(); - setSelectedDay(day); + setSelectionLocked(false); + setSelectedDay(startOfLocalDay(day)); if (patientIdToRefresh) { await refreshHistory(patientIdToRefresh); } @@ -807,22 +847,23 @@ export function TreatmentWorkspace({ setSelectedPreviewId(treatment.id); }, []); - const handleOpenTreatment = useCallback(() => { - void (async () => { - const treatment = previewTreatment; - if (!treatment?.appointmentId) { + const exitBrowse = useCallback(() => { + setSelectedPreviewId(null); + }, []); + + const loadTreatmentIntoWorkspace = useCallback( + async (treatment: PastTreatment, focusDetailClientId?: string) => { + if (!treatment.appointmentId) { showError(t('errorNoAppointmentForTreatment')); - return; + return false; } - if (isPreviewAlreadyOpen) return; - const ok = workspaceModeRef.current === 'live' ? await flushDraftSave() : true; - if (!ok) return; + if (!ok) return false; const isHistorical = isTreatmentDayHistorical(treatment.treatmentAt, todayStart); setWorkspaceMode(isHistorical ? 'historical' : 'live'); - setSelectedPreviewId(treatment.id); + setSelectedPreviewId(null); setSelectedDay(startOfLocalDay(new Date(treatment.treatmentAt))); setSelectionLocked(true); setSelectedAppointmentId(treatment.appointmentId); @@ -831,16 +872,58 @@ export function TreatmentWorkspace({ draftHydratingRef.current = true; hydrateFromTreatment(treatment); draftHydratingRef.current = false; - })(); - }, [ - previewTreatment, - isPreviewAlreadyOpen, - flushDraftSave, - hydrateFromTreatment, - showError, - t, - todayStart, - ]); + + if (focusDetailClientId) { + setActiveDetailId(focusDetailClientId); + const mappedLabCases = withoutEmptyLabCaseDrafts( + (treatment.labCases ?? []).map(mapLabCaseDraftFromApi), + ); + const linked = mappedLabCases.find( + (lc) => !lc.sentAt && lc.detailClientId === focusDetailClientId, + ); + if (linked) { + setActiveLabCaseId(linked.clientId); + } + requestAnimationFrame(() => { + scrollWithinMainScrollContainer(labPanelRef.current); + }); + } + + return true; + }, + [flushDraftSave, hydrateFromTreatment, showError, t, todayStart], + ); + + const handleLoadIntoWorkspace = useCallback(() => { + if (!previewTreatment) return; + void loadTreatmentIntoWorkspace(previewTreatment); + }, [loadTreatmentIntoWorkspace, previewTreatment]); + + const handleGoToLabDispatch = useCallback( + (item: LabDispatchAttentionItem) => { + if (item.isCurrentDraft) { + exitBrowse(); + setActiveDetailId(item.detailClientId); + const linked = labCaseDrafts.find( + (lc) => !lc.sentAt && lc.detailClientId === item.detailClientId, + ); + if (linked) { + setActiveLabCaseId(linked.clientId); + } + requestAnimationFrame(() => { + scrollWithinMainScrollContainer(labPanelRef.current); + }); + return; + } + + const treatment = + history.find((entry) => entry.id === item.treatmentId) ?? + historyPanelItems.find((entry) => entry.id === item.treatmentId); + if (!treatment) return; + void loadTreatmentIntoWorkspace(treatment, item.detailClientId); + }, + [exitBrowse, history, historyPanelItems, labCaseDrafts, loadTreatmentIntoWorkspace], + ); const uploadForDetail = useCallback( async (detailClientId: string, files: FileList | File[]) => { @@ -984,8 +1067,17 @@ export function TreatmentWorkspace({ } const activeDetail = details.find((d) => d.clientId === activeDetailId); - const shouldIncludeActive = - Boolean(activeDetail && labDependentCodes.has(activeDetail.treatmentType)); + if ( + activeDetail && + isLabDependentDetailMissingTeeth(activeDetail, labDependentCodes) + ) { + showError(t('labShipmentBlockedBody')); + return; + } + + const shouldIncludeActive = Boolean( + activeDetail && isDetailReadyForLabDispatch(activeDetail, labDependentCodes), + ); const orphan = cleaned.find((lc) => !lc.sentAt && !lc.detailClientId); if (orphan && shouldIncludeActive) { @@ -1068,21 +1160,28 @@ export function TreatmentWorkspace({ const response = await treatmentsApi.sendLabCase(refreshedLabCase.id); - const sentDetailClientId = labCase.detailClientId; - setDetails((prev) => - prev.map((detail) => { - if (detail.clientId !== sentDetailClientId) return detail; - return { - ...detail, - labCaseId: response.data.id, - sentAt: response.data.sentAt, - sends: response.data.sends, - sendToOrganizationIds: response.data.destinationOrganizationId - ? [response.data.destinationOrganizationId] - : detail.sendToOrganizationIds, - }; - }), - ); + const draftResponse = await treatmentsApi.getDraft(selectedAppointment.id); + if (draftResponse.data?.details?.length) { + const mapped = draftResponse.data.details.map(mapDetailFromApi); + setDetails(mapped); + setSavedSnapshot(serializeDetails(mapped)); + } else { + const sentDetailClientId = labCase.detailClientId; + setDetails((prev) => + prev.map((detail) => { + if (detail.clientId !== sentDetailClientId) return detail; + return { + ...detail, + labCaseId: response.data.id, + sentAt: response.data.sentAt, + sends: response.data.sends, + sendToOrganizationIds: response.data.destinationOrganizationId + ? [response.data.destinationOrganizationId] + : detail.sendToOrganizationIds, + }; + }), + ); + } setLabCaseDrafts((prev) => prev.map((lc) => @@ -1183,18 +1282,59 @@ export function TreatmentWorkspace({
)} + + + {isBrowsing && previewTreatment ? ( +
+

+ {t('browseBanner', { + date: new Date(previewTreatment.treatmentAt).toLocaleDateString(undefined, { + weekday: 'short', + year: 'numeric', + month: 'short', + day: 'numeric', + }), + })} +

+
+ + +
+
+ ) : null} + 1 ? ( - + ) : undefined } onToggle={(fdi) => { @@ -1254,8 +1391,12 @@ export function TreatmentWorkspace({ setActiveDetailId(next.clientId); }} onUploadFiles={(files) => void uploadForDetail(activeDetailId, files ?? [])} + onCommentError={showError} /> +
+ {showLabShipmentBlocked ? : null} + {showLabDispatchPanel ? ( void handleAddLabCase()} onSendLabCase={(lc, comment) => void handleSendLabCase(lc, comment)} onCommentError={showError} + canInviteLab={canAccessOrganizations} + onInviteLab={() => router.push('/organizations?action=invite-lab')} /> + ) : null} +
diff --git a/frontend/src/types/treatment.ts b/frontend/src/types/treatment.ts index 3a5d233..9a76cc0 100644 --- a/frontend/src/types/treatment.ts +++ b/frontend/src/types/treatment.ts @@ -69,6 +69,11 @@ export interface LabCaseSendInfo { /** @deprecated Use LabCaseSendInfo */ export type TreatmentCaseSendInfo = LabCaseSendInfo; +export interface LabCaseTaskProgress { + completed: number; + total: number; +} + export interface PastTreatmentDetail { id: string; clientId: string; @@ -78,6 +83,7 @@ export interface PastTreatmentDetail { attachmentMetas?: TreatmentAttachmentMeta[]; labCaseId?: string | null; destinationOrganizationId?: string | null; + taskProgress?: LabCaseTaskProgress | null; sends?: LabCaseSendInfo[]; sentAt?: string | null; } @@ -131,6 +137,7 @@ export interface TreatmentDetailDraft { comment: string; attachmentMetas: TreatmentAttachmentMeta[]; labCaseId?: string | null; + taskProgress?: LabCaseTaskProgress | null; sendToOrganizationIds: string[]; sends?: LabCaseSendInfo[]; sentAt?: string | null;